Tools, Function Schemas & MCP
A tool is how Claude reaches out of the text box and into the world: a database, an API, a shell. But the model never sees your code. It sees a name, a description, and a schema, and from those three strings it decides whether to call, which tool, and with what arguments. This lab treats tool authoring as what it actually is: prompt engineering with a JSON contract. Then it covers the delivery mechanisms (client-side versus server-side execution, approval gating, and MCP servers) and the decision the exam keeps asking: built-in tool, custom tool, Skill, or MCP?
- Tool Implementation (4.4%) — Tool implementation practices for Claude applications, including tool use and function calling, configuration for external system interaction, tool description writing, error handling, tool usage patterns (agentic harness dispatch, client-side vs. server-side tools, approval patterns), and tool set construction best practices.
- MCP Server Development (2.1%) — MCP server development practices, including server authoring, deployment, integration with Claude applications, MCP resources, tools, and prompts, and communication patterns (stdio, sockets, client vs. server).
- Agentic Customization (4.1%) — Tradeoffs among built-in Tools, custom Tools, Skills, and MCPs for selecting and applying the appropriate approach for a given use case.
description string and decides from that alone. If your tool under-triggers, is confused for another tool, or is called with garbage arguments, the fix is almost always in the text, not the code.
1. Tool Definition Anatomy
A tool is three fields. name is the identifier Claude returns when it wants to call. input_schema is a JSON Schema describing the arguments. And description is the natural-language brief the model reads to decide when to reach for this tool at all.
import anthropic
client = anthropic.Anthropic()
get_stock_price = {
"name": "get_stock_price",
# The description is the trigger. Be prescriptive about WHEN, not just WHAT.
"description": (
"Get the current market price for a publicly traded stock. "
"Call this whenever the user asks about a current or recent share "
"price, a quote, or how a ticker is trading today. Do NOT use this "
"for historical charts or company fundamentals."
),
"input_schema": {
"type": "object",
"properties": {
"ticker": {
"type": "string",
"description": "Stock ticker symbol, e.g. 'AAPL' or 'MSFT'.",
},
},
"required": ["ticker"],
},
}
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
tools=[get_stock_price],
messages=[{"role": "user", "content": "How's Apple trading right now?"}],
)
print(response.stop_reason) # -> "tool_use"
Notice what the description does. A weak version ("Gets a stock price") tells Claude what the tool does but not when to reach for it, so it under-triggers: the model answers from memory instead of calling. A prescriptive description names the trigger condition ("whenever the user asks about a current or recent share price") and the anti-condition ("do NOT use this for historical charts"). You are writing a decision rule, not a docstring.
"description": "Returns the stock price."
"description": "Get the current market price for a publicly traded stock.
Call this whenever the user asks about a current or recent share price, a
live quote, or how a ticker is trading today. Do NOT use this for historical
price series (use get_price_history) or company financials (use get_fundamentals)."
| Weak description | Strong description | Why it matters |
|---|---|---|
| "Gets weather." | "Get the current weather for a city. Call this when the user asks about current conditions, temperature, or forecast for a named location." | Names the trigger: the model knows when, so it stops answering from stale memory |
| "Sends a message." | "Send an email via the corporate mail server. Use only when the user explicitly asks to send or reply to an email and has supplied a recipient." | Bounds the trigger, preventing it from firing on any mention of "message" |
| "Ticker param." | "Stock ticker symbol, e.g. 'AAPL'. Uppercase, 1–5 letters, US exchanges only." | Per-property descriptions shape the arguments, not just the routing |
2. Writing Schemas Claude Fills Correctly
The schema is a contract, and Claude honors it best when it is specific. Four habits carry most of the weight:
- Enums over free text. If a field has a fixed set of legal values, list them.
"enum": ["celsius", "fahrenheit"]gets you one of two strings;"type": "string"gets you "F", "Fahrenheit", "degrees F", and whatever else. - A
descriptionon every property. Each field is another place to steer. Units, formats, examples, and constraints belong here. - Mark
requiredonly what is genuinely required. Over-requiring forces Claude to invent values for fields it has no basis to fill. - Use
strict: truewhen the input must validate exactly. Set top-level"strict": Trueon the tool, and give the schema"additionalProperties": Falseplus"required". Now the API guarantees the arguments conform: no extra keys, no missing required fields.
create_ticket = {
"name": "create_ticket",
"description": (
"Open a support ticket in the tracker. Call this when the user "
"reports a bug or asks to file an issue and has described the problem."
),
"strict": True, # exact validation: enforced against the schema below
"input_schema": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "One-line summary of the issue."},
"severity": {
"type": "string",
"enum": ["low", "medium", "high", "critical"], # not free text
"description": "Business impact. 'critical' = outage or data loss.",
},
"component": {
"type": "string",
"description": "Affected subsystem, e.g. 'billing' or 'auth'.",
},
},
"required": ["title", "severity"], # component is optional
"additionalProperties": False, # required for strict mode
},
}
block.input is already a parsed Python dict; read block.input["ticker"]. Matching against the serialized JSON string is a bug waiting to happen because whitespace and escaping vary between calls.
3. Tool Misrouting
The failure mode that surprises people: two tools with overlapping descriptions. If search_docs says "search the knowledge base" and search_tickets says "search support records," Claude has to guess which one "search for the login bug" means, and it will guess wrong a predictable fraction of the time. The fix is disambiguation in the description, not in the code.
Two rules of tool-set construction follow from this. First, make each tool's boundary explicit: say what it is for and, when there is a sibling, what it is not for ("use search_tickets for customer-reported issues; use search_docs for product documentation"). Second, keep the set focused. Selection accuracy degrades as the tool count climbs; a lean, well-bounded toolbox routes better than a sprawling one. When you genuinely need a large library, see §9 on tool search.
4. The Tool-Use Loop, Parallel Calls, and Errors
Tool use is a loop you drive. Claude returns stop_reason == "tool_use" with one or more tool_use blocks; you execute them, append the results, and call again. Parallel tool use is on by default (a single assistant turn can request several tools at once), which makes one rule non-negotiable.
tool_result in one user message. When Claude issues three tool_use blocks in a turn, you run all three and reply with a single user message containing all three tool_result blocks. And you must return a tool_result for every tool_use_id; omit one and the API rejects the request. Never split them across multiple messages, and never silently drop one.
messages = [{"role": "user", "content": "Compare Apple and Microsoft stock right now."}]
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
tools=[get_stock_price],
messages=messages,
)
# Append the assistant's FULL content to history -- it holds the tool_use blocks.
messages.append({"role": "assistant", "content": response.content})
# Claude may have asked for both tickers in parallel. Collect ALL results
# into ONE user message.
tool_results = []
for block in response.content:
if block.type == "tool_use":
try:
price = look_up_price(block.input["ticker"]) # your code
result = {"type": "tool_result",
"tool_use_id": block.id,
"content": f"{block.input['ticker']}: ${price}"}
except Exception as e:
result = {"type": "tool_result",
"tool_use_id": block.id,
"content": f"Error: {e}",
"is_error": True} # tell Claude it failed; do not drop it
tool_results.append(result)
messages.append({"role": "user", "content": tool_results}) # one message, all results
followup = client.messages.create(
model="claude-opus-4-8", max_tokens=1024, tools=[get_stock_price], messages=messages,
)
When a tool fails, do not swallow the exception and return a plausible-looking empty result; that teaches Claude the call succeeded. Return the tool_result with is_error: True and a readable message. Claude will see the failure and can retry with different arguments, try another tool, or tell the user.
{
"type": "tool_result",
"tool_use_id": "toolu_01A...",
"content": "Error: ticker 'APPL' not found. Did you mean 'AAPL'?",
"is_error": True,
}
If you ever need Claude to run tools strictly one at a time (say each depends on the previous result), pass tool_choice={"type": "auto", "disable_parallel_tool_use": True}. Otherwise leave parallelism on; it is faster and usually what you want.
5. Client-Side vs Server-Side Tools
Not all tools execute in your process. Anthropic ships two categories, and the difference is who runs the code.
Server-side tools run on Anthropic's infrastructure. You declare them; Anthropic executes them and the results come back in the same response. There is no execution loop for you to write. These include web search, web fetch, and code execution.
Client-side tools come in two flavors. Your custom tools (§1) are client-side: you define the schema and you execute. Anthropic also defines a few client-side tools where Anthropic supplies the schema but you still execute: bash, the text editor, and memory. You run the command or edit on your own machine and return the tool_result.
| Tool | Type declared | Who executes | Schema |
|---|---|---|---|
| Web search | web_search_20260209 | Anthropic (server-side) | Anthropic-defined |
| Web fetch | web_fetch_20260209 | Anthropic (server-side) | Anthropic-defined |
| Code execution | code_execution_20260120 | Anthropic (server-side) | Anthropic-defined |
| Bash | bash_20250124 | You (client-side) | Schema-less — no input_schema |
| Text editor | text_editor_20250728 (name str_replace_based_edit_tool) | You (client-side) | Schema-less — no input_schema |
| Memory | memory_20250818 | You (client-side) | Schema-less — no input_schema |
| Your custom tool | — (plain name) | You (client-side) | You define input_schema |
# Server-side: declare it and read the result from the SAME response.
# No execution loop -- Anthropic runs the search on its infrastructure.
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=2048,
tools=[{"type": "web_search_20260209", "name": "web_search"}],
messages=[{"role": "user", "content": "What shipped in the latest Claude release?"}],
)
# Client-side, Anthropic-defined, SCHEMA-LESS: type + name only.
tools = [
{"type": "bash_20250124", "name": "bash"},
{"type": "text_editor_20250728", "name": "str_replace_based_edit_tool"},
]
# You receive tool_use blocks for these and execute them yourself, then
# return tool_result -- exactly like a custom tool.
input_schema. They are declared with type and name only. Passing an input_schema for a schema-less Anthropic-defined tool is a request error. And because you execute them, treat every model-supplied path and command as untrusted: validate and confine them (Domain 7).
One more server-side nuance the exam likes: server-tool errors do not raise. A failed web search returns HTTP 200 with an error object inside the result block. Code-execution output arrives as bash_code_execution_tool_result blocks carrying .content.stdout, .stderr, and .return_code; you read them, you do not try/except them.
6. Approval Patterns & Agentic Harness Dispatch
Bash is the most powerful tool you can hand Claude and the hardest to govern. It gives the model enormous breadth (anything a shell can do) but it hands your harness an opaque command string. You cannot cleanly gate, render, audit, or parallelize bash -c "curl -X POST https://mail/api ...". You do not know it is sending an email until you parse the command, and by then it may have run.
The pattern: promote an action from bash to a dedicated tool whenever you need to control it. A first-class send_email tool with a typed schema is something your harness can intercept before execution, show the user for approval, log with structured fields, and dispatch in parallel with other typed calls. The same action buried in a bash string is none of those things.
| You need to… | Bash string | Dedicated tool |
|---|---|---|
| Gate (require approval) | Opaque — must parse the command first | Intercept by tool name before running |
| Render to the user | "Run this shell command?" | "Send email to X with subject Y?" |
| Audit | Freeform text log | Structured fields: recipient, subject, time |
| Parallelize | Serial, hard to reason about | Typed calls dispatched together |
Keep bash for genuine breadth: exploratory, one-off, or long-tail commands. Promote anything you need to govern. That trade (breadth versus control) is the whole of agentic harness dispatch.
7. MCP Servers
A custom tool lives inside one application. When the same capability is needed across several applications and should be maintained independently, you build an MCP server. The Model Context Protocol is an open standard: a server exposes tools (actions), resources (readable data), and prompts (reusable prompt templates), and any MCP-aware client (Claude Desktop, Claude Code, your own app via the connector) can consume them.
Servers communicate over a transport: stdio for a local subprocess the client launches, or HTTP/streamable for a networked server. The client (the Claude application) connects; the server (your process) answers. Here is a minimal server sketch using the Python SDK:
# pip install "mcp[cli]"
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("inventory")
@mcp.tool()
def check_stock(sku: str) -> str:
"""Return live on-hand quantity for a SKU from the internal inventory API.
Call this when the user asks whether an item is in stock or how many remain."""
qty = inventory_api.get_quantity(sku) # your internal REST call
return f"{sku}: {qty} on hand"
@mcp.resource("inventory://catalog")
def catalog() -> str:
"""The full product catalog as readable context."""
return inventory_api.dump_catalog()
@mcp.prompt()
def restock_report(warehouse: str) -> str:
"""Reusable prompt template for a restock summary."""
return f"Summarize items below the reorder threshold in {warehouse}."
if __name__ == "__main__":
mcp.run(transport="stdio") # or a streamable HTTP transport for networked use
8. Calling an MCP Server From the Messages API
Once your server is deployed at a URL, an application connects to it through the MCP connector (beta header mcp-client-2025-11-20, via client.beta.messages.create). This is the part people get wrong: it takes two coordinated pieces, and omitting either is a validation error.
mcp_servers=[{"type": "url", "name": ..., "url": ...}]— where the server is.tools=[{"type": "mcp_toolset", "mcp_server_name": ...}]— which server's tools to expose. Themcp_server_namemust match the server'sname.
response = client.beta.messages.create(
model="claude-opus-4-8",
max_tokens=2048,
betas=["mcp-client-2025-11-20"],
# BOTH halves are required.
mcp_servers=[{
"type": "url",
"name": "inventory",
"url": "https://mcp.internal.example.com/inventory",
}],
tools=[{
"type": "mcp_toolset",
"mcp_server_name": "inventory", # must equal the server name above
}],
messages=[{"role": "user", "content": "Do we have SKU-4471 in stock?"}],
)
mcp_servers without the matching mcp_toolset in tools (or vice versa) is a validation error. The server entry says where; the toolset entry says "and expose its tools." You need both.
9. Choosing: Built-in, Custom Tool, Skill, or MCP
These four are not interchangeable, and the exam's Agentic Customization objective is exactly the skill of picking the right one. The clean mental model:
| Approach | What it is | Reach for it when… |
|---|---|---|
| Built-in tool | A capability Anthropic already built and hosts (web search, web fetch, code execution) | Anthropic has already solved exactly your need; don't rebuild it |
| Custom tool | Your own capability, defined and executed by this application | One app needs to reach a specific system it owns |
| Skill | Task-specific instructions loaded on demand, a folder with a SKILL.md. Not a capability. | Claude needs to know how to do a task (a procedure, a format, a house style), not gain a new power |
| MCP server | A capability shared across apps and maintained independently, over an open protocol | Multiple Claude apps need the same capability and one team should own/deploy it |
The distinction that trips people is Skill vs everything else: a Skill delivers instructions, not a capability. It cannot call your API; it can tell Claude how to structure a report or which steps to follow. If the need is "reach an external system," it is a tool or an MCP. If the need is "follow this procedure," it is a Skill. And the distinction between custom tool and MCP is reuse and ownership, not raw capability. Both can call the same API; MCP is the answer when several applications share it and it is maintained separately.
10. Tool Search for Large Libraries
Selection accuracy degrades as the tool count climbs, and every schema you load costs tokens and pollutes the prompt cache. When you genuinely have dozens of tools, use tool search: mark the bulk of your tools defer_loading: true and add a tool-search tool. Claude searches for the tool it needs, and the matching schema is appended on demand; it augments the prompt rather than swapping it, which preserves the cache prefix.
tools = [
# The search tool itself must NOT be deferred.
{"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"},
# At least one real tool stays non-deferred, or you get a 400.
{"name": "get_stock_price", "description": "...", "input_schema": {...}},
# The long tail is deferred: schemas load only when searched.
{"name": "create_ticket", "description": "...", "input_schema": {...},
"defer_loading": True},
{"name": "send_email", "description": "...", "input_schema": {...},
"defer_loading": True},
# ... dozens more, all defer_loading: True
]
defer_loading on the search tool itself, and at least one tool must be non-deferred. A tool set where everything is deferred (or where the search tool is deferred) is rejected.
11. Lab Exercise: From a Tool to an MCP Server
Self-driven lab Lab4_Self_Driven_Lab.ipynbObjective: author tools the model uses correctly, feel a misroute and fix it with words, handle a failure, then graduate a capability to MCP.
- Define a strict tool. Write
create_ticketwithstrict: True, anenumonseverity,additionalProperties: False, and adescriptionthat names the trigger condition. Confirm Claude calls it with clean, schema-valid arguments. - Force a misroute, then fix it. Add a second tool,
search_tickets, with a description that overlapssearch_docs("search records"). Send "find the login bug" and watch Claude pick the wrong one on some phrasings. Now rewrite both descriptions to state what each is for and not for. Re-run and confirm the routing stabilizes. - Handle a tool error. Make
get_stock_priceraise on an unknown ticker. Return atool_resultwithis_error: Trueand a helpful message. Confirm Claude recovers (retries with a corrected ticker or tells the user) rather than fabricating a price. - Prove the parallel rule. Ask Claude to compare two tickers so it emits two
tool_useblocks. Return bothtool_results in one user message. Then deliberately omit one and observe the API reject the request. - Stand up an MCP server. Wrap your inventory logic in a FastMCP server exposing one tool, one resource, and one prompt. Run it over
stdio. - Connect it. Deploy the server at a URL and call it from the Messages API with both
mcp_serversand the matchingmcp_toolset. Drop the toolset and confirm the validation error, so you never forget the second half.
Step 2 is the one that teaches judgment: you fix a code-level symptom with a prose-level change. Do it.
Check Yourself
Exam-style items. Commit to an answer before expanding.
A team needs Claude to call their internal inventory REST API. The capability must be reusable across several of the company's Claude applications and maintained independently by the platform team. Which approach best fits?
Correct: A. The requirement has two tells: reusable across several applications and maintained independently. That is precisely what MCP is for: one server owns the capability, deploys on its own cadence, and every MCP-aware app consumes the same tools.
B is not reusable and not maintainable; the logic is copied into each app and drifts, and a system prompt cannot execute an API call anyway. C has no live access (the data is stale the moment it is pasted) and burns context on every request. D fails because built-in tools reach Anthropic-hosted capabilities and the public web, not an arbitrary internal API behind your firewall.
Claude issues three tool_use blocks in a single turn. One of the three tools raises an exception when you run it. How should you respond? (Choose two.)
Correct: A and C. Every tool_use_id must get a matching tool_result, and they all go back in a single user message. For the one that failed, set is_error: True with a readable message so Claude can recover.
B omits a required tool_result; the API rejects a turn that leaves a tool_use_id unanswered. D hides the failure and teaches Claude the call succeeded, which produces confidently wrong downstream behavior. E violates the single-message rule; all results for one assistant turn belong in one user message.
You declare the text editor tool as {"type": "text_editor_20250728", "name": "str_replace_based_edit_tool", "input_schema": {...}} and the request fails. Why?
Correct: B. Bash, the text editor, and memory are Anthropic-defined schema-less tools: you declare type and name and nothing else. Passing an input_schema for them is a request error.
A is wrong: str_replace_based_edit_tool is exactly the required name for that tool version. C is wrong: the versioned type string is required, not forbidden. D is wrong: the text editor is a client-side tool you execute yourself; it is declared in tools like the others.
An application should be able to answer questions using live results from the public web with no execution loop on your side. Which tool declaration fits, and why is there no loop?
Correct: B. Web search is a server-side tool: you declare it, Anthropic executes it on its own infrastructure, and the results arrive inside the same response, so there is no tool-use loop for you to write. (Server-tool errors also return HTTP 200 with an error object rather than raising.)
A works but makes you build and run the search yourself; it reintroduces the execution loop the requirement wants to avoid. C is the opaque-bash anti-pattern: ungated, unauditable, and still your loop to run. D is over-engineering for a capability Anthropic already hosts, and it too would put execution on your side of the connection.
Key Takeaways
- A tool is
name+description+input_schema. The description is the prompt: be prescriptive about the trigger condition, not just what the tool does, or it under-triggers. - Schemas Claude fills correctly use enums over free text, a
descriptionon every property,requiredonly for genuinely required fields, andstrict: True(+additionalProperties: False) when input must validate exactly. - Overlapping descriptions cause misrouting. Disambiguate in words and keep the tool set focused; too many tools degrade selection.
- Parallel tool use is on by default. Return every
tool_resultin one user message, one pertool_use_id; failures come back withis_error: True, never dropped. - Server-side tools (
web_search_20260209,web_fetch_20260209,code_execution_20260120) run on Anthropic infra with no loop for you. Client-side Anthropic-defined tools (bash,str_replace_based_edit_tool,memory) are schema-less; you execute them. - Promote an action from bash to a dedicated tool when you must gate, render, audit, or parallelize it.
send_emailis governable;bash -c "curl ..."is opaque. - Build an MCP server when a capability is reused across apps and maintained independently. The connector needs both
mcp_serversand a matchingmcp_toolset. - Built-in = Anthropic solved it. Custom tool = this app's capability. Skill = instructions, not a capability. MCP = a capability shared across apps.
- For large libraries, use tool search:
defer_loading: Trueon the bulk, never on the search tool, and keep at least one tool non-deferred, or 400.