Chapter 3 — Reaching Outside: Tool Use, Function Calling, and MCP
The previous chapter gave you full control over what the model says. You can steer its distribution, constrain its format, chain its calls, and test its reliability. But "what it says" is the ceiling. The model lives inside a context window. It cannot reach beyond the tokens you give it. Ask it for the current stock price and it will hallucinate one from its training data. Ask it to send an email and it will describe sending an email. Ask it to query your database and it will imagine what the results might look like. This chapter is about breaking that ceiling — giving the model the ability to take actions with real consequences in the real world, and understanding precisely what happens on the wire when it does.
Part 1: What Actually Happens During a Tool Call
The most important thing to understand about tool use is that the model never executes anything. This surprises most engineers when they first hear it stated plainly. The model generates text that describes an action; your code reads that text and executes the action; your code injects the result back into the context; the model generates the next response with the result in front of it. The model is always and only generating tokens. Your code is doing all the executing.
Let's walk through the wire-level sequence for a single tool call with a concrete example. Suppose you have a model with access to a get_weather tool.
Step 1: You describe the available tools in the system prompt or API call.
You tell the model what tools exist: their names, what they do, and what arguments they take. This is typically done through a structured API field (not as raw text) — but mechanically, it becomes part of the context the model sees.
Step 2: The model generates a structured action token sequence.
When the model decides it needs weather data, it doesn't call a function. It generates a token sequence that looks like a function call specification — typically a JSON object with the tool name and arguments. In OpenAI's API this is emitted as a special tool_calls field in the response; in Anthropic's API it's a tool_use content block. Either way, the model is just generating structured text that conforms to the format it was trained to use for tool invocations.
Step 3: Your application code intercepts the structured output.
Your code receives the model's response, detects the tool call (by checking the response format, not by parsing free-form text), extracts the tool name and arguments, and dispatches to the appropriate function in your codebase.
Step 4: Your code executes the function and collects the result.
get_weather(city="San Francisco", unit="celsius") runs in your application. It hits a weather API. It gets back {"temp": 18, "condition": "overcast", "humidity": 72}.
Step 5: You inject the result back into the context.
Your code appends the tool result to the conversation history — as a "tool" role message in most APIs — and sends the full context (original messages + tool call + tool result) back to the model in a new API call.
Step 6: The model generates a response using the result.
The model now has the weather data in its context and generates a natural-language response: "It's currently 18°C and overcast in San Francisco."
Sequence diagram:
Your App Model API Tool (weather API)
| | |
|--[messages + tool schemas]------>| |
| |--[generates tool_call JSON]--|
|<-[tool_calls: get_weather(SF)]----| |
| | |
|----------[get_weather(SF)]------------------------------->|
|<---------[{temp: 18, ...}]-------------------------------|
| |
|--[messages + tool_call + result]->|
| |--[generates final response]
|<-[It's 18°C and overcast....]-----|
The model is never in that right column. It generates a specification. Your code executes. This distinction matters for several reasons:
- You control what gets executed. You can validate the model's tool call before running it. You can refuse to run it. You can log it, rate-limit it, or require approval. The model has no way to bypass this layer — it can only ask.
- The tool result is just more context. There's nothing special about tool results; they're tokens injected into the context window like any other content. They're subject to the same attention patterns, the same lost-in-the-middle problem, the same context window budget constraints.
- The model can be wrong about arguments. The model predicts tool call arguments using the same token prediction mechanism it uses for everything else. It can hallucinate arguments that look plausible but are wrong. Schema validation at your dispatch layer is your first line of defense.
Part 2: Tool Schema Design — Quality Determines Reliability
A tool schema is the description of a tool that the model uses to decide whether and how to call it. Schema quality is directly and causally related to tool call reliability. A bad schema produces wrong tool calls. A good schema produces correct ones.
Here's why. The model decides to call a tool by predicting the tool call token sequence based on: (a) the user's request, (b) the conversation context, and (c) the tool schemas. The schema is the only source of information the model has about what the tool does and how to invoke it. If the schema is ambiguous, the model will fill the ambiguity with its prior — which may not match your intent. If the schema is missing a parameter the tool actually requires, the model won't include it. If the description is misleading, the model will use the tool in the wrong situations.
A high-quality schema has four components:
1. Name — Precise and Unambiguous
The name is part of the probability distribution over which tool to call. search is worse than search_customer_records. update is worse than update_order_status. The name should make the model's routing decision easy: for a given user request, there should be one obviously correct tool name, not multiple plausible candidates.
2. Description — Explains What, When, and Crucially When NOT
Most schemas include what the tool does. The high-leverage addition is when NOT to use it. This matters because the model will sometimes call a tool when a different tool (or no tool) is more appropriate. Negative examples in the description shift the distribution away from those misuses.
{
"name": "search_customer_records",
"description": "Search for customers by name, email, or customer ID.
Use this when the user asks about a specific customer's account,
order history, or contact information.
Do NOT use this for product searches, inventory lookups,
or general questions that don't require customer-specific data."
}
3. Parameters — Typed, Described, and Constrained
Every parameter needs a description of its semantic meaning, not just its technical type. "city": {"type": "string"} leaves the model to guess what format of city string is expected. "city": {"type": "string", "description": "City name as it appears in the ISO 3166-2 standard, e.g. 'San Francisco', not 'SF' or 'san francisco'"} eliminates the ambiguity.
Use enums wherever possible. If a field can only be "asc" or "desc", declare it as an enum — this constrains the model's output using the same constrained decoding mechanism described in Chapter 2 for JSON schemas.
Mark required vs optional parameters explicitly. The model will sometimes include optional parameters with reasonable defaults, which is fine, but it needs to know which parameters are mandatory to include.
{
"name": "create_refund",
"description": "Issue a refund for a completed order. Use only when the customer
is requesting a refund for a completed transaction. Do NOT use
for order cancellations (use cancel_order instead).",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "The order ID from the customer's order confirmation email, format: ORD-XXXXXXXX"
},
"amount_cents": {
"type": "integer",
"description": "Refund amount in cents (not dollars). Full refund = original order total. Partial refund must not exceed original total."
},
"reason": {
"type": "string",
"enum": ["defective_product", "not_as_described", "customer_changed_mind", "shipping_damage", "other"],
"description": "Reason for refund. Select the most specific applicable category."
}
},
"required": ["order_id", "amount_cents", "reason"]
}
}
4. Schema Count — Fewer is Better
Every tool schema in the context adds tokens and adds routing complexity. With 3 tools, the model has a simple routing decision. With 50 tools, the model has to predict which of 50 tools matches the request — and the probability distributions for similar tools start to overlap and cause confusion. For large tool libraries, dynamic tool loading (injecting only the subset of relevant tools for a given task) is preferable to always providing the full set.
Part 3: Parallel Tool Calls — What Happens Physically
Modern frontier model APIs support requesting multiple tool calls in a single model response. The model emits multiple tool call specifications simultaneously, before any of them have been executed. This matters for latency in a specific way: if your agent needs to call three independent services, you can execute all three concurrently rather than sequentially.
Sequential (3 calls):
Model → tool_call_1 → execute(1) → inject result → model → tool_call_2 → execute(2) → inject → model → tool_call_3 → execute(3) → inject → final response
Latency: sum of all execution times + model latency per call
Parallel (3 simultaneous calls):
Model → [tool_call_1, tool_call_2, tool_call_3] → execute(1,2,3) concurrently → inject all results → model → final response
Latency: max of all execution times + model latency for 2 calls (instead of 4)
The catch is that parallel tool calls only work for independent tools. If tool call 2's arguments depend on tool call 1's results, they cannot be executed in parallel — the model can't specify them simultaneously because it doesn't have the output of call 1 when predicting call 2.
In practice, the model is reasonably good at identifying when calls are independent and parallelizing them automatically when you request it — but it's not perfect. For performance-critical systems, explicit orchestration (you decide which calls can be parallelized) is more reliable than relying on the model to identify independence correctly.
Part 4: Partial Failures — What Happens When One of Three Tools Fails
This is where most early tool use implementations have gaps. You've dispatched three parallel tool calls. Call 1 succeeds. Call 2 times out. Call 3 returns a 500 error. What does your application do? What does the model do?
The default behavior if you don't handle this: your code throws an exception (or returns an error), and the error propagates back to the model via the tool result injection. The model sees an error in one result and attempts to continue with partial data. This sometimes works. Often it produces confabulated output because the model fills the missing data with plausible fiction.
You have several options, ordered from least to most robust:
Option 1: Inject the Error and Let the Model Decide
Inject the error as the tool result and let the model handle it:
{"role": "tool", "content": "Error: weather service returned 503 - Service Unavailable", "tool_call_id": "call_2"}
The model may decide to retry, ask the user for alternatives, or proceed without the data. This is fine for cases where partial data is acceptable. It's not fine for cases where the missing data is required for correctness — the model might silently proceed with a hallucinated substitute.
Option 2: Fail Fast and Retry the Full Call
If any tool call fails, discard all results, wait for the failed service to recover, and retry the full batch. Simple. Correct. Expensive in latency if failures are common.
Option 3: Implement a Retry Loop Per Tool Call
Track the status of each tool call independently. On failure, retry with exponential backoff (the same pattern you use for any distributed system call). Inject the result only when you have it. Only proceed to the next model call when all required results are available.
async def execute_tools_with_retry(tool_calls: list[ToolCall], max_retries=3) -> list[ToolResult]:
results = []
for call in tool_calls:
for attempt in range(max_retries):
try:
result = await execute_tool(call)
results.append(ToolResult(tool_call_id=call.id, content=result))
break
except RetryableError as e:
if attempt == max_retries - 1:
results.append(ToolResult(tool_call_id=call.id, error=str(e)))
await asyncio.sleep(2 ** attempt)
return results
Option 4: Degrade Gracefully with Known Fallbacks
For non-critical tool calls, define a fallback behavior: if weather data is unavailable, continue with "weather data unavailable" as the result. The model was trained on data that includes "information not available" patterns and handles them gracefully. This is the right choice for enrichment data (nice to have) vs. core data (required for correctness).
The key design principle: your application code is responsible for failure handling, not the model. The model will do its best with whatever you inject into its context, but "its best" in the presence of partial data can be confidently wrong. Fail loudly in your application layer rather than silently in the model output.
Part 5: The Model Context Protocol — What Problem It Solves
You've built a tool. It works great in your application. Now a colleague wants to use the same tool in their application. They reimplement the tool schema. Six months later, the tool's API changes, and you have to update three separate implementations across three codebases.
This is the coordination problem that the Model Context Protocol (MCP) was designed to solve.
The Problem Before MCP
In the tool use model described above, the tool is tightly coupled to the application that uses it. The tool schema lives in the application's code. The tool execution logic lives in the application's code. The application calls the model API directly with the tool schema. This means:
- Tools can't be shared across applications without copy-paste
- Tool implementations drift when maintained independently
- There's no standard interface for a model to discover what tools are available in an environment
- Building a new AI feature means reimplementing the tool plumbing from scratch each time
The MCP Architecture
MCP is a client-server protocol (built on JSON-RPC 2.0) that separates the tool server (which knows about the tools) from the client (the model application that wants to use tools).
MCP Architecture:
┌─────────────────────────────────────────────┐
│ Your Application (MCP Host) │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Model Client │ │ MCP Client │ │
│ │ (calls LLM API) │ │ (connects to MCPs│ │
│ └────────┬────────┘ └────────┬─────────┘ │
│ │ │ │
└───────────┼────────────────────┼─────────────┘
│ │
▼ ▼
Model API MCP Servers
(OpenAI, ┌──────────────┐
Anthropic) │ Database MCP │
└──────────────┘
┌──────────────┐
│ Filesystem │
│ MCP │
└──────────────┘
┌──────────────┐
│ GitHub MCP │
└──────────────┘
An MCP server exposes three types of resources:
- Tools — functions the model can call (get_weather, query_database, create_file)
- Resources — data the model can read (file contents, database schemas, documentation)
- Prompts — reusable prompt templates
The MCP client (embedded in your application) connects to MCP servers, queries them for available tools, converts those tools into the format the model API expects, and dispatches tool calls back to the appropriate server.
Transport
MCP supports two transport modes:
stdio transport — the client spawns the server as a subprocess and communicates over stdin/stdout. Simple, low overhead, used for local tools.
HTTP/SSE transport — the client connects to a running HTTP server. Used for remote tools, services shared across applications, or tools that need to run with a persistent process.
Building a Minimal MCP Server
# A minimal MCP server using the official Python SDK
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
app = Server("weather-tools")
@app.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="get_current_weather",
description="Get current weather conditions for a city. Use when the user asks about weather, temperature, or conditions in a specific location.",
inputSchema={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. 'San Francisco' or 'Tokyo'"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit"
}
},
"required": ["city"]
}
)
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
if name == "get_current_weather":
# Your actual weather API call here
city = arguments["city"]
unit = arguments.get("unit", "celsius")
weather_data = fetch_weather(city, unit)
return [types.TextContent(type="text", text=str(weather_data))]
raise ValueError(f"Unknown tool: {name}")
async def main():
async with stdio_server() as (read_stream, write_stream):
await app.run(read_stream, write_stream, app.create_initialization_options())
# MCP client connecting to the server above
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def run():
server_params = StdioServerParameters(
command="python",
args=["weather_server.py"]
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# Discover tools
tools = await session.list_tools()
print(f"Available tools: {[t.name for t in tools.tools]}")
# Call a tool
result = await session.call_tool("get_current_weather", {"city": "Tokyo"})
print(result.content[0].text)
The power here is that the weather server is now a standard, shareable MCP server. Any application that speaks MCP can use it. Claude Desktop, your custom application, your colleague's application — all connect to the same server via the same protocol, with no code duplication.
What MCP Actually Changes
Before MCP, tool use was app-specific glue code. After MCP, tools are independent services with a standard interface. The practical benefits:
- The Claude Desktop app can connect to dozens of MCP servers (GitHub, filesystem, database, Slack) and give Claude access to all of them with zero custom code
- Teams can publish internal MCP servers (your company's CRM, your internal analytics DB) that any AI application can consume
- The tool server can evolve independently of the applications that use it
MCP doesn't change the underlying token prediction mechanics at all. The model still emits a structured tool call specification. The MCP client translates that specification into an MCP protocol call and sends it to the appropriate server. The model has no idea MCP is involved.
Part 6: Agent-to-Agent (A2A) — Awareness Level
While MCP standardizes tool access from model applications, A2A (Agent-to-Agent, also called agent protocol or agentic networking) addresses a different problem: how does one agent call another agent?
In a multi-agent system (which you'll build in Chapters 5 and 6), a coordinator agent might need to delegate a subtask to a specialized agent. Without a standard protocol, each inter-agent connection is a custom integration — agent A's output format must match agent B's input format, and changes to either break the connection.
A2A (currently being standardized by Google and ecosystem partners as of 2025) defines a protocol for agents to discover each other's capabilities, delegate tasks, and return structured results — much like MCP does for tools, but for agents that are themselves running model inference.
The key insight: from the calling agent's perspective, another agent looks like a tool. It has a schema (what tasks it accepts, what inputs it needs), it returns a result, and the calling agent injects that result into its context. The fact that the "tool" happens to be another LLM inference call is an implementation detail. A2A makes that detail standard.
You'll see this in practice in Chapter 6 when you build multi-agent workflows. For now, what matters is the architectural principle: everything that can act is exposed as a tool-like interface. Tools, APIs, databases, other agents — they all look the same to the orchestrating model.
Part 7: Least-Privilege Tool Design
Here's a principle you know from systems security but that's worth restating in the AI context because the failure mode is specific.
A traditional privilege escalation attack requires an attacker to compromise a system component and leverage its permissions. With an AI agent that has tools, the attack surface is different: the attack can be in the input. A user message, a retrieved document, or an injected piece of text (via a compromised tool result) can instruct the model to misuse a tool it legitimately has access to. You'll study this in detail in Chapter 8 (prompt injection). Here you need to understand the architectural defense.
The defense is: give every agent the minimum set of tools required to complete its task, with the minimum permissions on each tool.
This means:
Don't give read-write when read-only is sufficient. If your agent retrieves documents from a database, give it a read-only database connection. It should not be able to modify or delete records unless that is explicitly required. A model that is induced (via prompt injection) to call DELETE FROM orders is catastrophic. A model that only has access to a SELECT-capable tool cannot cause that failure no matter how it's manipulated.
Don't give broad tools when narrow ones are sufficient. Instead of execute_sql(query: string) — which accepts arbitrary SQL — use search_orders(customer_id: string, status: string) — which translates to a specific, parameterized query in your code. The model can't inject a malicious query because the query is built by your code, not by the model.
Scope tools to the task. A customer support agent doesn't need access to the payments database. An order-tracking agent doesn't need to write to the CRM. Design your tool set for the specific role the agent is playing, not for what might someday be useful.
Consider approval gates for irreversible actions. For any tool call that has irreversible real-world consequences — send an email, charge a credit card, delete a record, publish content — add a human-in-the-loop approval step or at minimum a confirmation tool call that the model must make before the action executes. This is covered in depth in Chapter 5 when you build the full agent loop.
Least-privilege tool design matrix:
Agent Role | Available Tools | NOT Available
--------------------|----------------------------------------|------------------------------
Customer Support | search_customer(id) | delete_customer()
| get_order_status(order_id) | update_order_status()
| create_support_ticket(...) | access_payments_db()
| send_reply_to_customer(ticket_id) | modify_account_settings()
Order Fulfillment | get_pending_orders() | access_customer_pii()
| update_order_status(order_id, status) | send_marketing_email()
| query_inventory(sku) | delete_order()
| create_shipping_label(order_id) | access_financial_data()
The least-privilege principle costs you some flexibility — a more permissive tool set would let one agent do more — but it buys you a dramatically smaller blast radius when something goes wrong. And in AI systems, something will go wrong.
Vocabulary Map
| Term | What it actually is (physical, not dictionary) |
|---|---|
| Tool call | A structured token sequence emitted by the model that specifies a tool name and arguments. The model does not execute anything — it generates a request. Your code dispatches and executes. |
| Function calling | The older name for tool use. Same concept — model emits a structured specification, application code executes, result injected back. |
| Tool schema | The description of a tool provided to the model: its name, description, and parameter types. The schema is part of the context that the model uses to predict whether and how to call the tool. |
| Tool result injection | The act of appending a tool's execution result to the conversation history and sending the full context back to the model for the next completion. |
| Parallel tool calls | A model feature where the model emits multiple tool call specifications in a single response, enabling concurrent execution in your application. Only valid for independent calls. |
| Partial failure | The state where a subset of tool calls in a batch fail. Your application must handle this — the model will attempt to proceed with whatever is injected into its context. |
| Model Context Protocol (MCP) | A JSON-RPC 2.0 based protocol that separates tool servers from model applications. Enables tools to be shared, versioned, and discovered independently of the applications that use them. |
| MCP Server | A process (local or remote) that exposes tools, resources, and prompts via the MCP protocol. Can be shared across multiple applications. |
| MCP Client | The component in your application that connects to MCP servers, discovers available tools, and dispatches tool calls on behalf of the model. |
| MCP Transport | The communication channel between MCP client and server. stdio for local/subprocess servers; HTTP+SSE for remote servers. |
| Agent-to-Agent (A2A) | A protocol for one agent to call another agent as if it were a tool. From the calling agent's perspective, a sub-agent has a schema and returns a result — same pattern as a tool. |
| Constrained decoding (tool context) | When the model emits tool call arguments, constrained decoding (if implemented by the API) masks invalid tokens to enforce schema compliance. |
| Least-privilege tool design | Architectural pattern: give an agent only the tools it needs for its specific role, with minimum permissions on each. Limits blast radius of model errors and adversarial manipulation. |
Practice Problems
Problem 1 — Failure Analysis
Your customer support agent has access to a tool called get_order_info(order_id: string) and a tool called process_refund(order_id: string, amount: float). In production, you observe that the model sometimes calls process_refund without first calling get_order_info to verify the order exists. This results in attempted refunds on non-existent orders. Walk through two distinct causes for this failure (one schema-level and one context-level) and propose a fix for each. Then explain why adding "always call get_order_info before process_refund" to the system prompt is not a sufficient fix on its own.
Problem 2 — Design from Scratch
You're building an AI assistant for a software team. The assistant needs to: search Jira for issues, create new Jira issues, query a read-only analytics database, and post to a specific Slack channel. Design the complete tool set: write the schema for each tool (name, description, parameters with types and descriptions). Then apply least-privilege analysis — what permissions does each tool require, and which tools should have the most restrictive scopes? Finally, identify which tools require human-in-the-loop approval before execution and justify each decision.
Problem 3 — Synthesis
Explain why the following three design decisions are all expressions of the same architectural principle: (a) MCP separates tool servers from model applications, (b) the model emits a tool call specification rather than executing functions directly, (c) you should use narrow parameterized tools (search_orders(customer_id, status)) rather than broad string-injection tools (execute_sql(query)). What is the underlying principle, and why is it particularly important for AI systems compared to traditional software?
Problem 4 — Go Deeper
You're building a system where an agent has access to a send_email tool. A user asks the agent to read their email inbox (via a different tool) and respond to emails that mention an urgent meeting request. Analyze the full attack surface introduced by combining an inbox-reading tool with an email-sending tool. What is the most dangerous class of attack this combination enables? (Hint: think about what the agent reads in the inbox.) How would you architect this system to enable the legitimate use case while making the attack difficult or impossible? Be specific about the architectural changes.
Problem 5 — Connect to Previous Chapter
Chapter 2 established that structured outputs work via constrained decoding — the model's token probabilities are masked at sampling time to enforce a schema. Chapter 3 says tool calls are structured outputs. Connect these: when a model emits a tool call with a required order_id field of type string, what exactly happens at the token level to enforce that the field is present and string-typed? Then: if the tool schema requires the reason field to be one of three enum values, what token-level mechanism prevents the model from emitting a fourth value? And finally: why can't constrained decoding prevent the model from emitting an order_id value that looks syntactically valid but refers to an order that doesn't exist?
What's Coming Next
You can now give the model access to the real world — databases, APIs, filesystems, external services. It can query, write, send, and create through the tool layer you build on top of it. But there's a knowledge problem that tools don't solve: the model still doesn't know anything about your private data that wasn't in its training set. Ask it about your customers, your product documentation, your internal wiki, your proprietary codebase — and it will hallucinate. Tools let it act; they don't give it knowledge. Chapter 4 solves the knowledge problem, by giving the model access at runtime to information it was never trained on — and the machinery for doing that is surprisingly elegant.