
最近圈子里聊 MCPModel Context Protocol的人明显多了起来。不管是 IDE 里接数据库、让 Agent 调 Figma还是用 LangGraph 编排所谓“能让 AI 下地干活”的多工具链路MCP 几乎已经成了接入外部工具时的默认协议。网上讲概念的帖子很多但真正从协议层握手讲到LangGraph 里多 Server 调用的实操文章并不多。这篇就把我实际跑通的一条完整链路分享出来从最底层的 initialize 握手开始到用 LangChain 生态把多个 MCP Server 接进 LangGraph Agent每一步都附代码和踩坑记录。想搞懂 MCP 不只是在文档里复制粘贴配置的人这篇文章应该能帮你省不少时间。1. MCP 到底是什么它到底解了什么结1.1 从“工具接口混乱”说起先说一个最现实的问题在没有 MCP 之前如果我想让 AI 帮忙查数据库、搜文件、调用公司内部 API需要给每个数据源单独写一套“连接器”。LLM 本身只认识文本要让它操作外部系统要么走 Function Calling要么自己在代码里拼 prompt 和工具调用。Function Calling 解决的是“模型如何决定调什么函数”但函数从哪来、怎么规范化、怎么让多个不同应用共用一个工具能力它管不到。结果就是每个项目都在重复写类似的胶水代码数据库一个封装、文件系统一个封装、第三方 API 一个封装换个模型厂商可能还要再适配一遍。MCP 的诞生就是为了终结这种混乱。它把“AI 应用客户端”和“外部工具/数据源服务端”之间的通信方式标准化了。协议说白了就是一套规则客户端怎么连服务器、怎么列工具、怎么调用工具、怎么拿结果。一旦大家都遵守这套规则同一个 MCP Server 可以被任意支持 MCP 的客户端复用客户端也不用关心 Server 内部是 Java、Python 还是 Node 写的。MCP 的官方定位是“上下文协议”因为它不只是可以暴露函数Tools还可以暴露资源Resources和提示词Prompts。信息源和数据访问方式统一了模型拿到的“上下文”就不再只是一段文本而是整个可编程的工具环境。这个定位和单纯做“远程函数调用”的 RPC 本质上拉开了距离。1.2 协议组成和运行模式从部署模式看MCP 目前主力有两种 Transport传输方式stdioMCP Server 以子进程方式启动客户端通过标准输入输出和它交换 JSON 消息。本地开发最常用LangGraph 本地集成、FastMCP 默认模式基本都是这种。HTTP/SSEMCP Server 作为独立服务暴露 HTTP 接口客户端通过网络访问适合跨机器、跨容器的部署。一个 MCP 连接里有三个角色角色说明例子Host承载 AI 应用的进程通常是你写的 Agent 主程序LangGraph 应用、Claude Desktop、IDE 插件Client负责和 Server 建连、收发协议消息的组件MCP Python SDK 里的 ClientSessionServer暴露能力的一方提供方法供 Client 调用写好的文件搜索服务、数据库查询服务运行模式上客户端先启动传输层然后发起握手、能力协商最后进入正常工作状态。理解这个时序非常关键很多人用 LangGraph 接入 MCP 时遇到的怪问题有相当一部分就出在“没按协议时序来”这上面。2. 协议握手MCP 连接的第一步2.1 通信基础JSON-RPC 2.0MCP 协议的消息层建立在 JSON-RPC 2.0 之上也就是客户端和服务器之间发的是一个又一个 JSON 对象包含 jsonrpc 字段、method、params。请求需要带 id响应需要匹配这个 id通知则不需要响应。这套规范非常成熟理解起来也简单它就像两个人约定好对话格式一个人开口说“帮我做什么”另一个按编号回复“结果是什么”。MCP 的每一次方法调用最终都落到 JSON-RPC 的三大类消息上。比如客户端发{jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2025-06-18,capabilities:{},clientInfo:{name:my-agent,version:1.0.0}}}服务端回{jsonrpc:2.0,id:1,result:{protocolVersion:2025-06-18,capabilities:{tools:{}},serverInfo:{name:sql-server,version:0.1.0}}}很多读者看到这种 JSON 就头疼但实际开发中官方 SDK / FastMCP 已经帮你把这些底层逻辑封装好了你不需要手工编 JSON。但为什么我还要强调理解 JSON-RPC因为排查问题时要看协议日志一旦看到Method not found、Invalid params、JSON-RPC error你必须能立刻反应过来是协议层出了问题而不是业务代码出错。2.2 initialize 握手的完整过程MCP 的连接不是建立 TCP 连接就算完事它有一个类似 TCP 三次握手的“协议握手”过程。流程是这样的客户端发起initialize请求带上自己支持的协议版本和能力描述。服务端返回自己的协议版本、能力列表支持 tools、resources、prompts 中的哪些和服务标识。客户端收到后发送一个notifications/initialized通知表示“我知道你的能力了”。服务端收到通知后双方才进入“正常工作状态”。这里有个很关键的细节在notifications/initialized发出之前客户端是不允许调用tools/list、tools/call等业务方法的。协议上管这叫生命周期状态机。我在项目里看到过一种低级错误刚连上 server 就立刻调工具结果请求被 server 端无情拒绝报错是Connection not initialized。原因就是 SDK 的initialize在后台异步进行而你用asyncio.create_task提前并发发请求了。解决方法是确保连接流程走完再进入业务逻辑。版本协商也是一个重点。MCP 的 protocolVersion 是带日期的字符串比如 2025-06-18。client 和 server 各自声明版本最终协商后的版本取两者的交集。如果你手写协议对接一定要处理好版本不一致的情况别直接把服务端版本原样当成本地版本否则后续能力解析会错位。用官方 SDK 则不用太担心它会自动按兼容策略处理。2.3 能力发现tools/list 和 tools/call握手结束第一件真正意义上的“业务”操作就是能力发现。客户端发tools/list服务端返回工具列表。每个工具包含名称、描述、输入参数 schema。这个 schema 是 JSON Schema 格式也就是模型端做 Function Calling 时最需要的那份“说明书”。拿 SQL 查询服务举例工具返回会长这样{tools:[{name:query_order,description:按订单号查询订单信息,inputSchema:{type:object,properties:{order_id:{type:string},status:{type:string,description:订单状态默认全部},page:{type:integer}},required:[order_id]}}]}LangGraph 里的模型看到这份 schema 后会决定“什么时候调用工具、传什么参数”。工具描述写得好不好直接影响模型的理解准确率。MCP Server 把工具描述写清楚比在 Agent 代码里再加一堆 prompt 更管用。真正的执行动作是tools/call。客户端发{jsonrpc:2.0,id:2,method:tools/call,params:{name:query_order,arguments:{order_id:SO-001}}}服务端返回的结构里包含content数组内容类型默认是文本也支持图片等资源。SDK 里这个返回会被封装成工具调用结果LangGraph 的 ToolNode 拿到后直接写回状态。除了 toolsMCP 还有 resources 和 prompts 两类能力能力用途类比tools可执行的函数模型自主选择调用给 AI 配的“手”resources暴露只读数据比如配置文件、数据库记录给 AI 看的“资料室”prompts预置的提示词模板客户端可调用给 AI 准备的“话术库”这三样组合起来MCP Server 才能被称为“完整的上下文提供者”而不仅仅是“工具仓库”。2.4 心跳与连接生命周期建立连接后MCP 还有一套心跳机制。客户端可以对 server 发ping请求server 必须响应反过来 server 也可以 ping 客户端。目的是探测连接是否健康尤其在 HTTP/SSE 模式下长时间不通信可能导致网络中间设备把连接回收。我遇到过一个现象MCP 服务部署在远程服务器上Agent 空闲几分钟后调用工具失败排查发现连接已经被中间代理超时断开了。解决办法是在 Agent 的空闲分支里加定期 ping保持活跃。协议层面的连接状态还有 shutdown、aborting 等。正常关闭时双方会走shutdown流程释放资源。在 LangGraph 的异步环境里如果直接杀进程而不走关闭流程可能留下僵尸子进程尤其是 stdio transport 下子进程没有父进程回收会一直赖在系统进程列表里。3. LangGraph 里多 Server 到底怎么玩3.1 语言模型框架为什么需要 MCPLangGraph 本身的定位是用图的方式编排 Agent 的工作流节点负责执行边负责决定下一步去哪儿状态在节点之间流转。它并不关心工具怎么连外部的数据库、文件、API它只知道“模型说要调一个叫 xx 的工具参数是 yy谁来执行”这个执行者可以是任意实现了 Tool 接口的对象。MCP 接入 LangGraph最直接的好处是你不必为每个外部系统写 LangChain 专属的 Tool 封装只需写一个 MCP ServerLangGraph 应用通过 MCP 客户端就能使用它的能力。工具维护、数据源管理、权限控制都下沉到 Server 侧模型层和应用层保持干净。实际项目里我更推荐把 MCP Server 当成“一个 AI 可操作的能力单元”。比如你这个 Agent 需要两件事查数据库和操作文件系统那分别在两个 MCP Server 里实现。这样一来别人想复用时直接起 Server你想替换实现时也不用动 Agent 代码。3.2 多 Server 架构选择的三种方式当你有多个 MCP Server怎么设计 LangGraph 的调用策略我总结了三种常见方案。方案一全量工具注入。把所有 Server 的 tools 拉出来一股脑交给 LLM。简单粗暴适合工具总数量少10 个以内、且彼此功能独立的情况。缺点是当工具数量多、prompt 变长后模型的工具选择准确率会下降token 成本也高。方案二按子 Agent 分组。每个子 Agent 绑定一个 MCP Server再定义一个 Supervisor 路由节点根据用户意图决定调用哪个子 Agent。适合企业级的复杂场景比如“文件管理子 Agent”“数据库操作子 Agent”。代价是实现复杂度上了一个数量级需要设计拓扑和路由逻辑。方案三LLM 路由 动态加载。先用一个轻量分类器判断当前任务属于哪个领域然后在状态里动态挂载对应 Server 的工具。LangGraph 的状态持久化让这一招变得更加可行可以把已加载的工具缓存到状态中避免每次重复握手。我自己在中等复杂度的项目里默认先做方案一跑通后再按工具调用冲突或效果衰减来决定要不要升级到方案二。过早容器化、过早拆图都会让你在调试时多痛十倍。3.3 两条集成路线直接接 Protocol 还是用 Adapter接入 LangGraph 有两条主流路线很多人纠结选哪个路线 A自己写 MCP Client 封装。用官方mcpPython SDK创建ClientSessionlist_tools()拿工具然后在 LangGraph 的节点里直接调call_tool()。优点是完全掌控协议细节适合需要在调用前后加日志、鉴权、限流的场景缺点是一切手写代码量明显增加。路线 B使用langchain-mcp-adapters。这个库提供了MultiServerMCPClient能把多个 MCP Server 统一聚合成 LangChain Toolkit然后直接传给 LangGraph 的 ToolNode。优点是省心几行代码就连上了缺点是屏蔽了底层细节出问题时需要翻库源码。选哪种取决于你的项目阶段。如果是验证 demo绝对选 B如果要做生产级系统我的建议是先用 B 跑通然后把关键的 Server 调用改造成 A给自己留调试入口。4. 实操一个双 Server 调用的 LangGraph Agent4.1 环境准备与服务端编写我用一个典型例子来演示一个 Server 负责查 SQL 数据库里的订单另一个 Server 负责读取本地文件模板。Agent 的最终任务是这样的用户输入“查询订单 SO-001 的状态并用模板 A 生成摘要”Agent 需要先调数据库 Server 拿订单数据再调文件 Server 读模板最后合成为一段话。先安装环境依赖pip install mcp langchain langgraph langchain-mcp-adapters langchain-anthropic我这里的 LLM 用的是 Claude 模型你换成 OpenAI、通义千问等模型只需要替换 ChatModel 初始化对 MCP 链路没有影响。数据库查询 Serversql_server.pyfrom mcp.server.fastmcp import FastMCP mcp FastMCP(OrderQueryServer) mcp.tool() def query_order(order_id: str) - str: 按订单ID查询订单状态返回订单状态、金额、客户名 # 这里简化成模拟数据真实场景连接数据库 mock_db { SO-001: {status: shipped, amount: 199.0, customer: 张三}, SO-002: {status: pending, amount: 89.0, customer: 李四}, } order mock_db.get(order_id) if not order: return f订单 {order_id} 不存在 return f订单 {order_id} 状态: {order[status]}, 金额: {order[amount]}, 客户: {order[customer]} if __name__ __main__: mcp.run(transportstdio)文件 Serverfile_server.pyimport os from mcp.server.fastmcp import FastMCP import pathlib mcp FastMCP(FileTemplateServer) mcp.tool() def read_template(template_name: str) - str: 读取指定名称的模板文件内容模板位于当前目录 templates 文件夹下 base_dir pathlib.Path(./templates) safe_name os.path.basename(template_name) # 防目录穿越 file_path base_dir / safe_name if not file_path.exists(): raise FileNotFoundError(f模板 {template_name} 不存在) return file_path.read_text(encodingutf-8) if __name__ __main__: mcp.run(transportstdio)写完后在项目里建一个 templates 目录放一个 template_a.txt内容写上“客户 {customer} 的订单 {order_id} 当前状态为 {status}”。两个 Server 分别在不同终端启动python sql_server.py python file_server.py启动后终端会一直不返回等待标准输入收到协议消息。stdio transport 的特点就是这样一切交流都在标准输入输出里进行日志要输出到 stderr以免污染协议流。第一次写 FastMCP 的人很容易在这里翻车在工具函数里随手print()结果把调试信息打到了 stdout客户端解析直接失败。后面我会再强调一次这个坑。4.2 用 MultiServerMCPClient 连接两个 Server现在写主程序核心是配置两个 Server 的连接信息并获取聚合工具import asyncio from langchain_mcp_adapters.client import MultiServerMCPClient from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent async def main(): client MultiServerMCPClient( { sql-server: { transport: stdio, command: python, args: [sql_server.py], encoding: utf-8, }, file-server: { transport: stdio, command: python, args: [file_server.py], encoding: utf-8, }, } ) async with client: tools await client.get_tools() model ChatOpenAI(modelgpt-4o-mini, temperature0) agent create_react_agent(model, tools) result await agent.ainvoke( {messages: [(user, 查询订单 SO-001 的状态并读取模板 template_a 生成摘要)]} ) print(result[messages][-1].content) if __name__ __main__: asyncio.run(main())这里有几个要点。get_tools()返回的是 LangChain Tool 对象它们的名字默认会带上 Server 前缀吗不会默认就是 MCP Server 侧声明的工具名。如果两个 Server 里都有同名工具你会得到两个同名 Tool 对象LangGraph 的 ToolNode 遇到这种情况极大概率会出问题。所以我建议在 Server 侧就把工具名设计得带好前缀例如sql_query_order、file_read_template从源头避免冲突。async with client这一步真的很重要。它负责启动所有子进程、完成协议握手。如果你在循环里反复创建和销毁MultiServerMCPClient每次开启都会重新做握手开销很大更好的做法是复用一个长连接手动管理生命周期。4.3 用 ToolNode 构建带路由的 Agent 图create_react_agent是 LangGraph 预构建的 ReAct Agent适合快速验证。但如果你想控制多个 Server 的调用顺序、处理多步依赖我更推荐手写 StateGraph。下面是一个带工具调用的核心图from typing import TypedDict, Annotated from langgraph.graph import StateGraph, START, END from langgraph.prebuilt import ToolNode from langchain_core.messages import HumanMessage, BaseMessage from typing import Sequence class AgentState(TypedDict): messages: Annotated[Sequence[BaseMessage], operator.add] tool_names: list[str] def build_graph(model, tools): tool_node ToolNode(tools) def agent_node(state: AgentState): # 把当前所有工具绑定到模型让模型决定调用哪个 model_with_tools model.bind_tools(tools) response model_with_tools.invoke(state[messages]) return {messages: [response]} def should_continue(state: AgentState): last state[messages][-1] if hasattr(last, tool_calls) and last.tool_calls: return continue return end graph_builder StateGraph(AgentState) graph_builder.add_node(agent, agent_node) graph_builder.add_node(tools, tool_node) graph_builder.add_edge(START, agent) graph_builder.add_conditional_edges( agent, should_continue, {continue: tools, end: END}, ) graph_builder.add_edge(tools, agent) return graph_builder.compile()这套结构里AgentState里messages是对话历史所有节点都能读写agent_node把工具绑定给模型由模型生成“是否调用工具”的决定should_continue判断最后一条消息是否包含tool_calls有就跳进tools节点没有就收尾ToolNode收到模型生成的 ToolCall 后按工具名找到对应的执行函数执行完把结果作为 ToolMessage 塞回 messages。多 Server 在这里其实已经被抹平了不管工具来自哪个 MCP ServerToolNode 只认工具名。所以真正的多 Server 协调工作发生在工具名设计和模型意图理解上而不是在图中加特殊节点。4.4 完整执行流程与验证把上面代码组装起来跑一次完整流程。模型的执行逻辑大致是这样的看到用户消息“查询订单 SO-001 的状态并读取模板 template_a 生成摘要”。模型判定需要调sql_query_order(order_idSO-001)生成 ToolCall。Agent 节点返回带 ToolCall 的消息条件边把它送进 ToolNode。ToolNode 找到 SQL Server 对应的 MCP Client通过内部会话发tools/call拿到订单状态消息。状态更新回 messages再次进入 Agent 节点。模型看到订单结果认为还需要读模板于是生成第二个 ToolCallfile_read_template(template_nametemplate_a)。ToolNode 执行第二个 Server 的调用。模型拿到模板内容后合成最终摘要输出给用户。整个过程里两个 MCP Server 的调用顺序和次数由模型动态决定这就是 Agent 与普通固定流程最大的区别。实际运行到第三步时终端里能看到 SQL Server 子进程收到了协议请求但如果你在工具里放了 debug print注意它不会出现在主进程终端而会污染 stdio 通道——这个要在开发早期就养成输出到 stderr 的习惯。4.5 Supervisor 模式与更大规模的编排如果你的场景复杂度更高比如多个 MCP Server 分属不同团队、工具量上百我会建议再升级一层用 Supervisor 模式。大致思路是每个 MCP Server 对应一个子 Agent子 Agent 的 model 只绑定该 Server 的工具顶层再放一个 Supervisor Agent它不绑定任何工具只根据用户指令把任务分配给对应子 Agent子 Agent 执行完并把结果汇报给 SupervisorSupervisor 再做最终整合。LangGraph 对这种情况的支持非常好因为它天然支持图编排和多 agent 协作。不过要提醒一句多 Agent 的调试复杂度远高于单 Agent工具链路的故障传播也更隐蔽。我的建议是先在单图上跑通所有 MCP 工具确认没有协议层问题再按领域拆分。拆分后每个人的 Agent 只负责一种 Server问题定位起来舒服很多。5. 常见问题与排查实录5.1 握手失败与协议版本不一致现象启动客户端后server 子进程正常启动但客户端在get_tools()时报错或一直卡住。排查思路先确认 server 单独跑起来有没有启动成功有没有语法错误。stdio transport 下server 因为缺依赖、读不到环境变量等任何原因在启动阶段崩溃客户端都感知不到只会看到“连接不上”。我遇到过 Windows 环境下python命令指向了 Microsoft Store 的假 pythonserver 实际没起来客户端在那傻等。解决办法手动在终端执行同样的启动命令看有没有报错或者把启动命令具体化像C:/Python311/python.exe这样写绝对路径。还有一种很常见的情况协议握手阶段 client 和 server 的 protocolVersion 不一致。新版 mcp 库和旧版 server 之间容易出现版本协商失败。官方 SDK 一般会做兼容但如果你用的是自研协议实现这种版本不匹配就会直接告别。此时看协议日志找到initialize请求和响应的protocolVersion确认都到了同一天。5.2 stdio 污染问题现象工具明明执行了但客户端收到的内容全是乱码或奇怪的字符串。原因MCP Server 端代码里用了可以改变 stdout 输出的 API或者打印了日志信息导致标准输出里混入了非协议内容。协议解析器看到 JSON 中混着别的内容自然失败或解析错误。对策所有查日志、调试信息输出到sys.stderr不要用print()。FastMCP 里的日志配置也建议单独设置。这个坑无数次在开发阶段坑人严格执行“stdout 只跑协议”原则可以帮你避开 90% 的诡异错误。5.3 工具名冲突和多 Server 同名工具现象两个 Server 都提供了search工具LangGraph 模型选择时犹豫不决或者 ToolNode 执行时报错找不到唯一工具。原因LangChain Tool 集合要求工具名唯一同名时后加入的会覆盖前面一个或者抛异常。对策在 MCP Server 侧把工具名写得具体化用{domain}_{action}的模式命名例如sql_query_order、file_read_template。宁可名字长一点也别让模型在十万八千里外猜错工具。如果实在改不了 Server 侧名称可以在拿到 LangChain Tool 后通过tool.name重新复制一个新 Tool 对象改名。5.4 异步环境下的生命周期管理现象在 Jupyter 或 FastAPI 里跑 LangGraph Agent第一次调用成功第二次就报“connection closed”或者子进程退出了。原因MultiServerMCPClient的生命周期在异步环境里没有正确维护。你可能在某个 request 结束后调用了 client 的关闭方法或者没有用async with包住整个 Agent 生命周期。stdio transport 下的 MCP Server 是子进程父进程一关连接它也就退出了。对策把 MCP 连接的创建和 Agent 的执行放在同一个上下文里避免在中间穿插耗时操作导致连接被回收。服务化部署时建议在应用启动阶段建立 MCP 连接进程退出时再统一关闭不要在每次请求里新建连接。5.5 Windows 和容器环境的问题搜索热词里能看到一堆 Windows Server、Docker Desktop 下面 MCP/Server 的报错这确实和现实很贴合。Windows 上最常见的坑是命令名问题python、python3在不同环境指向不一致建议用绝对路径权限问题某些进程以管理员权限启动子进程继承权限后访问系统资源受限Docker 环境容器里跑 MCP Serverstdio transport 下客户端和 server 必须共享文件描述符所以通常不适合跨容器直接做 stdio要走 HTTP/SSE transport。我个人的经验本地开发阶段一律 stdio简单直接一旦要部署成多服务或者上生产迁移到 HTTP/SSE transport并在 Server 侧加上鉴权和日志。晚了会后悔。以上这些坑几乎都是我在真实项目里一个个踩出来的。MCP 的连接链路本来就长客户端、SDK、Server、模型 Agent 四层叠加任何一层出问题都会表现为“Agent 调不动工具”。我的排查顺序永远是协议层握手和工具列表引发的问题 → 传输层stdio或网络问题 → Agent 层工具绑定和模型决策问题。顺着这个顺序走你很快能找到根因。最后说一点个人体会。MCP 的价值不在于它能把工具调用写得有多炫而在于它把外部能力接入这个本来很“脏”的活变成了可以按标准重复做的事。多 Server 调用只是表面现象真正的核心是让 Agent 不关心工具在哪儿、服务是谁写的只关心工具叫什么、能干什么。这套思路一旦建立你会发现从“为每个系统写适配器”到“为每个能力写 Server”就像是把一堆乱七八糟的充电线换成了统一的 USB-C 口连接成本一下子就下来了。后续如果要扩展我建议从这个方向继续深入给 MCP Server 加完善的鉴权和审计日志定义更严格的工具描述规范再用 LangGraph 的检查点机制把多轮调用状态持久化。我个人在实际操作中的体会是工具描述写得越接近“人的指令”模型的理解越准确比在 Agent 层堆 prompt 有效得多。这篇就当个起点接下来你大可以自己动手把第一个 SQL Server 和一个文件 Server 接进你的 LangGraph Agent跑通之后再谈规模化。