构建 MCP 客户端:发现、调用、会话管理


文档摘要

构建 MCP 客户端:发现、调用、会话管理 本节摘要:多数 MCP 内容只出服务端教程,对客户端一笔带过。然而客户端代码才是硬核编排所在:拉起子进程、能力协商、跨多个服务端合并工具列表、sampling 回调、重连、命名空间冲突消解。本节构建一个多服务端客户端,把三个不同的 MCP 服务端提升进一个扁平的工具命名空间供模型使用——这是「玩具」与「可用」的分水岭。 学习目标 阅读完本节,你应当能够: 把一个 MCP 服务端作为子进程拉起,完成 ,并发送 。 维护每服务端会话状态(能力、工具列表、最近见过的通知 id)。 跨多个服务端合并工具列表到一个命名空间,并处理冲突。 把工具调用路由到拥有它的服务端,并重组响应。

构建 MCP 客户端:发现、调用、会话管理

本节摘要:多数 MCP 内容只出服务端教程,对客户端一笔带过。然而客户端代码才是硬核编排所在:拉起子进程、能力协商、跨多个服务端合并工具列表、sampling 回调、重连、命名空间冲突消解。本节构建一个多服务端客户端,把三个不同的 MCP 服务端提升进一个扁平的工具命名空间供模型使用——这是「玩具」与「可用」的分水岭。

学习目标

阅读完本节,你应当能够:

  1. 把一个 MCP 服务端作为子进程拉起,完成 initialize,并发送 notifications/initialized
  2. 维护每服务端会话状态(能力、工具列表、最近见过的通知 id)。
  3. 跨多个服务端合并工具列表到一个命名空间,并处理冲突。
  4. 把工具调用路由到拥有它的服务端,并重组响应。

一、问题与直觉

一个真实的 Agent host(Claude Desktop、Cursor、Goose、Gemini CLI)会同时加载多个 MCP 服务端。一个用户可能同时跑着文件系统服务端、Postgres 服务端、GitHub 服务端。客户端的活:

  1. 拉起每个服务端。
  2. 各自独立握手。
  3. 对每个调 tools/list 并把结果拍平
  4. 当模型发出 notes_search 时,在合并命名空间里查它,路由到正确的服务端。
  5. 处理任意服务端来的通知(tools/list_changed)而不阻塞。
  6. 传输失败时重连

把这些全手撸出来,正是「玩具」与「可用」的区别。官方 SDK 包了这些,但心智模型必须是你自己的

二、从零实现

子进程拉起

subprocess.Popenstdin=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。三条路:

  1. 按服务端名加前缀:notes/searchfiles/search。清晰但丑。
  2. 静默先到先得:后到的 search 覆盖先到的。有风险,隐藏冲突。
  3. 冲突拒绝:拒绝加载第二个服务端,通知用户。对安全敏感的 host 最稳。

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() # 后台读线程会填它

sampling 回调

若服务端在 initialize 声明了 sampling 能力,它可以发 sampling/createMessage 请求客户端跑它的 LLM。客户端必须:

  1. 阻塞对该服务端的后续请求直到 sample 解析(或实现支持并发则管线化)。
  2. 调用自己的 LLM provider。
  3. 把响应发回服务端。

第 11 节端到端讲 sampling,本节为完整起见留个桩。

通知处理

notifications/tools/list_changed 意味着重调 tools/list;notifications/resources/updated 意味着若该资源在用就重读。通知绝不能产生响应——别试图 ack 它们。

⚠️ 常见客户端 bug:在 tools/call 上阻塞读循环,而此时流里还坐着一条通知。用一个后台读线程把每条消息推到队列,主线程从队列取出分发。

重连

传输会失败:服务端崩溃、OS 杀进程、stdio 管道断开。客户端检测到 stdout 上的 EOF,就把会话标记为死。选项:

  • 静默重启服务端再握手——对纯只读服务端 OK。
  • 把失败抛给用户——对有用户可见会话的有状态服务端 OK。

第 09 节覆盖 Streamable HTTP 的重连语义;stdio 更简单。

Keepalive 与 session id

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 工具命名空间、基于工具名的路由决策、以及靠命名空间前缀防止的一次冲突。

五、练习

  1. 观察 EOF:运行 code/main.py,看服务端拉起日志。用 SIGTERM 杀掉其中一个模拟服务端进程,观察客户端如何检测 EOF 并标记该会话为死。

  2. 实现前缀消歧:两个服务端都暴露 search 时,把第二个重命名为 <server>/search。更新分发表,验证工具调用仍正确路由。

  3. 加退避重连:为服务端重启加连接池式退避:连续失败指数退避、上限 30 秒、三次失败后向用户发通知。

  4. 画百服务端客户端:为支持 100 个并发 MCP 服务端的客户端画设计。简单 dispatch dict 该换成什么数据结构?(提示:前缀命名空间用 trie,加一个每服务端工具数指标。)

  5. 移植到官方 SDK:把客户端移植到官方 MCP Python SDK。SDK 包了 stdio_clientClientSession,代码应从约 200 行缩到约 40 行,且保留多服务端路由。

本节要点回顾

  1. 客户端是编排核心:拉起、握手、合并、路由、重连——这是「玩具」与「可用」的分水岭。
  2. 每服务端一 Session:process、capabilities、tools、pending 四件套;请求天然异步。
  3. 合并命名空间三策略:按服务端前缀(清晰)、冲突拒绝(最稳)、静默先到先得(危险,禁用)。
  4. 路由靠分发表:tool_name → Session,写 stdin 等响应。
  5. sampling 回调:服务端声明 sampling 即可请客户端跑 LLM,第 11 节深入。
  6. 通知绝不响应:用后台读线程把消息推队列,主线程分发,避免阻塞 bug。
  7. 重连:EOF 即会话死;只读服务端可静默重启,有状态服务端抛给用户。
  8. stdio 无 session id:进程身份即会话,无 keepalive 需求;HTTP 才有 Mcp-Session-Id

下一节,我们深入 MCP 传输——stdio、Streamable HTTP,以及远程部署的重连与鉴权握手。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U