MCP: 一条 tool 调用链路的旅程

9 阅读3分钟

前言

在前面的文章,我们写了 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/calltools/listresources/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_syncPromise.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-RPC error 响应——"这次请求本身有问题"
  • 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/asyncinspect.iscoroutinefunctionfn_is_async + to_thread.run_sync
结果转换直接返回 dictconvert_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 帮我们做了什么、为什么需要它、以及手写实现差在哪里。