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

前言

在前面的文章,我们写了 ToolRegistry方法,现在再来读 MCP SDK,不再是黑盒调用。这篇沿着 call_tool 追踪完整链路,回答一个核心问题:SDK 比手写多做了什么?


一、客户端:请求的一生

scss 复制代码
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 为生产并发预留的设计。


二、服务端:两层路由

scss 复制代码
收到 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:校验、执行、转换

python 复制代码
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 标记,本质一样。


四、错误分层的智慧

python 复制代码
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/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 帮我们做了什么、为什么需要它、以及手写实现差在哪里。

相关推荐
张彦峰ZYF2 小时前
越过边界之前:Reward Seeker 实验如何改写智能体安全的时间逻辑
人工智能·ai agent·anthropic·智能体安全·reward seeker·“控制评价过程”泛化跨领域策略·envbom
拓恒技术2 小时前
无人机巡检AI算法的工程化落地:从需求到交付的5个核心环节
人工智能·算法·无人机
itmigrate2 小时前
前端项目里,Object.is 并不是 === 的平替
前端
小大宇4 小时前
Vue知识点6-computed计算属性
前端·javascript·vue.js
罗西的思考4 小时前
AI 时代的 “房谋杜断”:如何培养能力、品味与判断力
人工智能·算法·机器学习
程序员海军4 小时前
尤雨溪 : Vue 4 已经在规划中了!
前端·javascript·vue.js
2601_949970694 小时前
HumanizeDuration 跨端三方库仓颉适配全流程
前端·javascript·flutter
字节跳动视频云技术团队5 小时前
720P 直升 8K,解密 2026 抖音美好奇妙夜巨幕超分黑科技
人工智能·音视频开发
打工仔折腾 AI5 小时前
在 Ubuntu ARM64 部署 Q1 V3.0 MuJoCo 模型:加载、单步推理与执行器响应解析
linux·运维·人工智能·python·深度学习·ubuntu·性能优化
逐米时代5 小时前
从试点验证到协同深化:制造业AI转型三年路线图设计方法
人工智能