Phase 5: Model Context Protocol (MCP) · 75 min · Python · MCP SDK · Anthropic SDK
The Problem
Your company has a customer database, a ticket system, and a deployment API. Today, every AI feature that needs any of these tools requires a custom integration written by an engineer. When you add a new tool, every agent needs to be updated. When the underlying API changes, you fix it in five places.
With MCP, you build each integration once as an MCP server. The customer database server exposes search_customers, get_customer, get_orders. The ticket system exposes list_tickets, create_ticket, update_ticket. Deploy them, and every agent in the company gets access to all three — without touching the agent code.
This lesson is how you build those servers correctly. The protocol is straightforward. The quality trap is in the details: tool descriptions that don't tell the model enough, error messages that don't help recovery, and schemas that don't validate inputs.
A tool is a typed Python function
In the MCP Python SDK 2.x, MCPServer turns a decorated function into a tool. The type hints become the input schema the client validates against, and the docstring becomes the description the model reads when it decides whether to call the tool. tools/list and tools/call are handled for you.
MCP server structure
This lesson uses the MCP Python SDK 2.x (pip install "mcp>=2.3"). Older tutorials show the 1.x API — FastMCP, or @server.list_tools() with @server.call_tool() — which does not run on 2.x.
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("customer-data-server")
@mcp.tool()
def get_customer(customer_id: str) -> str:
"""Get one customer's full record by their 8-digit ID.
If you only have a name or email, call search_customers first."""
...
if __name__ == "__main__":
mcp.run() # stdio, the transport Claude Desktop launches
For web deployment, run the same server over Streamable HTTP with mcp.run(transport="streamable-http").
Writing great tool descriptions
Tool descriptions are what the model reads to decide which tool to call. A poor description gives the model no basis for decision-making. A great description answers four questions:
- When should I call this tool? (triggers)
- What parameters do I pass? (with examples)
- What do I get back? (return structure)
- When should I use a different tool? (disambiguation)
| Quality | Description | Model behavior |
|---|
| Poor | "Query the database" | Calls it at random, passes wrong parameters |
| Good | "Search customers by name or email. Use before get_customer to find an ID. Returns: id, name, email, plan, status. Do NOT use for orders — use get_orders." | Calls it when appropriate, passes correct params, uses the right tool |
What's wrong with this tool description: "name": "db_query", "description": "Query the database"?
The model decides which tool to call based on the description. "Query the database" gives it no basis for decision-making. A good description answers: When should I call this? What do I pass? What do I get back? When should I use a different tool instead? Invest in descriptions — they're the most impactful single thing you can do to improve tool-using agent behavior.
Error handling in MCP tools
An MCP tool result carries a content array and an isError flag on the result itself. The flag is what tells the client and the model that the call failed — an error message written into successful text output does not. The critical insight: the model reads tool errors and uses them to self-correct.
Poor error: "invalid input" — the model has nothing to work with and may retry with the same bad input.
Good error: "Customer ID 'abc' not found. Customer IDs are 8-digit numbers (e.g., 12345678). Use search_customers to find IDs by name or email." — the model knows what went wrong, what valid input looks like, and what to try next.
Write MCP error messages as developer-facing error messages for the most important developer on your team: the model.
from mcp.server.mcpserver.exceptions import ToolError
@mcp.tool()
def get_customer(customer_id: str) -> str:
"""Get one customer's full record by their 8-digit ID."""
if customer_id not in CUSTOMERS:
# Raising ToolError returns a result with isError: true and this text,
# so the failure is reported as a failure — with guidance the model can use.
raise ToolError(
f"Customer ID '{customer_id}' not found. "
"Customer IDs are 8-digit numbers (e.g., 12345678). "
"Use search_customers to find valid IDs by name or email."
)
return json.dumps(CUSTOMERS[customer_id])
A common mistake is to return {"isError": true, ...} inside the text of a normal result. The protocol then reports success, and the model may treat your error message as data.
An MCP tool receives an invalid parameter and should return an error. The error message should:
The model reads MCP tool errors and uses them to self-correct. "Invalid input" gives it nothing to work with — it might retry with the same invalid parameter. "Customer email must be in format user@domain.com. Got: 'john smith'" gives the model exactly what it needs to fix its next call. MCP errors are model-facing; write them as you'd write a helpful error to a developer.
Exposing resources
Resources are read-only context data identified by a URI. Register one with @mcp.resource():
@mcp.resource("customers://summary")
def customer_summary() -> str:
"""Total, active and churned customer counts."""
return json.dumps({"total": 150, "active": 132, "churned": 18})
Tools are model-controlled: the model decides when to call them. Resources are application-controlled: the host decides whether and when to read one — often because the user attached it — and how to put it in front of the model. Nothing is loaded into the context window automatically just because a server exposes it.
Build
Build it
The core discipline here — validate inputs, and write errors the model can act on — doesn't require the real SDK to practice. This mini tool handler shows the difference between a poor error and a model-facing one.
# Simulate an MCP tool call handler with model-facing error messages.
# (A plain-Python stand-in for the SDK: here isError is a key in the returned
# dict; with the real SDK you raise ToolError and it sets the result's flag.)
customers = {"12345678": {"name": "Ada Lovelace", "plan": "enterprise"}}
def call_tool(name, arguments):
if name != "get_customer":
return {"isError": True, "error": f"Unknown tool: {name}"}
customer_id = arguments.get("customer_id", "")
if not customer_id.isdigit() or len(customer_id) != 8:
return {
"isError": True,
"error": (
f"Customer ID '{customer_id}' is invalid. "
"Customer IDs are 8-digit numbers (e.g., 12345678). "
"Use search_customers to find valid IDs by name or email."
),
}
if customer_id not in customers:
return {
"isError": True,
"error": f"Customer ID '{customer_id}' not found. Double-check the ID or use search_customers.",
}
return {"content": customers[customer_id]}
for args in [{"customer_id": "abc"}, {"customer_id": "99999999"}, {"customer_id": "12345678"}]:
print(args, "->", call_tool("get_customer", args))
The code artifact lesson_05_02_mcp_server.py implements a complete CustomerDataServer:
- 4 tools:
search_customers, get_customer, get_orders, get_order_details — each with full descriptions following the 4-question framework - 2 resources:
customers://summary, customers://schema - Model-facing error responses with corrective guidance
- stdio and Streamable HTTP run modes
- A self-test that connects an in-process client, calls every tool and checks each error path returns
isError: true
Run it:
pip install "mcp>=2.3"
python lesson_05_02_mcp_server.py # self-test
python lesson_05_02_mcp_server.py stdio # serve for Claude Desktop