构建 MCP 客户端:发现、调用、会话管理 本节摘要:多数 MCP 内容只出服务端教程,对客户端一笔带过。然而客户端代码才是硬核编排所在:拉起子进程、能力协商、跨多个服务端合并工具列表、sampling 回调、重连、命名空间冲突消解。本节构建一个多服务端客户端,把三个不同的 MCP 服务端提升进一个扁平的工具命名空间供模型使用——这是「玩具」与「可用」的分水岭。 学习目标 阅读完本节,你应当能够: 把一个 MCP 服务端作为子进程拉起,完成 ,并发送 。 维护每服务端会话状态(能力、工具列表、最近见过的通知 id)。 跨多个服务端合并工具列表到一个命名空间,并处理冲突。 把工具调用路由到拥有它的服务端,并重组响应。
本节摘要:多数 MCP 内容只出服务端教程,对客户端一笔带过。然而客户端代码才是硬核编排所在:拉起子进程、能力协商、跨多个服务端合并工具列表、sampling 回调、重连、命名空间冲突消解。本节构建一个多服务端客户端,把三个不同的 MCP 服务端提升进一个扁平的工具命名空间供模型使用——这是「玩具」与「可用」的分水岭。
阅读完本节,你应当能够:
initialize,并发送 notifications/initialized。一个真实的 Agent host(Claude Desktop、Cursor、Goose、Gemini CLI)会同时加载多个 MCP 服务端。一个用户可能同时跑着文件系统服务端、Postgres 服务端、GitHub 服务端。客户端的活:
tools/list 并把结果拍平。notes_search 时,在合并命名空间里查它,路由到正确的服务端。tools/list_changed)而不阻塞。把这些全手撸出来,正是「玩具」与「可用」的区别。官方 SDK 包了这些,但心智模型必须是你自己的。
subprocess.Popen 配 stdin=PIPE, stdout=PIPE, stderr=PIPE。设 bufsize=1 并用文本模式逐行读。每个服务端一个进程;客户端每服务端持一个 Popen 句柄。
每服务端一个 Session 对象,持有:
process——Popen 句柄。capabilities——服务端在 initialize 声明的能力。tools——最近一次 tools/list 结果。pending——请求 id 到等待响应的 promise/future 的映射。请求天然异步:发给服务端 A 的 tools/call,在服务端 B 还在处理中时不能阻塞。要么用线程配队列,要么用 asyncio。
客户端看到聚合工具列表时,名字可能冲突——两个服务端都暴露 search。三条路:
notes/search、files/search。清晰但丑。search 覆盖先到的。有风险,隐藏冲突。Claude Desktop 用按服务端前缀;Cursor 用冲突拒绝配清晰错误;VS Code MCP 也采用按服务端前缀。
合并后,一张分发表把 tool_name -> session 映射好。模型按名发出调用,客户端找到 session,把一条 tools/call 写到该服务端的 stdin,再等响应。
@dataclass class Session: name: str process: subprocess.Popen capabilities: dict tools: list pending: dict def route_call(merged: dict, sessions: dict, name: str, args: dict): session_name = merged[name] # tool_name → server name sess = sessions[session_name] req_id = next_id() sess.pending[req_id] = Future() sess.process.stdin.write(json.dumps( {"jsonrpc":"2.0","id":req_id,"method":"tools/call", "params":{"name":name,"arguments":args}}) + "\n") sess.process.stdin.flush() return sess.pending[req_id].result() # 后台读线程会填它
若服务端在 initialize 声明了 sampling 能力,它可以发 sampling/createMessage 请求客户端跑它的 LLM。客户端必须:
第 11 节端到端讲 sampling,本节为完整起见留个桩。
notifications/tools/list_changed 意味着重调 tools/list;notifications/resources/updated 意味着若该资源在用就重读。通知绝不能产生响应——别试图 ack 它们。
⚠️ 常见客户端 bug:在
tools/call上阻塞读循环,而此时流里还坐着一条通知。用一个后台读线程把每条消息推到队列,主线程从队列取出分发。
传输会失败:服务端崩溃、OS 杀进程、stdio 管道断开。客户端检测到 stdout 上的 EOF,就把会话标记为死。选项:
第 09 节覆盖 Streamable HTTP 的重连语义;stdio 更简单。
Streamable HTTP 用 Mcp-Session-Id 头。stdio 没有 session id——进程身份即会话。Keepalive ping 可选;stdio 管道不会因不活跃而断。
| 命名空间策略 | 代表 host | 优点 | 缺点 |
|---|---|---|---|
| 按服务端前缀 | Claude Desktop、VS Code | 清晰、无隐藏冲突 | 工具名变长 |
| 冲突拒绝 | Cursor | 安全敏感最稳 | 用户体验受影响 |
| 静默先到先得 | (不推荐) | 简单 | 隐藏冲突,危险 |
💡 心法:生产 host 优先选「按服务端前缀」或「冲突拒绝」,绝不选「静默先到先得」——后者会让恶意或粗心的服务端静默劫持另一个服务端的工具名。
本节产出 outputs/skill-mcp-client-harness.md——给定一份 MCP 服务端的声明式清单(名字、命令、参数),它产出一个脚手架:拉起它们、合并工具列表、附带一个带冲突消解的路由函数。
code/main.py 把三个模拟 MCP 服务端作为子进程拉起,各自握手、合并工具列表、把工具调用路由到正确的服务端。「服务端」其实是跑着玩具响应器的其他 Python 进程(无真实 LLM)。运行可见:三次 initialize(各自能力集)、三份 tools/list 合并成 7 工具命名空间、基于工具名的路由决策、以及靠命名空间前缀防止的一次冲突。
观察 EOF:运行 code/main.py,看服务端拉起日志。用 SIGTERM 杀掉其中一个模拟服务端进程,观察客户端如何检测 EOF 并标记该会话为死。
实现前缀消歧:两个服务端都暴露 search 时,把第二个重命名为 <server>/search。更新分发表,验证工具调用仍正确路由。
加退避重连:为服务端重启加连接池式退避:连续失败指数退避、上限 30 秒、三次失败后向用户发通知。
画百服务端客户端:为支持 100 个并发 MCP 服务端的客户端画设计。简单 dispatch dict 该换成什么数据结构?(提示:前缀命名空间用 trie,加一个每服务端工具数指标。)
移植到官方 SDK:把客户端移植到官方 MCP Python SDK。SDK 包了 stdio_client 与 ClientSession,代码应从约 200 行缩到约 40 行,且保留多服务端路由。
tool_name → Session,写 stdin 等响应。Mcp-Session-Id。下一节,我们深入 MCP 传输——stdio、Streamable HTTP,以及远程部署的重连与鉴权握手。