ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MCP多Server编排实战:从协议握手到LangGraph工具整合

MCP多Server编排实战:从协议握手到LangGraph工具整合 如果你最近在折腾 AI AgentMCPModel Context Protocol模型上下文协议这几个字母大概率反复出现过。Claude 的桌面客户端、Cursor、各家 IDE 的 AI 插件都在拿它当标准接口对接外部工具。回看这两年的演进MCP 已经从最初的实验性协议变成了事实上的工具接入层标准。这篇分享我想从协议握手的底层交互讲起一路聊到如何在 LangGraph 里同时管理多个 MCP Server把本地文件工具、远程 HTTP 服务统一交给一个 Agent 编排最后附上我实际踩过的坑和排查思路。内容适合两类人一类是想彻底搞懂 MCP 握手过程、不再停留在“会用封装库”层面的开发者另一类是已经把 LangGraph 跑起来、正准备往图里接多个工具源的工程实践者。先给结论多 Server 调用不是简单的“多连几个客户端”它牵扯到能力协商、工具命名空间、注入策略、超时处理等一整套工程问题。你把这篇看完至少能避开我在生产环境里趟过的七八个坑。1. 从需求出发为什么团队最终选定 MCP 多 Server 架构1.1 一句话理解 MCP 的定位MCP 解决的问题其实特别朴素AI 应用要调用外部能力比如读数据库、操作文件、调第三方 API过去每个能力都要单独写胶水代码工具一多维护成本立刻爆炸。MCP 把“AI 应用”和“外部能力提供方”抽象成 Client 和 Server 两端中间走一套统一的 JSON-RPC 消息格式。你把能力封装成一个 ServerAI 应用只需要实现一个 Client就能通过标准动作发现和调用工具。这些“标准动作”就是协议规定的几个方法initialize、tools/list、tools/call、resources/list、prompts/list。客户端拿到工具清单后可以把工具 schema 交给大模型让模型决定什么时候调用哪个。生活化类比就是 USB-C 接口以前每个设备都得配专属线缆现在一根线通吃。MCP 就是 AI 世界里连接外部能力的那个 USB-C接口统一了剩下的插拔就简单了。1.2 对比自建工具层MCP 赢在哪很多人会说我自己用 FastAPI 写个工具层再塞给 LangChain 的 bind_tools不是也一样能跑吗确实能跑我以前也这么干过但有几个问题会在项目变大后集中暴露。第一是协议从零定。自己写工具层工具描述、参数校验、错误返回格式都是自己拍脑袋定的客户端要专门适配你的私有格式。一旦多端复用比如同一个工具既要给 Claude 桌面端用又要给 LangGraph Agent 用私有格式就成了对接成本。第二是生态没得比。现在大量现成服务直接暴露 MCP 接口数据库网关、浏览器控制工具、开发环境插件、企业知识库系统越来越普遍。你用标准 Client 一接就能用根本不用读对方一沓 API 文档。MCP 在工具发现、调用、鉴权协商、错误码、资源订阅这几个维度都给了既定方案省掉大量从零造的轮子。第三是换框架成本。今天你用 LangGraph明天想试试别的 Agent 框架只要两端都遵守 MCP迁移成本基本就是重画一张图的事。1.3 多 Server 是场景逼出来的必然选择单 Server 很好理解一个 Agent 接一个封装好的工具集。但真实业务里几乎不存在这么干净的场景。你本地有个文件系统工具远程有个企业知识库服务可能还要接一个 SQL 查询网关它们属于不同团队、不同运行环境、不同鉴权策略。如果每个工具源都单独写一套接入逻辑Graph 的节点就会越写越脏。MCP 的多 Server 架构思路是每个工具源独立跑成一个 ServerAgent 端通过多 Client 统一汇聚再把工具清单合并成一个统一命名空间交给模型。LangGraph 在这个场景下的价值在于它把“编排多个工具源”和“管理 Agent 状态”变成了图上的节点逻辑。工具是资源图是流程两者解耦这也是我最终放弃手写 while 循环调度、全面转 LangGraph 的原因。2. 协议垫底从 initialize 握手到 tools/call 的完整链路2.1 initialize双方先开个“能力对齐会”MCP 的会话从 initialize 开始这一步官方叫握手语义上就是一场“能力对齐会”。客户端发送一条 JSON-RPC 请求method 填 initialize参数里带上 protocolVersion、clientInfo 和 capabilities。capabilities 是客户端的能力声明比如我支持工具列表变更通知、支持流式响应等。服务端收到后返回自己的 protocolVersion、serverInfo 和 capabilities。服务端的能力声明会明确告诉客户端我实现了工具面、资源面、还是提示词面。这一步本质是把双方的底牌亮出来限定后续会话的沟通范围。一个很实际的细节握手完成后客户端必须再发一条 notifications/initialized 通知告诉服务端“我确认了你的能力可以开始干活了”。注意这个方法是 notification 而不是 request它不需要响应。很多人对接时漏掉这一步导致工具调用阶段出现莫名的“会话未就绪”错误。简化后的握手请求长这样{ jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2025-03-26, capabilities: { tools: { listChanged: true } }, clientInfo: { name: my-agent, version: 0.1.0 } } }2.2 工具发现与调用JSON-RPC 报文的实际长相握手通过后客户端要拿到服务器暴露的工具清单方法是 tools/list。响应里是工具数组每个工具包含 name、description 和 inputSchema。inputSchema 是 JSON Schema 格式描述参数的名称、类型、是否必填、枚举范围。Agent 框架会把这段描述拼进系统提示词或 tool schema 里交给 LLM 选择。真正执行时客户端发 tools/call带上 name 和 arguments。服务端执行完返回 content 数组content 的类型可以是 text、image 或 resource。这里有一个特别容易忽略的细节响应里还有一个 isError 字段它表示工具执行层是否出错。不管 isError 是不是 true传输层可能都正常返回所以 Agent 端必须判断 isError而不是只看请求有没有成功。工具调用报文大致长这样{ jsonrpc: 2.0, id: 2, method: tools/call, params: { name: read_file, arguments: { path: /tmp/demo.txt } } }2.3 传输方式与连接生命周期MCP 连接是有状态的常见传输方式有两种stdio 和 HTTP。stdio 适合本地进程级 Server客户端 spawn 一个子进程通过标准输入输出传 JSON-RPC 消息。好处是没有端口占用和跨域问题子进程生命周期天然跟随客户端崩了也能快速拉起。HTTP 传输适合远程服务客户端通过一个 URL 建立会话需要处理鉴权 header 和会话标识。生命周期上除了 initialize还有几个关键动作ping 用于保活cancellation 用于取消进行中的请求progress 用于进度通知。日常用封装库时这些动作大多被自动处理但理解它们对排查“连接卡住”“工具没响应”这类问题很有帮助。比如你发现一个 HTTP Server 长时间空闲后调用超时通常就是保活没做好服务端把会话回收了。这种问题看报错永远看不出答案回到生命周期图里一对照就清楚。3. LangGraph 多 Server 编排思路拆解3.1 多 Server 真正在解决什么问题我举一个具体场景。前阵子我做一个“代码仓库巡检 故障工单摘要”的 Agent它需要读取本地仓库文件又需要查企业工单系统。这两个能力不在一个环境一个在开发者的沙箱里一个在团队的云服务上。如果只用一个 Server就得把两个环境的依赖和权限全塞进同一个进程网络、鉴权、日志全都搅在一起。拆成两个 Server 之后本地工具的 stdio 子进程死掉不影响远程通道远程鉴权过期也不会拖垮本地文件操作。更重要的是两个 Server 可以由不同团队各自维护接口只要符合 MCP 规范就行这在跨团队协作中价值极大。如果你做的是平台类系统这个解耦几乎是刚需。3.2 工具命名空间隔离解决重名冲突多 Server 汇合后最直接的问题两个 Server 里的工具重名怎么办。比如 Server A 有个 get_statusServer B 也有个 get_status直接把两份工具列表拼在一起模型根本分不清该用哪个。我的做法是给每个 Server 一个逻辑前缀在注册阶段统一改名。比如把 Server A 的工具改成 fs_read_file、fs_write_file把 Server B 的改成 ticket_query_status。这里的关键点是改名逻辑放在适配层而不是服务端。服务端保持原样避免改到对方的接口契约。用 LangChain 的 MCPAdapter 加载工具后返回的 Tool 对象可以重新定义 name你包装一层再交给模型就行。命名规则建议是“服务缩写_动词_宾语”一眼就能看出工具来历。3.3 全量注入还是按需拾取多 Server 工具数量一多全量注入会有两个问题。第一是 token 浪费工具描述越长挤占的上下文越多模型有效推理空间变小。第二是选择准确率下降模型在几十个工具里选错工具或者编造参数的风险明显上升。我的经验是做两层控制。第一层按任务阶段分组代码巡检阶段只注入文件系统工具故障摘要阶段只注入工单查询工具这正好对应 LangGraph 的节点拆分。第二层是在节点内部做一次基于规则或 embedding 的预筛选把候选工具控制在 10 个以内。实测下来工具数量超过 20 个之后模型选错工具的概率会明显上升控制在 8 到 12 个是最稳的区间。别迷信“模型能力够强就能处理很多工具”真实跑线上任务时稳定比惊艳重要得多。4. 实操在 LangGraph 里同时接入两个 MCP Server4.1 准备环境与项目骨架我用 Python 3.11 来做演示依赖主要是 mcp、langgraph、langchain-openai、langchain-mcp-adapters。安装命令很简单pip install mcp langgraph langchain-openai langchain-mcp-adapters项目目录长这样agent/ main.py mcp_server_fs.py mcp_server_ticket.pymain.py 是 LangGraph 工作流入口mcp_server_fs.py 是本地文件系统 Servermcp_server_ticket.py 是模拟的远程工单 Server。实际生产环境中ticket 服务通常是一个 HTTP MCP Server部署在另一台机器上这里我们用本地进程模拟方便完整演示。4.2 第一个 Server本地 stdio 文件工具先用 FastMCP 写一个最简单的文件读取 Server。FastMCP 是官方团队提供的简化开发库装饰器一加函数就变成 MCP 工具省去手动声明 JSON Schema 的繁琐。# mcp_server_fs.py from mcp.server.fastmcp import FastMCP mcp FastMCP(demo-fs) mcp.tool() def read_file(path: str) - str: 读取指定文本文件的前2000个字符 with open(path, r, encodingutf-8) as f: return f.read()[:2000] mcp.tool() def list_files(dir_path: str) - list[str]: 列出目录下的所有文件名 import os return os.listdir(dir_path) if __name__ __main__: mcp.run()客户端这边用官方 SDK 的 stdio_client 建立连接。注意 StdioServerParameters 的 env 参数默认是 None意思是继承当前 Python 进程的环境变量如果你要传自定义环境变量给子进程就在这里加字典。from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def connect_stdio_server(command: list[str]): params StdioServerParameters( commandcommand[0], argscommand[1:], envNone, ) ctx stdio_client(params) read, write await ctx.__aenter__() session await ClientSession(read, write).__aenter__() await session.initialize() return session, ctx4.3 第二个 Server远程 HTTP 工单服务远程服务走 streamable_http 客户端。URL 是 MCP 端点地址headers 里放鉴权 token实际项目中你多半还需要附加租户 ID、客户端证书之类的字段。from mcp.client.streamable_http import streamablehttp_client async def connect_http_server(url: str, token: str): headers {Authorization: fBearer {token}} ctx streamablehttp_client(url, headersheaders) read, write await ctx.__aenter__() session await ClientSession(read, write).__aenter__() await session.initialize() return session, ctx这里有个常见误操作很多人以为 HTTP 连接就像调用一次 REST API用完就断。其实 MCP 的 HTTP 传输是会话制的session 建立后要保持活跃后续 tools/call 都复用这个会话。如果 Agent 生命周期比较长需要考虑心跳和重连策略。4.4 将两组工具汇入 LangGraph 工作流两个会话都建好后用 langchain-mcp-adapters 的 load_mcp_tools 加载工具然后把两个列表拼在一起。这一步就是“多 Server 统一命名空间”的核心。import asyncio from langchain_mcp_adapters.tools import load_mcp_tools from langchain_openai import ChatOpenAI from langgraph.prebuilt import ToolNode, tools_condition from langgraph.graph import StateGraph, START, END from typing import Annotated, TypedDict from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list, add_messages] async def load_all_tools(): fs_session, fs_ctx await connect_stdio_server( [python, mcp_server_fs.py] ) ticket_session, ticket_ctx await connect_http_server( http://localhost:8000/mcp, tokentest-token ) fs_tools await load_mcp_tools(fs_session) ticket_tools await load_mcp_tools(ticket_session) # 统一加前缀避免重名冲突 for tool in fs_tools: tool.name fs_ tool.name for tool in ticket_tools: tool.name ticket_ tool.name all_tools fs_tools ticket_tools contexts [fs_ctx, ticket_ctx] return all_tools, contexts def build_graph(tools, model): llm_with_tools model.bind_tools(tools) def agent_node(state: AgentState): response llm_with_tools.invoke(state[messages]) return {messages: [response]} graph StateGraph(AgentState) graph.add_node(agent, agent_node) graph.add_node(tools, ToolNode(tools)) graph.add_edge(START, agent) graph.add_conditional_edges( agent, tools_condition, {tools: tools, END: END} ) graph.add_edge(tools, agent) return graph.compile()这个图的核心逻辑是模型决定是否要调用工具如果调用就进 tools 节点执行执行完再回到 agent 节点做下一步推理。多 Server 的工具被统一塞进同一个 ToolNode 里对图来说它们就是一组普通工具区别只在命名前缀。4.5 完整流程实测效果主流程代码先加载工具再编译图最后把用户请求灌进去。async def main(): tools, contexts await load_all_tools() model ChatOpenAI(modelgpt-4o, temperature0) app build_graph(tools, model) result await app.ainvoke({ messages: [ { role: user, content: 读取本地 app/main.py 的正文并查一下工单 WS-1002 的状态总结两件事 } ] }) print(result[messages][-1].content) if __name__ __main__: asyncio.run(main())我实测跑一趟的效果是模型先后调用了 fs_read_file 和 ticket_query_status 两个工具中间没有来回纠偏最后输出一段包含文件头部内容说明和工单状态判断的总结。整个过程能明显看到工具调用是分步的每一次工具结果都会回流到模型上下文模型再决定下一步动作。这里我要特意提醒不要忘记关闭会话上下文。上面示例里我用aenter建立的 context 其实应该用 async with 管理或者至少在 Agent 跑完后执行aexit否则本地 stdio 子进程会一直挂在那HTTP 连接也不释放。长驻服务里这就是内存和句柄泄露。5. 常见问题与排查技巧实录5.1 握手失败与版本不匹配现象是 ClientSession.initialize() 直接抛异常。排查分两种情况stdio 模式下先手动在终端跑一下 Server 的启动命令看它能不能独立运行。很多握手失败是因为 Python 解释器路径不对或者 Server 文件 import 了本机没有的依赖。HTTP 模式下先确认 URL 可达再确认鉴权头格式对不对。还有一个隐蔽问题某些旧版 Server 对新的协议版本支持不完整虽然 SDK 会自动降级协商但降级后能力集可能会缩水。遇到 initialize 报版本相关错误优先检查 Server 端 SDK 版本。5.2 stdio 下的 stdout 污染坑这个坑我踩得很惨。之前在一个文件 Server 里随手加了一行 print(server started)结果客户端 tools/list 频繁解析失败报 JSON 解析错误。原因很简单stdio 传输用的是标准输出传 JSON-RPC 消息你任何多余的 print 都会混进管道把消息流搅乱。客户端拿到的数据不是合法 JSON解析自然挂。解决方法是Server 端所有日志一律写 stderrstderr 不走协议通道。你可以用 logging 库把 handler 绑到 sys.stderr开发调试时打印再多也不影响协议。类似的问题还有 SDK 内部 debug 输出开启前先确认输出方向。5.3 HTTP Server 超时与鉴权过期远程 Server 的典型问题是长连接空闲后失效。你半小时前初始化了会话期间 Agent 在跑别的任务回头再调工具时服务端已经回收会话报错要么是连接关闭要么是 token 过期。我的应对方案有两层一是在图的关键节点前做一次轻量 ping发现连接异常就重新初始化二是封装一个带重连逻辑的 session 提供者调用工具前检查连接状态必要时自动重建。鉴权过期是另一个高频问题。如果用的是短 token要设计成从配置中心动态读取而不是进程启动时一次性加载。很多团队在本地调试没事一上生产就频繁 401多半就是 token 固定写死导致的。5.4 多 Server 工具名冲突的规避工具名冲突不只在重名时出现两个工具描述高度相似也会让模型混淆。比如 Server A 的 get_user 和 Server B 的 fetch_user_profile虽然名字不同语义太接近模型照样可能选错。规避方案有三个层面命名前缀是第一层上面代码已经展示第二层是在描述里写清楚工具的数据来源和适用条件比如“仅用于查询内部CRM系统工单状态请用 ticket_query_status”第三层是前面说的按阶段注入用不上就别让模型看见。我还习惯在 ToolNode 外面包一层自定义路由记录每个工具最近 10 次的调用成功率和平均耗时低于阈值的工具自动降权。这个机制在生产环境帮我过滤过不少不稳定的工具推荐你们也试试。我自己在实际操作里的体会是MCP 多 Server 调用的难点从来不在“把多个连接建立起来”而在于你怎么管理工具的可信度、命名和生命周期。协议层面把连接标准化了剩下的是工程问题。LangGraph 的价值则是把这类工程问题重新组织成一张清晰的图每一步都能观测、能控制。如果你正在做的 Agent 也遇到了“工具越接越多、逻辑越来越乱”的情况不妨试试 MCP 加 LangGraph 这套组合先从一个本地 Server、一个远程 Server 开始跑通之后再逐步扩展。最后再分享一个小技巧给每个 MCP Server 都配一个独立的日志文件文件名带上 Server 标识。这样多 Server 并发调用时出问题你一眼就能看出是哪条链路挂了而不是在混合日志里翻到怀疑人生。工具多一些之后这个习惯能帮你省下大量排查时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进