前阵子调一个Agent项目,被MCP的报错折磨到半夜。倒不是LangGraph本身难用,真正让人头疼的是多Server组合之后那一堆协议层面的初始化问题:握手超时、工具列表为空、工具重名、服务器能力协商失败……这些问题如果只看应用层代码,根本看不出原因。后来把MCP协议握手那套流程完整捋了一遍,再回头看这些报错,瞬间就通透了。
这篇文章我就从两条主线展开:第一,MCP从协议握手到一次工具调用的完整生命周期到底发生了什么;第二,在LangGraph里怎么正确挂多个MCP Server,并且让它们统一交给Agent调度。适合正在做LangGraph应用、准备把MCP接进生产环境的开发者,也适合那些刚接触MCP、被ts和npx那一堆命令行搞晕的新手。
1. MCP协议到底是什么?先搞懂握手的目标
1.1 MCP的三层协议栈
很多人第一次接触MCP,会把它当成一个“工具框架”,这其实是理解上的偏差。MCP(Model Context Protocol)本身不是一个框架,也不是某个特定的SDK,它是一个协议——定义AI应用(宿主)如何发现、读取、调用外部工具和资源的统一标准。你可以类比成USB-C:以前数据线、耳机、充电器各有各的接口,现在统一成一个口,设备和协议互相兼容。MCP做的事情就是把模型和外部世界之间的连接标准化,模型不用再为每个系统写一份定制封装。
整个协议栈可以拆成三层理解:
- 传输层:负责字节流的搬运,常见的是stdio(本地子进程)、SSE(Server-Sent Events)、Streamable HTTP(推荐用于远程)。
- 协议层:使用的报文格式是JSON-RPC 2.0,所有请求、响应、通知都遵循这套语义。
- 语义层:MCP定义的那一套领域模型,包括tools(工具)、resources(资源)、prompts(提示词模板)、sampling(采样)、roots(根目录)等。
我记得刚接触MCP时有个误区,以为MCP只是做工具调用(tools/call)。其实不是,MCP的三类核心能力里,tools是执行类操作(比如创建文件、发请求),resources是只读数据的暴露(比如数据库查询结果、日志文件),prompts则是服务器端预定义的提示词模板。这三者组合起来,相当于给模型提供了一套完整的外部能力抽象,而工具只是其中最常用的一块。
在协议层,消息分为三种:请求(request)、响应(response)、通知(notification)。请求必须带id,响应必须对应同一个id;通知没有id,也不需要应答。MCP握手之所以被很多人搞混,就是因为初始化用了一对“请求/响应”,紧接着又发了一个通知,顺序绝对不能乱。
1.2 握手为什么重要:capabilities 协商
MCP握手不是简单的“打个招呼确认连接”,它的核心目标是能力协商和版本兼容。你可以把它理解成两个人在合作前先互相说清楚各自会什么、不会什么、能容忍什么版本。如果这一步没做对,后续任何调用都可能返回“Method not found”或“Unsupported protocol version”之类的错误。
握手的标准流程是:
- 客户端发送
initialize请求,携带自己期望的protocolVersion、clientInfo(客户端名称和版本)以及客户端的capabilities。 - 服务端返回自己的
protocolVersion(实际支持的版本)、serverInfo、capabilities、instructions(给客户端的指导说明)。 - 客户端收到响应后,发送
notifications/initialized通知,告诉服务端“我已确认你的能力,可以开始业务了”。 - 在收到
initialize响应之前,客户端不允许向服务端发送任何其他业务请求。
这里有个很多人都忽略的细节:capabilities是双向的。客户端要声明自己支持什么,服务端也要声明。目前常见的服务端能力包括tools.listChanged、resources.subscribe、resources.listChanged、prompts.listChanged、logging、completions等。客户端的capabilities则包含sampling(允许服务端反过来请求LLM补全)和roots(允许服务端读取文件系统的根路径)。
能力协商不是为了走形式,它解决了一个实际问题:防止调用方调用了对方根本不支持的接口。比如某个MCP Server只暴露了resources能力,没暴露tools能力,如果你的初始化阶段没有正确协商到tools.listChanged,后面调用tools/list就会拿到空列表或者直接报错。这种错误在日志里非常难排查,因为协议层面它没有明确的“该服务不支持tools”提示。
1.3 传输层:stdio、SSE和Streamable HTTP
传输层是三选一的问题。我实际用的比较多的是stdi和Streamable HTTP,分别对应本地和远程两种场景。
stdio(标准输入输出):客户端以子进程方式启动服务端进程,通过进程的stdin写入请求、从stdout读取响应。这个方式只适合本地场景。比如用npx -y @modelcontextprotocol/server-filesystem /tmp启动一个文件系统服务,就是这个类型。stdio的方式好在延迟低、没有网络开销、环境变量可以直接传给子进程,缺点是一旦进程挂掉或者stdout被其他日志污染,协议流就断了。
SSE(Server-Sent Events):这是旧版远程MCP的标准做法。客户端通过HTTP POST发送请求消息,通过一个GET请求的长连接接收服务端推送的消息。它的问题是需要两个端点配合,客户端先通过GET连接建立SSE通道,从中拿到一个session id,再往POST端点发消息。实际部署时CORS、代理、连接保活都容易出问题。
Streamable HTTP:从协议版本2025-03-26开始,MCP引入了这种新传输方式。相比传统SSE,它还允许同一个HTTP端点通过POST发送请求,同时通过GET或POST获得流式响应,服务端的实现可以统一到一个端点。以mcp python sdk为例,支持transport="streamable-http"的StreamableHttpSession。如果要从零搭远程服务,直接选这条路,别再用旧SSE了。
三种传输方式的对比我整理了一个表:
| 传输方式 | 消息模型 | 典型场景 | 优点 | 缺点 |
|---|---|---|---|---|
| stdio | 子进程 stdin/stdout | 本地Agent容器、开发调试 | 低延迟、进程隔离、环境变量可控 | 不适合分布式、进程生命周期要自己管 |
| SSE | HTTP POST + GET长连接 | 远程服务(旧方案) | 可靠、标准HTTP | 两端点复杂、CORS麻烦、保活麻烦 |
| Streamable HTTP | HTTP POST/GET流式 | 远程服务(新方案) | 单端点、支持流式、OAuth友好 | 需要服务端实现较新的协议版本 |
LangGraph本地Agent场景,我优先用stdio,因为简单、快、不用处理认证。远程多服务场景,用Streamable HTTP更稳妥。判断标准很简单:进程跑在哪,就用哪种传输。
2. 从握手到一次完整工具调用,走一遍全过程
2.1 initialize握手报文细节
讲完理论,看实际报文。我用Python的mcp官方SDK作为例子,一个典型的initialize请求长这样:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": { "name": "my-agent", "version": "0.1.0" } } }服务端正常情况下返回:
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2024-11-05", "capabilities": { "tools": { "listChanged": true } }, "serverInfo": { "name": "filesystem", "version": "1.0.0" }, "instructions": "Use the filesystem tools to read, write, and manage files." } }客户端收到后,立即发送初始化通知:
{ "jsonrpc": "2.0", "method": "notifications/initialized" }这就是三件套。注意protocolVersion的协商机制:如果客户端请求的协议版本服务端不支持,服务端不能擅自帮你降级。它只会返回一个错误,正确的做法是客户端捕获异常后,换一个更低版本的协议重新初始化。SDK层面,MCP Python官方库已经把这种重试逻辑封装好了,但我个人建议在调试阶段还是要知道原理,不然看到日志里一连串报错根本不知道哪一步先失败。
实际项目中,我喜欢用一个最简单的脚本,跳过LangGraph直接验证MCP Server本身是否健康。这样可以快速把问题定位到协议层,而不是框架层:
import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): server_params = StdioServerParameters( command="npx", args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], ) 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.tools: print(tool.name) print(tool.description) print(tool.inputSchema) result = await session.call_tool("list_directory", {"path": "/tmp"}) print(result) asyncio.run(main())这段代码干的事情,就是上面握手三件套的封装。如果你能通过这段脚本把一个Server的handshake和tool调用跑通,那它99%是正常的;剩下的问题几乎都属于上层框架集成。
2.2 tools/list与tools/call的闭环
握手完成之后,业务层就简单多了。服务端暴露的工具放在result.tools数组里,每个工具包含name、description、inputSchema。inputSchema是JSON Schema格式,LLM在拿到这个schema之后,会自己组织参数,生成一个合法的调用请求。
一个完整的工具调用闭环是这样的:
- LLM根据tools列表,判断自己需要调用哪个工具。
- 生成
tools/call请求,携带name和arguments。 - 服务端执行工具,返回
content数组和一个isError标记。 - 客户端把工具执行结果作为新的消息喂回给LLM,LLM继续决定下一步动作。
content数组最常见的类型是文字:
{ "content": [ { "type": "text", "text": "file listing complete..." } ], "isError": false }注意isError字段。工具业务上返回了错误提示(比如“文件不存在”),也不能代表协议层出错,协议层依然是200 OK,但isError要置为true,语义上注意区分。很多人在排查时看到HTTP 200就觉得一切正常,其实工具已经失败了。
执行过程中有一个容易被忽略的点:同一个工具名称不能重复。在单Server场景下问题不大,但是一旦进入多Server组合,这个约束会立刻变成痛点,后面我详细讲。
2.3 认证、CORS与生产环境注意事项
如果你走的是远程MCP Server,认证绕不开。从2025-03-26协议版本开始,MCP官方将OAuth 2.0授权流程纳入了规范,具体包含Authorization Server Metadata discovery、authorization code流程、DPoP绑定令牌等。实际服务端实现不完全相同,但客户端只要支持标准OAuth 2.0就能兼容大多数。
如果你只在本地用stdio,认证不是问题,但有两个隐患要提前排除:
- 环境变量透传:stdio子进程会继承父进程的环境变量。如果Agent进程环境变量里有高危密钥,那这个MCP Server也看得到。生产环境下,建议单独为子进程构造最小化的env参数,而不是直接继承整个父进程环境。
- 日志污染:stdio Server往stdout写日志是最大的坑。协议消息走stdout,日志必须走stderr。我曾遇到过某个Server把启动日志打到stdout,结果客户端解析JSON直接崩了,报错还不直观,排查了很久才定位到是子进程stdout混入了非JSON内容。
另外,浏览器里调用MCP Server要处理CORS。服务端需要正确配置Access-Control-Allow-Origin、Access-Control-Allow-Headers、Access-Control-Allow-Methods。如果是在Node服务端做中转,这一步尤其容易漏。
3. LangGraph多Server调用:架构设计
3.1 为什么会有“多Server”需求
一个Agent只挂一个MCP Server,当然也能跑,但实际应用中很快会发现不够用。
我这里举一个真实场景。做一个智能运维助手,它需要三种能力:查询PostgreSQL数据库(数据库MCP Server)、读取日志文件目录(文件系统MCP Server)、给监控系统发告警(内部HTTP API暴露的MCP Server)。如果不用多Server,就得把这三个能力全写进一个Server里,看起来是少了一次部署,实际上问题更多:安全边界模糊、运维切换风险大、团队协作要碰同一个仓、权限没法做细粒度隔离。
多Server的价值,本质上跟微服务的价值一脉相承:
- 单一职责:每个Server只负责一类能力,内存开销、依赖、故障影响范围都能控制住。
- 独立更新:某个Server升级不影响其他Server。
- 生态复用:社区里有大量现成Server,比如filesystem、github、fetch、postgres、git等,直接拿来组合就够用。
- 权限最小化:远程Server之间通过不同token隔离,各自能访问的资源范围是固定的。
LangGraph本身不限制Agent用多少工具。模型看到的是一份最终合并的工具清单,底层能调用多少Server对模型来说不透明。关键是怎么把多个Server的工具统一收编到一起,注入到图的状态流里。
3.2 MultiServerMCPClient的选型分析
LangChain生态接入MCP,主流方案是langchain-mcp-adapters。这个库提供了两个核心入口:单个的MCPClient和批量的MultiServerMCPClient。
刚开始我用的是一个一个初始化:
from langchain_mcp_adapters.client import MCPClient async with MCPClient(transport="stdio", command="npx", args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]) as client: tools = await client.get_tools()这样搞两个Server还能接受,三个以上就非常痛苦,而且还要自己处理event loop、session lifecycle、工具合并。后来改用MultiServerMCPClient,配置管理直接变成一张dict,清爽得多:
from langchain_mcp_adapters.client import MultiServerMCPClient client = MultiServerMCPClient({ "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/sandbox"], }, "fetch": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-fetch"], }, "remote-api": { "url": "https://your-mcp-server.example.com/mcp", "transport": "streamable-http", "headers": {"Authorization": "Bearer <token>"} } })这个client会在初始化阶段并发拉起所有Server连接,异步获取各自工具列表,最终聚合成一个大的工具列表。它帮你管理了session生命周期,省掉了大量重复代码。
有一个细节值得注意:MultiServerMCPClient里边的key(配置字典的键)会自动作为对应工具的默认前缀吗?实际并不总是这样。根据我的实测,工具名称不会自动带server前缀,全部是平铺在同一个名字空间里。这就引出了冲突治理问题。
3.3 工具命名空间与冲突治理
多Server组合后最典型的问题就是工具重名。比如filesystem Server里有一个search_files,另一个自定义Server里也可能有个search_files。LLM拿到工具列表后,发现两个同名但功能不同的工具,会困惑到底该调用哪个,甚至直接报错“duplicate tool name”。
我在项目中采用的方案是给所有工具手动加前缀:
tools = await client.get_tools() for tool in tools: namespace = tool.name.split("/")[-1] tool.name = f"mcp_{namespace}_{tool.name}"更稳妥的做法是,在遍历时按Server名分组加前缀。如果你用MultiServerMCPClient拉取的是合并列表,你就没法区分工具来自哪个Server。所以如果冲突严重,我建议逐个Server用MCPClient拿工具,在拿到的第一时间打上标签,再合并进总列表。两种方式的取舍很清楚:合并用省事但难做细粒度控制,分开用虽然多写几行代码但可控性更强。
工具数量也是需要考虑的问题。一个Server通常暴露10到30个工具,三个Server加起来可能超过60个。LLM的context window虽然很大,但同一时间给模型塞60个工具,效果并不好:模型在较长的工具清单里容易“看漏”,选择性能下降。治理手段有几个:
- 按任务动态选择工具:用户意图被路由到某个子Agent后,再加载对应的Server工具集。
- 按名称过滤:只暴露跟当前任务域相关的工具ID。
- 给工具描述写清楚边界:在树图中保留工具的description原始含义,但要在前面加Server名作区分,降低模型混淆概率。
4. LangGraph多Server调用的完整落地
4.1 环境准备与必要依赖
落地之前,先把环境准备好。我用到的依赖如下:
pip install langgraph langchain-openai langchain-mcp-adapters mcp逐个说一下用途:langgraph负责状态机和图执行;langchain-openai提供Chat模型的接入(换成你的实际模型供应商也行);langchain-mcp-adapters是LangChain和MCP之间的桥接层;mcp是MCP官方Python SDK,主要用于底层传输会话。
还需要一个Node环境,因为大部分官方MCP Server都是npm包,比如@modelcontextprotocol/server-filesystem需要用npx启动。我的环境是Node 20+,Python 3.10+。
4.2 逐步搭建:从创建Client到绑定ToolNode
完整代码逻辑如下,先创建client并获取全部工具,然后绑定到模型和ToolNode:
import asyncio from langchain_mcp_adapters.client import MultiServerMCPClient from langchain_openai import ChatOpenAI from langgraph.prebuilt import ToolNode from langgraph.graph import StateGraph, MessagesState, START, END from langgraph.checkpoint.memory import MemorySaver async def build_graph(): client = MultiServerMCPClient({ "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/sandbox"], }, "fetch": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-fetch"], }, "remote-api": { "url": "https://your-mcp-server.example.com/mcp", "transport": "streamable-http", "headers": {"Authorization": "Bearer token"}, }, }) await client.initialize() tools = await client.get_tools() # 工具重名治理:统一给工具加前缀 for idx, tool in enumerate(tools): used_names = {t.name for t in tools[:idx]} original_name = tool.name new_name = f"mcp_{idx}_{original_name}" if new_name not in used_names: tool.name = new_name llm = ChatOpenAI(model="gpt-4o", temperature=0) llm_with_tools = llm.bind_tools(tools) graph_builder = StateGraph(MessagesState) graph_builder.add_node("agent", llm_with_tools) graph_builder.add_node("tools", ToolNode(tools)) graph_builder.add_edge(START, "agent") def should_continue(state): last_message = state["messages"][-1] if getattr(last_message, "tool_calls", None): return "tools" return END graph_builder.add_conditional_edges("agent", should_continue, ["tools", END]) graph_builder.add_edge("tools", "agent") graph = graph_builder.compile(checkpointer=MemorySaver()) return graph, client async def main(): graph, client = await build_graph() try: config = {"configurable": {"thread_id": "test-1"}} while True: user_input = input("You: ") if user_input.lower() in {"exit", "quit"}: break async for event in graph.astream( {"messages": [("user", user_input)]}, config=config, stream_mode="values", ): last_msg = event["messages"][-1] if last_msg.type == "ai" and last_msg.content: print("Agent:", last_msg.content) finally: await client.aclose() asyncio.run(main())这个例子跑起来之后,你可以直接问“帮我列出/tmp/sandbox下的文件,顺便抓取 https://example.com 的标题”,它会走多个Server完成调用,再汇总回答。
流程上,LangGraph做的其实是ReAct循环:agent节点(绑定工具的LLM)生成计划或者工具调用,ToolNode执行工具,把结果回传给agent。循环会一直持续到agent给出最终回答。注意ToolNode接收的参数必须是tool_calls已经生成好的消息结构,所以我们的条件路由判断的是last_message.tool_calls是否存在。
4.3 加入状态控制与流式输出
多轮对话场景下,要想让Agent记得之前的对话上下文,必须给图加上检查点器。上面代码里已经用了MemorySaver(),并且在调用时传了thread_id。每次同一个thread_id的请求,LangGraph会自动拉取历史消息,拼接到当前消息流里。这个设计很适合聊天应用。
流式输出多Server场景下的表现需要特别关注。两种流式模式我常用:
stream_mode="messages":按token流式输出Agent逐步生成的回答,实时呈现效果最好。stream_mode="values":按节点逐步拿到最新状态,可以看到Agent的思考和工具调用顺序。
多Server场景流式输出的真正问题在于,某个远程Server响应慢会拖慢整个Agent流程。比如本地文件系统Server返回很快,但远程API Server耗时10秒,这10秒里Agent流程是暂停的,前端看不到任何流畅输出。我的处理方式是,在Agent调用工具前后打事件日志,前端显示“正在调用远程工具…”,至少让用户感知到系统还在工作。
async for event in graph.astream( {"messages": [("user", user_input)]}, config=config, stream_mode="updates", ): for node_name, update in event.items(): if node_name == "tools": print(f"[tool execution completed] {list(update.keys())}")5. 常见问题排查清单(踩坑实录)
5.1 握手失败类
最常见的一类错误是连接层和握手层的问题。我把报错现象、常见原因、排查思路整理成一个速查表:
| 报错现象 | 常见原因 | 排查思路 |
|---|---|---|
Could not connect to server/Connection refused | Server没启动;端口被占用;stdio子进程命令错误 | 单独跑一次npx命令验证Server是否可用;检查command和args是否拼写正确 |
Handshake failed/Unsupported protocol version | 客户端和服务端MCP协议版本不匹配 | 升级客户端SDK到较新版本;检查初始化请求的protocolVersion;SDK自动降级失败时手动指定旧版本 |
| 握手后马上连接断开 | stdio子进程崩了;Server输出的内容污染了stdout | 检查stderr日志;确认Server有没有把日志打到stdout |
| SSE连接一直在pending | 服务端SSE端点没有返回;代理/防火墙拦截 | 用curl或者浏览器直接访问SSE端点,看是否正常返回event stream;检查CORS和代理超时配置 |
我遇到最多的情况是stdio启动失败,本质上不是代码问题,而是npx在子进程里下载包太慢或者网络受限。想要避免,提前把@modelcontextprotocol/server-filesystem等包装好,然后用绝对路径直接启动,避免在线下载等待。
5.2 工具调用类
tools/list返回空列表
如果Server本身有工具,但LangGraph拿到的list是空的,先从两方面查。第一,查看Server的capabilities是否宣称支持tools能力;第二,检查我们用的是不是同一个session在调用。MultiServerMCPClient里如果某个Server初始化失败,它会抛异常,但你捕捉异常后继续执行,可能导致该Server的工具列表为空但总体上其他Server的工具拿到了。正确做法是每个Server单独try/except,确保失败的Server不影响其他Server的工具注册。
config = { "filesystem": {...}, "fetch": {...}, } client = MultiServerMCPClient(config) try: await client.initialize() except Exception as e: print("[mcp init error]", e)工具重名导致模型调用混乱
LangGraph和LangChain的工具列表要求名字唯一。多个Server有相同工具名的场景,我在3.3节里讲过了。除了加前缀,还可以在工具入图前做一个过滤:
allowed_prefixes = ("filesystem", "remote_api") filtered_tools = [t for t in tools if t.name.split("_")[0] in allowed_prefixes]这个方法适合你想有选择性地给Agent暴露能力的时候用。
5.3 性能与超时类
多Server初始化很慢
MultiServerMCPClient.initialize()底层是并发初始化,但流的健康程度不一致:如果某个地址检测Server正好在卡DNS解析,整体初始化会被拖慢。我建议在生产环境中先对每个Server单独做健康检查,无非就是拉一次initialize和tools/list,确认可用后再进入图执行流程。
工具调用卡住,进程不退出
这个问题多半是远程Server在等待响应,或者SSE长连接超时了。如果用的是stdio,检查子进程是否还活着;如果是远程Server,需要给LLM和工具调用分别设置明确的超时参数。比如OpenAI模型请求超时,可以用ChatOpenAI(timeout=60)控制。
Agent循环无限执行
某个工具调用成功返回后,Agent又继续调用同一个工具,形成死循环。缓解办法是给图加一个最大迭代次数:在LangGraph条件边里判断历史工具调用次数,超过阈值就返回END。
def should_continue(state): last_message = state["messages"][-1] if len(state["messages"]) > 20: return END if getattr(last_message, "tool_calls", None): return "tools" return END这个兜底逻辑虽然不是银弹,但能避免不少生产事故。
6. 一个经验建议:先做Server健康检测,再进图
如果在做一个稳定的线上Agent服务,我的建议是:不要在每次对话里才初始化MCP Client。把Server初始化做成启动时的预加载流程,启动完成后做一次全量工具列表的探活,探活失败直接告警。这样每次对话调用工具时,只是复用已建立的session,延迟会显著降低,故障也能提前暴露。
我之前线上遇到过一个问题:某个远程Server周日晚间维护,Agent在调用时才发现它连不上,结果图流程直接异常中断,前端一直转圈。后来我把健康检查挪到每分钟一次的定时任务,通过session探测远程Server的tools/list,失败就移除该Server的工具集并通知维护群,线上体验明显稳定很多。这个思路延续了多Server架构的核心原则:单个Server故障不应该拖垮整个Agent,该熔断就熔断,该降级就降级。
MCP这套协议真正解决的不只是“AI应用怎么调用外部系统”的问题。它给了整个Agent生态一个标准化的接入层:工具复用、能力协商、传输隔离都有了共同语义。我个人的体会是,LangGraph这种编排框架和MCP是天然互补的,LangGraph负责把Agent的思考流程组织成图,MCP负责把外部世界折叠成模型看得懂的工具和资源。两者配合,Agent编排的边界就清晰了。
多Server调用这条路上,工具重名、握手超时、Context窗口噪音这几个问题后面大概率都会遇到。处理这些问题的思路不复杂:先协议层探活,再编排层注册,最后模型层筛选。按这个顺序来,大部分坑都能绕过去。如果你近期也在做类似的东西,或者踩了某个我没提到的坑,欢迎一起交流。