Skip to main content

Tool Integration and MCP

Adapted from: Chapter 7, "Tool integration and MCP" — rewritten and condensed for teaching, with protocol details and code corrected against the MCP specification.

A customer texts: "Do you have waterproof hiking boots under $150?" In seconds, the assistant must search the catalog, check availability, and answer. Historically, every new API meant more custom glue code, and every new app meant writing it again. The result was a tangle of fragile one-off integrations that break independently.

The Model Context Protocol (MCP) standardizes how AI applications connect to tools and data. You write a tool once, as an MCP server, and any MCP-capable application can discover and call it. Lesson 6 introduced agents. This lesson builds the tools they call, the right way.

What you'll be able to do after this lesson:

  • Explain the N×M integration problem and how MCP turns it into N+M.
  • Describe MCP's architecture: hosts, clients, servers, the JSON-RPC messages, and the transports.
  • Build an MCP product-search server with FastMCP, test it, and connect it to OpenAI and Claude.
  • Design tools models can use well, with clear names, descriptions, and schemas.
  • Return structured errors, distinguish empty results from failures, and add timeouts and retries.
  • Place tools inside a harness and sandbox architecture, and design for security.

The N×M Problem​

Say your team builds five AI apps: a support chatbot, an email assistant, a calendar agent, a document summarizer, and a shopping assistant. Between them they need ten systems: Salesforce, Slack, Google Calendar, Shopify, Stripe, internal databases, and more. If every app integrates with every system directly, that's 5 × 10 = 50 custom integrations. Change one API, and you refactor in five places.

The growth is multiplicative, not exponential as it's sometimes described, but it's bad enough. A Fivetran report found that 74% of enterprises manage, or plan to manage, more than 500 data sources.

flowchart LR
subgraph Before["Without MCP: N × M = 50"]
direction LR
A1[Support bot] & A2[Email assistant] & A3[Calendar agent] --- X(("every app ×<br/>every API")) --- S1[Salesforce] & S2[Slack] & S3[Stripe]
end
subgraph After["With MCP: N + M = 15"]
direction LR
B1["Support bot<br/>(MCP client)"] & B2["Email assistant<br/>(MCP client)"] & B3["Calendar agent<br/>(MCP client)"] --- P(("MCP")) --- T1["Salesforce<br/>(MCP server)"] & T2["Slack<br/>(MCP server)"] & T3["Stripe<br/>(MCP server)"]
end

With MCP, each app implements the client side once, and each system is wrapped once as an MCP server. Five apps plus ten servers means 15 components, and every server works with every client. It's often compared to USB for AI: plug in a new tool and every compatible app can use it.

What MCP Is​

MCP is an open protocol, released by Anthropic in late 2024 and since moved to the Linux Foundation, with support from OpenAI, Google, Microsoft, and AWS. Thousands of servers are listed in public registries, and most agent frameworks (LangGraph, the OpenAI Agents SDK, the Claude Agent SDK, and others) speak it natively.

flowchart LR
subgraph Host["MCP host (your AI app: chat UI, IDE, agent)"]
LLM[LLM] --- C1[MCP client]
LLM --- C2[MCP client]
end
C1 <-- "JSON-RPC over stdio" --> S1["MCP server<br/>local files"]
C2 <-- "JSON-RPC over Streamable HTTP" --> S2["MCP server<br/>product catalog"] --> API[(Catalog DB)]
  • Host: the AI application, such as a chat app, IDE, or agent runtime.
  • Client: a connection inside the host, one per server.
  • Server: exposes capabilities. MCP has three kinds:
    • Tools: actions the model can invoke, like search_products.
    • Resources: data the app can read, like files or records.
    • Prompts: reusable prompt templates.
  • Messages are JSON-RPC 2.0. Clients call methods such as tools/list, which returns every tool's name, description, and input schema, and tools/call, which runs a tool with arguments.
  • Transports:
    • stdio, for local servers launched as subprocesses.
    • Streamable HTTP, for remote servers. It replaced the older HTTP+SSE transport.

A tool is described by a name, a description, and a JSON Schema for its inputs:

{
"name": "send_email",
"description": "Send an email via the company email service",
"inputSchema": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Recipient address"},
"subject": {"type": "string"},
"body": {"type": "string"}
},
"required": ["to", "subject", "body"]
}
}

What MCP standardizes, and what it doesn't. MCP standardizes the interface: discovery, schemas, call and result formats, and error shapes. You still implement each tool's logic: the actual Stripe call, retries, and business rules. You also still own authentication, authorization, and security for your servers. A five-line @mcp.tool wrapper replaces the integration plumbing, not the work of talking to Stripe correctly.

Without MCPWith MCP
Custom code per app per APIOne server per system, reused everywhere
Brittle, tangled dependenciesModular, swappable connectors
Hard to testTestable through a standard interface
Cost grows as N × MCost grows as N + M

Check: why MCP​

Quiz

Integration math and scope

1 / 2

You have 8 AI apps and 12 external systems. How many integrations do you need without MCP, and how many MCP components with it?

Pick an answer to see if you are right.


Build an MCP Server: A CSV Product Catalog​

Start with a CSV file, the way many real catalogs begin: a spreadsheet exported from a legacy system. It needs no credentials or network, so anyone can run it. Save this as products.csv:

id,title,price,available
1,Men's Trail Running Shoes,89.99,True
2,Waterproof Rain Jacket,79.99,True
3,Women's Hiking Pants,64.99,False
4,Insulated Down Jacket,129.00,True
5,Breathable Base Layer Top,39.99,True
6,Men's Wool Beanie,24.99,True
7,Women's Down Vest,99.00,True

The columns are an ID, the title customers see, the price in USD, and whether the item is in stock.

The server​

FastMCP turns decorated Python functions into MCP tools. It generates the JSON schema from the type hints and the description from the docstring.

pip install fastmcp pandas
# product_server.py
import re
import pandas as pd
from fastmcp import FastMCP

mcp = FastMCP("CSV Product Search")
PRODUCTS = pd.read_csv("products.csv") # load once at startup

def _words(text: str) -> set[str]:
"""Lowercase word tokens with a naive plural strip ('jackets' -> 'jacket')."""
return {w[:-1] if len(w) > 3 and w.endswith("s") else w
for w in re.findall(r"[a-z0-9]+", text.lower())}

@mcp.tool
def search_products(query: str, max_price: float | None = None, limit: int = 5) -> list[dict]:
"""Search the product catalog by keywords. Returns in-stock products whose titles
contain every keyword, optionally at or under max_price (USD), up to `limit` results."""
wanted = _words(query)
df = PRODUCTS[PRODUCTS["title"].map(lambda t: wanted <= _words(t))]
if max_price is not None:
df = df[df["price"] <= max_price]
df = df[df["available"]]
return df.head(limit).to_dict(orient="records")

if __name__ == "__main__":
mcp.run(transport="http", host="127.0.0.1", port=8000) # Streamable HTTP at /mcp
Substring search silently misses products

The source filters with title.str.contains(query), which treats the whole query as one substring. "jacket" works, but "waterproof jacket" matches nothing, because the title is "Waterproof Rain Jacket". "jackets" (plural) also fails. Matching on words instead (every query word must appear in the title) fixes both cases. The practice exercise below makes you build that version.

Test it without an LLM​

Call the tools directly in-process with FastMCP's client:

import asyncio
from fastmcp import Client
from product_server import mcp

async def main():
async with Client(mcp) as client: # in-memory: no network needed
print([t.name for t in await client.list_tools()]) # -> ['search_products']
result = await client.call_tool("search_products", {"query": "jacket", "max_price": 100})
print(result.data) # -> [{'id': 2, 'title': 'Waterproof Rain Jacket', 'price': 79.99, 'available': True}]

asyncio.run(main())

To poke at a running server interactively, the MCP Inspector (npx @modelcontextprotocol/inspector) connects to it, lists its tools, and lets you call them by hand. (The source suggests an mcp-dev command; the Inspector and the FastMCP client are the standard tools.)

Try vest, pants, and layer. In-stock matches come back, and Women's Hiking Pants never does, because it's out of stock. When an assistant hears "any warm layers under $100?", the model turns that into a call like {"query": "layer", "max_price": 100}. That turn from vague language into a precise query is the foundation of every shopping conversation.

Practice: word-based product search with structured responses​

Implement search(rows, query, max_price=None, limit=5) over a list of product dicts. It returns a structured response:

  • Validation errors:
    • An empty or blank query returns {'success': False, 'error': 'empty_query', …}.
    • max_price <= 0 returns {'success': False, 'error': 'invalid_price', …}.
  • Clamp limit to the range 1–50.
  • Matching: a product matches if every query word, using the provided words(), appears among its title words. It must also be available, and priced at or under max_price when one is given.
  • No matches is not an error: return {'success': True, 'results': []} plus a suggestion.
  • Matches: {'success': True, 'results': [...first limit matches], 'total_matches': n}.

The wrapper prints a one-line summary per query. Input: one query per line, as query|max_price|limit, where the last two may be blank.


Connecting Models to Your MCP Server​

How models learn which tools exist​

With plain function calling (Lesson 1), you paste the tool schemas into every request. With MCP, the server describes itself. The client sends a tools/list request, receives every tool's name, description, and schema, and puts them in front of the model. When the model picks a tool, the client sends tools/call and returns the result.

sequenceDiagram
participant U as User
participant H as Host (LLM + MCP client)
participant S as MCP server
H->>S: tools/list
S-->>H: search_products(query, max_price, limit)
U->>H: "I need a warm jacket under $100"
Note over H: LLM decides: search_products(query="jacket", max_price=100)
H->>S: tools/call search_products {...}
S-->>H: [{"title": "Waterproof Rain Jacket", "price": 79.99, ...}]
H-->>U: "The Waterproof Rain Jacket is $79.99 and in stock."

OpenAI: hosted MCP tools in the Responses API​

OpenAI can call your MCP server from its own infrastructure. That means the server must be reachable from the public internet. A localhost URL is unreachable from OpenAI's servers, so the source's http://localhost:8000 example can't work. For local development, expose the server through a tunnel, or deploy it, with authentication.

curl https://api.openai.com/v1/responses \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4.1-mini",
"input": "I need a lightweight vest under $100.",
"tools": [{
"type": "mcp",
"server_label": "catalog",
"server_url": "https://catalog.example.com/mcp",
"require_approval": "never"
}]
}'

Step by step:

  1. The input is plain English, with no hint about which tool to use.
  2. The runtime asks your server for its tools (tools/list).
  3. The model decides that a search would help. Note that "lightweight vest" needs two matching words, and the catalog has no "lightweight" title, so a careful model may retry with just "vest" after an empty result.
  4. The runtime sends tools/call to your server.
  5. Your server queries the CSV and returns JSON.
  6. The model answers from that result: "The Women's Down Vest is $99.00 and in stock." (The source's sample answer says $99.99, which doesn't match its own catalog.)
require_approval: "never" is for demos only

With approval off, the model can run any tool on the server without a human in the loop, including tools that change data, spend money, or send messages. In production, require approval ("always", or a per-tool policy) for anything that changes state.

Claude: the MCP connector​

Claude's Messages API can also connect to a remote MCP server directly. This is a beta feature, so check the current docs. The server must be publicly reachable here too.

import anthropic

client = anthropic.Anthropic()
response = client.beta.messages.create(
model="claude-opus-5-5",
max_tokens=16000,
betas=["mcp-client-2025-11-20"],
mcp_servers=[{"type": "url", "url": "https://catalog.example.com/mcp", "name": "catalog"}],
tools=[{"type": "mcp_toolset", "mcp_server_name": "catalog"}],
messages=[{"role": "user", "content": "I need a lightweight vest under $100."}],
)

You can also keep everything local: run an MCP client in your own code, using the FastMCP client or an agent framework, translate the listed tools into the model's tool format, and execute tools/call yourself. That's the tool-calling loop from Lesson 6, with MCP as the tool source.

What you no longer write​

  1. Tool schemas pasted into every request: the server provides them.
  2. Glue code mapping each model function call to each backend.
  3. Per-app serialization and argument validation: the protocol and schemas cover them.

What remains is policy: deciding which calls need human approval. MCP doesn't decide that for you.

Practice: speak MCP's JSON-RPC​

Implement a minimal MCP request handler, handle(request), that follows the protocol's conventions:

  • The request must be a dict with "jsonrpc": "2.0" and a method. Otherwise return the error code -32600 ("Invalid Request").
  • tools/list: return result.tools, a list of {name, description, inputSchema}.
  • tools/call, with params: {name, arguments}:
    • An unknown tool returns error -32602, with message Unknown tool: <name>.
    • Arguments that fail validation return error -32602, with message Invalid arguments: <problem>. Use the provided validate(schema, args).
    • If the tool runs but raises, that's not a protocol error. Return a normal result with isError: true and the exception message as text, so the model can read it and recover.
    • On success, return result: {content: [{type: "text", text: json.dumps(output, sort_keys=True)}], isError: false}.
  • Any other method returns error -32601 ("Method not found: …").

The wrapper prints a compact summary of each response. Input: one JSON-RPC request per line.

Notice the split. Protocol problems (bad JSON-RPC, unknown method, invalid arguments) are JSON-RPC errors. A tool that ran and failed returns a normal result with isError: true, so the model sees "Product ID must be a positive number" and can fix its call. Also note "admin": true: additionalProperties: false rejects arguments you never meant to accept.


Tool Design Is Interface Design​

Your server's tools are an API for a reasoning model. Names, descriptions, and parameter schemas determine whether the model uses them correctly.

Poor designGood design
do_thing(x: string), with no descriptionsearch_products(query, max_price): "Search the catalog by keyword…"
The model can't tell what it does or when to use itSelf-documenting purpose, parameters, and units

Better schemas produce better model behavior.

Adding a second tool, and chaining​

A customer asks, "Is the down jacket still in stock?" Add a tool that verifies a specific item:

@mcp.tool
def check_inventory(product_id: int) -> dict:
"""Check stock status, price, and availability for ONE product by its numeric ID
(as returned by search_products). Use to verify a specific item before recommending it."""
if product_id < 1:
return {"success": False, "error": "invalid_id", "message": "Product ID must be a positive number."}
row = PRODUCTS[PRODUCTS["id"] == product_id]
if row.empty:
return {"success": True, "found": False, "message": f"No product found with ID {product_id}."}
p = row.iloc[0]
return {"success": True, "found": True,
"product": {"id": int(p["id"]), "title": p["title"], "price": float(p["price"]),
"available": bool(p["available"]),
"status": "In Stock" if p["available"] else "Out of Stock"}}

Asked "Do you have waterproof jackets? Is the cheapest one in stock?", the model chains the tools:

  1. It calls search_products(query="waterproof jacket") and gets back [{id: 2, title: "Waterproof Rain Jacket", price: 79.99}].
  2. It calls check_inventory(product_id=2) and gets back {found: true, status: "In Stock"}.
  3. It answers: "Yes! The Waterproof Rain Jacket is $79.99 and in stock."

You wrote no orchestration code. The descriptions act as routing instructions: one tool is for discovery and the other for verification. Name them vaguely, as get_products and get_product_info, and the model will struggle to choose between them.

Check: discovery and design​

Quiz

Using MCP tools well

1 / 3

You pass "server_url": "http://localhost:8000/mcp" to OpenAI's hosted MCP tool and it can't connect. Why?

Pick an answer to see if you are right.


Handling Failures Gracefully​

APIs time out, databases go down, and queries match nothing. Without structure, the model sees a stack trace and either says something unhelpful ("I encountered an error") or makes something up. With structure, it can explain the problem, suggest alternatives, or retry.

A consistent response shape​

Return the same envelope from every tool:

{"success": true | false, "error": null | "<code>", "message": "<human-readable>", "results": [...], ...}
  • success tells the model whether the operation worked.
  • message is text the model can relay to the user.
  • error is a code that tells it what to do next: retry, ask a clarifying question, or try something else.

Compare a raw TypeError: 'NoneType' object is not iterable (the model can only apologize) with {"success": false, "error": "invalid_price", "message": "Price must be greater than zero."}. The second lets the model ask, "Did you mean a different price limit?"

Empty results are not errors​

  • Error: something broke, such as an invalid input, a database outage, or a timeout. Return success: false.
  • Empty: the search worked and nothing matched. Return success: true, results: [], and a suggestion.

With an empty result, the model can say "No jackets under $50, but here's one at $79.99" and search again. With an error, it knows to apologize and suggest trying later. The search exercise above already makes this split.

Timeouts and retries​

A tool that calls a slow external API must not hang the agent. Wrap calls in a timeout, and retry transient failures with exponential backoff before returning a structured timeout error:

import asyncio
from functools import wraps

def with_timeout(seconds: float):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
try:
return await asyncio.wait_for(func(*args, **kwargs), timeout=seconds)
except asyncio.TimeoutError:
return {"success": False, "error": "timeout",
"message": f"Request timed out after {seconds} seconds.", "results": []}
return wrapper
return decorator

Apply it under the tool decorator (@mcp.tool on top, @with_timeout(5.0) beneath), so FastMCP registers the wrapped function. Retry only errors that can succeed on a second try, such as timeouts, connection resets, and 429 or 503 responses. Never retry invalid input, and never blindly retry actions that aren't idempotent, like payments.

Practice: retry with exponential backoff​

Implement call_with_retries(fn, attempts, base_delay, sleep):

  • Call fn(). If it returns, return that value.
  • If it raises TimeoutError and attempts remain, call sleep(base_delay * 2**i) (where i is the zero-based attempt number) and try again.
  • If it raises TimeoutError on the last attempt, re-raise it.
  • Any other exception (a permanent failure) is re-raised immediately, without retrying.

sleep is injected, so the tests run instantly and can record the delays. Input: line 1 is attempts base_delay, and line 2 lists the scripted outcomes (timeout, ok, or bad_input).

In production, add jitter (randomize each delay slightly) so that many clients don't retry in lockstep.

Check: failures​

Quiz

Errors the model can act on

1 / 2

A search succeeds but finds nothing. What should the tool return?

Pick an answer to see if you are right.


Beyond Tools: Agent Harnesses​

A tool on its own does nothing. Something has to decide when to call it, what to do with the result, and how to recover. That something is the agent harness. "The model enables reasoning. The harness enables capability."

flowchart LR
subgraph Harness["Harness: control plane"]
L[Agent loop + state] --- R[Tool routing] --- AP[Human approvals]
TR[Tracing] --- MEM[Memory + context] --- CRED[(Credentials, billing, audit logs)]
LLM[LLM calls go through the harness]
end
subgraph Sandbox["Sandbox: compute plane"]
CODE[Code execution] --- FS[File I/O] --- SH[Shell] --- MCPT[MCP tool calls]
end
Harness -- tasks --> Sandbox
Sandbox -- results --> Harness
  • The harness, or control plane, owns the agent loop, tool routing, approvals, tracing, memory, and state. It holds the credentials.
  • The sandbox, or compute plane, is where model-generated code runs, files are read and written, and shell commands execute. It has no direct model access and no secrets. If it crashes, the harness restores state from a checkpoint.

The source describes this pattern spreading across the industry: OpenAI's agent tooling and Codex CLI separate the harness from sandboxed compute. It reports open-source harnesses such as OpenClaw passing 300,000 GitHub stars, model-agnostic and local-first with dozens of messaging integrations. It also describes NVIDIA's enterprise NemoClaw / OpenShell runtime enforcing kernel-level permission boundaries. Those figures are as reported. The lasting point holds regardless: the model is an interchangeable module, and the harness defines the product. Your MCP tools are the hands, and the harness decides how they're used.

Designing agent-legible environments​

The source cites an OpenAI team that built a product with agents writing the code, roughly a million lines. Their main finding: when something failed, the fix was rarely "try harder" and almost always "what capability is missing, and how do we make it legible to the agent?" The practices that follow from that:

  • Give agents a map, not an encyclopedia. An AGENTS.md works best as a short table of contents (about 100 lines) that points to focused documents. Huge instruction files crowd out the task and go stale. It's the same principle as small, specific tool descriptions.
  • Make the repository the system of record. Whatever the agent can't reach in context doesn't exist for it: Slack threads, other people's documents, tribal knowledge. Keep decisions and constraints as versioned files, and have tools return structured JSON.
  • Enforce invariants mechanically. Use linters and tests whose error messages explain the fix, the same idea as {"error": "invalid_price", "message": "Price must be greater than zero."}.
  • Prefer boring, well-documented technology. Agents work best with stable APIs that are widely represented in their training data.

The security gap​

The source cites surveys finding that most organizations already use agents, but far fewer have security approval for them, and fewer still feel ready to deploy them securely. Treat every tool as attack surface: anything that reads files, queries databases, or calls APIs can be abused through prompt injection (instructions hidden in data the agent reads) and data exfiltration.

Defenses, in layers:

  1. Validate inputs with strict schemas and additionalProperties: false.
  2. Return structured errors, never stack traces or secrets.
  3. Grant each tool least privilege, with scoped credentials.
  4. Require human approval for actions that change state.
  5. Keep secrets in the harness and sandbox the execution.
  6. Keep audit logs of every call.

Check: harnesses and security​

Quiz

Control plane vs. compute plane

1 / 2

Why keep API credentials in the harness rather than in the sandbox?

Pick an answer to see if you are right.


Key Takeaways​

  • Point-to-point integration costs N × M. MCP makes it N + M: one client per app, one server per system, all interoperable.
  • MCP is JSON-RPC 2.0 over stdio or Streamable HTTP. Servers expose tools, resources, and prompts. Clients discover tools with tools/list and run them with tools/call, validated against JSON Schema.
  • FastMCP turns typed, documented Python functions into tools. Test them in-process or with the MCP Inspector. Hosted MCP (OpenAI, Claude) requires a public, secured server URL.
  • Tool design is interface design. Purposeful names, clear descriptions, and documented parameters let models choose and chain tools without orchestration code.
  • Return a consistent envelope. Treat empty results as success and failures as success: false (or isError results). Add timeouts and backoff retries for transient errors only.
  • Put tools inside a harness (control plane) with sandboxed compute. Assume prompt injection: least privilege, approvals, isolated secrets, and audit logs.

Previous lesson: Creating Effective AI Agents.

Next lesson: Multi-Agent Systems — orchestrate specialized search, policy, and vision agents with LangGraph, and test the seams.


References​

  1. Model Context Protocol: specification and documentation
  2. FastMCP
  3. MCP Inspector
  4. OpenAI: Remote MCP servers in the Responses API
  5. Anthropic: MCP connector
  6. JSON-RPC 2.0 specification

Source chapter references (with practice quizzes)​

These are the references cited in the source chapter. Pass a citation's quiz to unlock it, then use it in the Essay Lab.

LockedWhat is the Model Context Protocol (MCP)?Model Context Protocol[@mcp-what-is-mcp]
LockedOpenClaw: Anatomy of a viral open source AI agentAll Things Open (2026)[@ato-openclaw-anatomy-2026]
LockedThe next evolution of the Agents SDKOpenAI (2026)[@openai-agents-sdk-evolution-2026]
LockedOpenClaw GitHub repositoryOpenClaw[@openclaw-github]
LockedNemotron Labs: What OpenClaw Agents Mean for Every OrganizationNVIDIA (2026)[@nvidia-openclaw-agents-2026]
LockedHarness engineering: leveraging Codex in an agent-first worldOpenAI[@openai-harness-engineering]
LockedThe State of AI Agent Security 2026Gravitee (2026)[@gravitee-agent-security-2026]
LockedThe State of AI Security Report 2026Cisco (2026)[@cisco-ai-security-2026]