前言
在前面的文章,我们写了 ToolRegistry方法,现在再来读 MCP SDK,不再是黑盒调用。这篇沿着 call_tool 追踪完整链路,回答一个核心问题:SDK 比手写多做了什么?
一、客户端:请求的一生
call_tool(name, arguments)
→ CallToolRequest(params) 构造请求对象
→ model_dump() 序列化成 JSON dict
→ JSONRPCDispatcher.send_raw_request(method, params)
├─ 分配 request_id
├─ _pending[id] = 内存流等待者 ← 关键设计
├─ JSONRPCRequest 写入传输层
└─ receive.receive() 挂起等响应
→ 响应按 id 反查 _pending → 唤醒 → CallToolResult
_pending 关联表是客户端最值得学的设计。**
并发场景下同时发 N 个请求,响应乱序返回——没有这张表,无法把每个响应送回正确的等待者。_pending 是"request_id → 等待者"的 Map,响应到达时按 JSON-RPC 响应的 id 字段反查。
前端类比:同时发 10 个 fetch,每个都有自己的 Promise 在 await——谁先返回谁先 resolve,互不覆盖。_pending 就是那个 Map<id, Promise>。
在前面文章中我的串行 Agent 用不到这个机制(一次只发一个请求)——但这就是 MCP 为生产并发预留的设计。
二、服务端:两层路由
收到 JSON-RPC 请求
→ 第一层路由:method 字段 → _request_handlers["tools/call"]
→ 第二层路由:params.name → ToolManager._tools[name]
→ Tool.run(arguments)
两层路由的分离是协议设计的关键:
- 第一层按
method路由——协议层的方法名(tools/call、tools/list、resources/read) - 第二层按
name路由——业务层的工具名(search_catalog)
_request_handlers 是 {method: handler} 表,ToolManager._tools 是 {name: Tool} 表。这跟之前手写的 ToolRegistry 完全同构——只是多了一层协议路由,让 SDK 能同时支持 tools/resources/prompts 三类能力。
三、Tool.run:校验、执行、转换
async def run(self, arguments, context, convert_result=False):
# 校验
validate_arguments(arguments) # jsonschema 校验
# 执行
if fn_is_async:
result = await fn(**arguments) # 异步函数直接 await
else:
result = anyio.to_thread.run_sync(fn, ...) # 同步函数丢线程池
# 转换
if convert_result:
result = self.fn_metadata.convert_result(result) # → CallToolResult
return result
同步函数不卡事件循环是执行层的关键设计。
anyio.to_thread.run_sync 把阻塞调用丢进线程池,不占用事件循环——否则一个同步工具函数耗时 2 秒,整个 Agent 的其他异步任务全被卡住。
前端类比:to_thread.run_sync ≈ Promise.resolve(syncFn) + Web Worker——把阻塞操作移出主线程。 在前面我用 inspect.iscoroutinefunction 判断同步异步,MCP 用 fn_is_async 标记,本质一样。
四、错误分层的智慧
except MCPError:
raise # 协议级错误 → 原样抛 → JSON-RPC error 响应
except Exception as e:
raise ToolError(f"Error executing tool {self.name}: {e}") # 执行失败 → isError=True
两类错误区分处理,这是 MCP 最值得学的设计:
MCPError→ 协议错误 → JSON-RPCerror响应——"这次请求本身有问题"ToolError→ 执行失败 →CallToolResult(isError=True)——"工具跑崩了"
这个区分告诉调用方该不该重试:协议错误重试没用(请求非法),执行失败可能有用(瞬时故障)。
对比前面的手写:把所有异常压成 {"success": False},丢失了"该不该重试"的信息。MCP 的错误分层让调用方有决策依据。
五、MCP vs 手写:替换成本与设计取舍
| 维度 | ToolUse手写 | MCP |
|---|---|---|
| 工具表 | dict[name] = {schema, fn} | ToolManager._tools: {name: Tool} |
| 请求匹配 | 串行,无关联表 | _pending[id] 并发安全 |
| 错误处理 | 统一 {success: False} | MCPError / ToolError 分层 |
| sync/async | inspect.iscoroutinefunction | fn_is_async + to_thread.run_sync |
| 结果转换 | 直接返回 dict | convert_result → CallToolResult |
| 传输 | 直接函数调用 | stdio/SSE/HTTP 可插拔 |
如果让我用 MCP SDK 替换ToolUse手写实现:
tool_executor.py(执行器)→ 换成@mcp.tool()装饰器注册tool_registry.py(注册表)→ 换成 MCP Server 的请求路由agent.py(Agent Loop)→ 基本不用改——call_tool返回CallToolResult,解析content即可
替换成本只动执行层——因为前面的 Agent Loop 是围绕"工具调用"抽象写的,不关心底层是函数调用还是 JSON-RPC。
结论
MCP 的生产级设计 = 并发关联表(_pending)+ 错误分层(MCPError/ToolError)+ 传输抽象(stdio/HTTP)。
手写理解原理,SDK 做工程化。读完这条链路的收获不是"我会用 MCP 了"——是知道 SDK 帮我们做了什么、为什么需要它、以及手写实现差在哪里。