ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

LangGraph 多 MCP Server 接入:从协议握手到工程落地全指南

LangGraph 多 MCP Server 接入:从协议握手到工程落地全指南 最近总有朋友在问同一个问题“LangGraph 里要同时接好几个 MCP Server到底应该怎么组织”问的人是越来越多我干脆把从协议握手到多 Server 调用的完整链路从头到尾写一遍。MCPModel Context Protocol本身不算新东西了但大部分人还是停留在“跑通一个 demo”的层面——尤其协议握手阶段到底发生了什么、多 Server 场景下 session 生命周期怎么管这两块文档写得少踩坑的人却最多。这篇面向两类读者一是被 LangGraph 工具接入折磨过的开发者二是想把 Agent 接到多个外部数据源、但不想被各家私有 API 绑死的人。读完你至少能搞清楚三件事握手阶段为什么不能跳过、多 Server 该怎么设计调用层、以及那些“复制就能用”的代码为什么会在运行一小时后突然报错。1. MCP 解决的核心问题与整体架构认知1.1 为什么需要标准化工具协议在 MCP 出现之前给大模型接外部工具是一件相当自虐的事。我早期做过一个文档问答 agent需要同时访问公司 Wiki、GitHub Issue 和一个内部数据库。每个系统都要写一套独立的工具封装Wiki 要处理 HTML 解析和登录态GitHub 要带 Token 并处理 API 限流数据库要手写查询参数校验。更麻烦的是这三套封装返回的数据格式还各不一样有的返回 Markdown有的返回 JSON有的直接抛异常。模型那边的工具描述要逐个手调每加一个数据源就要重写一遍注册逻辑。MCP 把这件事标准化了。它给“LLM 外部工具”定义了一套统一协议工具发现、调用、参数传递、结果返回、资源读取全部走同一个管道。用我常打的比方MCP 之于 AI Agent相当于 USB-C 接口之于手机外设——以前每个外设都要专用充电口现在一个口全解决只要设备遵循同一套协议就行。这里要纠正一个常见误解MCP 不是 Agent 框架也不是工具执行引擎。它只管“模型怎么描述一个工具、怎么发出调用请求、怎么拿回结果”这一层。真正决定“什么时候调、调哪个、调完怎么用”的还是你手里的 Agent 编排框架——我们后面要说的 LangGraph 就是干这个的。MCP 和 LangGraph 是分工关系不是替代关系。1.2 一次对话中的各方角色理解 MCP 的架构最省力的方式是记住三个角色Host模型和 Prompt 所在的宿主环境比如 Claude Desktop、IDE 插件或者你自己的 LangGraph 应用。Host 负责决定“要不要调用工具”但它不直接跟具体工具打交道。ClientMCP 协议里真正发出请求的一方。Host 里面会内嵌一个或多个 Client每个 Client 对应一个 Server 连接。在 LangGraph 场景下你的应用本身就是 Host内部创建的ClientSession就是 Client。Server暴露工具、资源、提示词的一方。它可以是你本地起的 Python 进程也可以是远程部署的 HTTP 服务。三者协作的大致链路是Host 通过 Client 向 Server 发tools/list拉取工具清单把工具描述塞进 Prompt 让模型看到模型说要调某个工具时Host 让 Client 发tools/call请求Server 执行完毕后把结果返回Host 再把结果塞回给模型继续推理。有个经常被忽略的点一个 Server 可以既是 Server 又是 Client。MCP 规范里允许 Server 内部再挂别的 Server这种嵌套结构在某些中间件场景很有用但在 LangGraph 多 Server 集成里我建议第一版千万别搞嵌套先让所有 Server 平级接进来把链路跑通再说。后面我们会看到平级接入已经足够让人头疼了。2. 协议握手拆解initialize 的每一步与版本协商2.1 JSON-RPC 生命周期与状态约束MCP 的通信协议建立在 JSON-RPC 2.0 之上所以有几个基础约定你得先刻在脑子里请求必须带id通知不带id响应必须跟请求的id对应。这个id匹配是客户端判断“这条响应对应于哪条请求”的唯一依据没有它异步模式下你根本分不清返回的是工具列表还是调用结果。MCP 会话有一个严格的初始化状态机大致是未初始化Init连接刚建立此时客户端唯一能发的就是initialize请求。已初始化Initialized收到initialize响应、并且发送notifications/initialized通知之后才能发送tools/list、tools/call这些正式请求。关闭Shutdown会话结束时关闭连接。这个顺序是硬约束。我见过不少新手直接跳过握手就去拉工具列表然后收到一个-32600之类的错误码一头雾水。实际上不是 Server 不支持你而是协议根本不允许你在初始化之前访问任何业务方法。2.2 握手报文逐段拆解以一个本地 stdio Client 为例握手阶段客户端会发出这样一条请求{ jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2025-06-18, capabilities: {}, clientInfo: { name: my-rag-agent, version: 0.1.0 } } }Server 收到后返回{ jsonrpc: 2.0, id: 1, result: { protocolVersion: 2025-06-18, capabilities: { tools: { listChanged: true }, resources: {}, prompts: {} }, serverInfo: { name: data-query-server, version: 0.2.3 } } }拿到这个响应后客户端还要再发一条通知告诉 Server“我这边已经准备好接收你的能力了”{ jsonrpc: 2.0, method: notifications/initialized }这条通知没有id因为它不需要回应。发完之后双向通道才算正式打开。随后客户端才会发送tools/list去拉取工具清单或者直接tools/call调用某个已知工具。握手报文里有三个字段要特别留意protocolVersion双方协商后最终采用的协议版本以 Server 返回的为准。这是后面 2.3 节的重点。capabilitiesServer 声明自己支持哪些能力常见的有tools、resources、prompts、sampling、logging。注意这只是一个能力声明不代表实现一定可靠。serverInfoServer 的名称和版本号。在多 Server 场景下这个字段是你在日志里区分“到底是哪个 Server 挂了”的关键线索。用 Python SDK 时这些琐碎的 JSON 收发通常被封装好了from mcp import ClientSession, StdioServerParameters, stdio_client server_params StdioServerParameters( commandnpx, args[-y, modelcontextprotocol/server-github], env{GITHUB_PERSONAL_ACCESS_TOKEN: ...} ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools await session.list_tools() for tool in tools: print(tool.name, tool.description)但封装归封装底层发生了什么你必须心里有数。因为后面排查问题的时候你会发现所有诡异现象都能追溯到握手阶段的某个字段没对上。2.3 能力协商与版本降级的细节版本协商的逻辑是客户端在initialize里声明一个自己支持的protocolVersionServer 在响应里返回它自己实际支持的版本。如果 Server 支持客户端声明的版本那就原样返回如果不支持Server 会返回它能支持的最高或某个兼容版本客户端需要用返回的这个版本继续后续通信。这里隐藏着一个很坑的细节有些 Server 实现比较粗糙它不降级而是直接拒绝握手返回一个错误。我遇到过一款内部自研的 Server只实现了2024-11-05版本的协议客户端声明2025-06-18时它直接抛initialize failed而不是优雅地返回自己支持的版本。这种时候你只能手动把客户端声明的版本调低。好在 MCP Python SDK 允许你显式指定协议版本session ClientSession(read, write) await session.initialize()如果 SDK 默认声明的版本跟 Server 不兼容就要回落到一个更保险的版本。我的建议是连接任何第三方 Server 之前先看一眼它的文档搞清楚它支持哪个协议版本区间。不要默认所有 Server 都跟得上最新规范——事实恰恰相反多数 Server 落后客户端一个甚至两个版本。另外capabilities字段里的声明要打折看待。有的 Server 在capabilities里把tools、resources、prompts全声明了实际上它只实现了tools其它两个调用就会返回method not found错误码-32601。所以做客户端容错时别把 capabilities 当契约要假设能力可能缺失、调用可能失败做好兜底。3. LangGraph 多 Server 调用的两种架构方案3.1 方案对比聚合工具表 vs 按需路由搞懂握手之后真正的重头戏来了在 LangGraph 里到底怎么让一个 Agent 同时调用多个 MCP Server 提供的工具LangGraph 本身不认识 MCP它只认识“工具”——也就是符合 LangChain Tool 接口的可调用对象。所以 MCP 接入 LangGraph 的本质就是把 MCP Server 暴露的工具翻译成 LangGraph 能用的 Tool 对象然后交给模型选择、由节点执行。多 Server 只是这个翻译过程要做多遍而已。我实际用过两种架构方案各有适用场景维度方案一聚合工具表方案二按需路由实现方式把所有 Server 的工具全部装载重命名后合并成一个大的 ToolNode只暴露一个call_mcp路由工具内部根据参数分发调用模型可见工具数多全部铺给模型少通常只有一个Prompt 消耗工具一多token 消耗直线上升稳定不随 Server 数量增长工具冲突处理需要手动重命名加前缀天然隔离无需重命名适用场景Server 少、工具少20Server 多、工具多几十上百灵活性模型可以直接选到具体工具语义清晰模型可能传错server_name或tool_name参数我见过不少团队一上来就照着方案一写结果接了 5 个 Server、80 多个工具Prompt 塞得又臭又长模型开始瞎选工具。如果你预估工具总数会超过二十个直接上方案二。3.2 聚合方式全量工具装载的代码实现方案一的代码不算复杂但有一个大坑连接生命周期必须管理好。很多人从教程里复制来一段代码用async with把连接包起来装载完工具就释放连接回头一调用就报session closed。注意看下面这段连接不是装载完就扔的而是挂在全局MCPToolHub上长期存活import asyncio from mcp import ClientSession, StdioServerParameters, stdio_client from langchain_mcp_adapters.tools import load_mcp_tools from langgraph.prebuilt import ToolNode class MCPToolHub: def __init__(self): self.active_contexts [] # 持有 stdio_client 上下文 self.sessions {} # server_name - ClientSession self.server_params {} # server_name - StdioServerParameters async def connect(self, server_name: str, params: StdioServerParameters): ctx stdio_client(params) read, write await ctx.__aenter__() session await ClientSession(read, write).__aenter__() await session.initialize() # 保存上下文和会话保持存活 self.active_contexts.append(ctx) self.sessions[server_name] session self.server_params[server_name] params tools await load_mcp_tools(session) # 关键加前缀避免多个 Server 的工具重名 for tool in tools: tool.name f{server_name}__{tool.name} return tools async def close(self): for session in self.sessions.values(): await session.__aexit__(None, None, None) for ctx in self.active_contexts: await ctx.__aexit__(None, None, None)然后在 LangGraph 里把所有工具的 ToolNode 合并from langgraph.graph import StateGraph, MessagesState, START, END from langgraph.prebuilt import ToolNode, tools_condition async def setup_graph(server_map): hub MCPToolHub() all_tools [] for name, params in server_map.items(): tools await hub.connect(name, params) all_tools.extend(tools) tool_node ToolNode(all_tools) graph StateGraph(MessagesState) graph.add_node(agent, CallModel(all_tools)) graph.add_node(tools, tool_node) graph.add_edge(START, agent) graph.add_conditional_edges(agent, tools_condition) graph.add_edge(tools, agent) return graph.compile()这里最反直觉的地方是工具对象的存活期跟 session 的存活期绑死。如果你用async with把load_mcp_tools包起来上下文一退出session 就废了工具再调用必然失败。所以我把stdio_client的上下文挂到 hub 上长期保存只有显式调用close()才释放。不少生产事故就是这么来的连接池没养好运行几小时后所有工具调用全部超时。3.3 路由方式按需代理的具体实现方案二的核心思路是不把所有工具铺给模型而是塞一个通用的call_mcp工具让模型指定“去哪个 Server 调哪个函数传什么参数”。MCP 的call_tool方法本来就是这个签名所以实现起来相当直白from langchain_core.tools import tool from langgraph.prebuilt import ToolNode tool async def call_mcp(server_name: str, tool_name: str, arguments: dict) - str: 调用指定 MCP Server 上的工具。 参数说明 - server_name: 已注册的 Server 标识可选值: github, filesystem, sqlite - tool_name: 目标 Server 上的工具名必须是 tools/list 返回的名称 - arguments: 传给工具的参数必须是 JSON 对象 hub get_global_hub() if server_name not in hub.sessions: return f错误: Server {server_name} 不存在可选: {list(hub.sessions.keys())} session hub.sessions[server_name] try: result await session.call_tool(tool_name, arguments) return result.content[0].text if result.content else 无返回值 except Exception as e: return f调用失败: {e.__class__.__name__}: {e}这种方案的优点立竿见影无论接多少个 Server模型视野里永远只有一个工具Prompt 消耗稳定。但它也有代价——模型可能拼错参数。server_name还好说tool_name一旦拼错调用直接失败。我的缓解办法是在工具描述里把可用的 Server 列表和常见工具名写清楚并让工具在出错时返回“可选值有哪些”这样模型能自我纠错。还有一种折中做法不是暴露一个裸的工具而是把路由层包一层语义——比如把“查 GitHub Issue”封装成query_github_issue内部再路由到对应 Server。这样模型看到的工具名是有业务语义的而不是“去某个地方调某个很底层的函数”错误率会显著下降。缺点是每多一层封装就要多维护一套映射逻辑。4. 多 Server 场景的工程化细节命名冲突、session 与并发4.1 工具重命名与命名空间策略多 Server 接入后第一个让你头大的问题就是工具重名。我真实遇到过一个 GitHub Server 暴露了search_repositories另一个代码搜索 Server 也暴露了search_code本来不冲突。但有一次我接的两个 Server 都提供一个叫get_file的工具一个返回文本文件一个返回二进制数据。LangGraph 的ToolNode内部是按工具名做索引的重名不会报错而是后注册的把先注册的静默覆盖。这种“静默覆盖”最恶心的地方在于模型看到的工具列表里两个工具都还在但真正执行时你调get_file永远执行的是最后注册的那个。我排查这个问题花了整整一个下午。从那以后我强制规定所有 MCP 工具接入 LangGraph 前必须加命名空间前缀格式统一为{server_name}__{tool_name}。上面的MCPToolHub里已经写了这段逻辑for tool in tools: tool.name f{server_name}__{tool.name}如果你想保持工具名的语义可读性可以用|或:做分隔符但一定要在工具描述里写明命名规则否则模型看到github__get_file这种名字会困惑。我个人的经验是双下划线最稳定因为工具名本身很少包含连续双下划线不容易二次冲突。4.2 session 生命周期与连接池管理多 Server 场景下每个 Server 就是一条独立的连接、一个独立的 session。session 生命周期管理是工程化里最容易翻车的部分。让我们分传输层来看stdio 模式每个 Server 对应一个子进程。session 活着的前提是子进程活着。如果 Server 进程崩了比如内存不够、某个工具调用导致 segfaultsession 立即失效。LangGraph 任务通常跑得很久所以你必须实现重连逻辑——检测到 session 异常时重新拉起进程、重新握手、重新拉工具列表。这里有个容易忽略的细节重连之后工具对象需要重新装载。你之前 bind 到模型上的工具引用全废了得重新走一遍注册流程。streamable HTTP 模式远程 Server 用 HTTP 通信session 通过Mcp-Session-Id标识头维护。POST 请求带Mcp-Session-Id后续请求还要带If-Match头防止 session 冲突。这个模式对服务器友好适合横向扩展但对客户端要求更苛刻每次请求的 session 头不能丢丢了服务器就认为你开了一个新 session。如果你用了 HTTP 连接池或负载均衡要确保 session 头绑定到同一台后端实例否则请求被路由到别的实例session 状态就对不上了。我在生产环境用 streamable HTTP 接过一个远程 MCP 服务前半小时一切正常之后突然报Invalid session。查了半天才发现是内部 HTTP 客户端把Mcp-Session-Id头漏传了。所以用 HTTP 模式时第一步就是写好 session 头的透传逻辑并且日志里打印 session id 的变更。4.3 并发调用与超时控制LangGraph 里Agent 可能在一个节点里同时触发多个工具调用比如并行分支这在多 Server 场景下会瞬间打出多个并发请求到不同 Server。大部分 MCP Serve r本身并发能力有限有的甚至是纯串行处理的stdio 模式的 Python Server 经常是这样。我的做法是给每个 Server 加一个asyncio.Semaphore限制并发数class MCPToolHub: def __init__(self): self.semaphores {} # server_name - asyncio.Semaphore self.max_concurrency 3 async def call_tool_with_limit(self, server_name, tool_name, arguments): sem self.semaphores.setdefault( server_name, asyncio.Semaphore(self.max_concurrency) ) async with sem: session self.sessions[server_name] return await session.call_tool(tool_name, arguments)超时控制同样不能省。MCP 协议里没有内建超时机制不同 Server 的响应速度天差地别——有的本地工具几十毫秒返回有的远程服务要几十秒。如果你在 LangGraph 节点上设置统一超时快的 Server 没问题慢的 Server 可能一直被截断。我的建议是按 Server 单独设置超时在连接时记录各自的经验值self.timeouts[server_name] params.get(timeout, 30.0) result await asyncio.wait_for( session.call_tool(tool_name, arguments), timeoutself.timeouts[server_name] )还要注意重试的幂等性。工具调超时后你是否重试取决于这个工具是否幂等。MCP 协议本身不保证幂等一个“创建订单”的调用如果超时了盲目重试很可能导致重复下单。我的经验是默认不重试非幂等工具只重试明显只读的工具比如查询类、搜索类。这个判断标准必须写进工程规范里靠人记是记不住的。5. 实测踩坑记录握手超时、工具覆盖与版本不匹配5.1 工具重名导致的静默覆盖一次完整排查前面说了工具重名的坑这里展开讲一次完整排查链路。当时我接了一个本地文件系统 Server 和一个远程代码搜索 Server两个都暴露了get_file。LangGraph 跑起来后模型一直在调get_file想读本地文件但返回的总是远程代码搜索结果。第一步我先看模型实际选择的工具名。LangGraph 日志里能看到模型输出的 tool_calls工具名确实是get_file没有歧义。第二步我看ToolNode执行时调用的函数发现get_file被绑定到了远程 Server 的实现上。第三步检查两个 Server 的工具列表发现重名。第四步查注册顺序后注册的远程 Server 把本地 Server 覆盖了。整个排查过程基于一个关键认知LangGraph 的 ToolNode 按工具名建索引重名不报错、不警告。如果索引是数组按顺序执行的就不会有覆盖问题但它是字典所以必然覆盖。这个认知现在被写进了我的团队开发规范任何 MCP 到 LangGraph 的接入工具必须带{server_name}__前缀没有例外。5.2 streamable HTTP 的 session 复用问题远程 Server 的 session 复用问题我也踩过一次。当时用 streamable HTTP 模式接了一个图床管理 ServerAgent 要连续上传多张图片。上传第一张正常第二张开始报Invalid session。我的排查思路是这样的先看请求头第一张图片上传时带上了Mcp-Session-Id第二张图片上传时这个头变成了空字符串。追踪代码后发现问题出在 HTTP 客户端的一个“优化”——它把Mcp-Session-Id当成普通请求头做了去重合并空值覆盖了有效值。修复很简单强制把 session id 存在一个独立变量里每次请求都重新塞进 headers。但这个问题提醒我用 HTTP 模式接 MCPsession 头是你最需要盯紧的生命线。5.3 stdio 进程退出与长时间任务的 keep-alive本地 stdio 模式有个经典故障Agent 任务跑了几十分钟突然所有工具调用都报“连接已关闭”。原因是 Server 子进程悄悄退出了。我们排查发现那个 Server 的日志里有一条内存溢出的记录——某个工具在解析一个超大文件时吃光了内存操作系统直接杀了进程。这类问题的修复分两层。第一层进程守护写一个 supervisor 逻辑定期发ping探活发现进程没了就重启、重新握手、重新装载工具。第二层调用前探活在call_tool之前检查 session 状态无效就先重连别等调用失败再补救。async def ensure_alive(self, server_name): session self.sessions.get(server_name) if session is None or not session.session_ready: await self.reconnect(server_name) return self.sessions[server_name]这个ensure_alive方法在每次工具调用前都执行一遍代价很低但能避免大量无效调用造成的超时堆积。5.4 协议版本不兼容导致的握手失败最后再讲一个版本不兼容的案例。有个第三方 MCP Server 只实现了早期版本的协议我这边 SDK 默认声明的是新版本协议。结果每次连接Server 直接拒绝握手报Unsupported protocol version。解决方式有两个。第一个强制客户端声明旧版本协议版本号让它匹配 Server 的实现。第二个如果 Server 支持多种版本就让它走能力协商自动降级。但实践中很多 Server 的降级逻辑写得并不好所以最稳妥的做法还是读文档、写死兼容的协议版本。多 Server 场景下不同 Server 可能支持不同版本的协议你的客户端要有能力针对每个 Server 单独指定版本号而不是全局统一一个。6. 从 Resource 到 Server 规划MCP 的下一步6.1 Resource 的三种形态与实战很多人以为 MCP 只有工具Tools其实协议里还有两大块Resource资源和Prompt提示词模板。Resource 在热词里也有人专门搜“MCP resource 实战”这里值得花点篇幅。Resource 解决的是“让模型能读取数据而不只是调用函数”的问题。它有三种形态静态 Resource固定 URI比如file:///etc/config.json内容相对稳定。Resource Template带参数的模板 URI比如file:///logs/{date}参数不同内容不同。Resource 订阅Server 主动推送资源变更通知客户端可监听更新。在 LangGraph 里Resource 可以作为一个“上下文预加载”机制使用。比如秘书类 Agent 启动时先用resources/read读取一份团队通讯录把它塞进 system prompt之后模型回答时就不需要每次调工具去查人了。这个模式能显著减少工具调用次数降低延迟。不过要提醒的是Resource 读取的内容会占用上下文窗口。一次读个 100KB 的日志文件GPT-4 级别的窗口直接吃掉一大块。所以读 Resource 之前先看大小设定截断策略别一股脑全塞进去。6.2 多 Server 架构的合理规划最后的最后说说多 Server 的架构规划。我见过两种极端一种是所有工具塞进一个巨大的 Server另一种是每个小功能单独起一个 Server。前者的问题是“单点故障”——一个工具崩了拖垮全部后者的问题是连接数爆炸光维护 session 就要花掉一半精力。我的建议是按领域切分控制规模。比如一个数据密集型 Agent可以拆成database-server统一封装各种数据库查询。filesystem-server处理文件读写、目录扫描。api-gateway-server对接外部业务系统CRM、ERP 等。每个 Server 内的工具数量控制在十个以内这样工具描述不会太长模型选择压力小session 数量也可控。另外给每个 Server 设定明确的权限边界。MCP Server 往往能访问文件系统或外部网络一个被攻破的 Server 等于给攻击者开了一扇门。我在接入第三方 Server 时会在一个受限用户下运行 stdio 进程限制它只能读写某个目录网络访问也走白名单。现在整个生态扩张得很快连 SQL Server、IDA、x32dbg 都已经有人写了 MCP ServerUnreal Engine 那边也有人在搞 MCP 集成。这说明协议的价值正在被整个行业消化——工具描述标准化的边际收益会随着接入方增多而指数级放大。回到开头那个问题LangGraph 多 Server 调用难的不是“调”而是“管”——管好握手、管好 session、管好命名、管好并发。我自己把这些流程固化成了MCPToolHub这个连接管理器之后接入新 Server 的成本从半天压缩到了十几分钟。如果你也打算在项目里面对多个 MCP Server我建议你第一件事不是写业务逻辑而是先把连接管理和命名规范立起来。这两个地基打牢了后面加多少个 Server 都不至于塌方。
RELATED READING

延伸阅读

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