Phase 5: Model Context Protocol (MCP) · 75 min · Python · MCP SDK · Anthropic SDK
The Problem
You've built MCP servers. Now you need to consume them from your own agents — not through Claude Desktop, but programmatically. An autonomous research agent needs to call tools from a database server, a filesystem server, and a web search server. The agent shouldn't know or care that these tools come from three different processes.
This is the MCP client's job: connect to servers, discover tools at runtime, merge tool definitions into one list, and route each model tool call to the correct server. The model sees a single unified tool interface. The client handles all the plumbing.
This lesson builds the client layer from scratch: MCPClientManager, ToolRegistry, and the full agentic loop.
Dynamic tool discovery at startup
The client doesn't hardcode which tools exist. It calls tools/list on each connected server at startup, merges the results, and passes them to the model. This means new tools added to a server become automatically available the next time the agent starts — no code changes required.
MCP client lifecycle
The full lifecycle, step by step:
- Connect — establish transport connection to each server (stdio subprocess or HTTP)
- Initialize — exchange protocol versions (client sends
initialize, server responds with capabilities) - Discover — call
tools/list on each server; collect all tool definitions - Aggregate — merge all tool definitions into one list; build a registry mapping tool names to servers
- Run model — pass merged tool list to the Anthropic API as the
tools parameter - Route — when model returns
tool_use, look up tool name in registry → find server → call tools/call - Return result — add the result as a
tool_result message and continue the loop - Repeat until
stop_reason == "end_turn"
The client owns steps 1-4 (infrastructure) and 6-7 (routing). The Anthropic API handles the model side.
Dynamic tool discovery
The key pattern is aggregation at startup:
all_tools = []
tool_registry = {} # tool_name -> server
for server in connected_servers:
tools = await server.list_tools()
for tool in tools:
all_tools.append({
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
})
tool_registry[tool.name] = server
# Pass to Anthropic API
response = client.messages.create(
model="claude-opus-4-5",
tools=all_tools,
messages=[...]
)
The model sees search_customers, list_directory, web_search as equals — it has no knowledge of which server backs each tool. When it calls search_customers, the client looks up tool_registry["search_customers"] → database_server, calls database_server.call_tool("search_customers", args), and returns the result.
Your MCP client connects to 3 servers: a database server (5 tools), a file server (3 tools), and a search server (4 tools). How many tools does the model see?
The client's job is to aggregate: list tools from all servers, merge into one list, pass to the model. The model sees a unified tool set with no knowledge of which server backs each tool. This is the power of MCP's abstraction — the model sees "search_web," "query_database," "read_file" as equals, regardless of which server provides them.
Multi-server tool routing
When the model returns a tool_use content block, route it:
for block in response.content:
if block.type == "tool_use":
server = tool_registry[block.name]
result = await server.call_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
The model may call multiple tools in one response (Claude returns all tool_use blocks before requesting results). Process all of them, then send a single user message containing all tool_result objects.
Handling tool name conflicts
Two servers may register the same tool name — for example, a database server and a CRM server both exposing search. You need an explicit policy:
Namespace: prefix each tool name with the server ID — db_search, crm_search. Clean, no information loss. Slightly more verbose for the model.
Last-wins: the later-registered server's tool overwrites the earlier one. Simple but silently shadows tools — always log a warning when this happens.
Error: refuse to start if any name conflicts exist. Forces intentional resolution but may break when you add a new server.
Two MCP servers both register a tool named "search". How should your client handle this?
Tool name conflicts are a real operational issue in multi-server deployments. Namespacing is the cleanest solution: prefix tool names with the server ID. Last-registered-wins is simpler but silently shadows tools — always log a warning. The right policy depends on your use case; what matters is having an explicit policy rather than undefined behavior.
Build
Build it
Before diving into the full client code, this pure-Python sketch simulates the core aggregation-and-routing logic in miniature — three mock servers, a merged registry, and a routed call.
servers = {
"database": {"search_customers": lambda args: f"Found 3 customers matching '{args['query']}'"},
"filesystem": {"list_directory": lambda args: f"Listed files in {args['path']}"},
"search": {"web_search": lambda args: f"Top result for '{args['query']}'"},
}
# Step 1-4: discover and aggregate into one registry
tool_registry = {}
for server_name, tools in servers.items():
for tool_name in tools:
tool_registry[tool_name] = server_name
print(f"Model sees {len(tool_registry)} unified tools: {sorted(tool_registry)}")
# Step 5-7: simulate the model calling tools, client routes each one
calls = [
("search_customers", {"query": "acme"}),
("list_directory", {"path": "/reports"}),
("web_search", {"query": "mcp client pattern"}),
]
for tool_name, args in calls:
owning_server = tool_registry[tool_name]
result = servers[owning_server][tool_name](args)
print(f"[{owning_server}] {tool_name}({args}) -> {result}")
The code artifact lesson_05_03_mcp_client.py implements:
MCPClientManager — connects to servers, runs discovery, builds the merged tool listToolRegistry — maps tool names to servers, implements last-wins and namespace conflict policies- Full agentic loop — Anthropic API call → tool call routing → result injection → repeat
- Multi-server demo — two mock servers (database + filesystem), all tool calls logged with server attribution
- Conflict resolution demo — namespace policy in action
Run it:
pip install mcp anthropic
python lesson_05_03_mcp_client.py