Lab 4 · Domain 8 · 10.6%

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?

Answer key Lab4_Complete.ipynb
Exam skills covered
  • 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.
The description is the prompt. Everything difficult about tools reduces to this. Claude does not read your function body, your type hints, or your comments; it reads the 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.

python
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.

Weak — describes the function, under-triggers
text
"description": "Returns the stock price."
Strong — names the trigger and the boundary
text
"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 descriptionStrong descriptionWhy 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 description on every property. Each field is another place to steer. Units, formats, examples, and constraints belong here.
  • Mark required only what is genuinely required. Over-requiring forces Claude to invent values for fields it has no basis to fill.
  • Use strict: true when the input must validate exactly. Set top-level "strict": True on the tool, and give the schema "additionalProperties": False plus "required". Now the API guarantees the arguments conform: no extra keys, no missing required fields.
python
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
    },
}
Parse tool inputs as JSON objects; never string-match the serialized input. 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.

Return every 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.
python
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.

Error round-trip — surfaced, not swallowed
python
{
    "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.

ToolType declaredWho executesSchema
Web searchweb_search_20260209Anthropic (server-side)Anthropic-defined
Web fetchweb_fetch_20260209Anthropic (server-side)Anthropic-defined
Code executioncode_execution_20260120Anthropic (server-side)Anthropic-defined
Bashbash_20250124You (client-side)Schema-less — no input_schema
Text editortext_editor_20250728 (name str_replace_based_edit_tool)You (client-side)Schema-less — no input_schema
Memorymemory_20250818You (client-side)Schema-less — no input_schema
Your custom tool— (plain name)You (client-side)You define input_schema
python
# 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.
Bash and the text editor take no 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 stringDedicated tool
Gate (require approval)Opaque — must parse the command firstIntercept by tool name before running
Render to the user"Run this shell command?""Send email to X with subject Y?"
AuditFreeform text logStructured fields: recipient, subject, time
ParallelizeSerial, hard to reason aboutTyped 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:

python
# 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
Reach for MCP when reusability and independent maintenance are the point. The signal is not "Claude needs to call an API"; a custom tool does that. The signal is "several Claude applications need to call this API, and one team should own and deploy it separately." That is the exam's Sample 3, verbatim: an internal inventory REST API, reusable across apps, independently maintained → build an MCP server.

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. The mcp_server_name must match the server's name.
python
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?"}],
)
Both halves or a 400. Declaring 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:

ApproachWhat it isReach for it when…
Built-in toolA capability Anthropic already built and hosts (web search, web fetch, code execution)Anthropic has already solved exactly your need; don't rebuild it
Custom toolYour own capability, defined and executed by this applicationOne app needs to reach a specific system it owns
SkillTask-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 serverA capability shared across apps and maintained independently, over an open protocolMultiple 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.

python
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
]
Two rules or a 400. Never set 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.ipynb

Objective: author tools the model uses correctly, feel a misroute and fix it with words, handle a failure, then graduate a capability to MCP.

  1. Define a strict tool. Write create_ticket with strict: True, an enum on severity, additionalProperties: False, and a description that names the trigger condition. Confirm Claude calls it with clean, schema-valid arguments.
  2. Force a misroute, then fix it. Add a second tool, search_tickets, with a description that overlaps search_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.
  3. Handle a tool error. Make get_stock_price raise on an unknown ticker. Return a tool_result with is_error: True and a helpful message. Confirm Claude recovers (retries with a corrected ticker or tells the user) rather than fabricating a price.
  4. Prove the parallel rule. Ask Claude to compare two tickers so it emits two tool_use blocks. Return both tool_results in one user message. Then deliberately omit one and observe the API reject the request.
  5. 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.
  6. Connect it. Deploy the server at a URL and call it from the Messages API with both mcp_servers and the matching mcp_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?
  • A. Build an MCP server that wraps the inventory API and expose it to each application through the MCP connector.
  • B. Hard-code the inventory lookup logic into each application's system prompt.
  • C. Paste the current inventory data into the context window on every request.
  • D. Rely on a built-in tool such as web search to reach the inventory API.

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.)
  • A. Return all three tool_result blocks in one user message.
  • B. Return only the two successful tool_result blocks and omit the failed one.
  • C. For the failed tool, return a tool_result with is_error: True and an error message.
  • D. Silently return an empty successful tool_result for the failed tool so Claude keeps going.
  • E. Split the three results across three separate user messages, one per tool.

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?
  • A. The name is wrong; it should be text_editor.
  • B. The text editor is schema-less; Anthropic-defined client-side tools take type and name only, never an input_schema.
  • C. The date suffix 20250728 is not allowed on tool types.
  • D. The text editor can only run as a server-side tool and cannot be declared in tools.

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?
  • A. A custom tool web_search with an input_schema that you execute against a search API.
  • B. The server-side tool {"type": "web_search_20260209", "name": "web_search"}, because Anthropic runs it and returns results in the same response.
  • C. A bash tool that shells out to curl.
  • D. An MCP server that proxies a search engine.

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 description on every property, required only for genuinely required fields, and strict: 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_result in one user message, one per tool_use_id; failures come back with is_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_email is 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_servers and a matching mcp_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: True on the bulk, never on the search tool, and keep at least one tool non-deferred, or 400.