上个月在内部AI Agent平台里,我们撞上一个很典型的场景:同一个工作流里,既要查MySQL里的订单数据,又要读本地文件系统里的合同文档,还得调一个外部汇率接口。三套能力来自三个完全不同的服务,如果按传统方式给大模型挨个写适配器,每个都要处理认证、参数规范、错误码,维护成本直接爆炸。后来我们转用MCP(Model Context Protocol)统一接入,并在LangGraph里完成了多Server编排——用一套协议同时搞定三处数据源。这篇文章从MCP的协议握手讲起,把从握手、工具发现到LangGraph多Server调用的整条链路拆开细聊,适合正在考虑把MCP接进生产业务、或者准备在LangGraph里做多工具编排的团队参考。
1. MCP协议握手:为什么它是整个调用链的地基
1.1 一次完整MCP会话从initialize开始
MCP的本质是一个基于JSON-RPC 2.0的消息服务,Client和Server之间通过某种传输层交换消息。传输层可以是一对stdio管道,也可以是HTTP/SSE连接,但无论底层用哪种方式,业务层面的第一件事永远是同一个:完成一次正式握手。
这个握手过程拆开看是四个步骤。第一步,Client发送一个initialize请求,里面带三样东西:自己期望的protocolVersion、自己支持的capabilities列表,以及clientInfo用于标识客户端身份。第二步,Server收到后返回自己的能力声明,包括它支持的协议版本、它自己实现了哪些capabilities(比如是否支持tools、resources、prompts),以及serverInfo。第三步,Client拿到Server的能力声明后,再补发一条notifications/initialized通知,相当于告诉Server:“我已经看清你的能力,可以开始正式干活了”。第四步,只有在这条通知之后,Client才能发tools/list、resources/list、tools/call这类真正的业务请求。
为什么协议要设计成这么麻烦?因为MCP是一个能力协商型协议,不是把消息硬编码成固定格式。不同版本的Server,有的支持工具,有的只支持资源,还有的支持流式日志。Client如果不先做一次能力探测就直接发请求,很容易收到一堆“方法不存在”的错误。放到生活场景里,这就像两个人刚开始共事,必须先告诉对方自己擅长什么,后面沟通才不会鸡同鸭讲。省掉这一步,所有基于MCP的调用都会变成盲打,这也是很多新手接入时第一个踩坑的地方。
1.2 握手阶段到底协商了什么
握手阶段真正协商的内容,总结起来是三块:协议版本、能力清单、身份信息。
protocolVersion是重中之重的字段。MCP从2024年底公开到现在一直在迭代,我实际见到的版本标记就有2024-11-05、2025-03-26、2025-06-18这几代。Client会在initialize里声明它想用的协议版本,Server如果支持就返回同样的版本号;如果不支持,有些Server会尝试向下兼容到两个版本都认的公共子集,有些直接返回协议版本不支持的错误。我踩过最典型的一个坑是:用新版SDK创建的Client,去连一个还停留在旧版协议的本地Server,结果initialize阶段直接失败,错误日志里明确写着protocol version not supported。遇到这种情况,要么升级Server端的SDK,要么显式把Client端协商的版本拉低,没有第三条路可走。
capabilities则是能力发现的提前量。Client侧常见的capabilities包括roots和sampling:roots允许Server访问客户端指定的本地目录,sampling允许Server反向向模型请求采样结果。Server侧的capabilities包括tools、resources、prompts、logging等。但要注意,capabilities只表示“我支持这一类交互”,并不代表“我现在具体有哪些工具”。具体工具要等握手完成后调tools/list才能拿到。这两个概念很容易混淆,我见过有同事以为capabilities里写了tools就万事大吉,结果tools/list返回空列表,追了半天才发现是Server端工具注册出了问题。
clientInfo和serverInfo就是名字和版本号,主要给日志和监控用。多Server同时跑的时候,如果所有连接的clientInfo都叫“my-client”,出了问题连日志都分不清是谁发的请求。建议在封装MCP Client时,把clientInfo命名为业务模块名加上版本号,比如revenue-agent/1.2.0,排查问题的效率会高很多。
1.3 握手阶段的高频失败信号
翻了一下我们这半年接过的MCP Server,握手失败的原因基本就三类。
第一类是stdio管道被污染。stdio传输模式下,Client把JSON-RPC消息写到Server的标准输入,然后从Server的标准输出读响应。如果Server代码里有人写了print("debug"),或者用了某个喜欢往stdout打日志的库,这些垃圾内容就会混进协议流。Client端解析不到合法的JSON消息,握手直接挂掉。这个问题非常隐蔽,因为它不会直接报“管道被污染”,而是表现为握手超时、JSON解析失败、甚至偶发性的调用错乱。
第二类是初始化超时。大量社区MCP Server通过npx动态拉取,第一次启动要先下载整个npm包,耗时从几秒到几十秒不等。如果Client侧没有调大连接等待时间,就会在“initialize已经发出、Server还没就绪”的情况下误判失败。这个问题在本地测试时往往不出现,一上容器或CI环境,网络缓存一冷,立刻就炸。
第三类是版本协商失败,前面已经说过,新旧版本标记不兼容时Server会直接拒绝。判断这类问题最快的办法是用MCP Inspector连一下看原始报错,而不是在业务代码里反复打日志。
2. 握手之后:工具发现、元数据缓存与一次调用的完整链路
2.1 tools/list的时机与缓存策略
握手完成后,Client才有资格调用tools/list。这一步会返回Server当前支持的所有工具,每个工具带三个关键字段:name、description、inputSchema。大模型正是靠这三个字段决定“这个工具该不该调、参数怎么填”,所以description和inputSchema的质量,直接决定工具调用的准确率。
生产环境里,我不建议每次Agent启动都把全部工具的元数据拉一遍。工具数量少还好说,一旦一个Server上挂十几个工具,description总长度很容易超过几千token。模型每次推理都要把这些无关工具的说明塞进上下文,既浪费窗口,又会稀释模型对目标工具的注意力。实测里,一个Agent工作流的有效工具数最好控制在5到8个以内,超出这个范围就该考虑分组或延迟加载。
我现在的做法是把工具注册表缓存到本地进程,按server_name + tool_name做Key,Value就是完整的JSON Schema。只有在Server资产版本变更时才重新拉取tools/list。接入LangGraph之后,这层缓存做在ClientSession和ToolNode之间,模型层完全无感。另外一个隐藏收益是启动速度:省去了每次进程拉起时的元数据交换,冷启动时间能砍掉一半以上。
2.2 一次工具调用的完整生命周期
当模型决定使用某个工具时,完整调用链是这样的:模型根据inputSchema生成一个结构化调用请求,Agent框架拿到这个请求,通过Session发送tools/call,携带工具名和参数。Server执行对应逻辑,可能查库、读文件、调外部接口,然后返回一个content数组。这个数组可以是纯文本,也可以是资源链接或图片块。Client把返回结果包装成标准的ToolMessage回填到大模型的上下文中。模型结合返回值继续推理,判断是结束还是再次调用其他工具。
这里有一个容易误解的点:MCP协议本身不关心“模型为什么选择这个工具”,它只负责传输和执行。真正决定调不调、先调谁、调用结果如何反馈的逻辑都在Agent层。所以多Server接入的核心难点其实不在MCP协议上,而在Agent层如何编排多个Server暴露的工具集合。这一点我在LangGraph部分会重点展开。
2.3 资源与提示词为什么常被忽略
MCP除了工具,还有资源和提示词两类能力。资源用于向模型提供可直接引用的结构化数据,比如数据库表结构、配置文件内容;提示词则是可复用的Prompt模板。初期我们只用了tools/list和tools/call,对resources完全没管,后来才发现很多数据类Server把表结构元数据通过resources暴露出来,而这恰恰是模型生成正确查询语句的关键上下文。
但资源与工具的角色必须分清:工具是“要执行的操作”,资源是“可直接引用的数据”。一个典型的Agent工作流里,通常先从资源加载必要上下文,再决定调用哪个工具去执行。LangGraph的状态节点里可以把资源加载结果先放进State,再进入工具调用节点,整体流程会顺很多。
3. 多Server架构选择:为什么需要多个MCP Server而不是一个大而全的Server
3.1 多Server的边界划分原则
进入LangGraph之前,首先要拍板的是“拆几个Server、怎么拆”。我的三条原则是:按权限边界拆、按生命周期拆、按协议形态拆。
按权限边界拆是最重要的。文件Server只授予文件操作权限,数据库Server只授予SQL执行权限,外部API Server只授予特定域名访问权限。这样即使单一Server被攻破,影响面也被限制在最小范围。真实团队里,Server可能是不同小组维护的,你至少在配置层把每个Server的网络访问策略收敛到它自己的业务域。
按生命周期拆解决的是发布效率问题。数据库查询Server可能一周迭代一次,文件系统Server可能一个季度都不动。硬把两者合进一个进程,任何一侧变更都需要整体回归,部署风险随之上升。MCP的好处就是可以独立部署、独立版本、独立回滚。
按协议形态拆则是为了避开运行环境冲突。某个工具依赖Node.js,另一个依赖Python,塞进同一个Server进程会被环境依赖折腾死。MCP允许每个Server用最顺手的语言和运行时实现,LangGraph这边只关心Session是否连上,完全不关心Server内部是技术栈是什么。
3.2 LangGraph里怎么承载多Server
LangGraph本质上是一个面向状态化Agent编排的框架,图节点做推理,边做控制流控制。接入MCP Server的常见做法有三种。
第一种,最常用的方案:为每个MCP Server建立一个ClientSession,用SDK把该Server暴露的工具加载成LangChain标准工具,然后合并为一个tools列表,绑定到模型上。模型每次需要工具时,调用的目标就是某个Server的某个工具,Agent层的工具执行节点根据工具名路由到对应Session执行。这套方案直接、可控,也是我推荐多数团队从它入门的原因。
第二种是使用现成的多Server Toolkit,比如langchain-mcp-adapters里的MultiServerMCPToolkit,读取一份包含多个server的配置,自动创建Session并合并工具。适合工具总量不大、拓扑简单的场景,代码量最少,但出了问题需要翻框架源码时会更绕。
第三种是在LangGraph节点中动态连接Server:某个节点运行到确实需要查询数据库时,才临时拉起数据库Server的Session,用完立刻释放。适合Server多、但单次对话只会用到其中一小部分的场景,能大幅节省常驻资源和启动时间。
我目前的实践是第一种和第三种的混合:常驻Session留给高频Server,低频Server按需拉起。如果一股脑把所有Server全连上,代码虽然最简单,但工具总量一大,首轮推理延迟会明显上升,模型也容易被不相干的工具描述干扰。
3.3 控制流交给Agentic Loop
LangGraph真正让多Server好用起来的,是它的Agentic循环。模型可以在同一轮对话里连续调用多个Server的工具:先调数据库Server查订单状态,发现需要展示用户合同,再调文件Server读合同摘要,最后调外部API查当日汇率。这个流程里的每一次工具调用,都发生在同一个图的不同节点之间,LangGraph通过条件边决定“继续调工具”还是“直接输出最终答案”。
具体的图结构通常是这样:一个agent节点负责模型推理并绑定所有工具,一个tools节点负责具体执行工具调用,一条条件边判断模型输出里是否含有tool_calls,有就回到agent节点继续,没有就进入结束节点。多个MCP Server只是为tools节点提供了更多工具来源,并不改变图的整体结构。真正决定流程稳不稳的,是工具调用层的错误处理:某一个Server中途失联时,tools节点要把错误信息包装成ToolMessage塞回上下文,让模型知道这次调用失败,而不是让整个图直接崩溃。
4. 实操:用LangGraph编排多个MCP Server的完整调用流
4.1 最小工程骨架与依赖选型
我这次用的是LangGraph 0.2.x,配合langchain-mcp-adapters和官方mcpSDK。核心依赖就四个:langgraph、langchain-openai、langchain-mcp-adapters、mcp。
选型上有一条建议:底层Session一定要用官方mcpSDK来创建,不要套太多第三方封装。langchain-mcp-adapters只承担“把Session暴露的工具转换成LangChain标准工具结构”这一件事,职责单一,出了问题好排查。如果封装太厚,某个环节报错时你根本分不清是协议问题、SDK问题还是LangGraph节点配置问题。
4.2 Server配置:stdio与HTTP/SSE两种形态
多Server接入前,先把一份清晰的Server配置管理起来。stdio形态大概是这样的:
{ "mcpServers": { "file_server": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/workdir"] }, "db_server": { "command": "python", "args": ["db_mcp_server.py"], "env": { "DATABASE_URL": "sqlite:///app.db" } } } }HTTP/SSE形态则是给Server一个URL:
{ "mcpServers": { "remote_api": { "url": "http://localhost:8000/mcp" } } }注意一个细节:配置里的server name必须唯一且要有业务辨识度。用server1、server2这种名字,后面工具名冲突排查时你会哭的。我们统一采用业务域_server的命名法,比如file_server、db_server、exchange_server,工具加载后一眼就能看出它来自哪个Server。
4.3 把多个Server的工具注入LangGraph节点
核心代码其实不长。先把每个Server的Session和工具加载出来:
import asyncio from typing import TypedDict from langchain_openai import ChatOpenAI from langchain_mcp_adapters.tools import load_mcp_tools from langgraph.graph import StateGraph, END from langgraph.prebuilt import ToolNode from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client class AgentState(TypedDict): messages: list async def connect_stdio(command: str, args: list[str], env: dict | None = None): params = StdioServerParameters(command=command, args=args, env=env or {}) reader, writer = await stdio_client(params).__aenter__() session = await ClientSession(reader, writer).__aenter__() await session.initialize() tools = await load_mcp_tools(session) return session, tools async def build_all_tools(): all_tools = [] _, file_tools = await connect_stdio("npx", ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/workdir"]) _, db_tools = await connect_stdio("python", ["db_mcp_server.py"], {"DATABASE_URL": "sqlite:///app.db"}) all_tools.extend(file_tools) all_tools.extend(db_tools) return all_tools注意load_mcp_tools接收的是已经初始化完成的ClientSession实例,Session内部会维护与Server的连接状态。工具执行时,实际上是LangChain的ToolNode调用了工具封装内部的回调,由这个回调通过Session发出tools/call请求。所以Session的生命周期必须比图执行更久,建议在应用启动时就连接好,而不是在每次图运行里重复连接。
HTTP/SSE形态也类似,只是stdio_client换成了流式HTTP客户端,加载流程不变。实际写代码时,建议把不同形态的连接函数分开,便于单独测试。
接下来组装LangGraph:
model = ChatOpenAI(model="gpt-4o", temperature=0) all_tools = await build_all_tools() llm_with_tools = model.bind_tools(all_tools) def agent_node(state: AgentState): result = llm_with_tools.invoke(state["messages"]) return {"messages": [result]} def should_continue(state: AgentState): last_message = state["messages"][-1] if last_message.tool_calls: return "tools" return END builder = StateGraph(AgentState) builder.add_node("agent", agent_node) builder.add_node("tools", ToolNode(all_tools)) builder.add_edge("agent", "tools") builder.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END}) builder.set_entry_point("agent") graph = builder.compile()这个图的核心逻辑就两行:agent节点让模型决定调什么工具,tools节点执行并返回结果,should_continue判断要不要继续。多Server的复杂度全部被吸收到all_tools里,LangGraph本身不需要知道工具的Server来源。
4.4 真实的多工具调用输出验证
跑一个综合示例:用户输入“查一下订单DB-1023的金额,读一下同目录下的合同摘要,再给我今天的美元汇率”。模型大概率会依次触发db_server的订单查询工具、file_server的文件读取工具、remote_api的汇率查询工具。你会在LangGraph的trace里看到这几个工具调用按序执行,每次返回结果都回填到上下文,最终模型综合三份信息生成回答。
这其实就是多Server编排的核心价值:对LangGraph来说,工具来源可以来自任意Server,它只管路由和状态流转;对MCP Server来说,它只负责执行自己那部分工具;对模型来说,它只看到一个标准的tools列表。三层各做各的事,整条链路才能保持清爽。如果某一步想验证工具是否真的被调用,可以在tools节点外部加一层RunnableLambda打印工具名和参数,不要直接在工具内部print,原因在下一节展开。
5. 多Server调用中的坑位盘点与排查经验
5.1 stdio Server的stdout污染
这个坑我在开头提过,它真的值得反复强调。只要你自研MCP Server跑在stdio模式下,日志输出就必须走stderr,或者通过MCP的logging能力发给客户端。任何一条print输出,都会破坏JSON-RPC协议流,轻则握手失败,重则工具调用返回解析错误。
Python的logging默认输出到stderr,一般没事;但有些第三方库会偷偷往stdout写提示,比如命令行工具的banner、警告信息。排查办法很直接:在终端手动运行Server命令,观察stdout是否干净;或者在Server入口封装一层,把子进程的stdout重定向到文件,只保留stdin和专用的协议stdout通道。
5.2 工具名冲突与覆盖
多个Server合并工具列表时,同名工具会互相覆盖,更隐蔽的问题是模型分不清某个工具属于哪个Server。我们踩过“两个Server都有read_file”的情况,LangChain在合并时并没有报错,但实际调用时路由到了第一个Server,结果数据完全不对。
现在的防护习惯是三层都做:配置层给server name起有区分度的名字;工具层加载后重新遍历,给每个工具名加业务前缀,比如db_query_order、file_read_file,同时同步修改description里的调用说明;模型层bind_tools时确保工具名无歧义,避免模型生成错误名称导致tools/call找不到方法。
5.3 握手超时与首包下载延迟
stdio模式最常见的握手延迟来自npx首次下载包。生产环境我强烈建议不要用npx动态脚本,而是把Server构建成独立可执行文件,或者锁死npm版本。如果只能临时用npx,就先手动预热一次,让npm缓存落在机器上,再让Client去连。
同时,给ClientSession加超时参数。不同SDK写法不同,Python SDK通过初始化参数控制读取超时,具体以当前版本为准。我们线上把超时从默认值调到30秒之后,握手失败率肉眼可见地下降。调大超时不会拖慢正常请求,只是给慢启动留了余地,这笔账很划算。
5.4 会话复用与并发隔离
多Server的Session全局复用时,并发是另一个隐患。某些MCP Server对tools/call是按顺序处理的,如果你在LangGraph的tools节点里用asyncio.gather并发调用同一个Session下的多个工具,可能触发Server端状态错乱。稳妥做法是:高频工具走独立连接,或者干脆在工具节点里串行执行工具调用。收益是稳定性,代价是单轮耗时变长,但在多数业务场景里,稳定比快更重要。
5.5 排查三板斧:MCP Inspector、原始报文与最小复现
排查多Server问题,我现在的流程固定三步。
第一步用MCP Inspector。执行npx @modelcontextprotocol/inspector启动可视化调试工具,填入server命令和参数,就能看到完整的握手消息、工具列表和每次调用结果。协议层的问题用它基本都能定位。
第二步抓原始报文。stdio模式下可以写一个中间透传脚本,把stdin/stdout的原始JSON流落盘;HTTP模式则用抓包工具看/mcp端点的请求响应。这一步主要对付那些框架层已经吞掉错误信息的情况。
第三步做最小复现。把LangGraph撤掉,直接用ClientSession连Server,单独调一个工具。如果这一步能跑通,问题一定在编排层;如果跑不通,问题在协议层或Server本身。这个简单的二分法,能省掉大量无谓的排查时间。
最后说点个人体会。多Server MCP接入LangGraph这套组合,真正的收益不在于代码多炫,而在于把“模型、协议、工具实现”三层彻底解耦。加一个新数据源时,只需要写好一个MCP Server,上层几乎不用动。而且按我目前的经验,先单Server跑通,再逐步扩展多Server,比一次配齐多个反而更省时间——环境问题、端口问题、工具冲突问题都会小得多。如果你正在做类似的Agent平台,建议第一步就引入MCP,后面接SQL Server、文件系统、内部API都会顺很多。