用 MCP 打通模型和工具之前,得先把“对话”的规矩搞清楚。最近在折腾 LangGraph 多 Server 编排时,踩了一堆 MCP 握手和生命周期管理的坑,从协议底层到实际工程实现折腾了大半周,这篇文章就把整个链路从协议握手讲到 LangGraph 多 Server 调度,全是实际操作过的经验。
MCP(Model Context Protocol)现在已经是 LLM 工程里绕不开的协议了。它把“模型要调用工具”这件事标准化:模型不再需要知道每个工具的内网 IP、鉴权 token、请求格式,而是通过一个统一的协议代理层去发现和调用。我这次要解决的是在一个 LangGraph 工作流里,同时挂上文件系统工具、数据库查询工具和浏览器自动化工具,让 LangGraph 根据任务动态调度。
1. MCP 的定位与整体架构拆解
1.1 MCP 到底解决什么问题
先说个生活化的类比。MCP 就像你家里装修时请的“总包工头”。过去你请电工、水管工、木工,每个人都有自己的报价方式、沟通话术、进场时间,你得一个个去对接。MCP 出现之后,所有工种都统一成一套沟通规范:你要做什么、需要什么材料、回报什么结果。这样业主(也就是 LLM Agent)只需要面对一个标准接口,剩下的路由、权限、并发都交给协议层处理。
从工程层面拆解,MCP 的角色有四个:
- Host:承载 LLM 和工具调度逻辑的进程,也就是你的应用本体
- Client:在 Host 内部,负责和 Server 建立连接、执行协议交互
- Server:暴露具体能力(工具、资源、提示词模板)的独立服务
- Transport:Client 和 Server 之间的通信通道,分为 stdio 和 HTTP 两类
这套模型的好处是:工具的提供方完全不用关心上层是 LangGraph、AutoGen 还是别的 Agent 框架,只要实现了 MCP 协议,谁都能接。同样,Agent 框架也不用为每个工具写适配器,一次接好,处处能用。
1.2 传输层的选型逻辑
MCP 规范里定义了两种主要的传输方式:
- stdio:Client 用子进程方式拉起 Server,通过标准输入输出通信。适合本地工具进程,比如文件系统操作、本地脚本执行
- Streamable HTTP:走 HTTP 长连接/流式通道,适合远程服务、微服务、多人共享的公共服务
选型判断逻辑很直白:进程要跟着应用本地生命周期走的,用 stdio;工具独立部署、需要被多个 Agent 共享的,用 HTTP。不过 HTTP 传输有一个坑:如果你用的是 SseClient,连接会一直保持;而新版 StreamableHttpClient 可以在响应结束后关闭连接,减少资源占用。LangGraph 里两种都支持,实测下来 StreamableHttpClient 的稳定性更好,尤其在 Server 频繁重启的场景下。
1.3 一次完整的 MCP 交互说了什么
MCP 的交互模型是“请求-响应 + 通知”,类似 HTTP 但语义更靠近 RPC。一个完整生命周期包括四步:
- 初始化握手:Client 发送
initialize请求,Server 返回协议版本和能力声明 - 能力确认:Client 发
notifications/initialized通知,双方进入就绪状态 - 能力发现:Client 调用
tools/list、resources/list、prompts/list获取可用的工具清单 - 工具调用:Client 调用
tools/call,Server 执行并返回结构化结果
实际去抓协议内容时你会发现,initialize里携带了protocolVersion、capabilities、clientInfo三个关键字段。protocolVersion是版本协商的锚点,capabilities决定后续能走哪些路子。比如 Server 声明了toolscapability,Client 才知道可以去拉工具列表。
2. 协议握手原理与核心机制全拆解
2.1 握手过程的字段细节
先上一段我用 MCP Inspector(官方调试工具)实际抓到的握手请求:
{ "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": { "roots": { "listChanged": true }, "sampling": {} }, "clientInfo": { "name": "langgraph-mcp-demo", "version": "0.1.0" } } }Server 返回的响应格式大致这样:
{ "jsonrpc": "2.0", "id": 0, "result": { "protocolVersion": "2024-11-05", "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": true } }, "serverInfo": { "name": "filesystem-server", "version": "1.0.0" }, "instructions": "此服务器提供文件读写能力..." } }有几个字段值得重点说:
- protocolVersion:双方协商出一个都能接受的最低版本。比如 Server 支持
2025-03-26,但 Client 声明只支持2024-11-05,那 Server 应该降级到 Client 支持的版本。版本不匹配轻则警告,重则直接握手失败 - capabilities:这是能力协商的关键。Client 声明的是“我能做什么”(比如支持 root 文件系统权限、采样),Server 声明的是“我提供什么”(比如工具列表、资源订阅)。如果 Client 不声明
sampling,Server 就不能在工具执行过程中反过来请求 LLM 补生成内容 - instructions:Server 可以向 Client 传递一段自然语言指令,描述“这个服务怎么用最合适”。LangGraph 的 system prompt 拼接阶段可以把这段指令吸收进去,让模型更理解工具语义
2.2 SDK 做了什么以及为什么
直接用原生 JSON-RPC 写 MCP 客户端非常痛苦,因为你要自己处理消息 id 配对、超时重试、生命周期状态机。官方 SDK 把这些都封装了,我实际用 Python SDK 开发时感受最深的是三个隐藏逻辑:
- 消息 id 自增配对:SDK 维护一个
request_id计数器,发出的请求自动带 id,异步返回时根据 id 找到对应的 future 并 resolve - 状态机守护:只有握手完成之后才能调用工具,如果你在握手前发
tools/call,SDK 会直接抛异常 - 能力拦截:如果 Server 没声明工具能力,
list_tools()返回空列表而不是报错,避免无意义的网络请求
如果在 LangGraph 里封装 MCP 客户端,最好用contextlib.AsyncExitStack管理生命周期。这是我在实践中踩坑最多的地方——没有做好异步退出清理,连接断开后子进程残留,端口被占满。
2.3 握手失败时先看这几个地方
握手失败的原因八九不离十是这个:
- 协议版本不一致:尤其当你用了早期版本 SDK 连新协议 Server,经常出现
Unsupported protocol version错误。解决办法:升级 SDK 到最新版 - stdio 子进程启动崩溃:Server 入口写错、依赖缺失、启动时抛异常,Client 这边只看到 EOF 或空响应。排查方式:先在命令行手动运行 Server 命令,看看能不能正常跑起来
- HTTP 长连接被中断:负载均衡器和代理服务器如果配置了空闲超时,握手响应慢可能会导致连接被杀。需要调长代理超时时间
总之,协议本身不复杂,复杂的是你没理解它做对了什么。MCP 把“工具能力 + 对话上下文 + 权限控制”三件事合并成一套协议,比 Function Calling 的纯粹“函数描述”往前多走了半步。
3. LangGraph 多 Server 调用实战
3.1 为什么单 Server 不够用
我最初只挂了一个文件系统 Server,后来发现任务一复杂就卡壳。原因很简单:一个 Server 通常只擅长一类事。文件系统 Server 做不了数据库查询,数据库 Server 做不了网页操作。把不同能力塞进一个 Server 不是不行,但会造成耦合:
- 工具列表太长,模型做工具选择时容易混淆
- 权限边界模糊,一个 Server 要么都有权限要么都没有
- 更新一个工具要重启整个服务,影响其他工具可用性
所以正确的姿势是按能力域拆分 Server,再交给 LangGraph 做调度编排。我这次的实际配置是三套 Server:
| Server 名称 | 能力域 | 传输方式 | 挂载节点 |
|---|---|---|---|
| fs-server | 文件读写、目录遍历、文件搜索 | stdio | 文档处理节点 |
| db-server | SQL 查询、表结构感知、数据摘要 | Streamable HTTP | 数据分析节点 |
| browser-server | 网页导航、内容提取、表单操作 | stdio | 信息采集节点 |
3.2 LangGraph 里集成多个 MCP Client
LangGraph 本身不直接管 MCP,它是通过langchain-mcp-adapters把 MCP 工具转成 LangChain 的BaseTool,然后塞进图的节点里执行。
看一下我封装客户端的核心代码:
from contextlib import AsyncExitStack from langchain_mcp_adapters.tools import load_mcp_tools from mcp import ClientSession, StdioServerParameters, stdio_client from mcp.client.streamable_http import streamablehttp_client class MCPToolRegistry: def __init__(self): self.exit_stack = AsyncExitStack() self.tools_by_server = {} async def connect_stdio(self, server_name: str, command: str, args: list[str]): params = StdioServerParameters(command=command, args=args) read, write = await self.exit_stack.enter_async_context(stdio_client(params)) session = await self.exit_stack.enter_async_context(ClientSession(read, write)) await session.initialize() tools = await load_mcp_tools(session) self.tools_by_server[server_name] = tools return tools async def connect_http(self, server_name: str, url: str): read, write = await self.exit_stack.enter_async_context(streamhttp_client(url)) session = await self.exit_stack.enter_async_context(ClientSession(read, write)) await session.initialize() tools = await load_mcp_tools(session) self.tools_by_server[server_name] = tools return tools async def aclose(self): await self.exit_stack.aclose()注意几个细节:
AsyncExitStack是保证生命周期安全的唯一解。它能按后进先出的顺序关闭所有上下文,保证子进程被回收、HTTP 连接被释放- 每个 Server 独立
ClientSession,互不干扰。如果共用一个 session,协议层的消息 id 会串 initialize()必须显式调用,不然后续load_mcp_tools返回空列表,你根本发现不了工具缺失
3.3 把多个 Tool 列表注入图节点
在 LangGraph 里,每个节点函数通过绑定工具列表的方式调用模型。你可以把所有 Server 的工具合并(add_node之前拼成一个大的工具列表),也可以每个节点绑定自己域的工具子集。
我最终采用的做法是:每个节点只感知自己域的工具,这样模型做工具选择时搜索空间小,误调用概率直线下降。
from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, END class AgentState(TypedDict): task: str docs: list query_result: dict web_content: str async def doc_node(state: AgentState): model = ChatOpenAI(model="gpt-4o").bind_tools(tool_registry.tools_by_server["fs-server"]) result = await model.ainvoke(state["task"]) # 解析 tool_calls 做进一步调用... return {"docs": result} async def data_node(state: AgentState): model = ChatOpenAI(model="gpt-4o").bind_tools(tool_registry.tools_by_server["db-server"]) # ... async def web_node(state: AgentState): model = ChatOpenAI(model="gpt-4o").bind_tools(tool_registry.tools_by_server["browser-server"]) # ... builder = StateGraph(AgentState) builder.add_node("doc", doc_node) builder.add_node("data", data_node) builder.add_node("web", web_node) builder.add_edge("doc", "data") builder.add_edge("data", "web") builder.add_edge("web", END) graph = builder.compile()这种方式最大的好处是:图的拓扑本身就表达了“先处理文档,再查数据,再采集网页”的业务流程。模型在每个节点只需要做局部决策,不需要全局理解所有工具。
3.4 动态发现工具能力的技巧
还有一个实战技巧:不要硬编码工具列表,而是在启动时动态拉取。MCP 协议原生支持listChanged通知,Server 的工具列表发生变化时会主动通知 Client。
LangGraph 侧每次运行图之前,可以先检查工具列表是否有更新。如果变多了,重新绑定工具;如果变少了,移除对应绑定。这样做的好处是:Server 增加新工具、调整参数描述时,Agent 侧无需重启,下一次运行自动感知。
async def sync_tools(self, server_name: str, session: ClientSession): current_tools = self.tools_by_server.get(server_name, []) latest_tools = await load_mcp_tools(session) if len(current_tools) != len(latest_tools): self.tools_by_server[server_name] = latest_tools logger.info(f"[MCP] {server_name} 工具列表更新: {len(latest_tools)} 个工具") return latest_tools3.5 工具结果回流模型的完整链路
工具调用的结果需要回流给模型,模型才能继续生成后续内容。关键点在于:工具结果要按顺序原样返回,不要自己提前加工。
以文件读取为例:
tool_result = await tool_runner.ainvoke({ "name": "read_file", "arguments": {"path": "/data/report.md"} }) model_input = state["messages"] + [AIMessage(content=str(tool_result))] response = await model.ainvoke(model_input)这里的tool_runner就是你绑定了工具的模型产生的tools接口。LangChain 的bind_tools生成的是让模型决定调用的结构,但真正执行工具需要你手动调用。一个常见的坑是:模型返回了多个tool_calls,但你只执行了第一个。必须遍历执行,然后一次性把结果拼回去:
for tool_call in response.tool_calls: result = await tool_runner.ainvoke(tool_call) messages.append(ToolMessage(content=result, tool_call_id=tool_call["id"]))tool_call_id必须要对应上,这是 LangChain 内部做消息配对的依据,写错会导致模型无法理解“这次工具结果对应的是哪次调用”。
4. 常见问题与排查技巧实录
4.1 stdio 模式下子进程残留
这是最典型的坑。你在 LangGraph 里连接了一个 stdio Server,图跑完了,AsyncExitStack调用aclose()关闭了 session,但 Server 子进程还在跑。最典型的场景:文件锁没释放,下次启动同一路径时直接端口冲突或文件占用。
原因解析:stdio 的关闭分两层——关闭管道(Client 端)和退出进程(Server 端)。Server 如果要“优雅退出”,得自己监听 stdin EOF;但有些 Server 实现没处理这个信号,就会挂在那。
排查思路:
- 检查系统进程列表,确认 Server 进程是否残留
- 看 Server 日志,确认是否收到退出通知
- 实在不行,准备一个超时强杀机制:给子进程设
timeout,超过就terminate()
我最后的解决方法是给 stdio Server 包了一个 watchdog 脚本,空闲超过 5 分钟自动退出,这样即使 Client 端没杀干净,也不会无限占用资源。
4.2 HTTP Server 连接不稳定
Streamable HTTP 的稳定性取决于网络基础设施。实际遇到的坑:
- 代理缓冲导致流式响应卡住:SSE 流如果经过 Nginx 缓冲,客户端可能收不到增量事件,表现为“服务端执行完了但客户端一直没结果”。解决方案:关掉 Nginx 对 SSE 路径的缓冲,或者把响应头
X-Accel-Buffering: no加上 - TCP 连接复用导致旧连接接管新请求:HTTP 长连接如果服务端重启了,旧连接在池子里还是“健康”的,新请求打到关闭的连接上直接报错。SDK 一般有重试机制,但重试次数有限
实践建议:HTTP MCP Server 的地址不要用域名负载均衡的短 TTL 解析,尽量绑 IP 直连,或者自己维护一层连接池,定期探活。
4.3 工具调用内容太大把上下文塞爆
不夸装地说,这是我用 LangGraph 多 Server 时最常面临的问题。文件 Server 返回了一个超大 JSON,塞进消息历史后,模型直接报上下文超限。
解决思路:在 Server 侧做结果裁剪,而不是在模型侧硬吞。MCP 协议本身没规定返回大小限制,但你可以自己实现一个包装层,针对大文件读取、全表查询这类操作,强制分页或者摘要化处理。
比如文件读取的 Server 端代码,我加了行数和字节限制:
@mcp.tool() async def read_file(path: str, max_bytes: int = 8192) -> str: data = await async_read(path, max_bytes=max_bytes) if len(data) >= max_bytes: return data + "\n...[内容已截断,需要分段读取]" return data这个限制要在 Server 端做,因为客户端拿到的协议层数据已经是裁剪后的,模型和 Agent 都不会超载。
4.4 工具选择错乱:模型调错 Server
多 Server 场景下,模型可能把“读取数据表结构”这种该走数据库 Server 的请求,调到了文件系统 Server 上,然后自然报错“文件不存在”。这不是模型蠢,是你给的工具描述不够清晰。
MCP 工具的描述字段description是模型选择工具的超级关键依据。我把每个 Server 的工具描述统一加了“领域前缀”,比如:
fs_开头的工具,说明是“文件系统操作,路径处理”db_开头的工具,说明是“数据库查询,只处理结构化数据”web_开头的工具,说明是“网页抓取与表单交互”
并在系统提示词里加一条硬规则:涉及数据查询的,优先选择 db_ 工具;涉及路径和文件内容,选择 fs_ 工具;涉及网页交互,选择 web_ 工具。模型再出现选择错的概率就大幅下降了。
4.5 多 Server 并发调用时的共享状态
如果你在一个图节点里同时触发了多个 MCP Server 的工具,它们之间不要共享任何内存状态。这不是 MCP 的限制,而是分布式系统的基本常识。每个 Server 都独立持有自己的配置、连接、临时文件。如果你需要跨 Server 传递数据,显式把数据放到 LangGraph 的 State 里,通过图的边传递,而不是用全局变量。
我遇到的一个真实案例:文件 Server 读了一个 CSV,数据库 Server 要把它导入表。第一版实现,我在文件 Server 里写了全局缓存,数据库 Server 去读那个缓存,结果文件 Server 重启后,数据库 Server 还在从幽灵缓存里读,导了旧数据。后来改成统一写在临时目录,文件名通过 State 传递,问题彻底消失。
4.6 排查实录:一个 Real 案例
分享一个完整的排查过程。某次运行时,db-server始终返回空工具列表,图跑起来永远报“找到可用工具”。排查链路:
- 先用 MCP Inspector 手动连接
db-server,发现正常返回工具列表,排除 Server 本身问题 - 在 LangGraph 的
connect_http里打印 session 握手结果,发现initialize返回 200 但serverInfo为空 - 抓包看响应体,发现 Server 响应的既不是 JSON-RPC 也不是 SSE 事件,而是一段 HTML 错误页——
db-server网关上挂了认证层,未带 token 的请求被挡 - 在 HTTP 客户端参数里加
Authorization头,问题解决
这个案例的教训是:很多“协议级”的问题,其实根子在“传输级”。排查时必须一层层剥,先看传输通不通,再看协议通不通,最后才是业务逻辑通不通。
5. 进一步扩展的思路
写完这套多 Server 调用后,有几个方向可以继续深挖。
- 动态 Server 注册中心:把 MCP Server 的信息(命令、URL、能力声明)写进配置文件,LangGraph 启动时动态加载。这在 Server 数量超过 4 个时几乎是刚需,不然代码里全是硬编码连接逻辑
- 权限隔离:MCP 协议本身没有细粒度的权限控制。实际工程里,不同 Server 应该绑定不同的服务账号,比如文件 Server 用只读账号、数据库 Server 用查询只读账号,避免一个工具被滥用导致全链路风险
- prompts 模板下沉:MCP Server 可以声明
prompts,把 Server 的“使用说明书”直接放进协议层。LangGraph 可以在组装 system prompt 时自动拉取这些描述,避免每个节点手写工具说明
协议这一层花时间搞明白之后,后面接什么框架都不是问题。MCP + LangGraph 这套组合最核心的心得就是:MCP 管好了“工具上桌”,LangGraph 管好了“上菜顺序和节奏”。协议握手的细节到位,多 Server 才能各自安好;工具选择的边界清晰,模型才不会像一个进了玩具店的孩子,什么都想碰一下。如果你也在搞多工具编排,建议从最小链路起步:先单 Server 跑通全流程,再横向扩 Server,最后再上动态发现和权限隔离。把地基打牢,业务层怎么搭都是顺的。