
1. 从一次真实的踩坑经历说起MCP 到底是什么去年年底我接手了一个内部工具链整合的活儿需求说起来很简单让团队里的 AI 助手能同时调用本地文件系统、公司内部的 PostgreSQL 数据库还有一个自己写的日志查询服务。一开始我想得很天真觉得无非就是给每个服务写个函数让模型去调就行了。结果真动手才发现每个服务的调用方式、参数格式、返回结构都不一样光是让模型正确理解“什么时候该调哪个工具”就折腾了整整一周。后来一个做 AI 基础设施的朋友跟我说你该看看 MCP。MCP全称 Model Context Protocol翻译过来叫“模型上下文协议”。你可以把它理解成 AI 世界里的 USB-C 接口标准。以前每个外设厂商都有自己的充电口诺基亚圆口、苹果闪电口、Micro-USB 各玩各的用户出门得带一把线。MCP 想做的事情就是不管你是文件系统、数据库、浏览器自动化工具还是逆向调试器只要按这套协议实现一个 Server任何支持 MCP 的客户端就能即插即用。这个类比我觉得是最贴切的因为它抓住了 MCP 最核心的价值——标准化。那它具体解决什么问题在没有 MCP 之前如果你想让 Claude、GPT 或者本地的开源模型去调用外部工具通常有两条路。第一条是 Function Calling你得为每个模型平台单独写一套工具描述 JSON换一个模型就得重写一遍。第二条是各种框架自带的 Tool 抽象比如 LangChain 的 Tool 类但这些东西跨框架不通用LangChain 的 Tool 拿到别的框架里就用不了。MCP 的出现把这两层都统一了协议层统一通信格式Server 层统一能力暴露方式。你写一个 MCP Server理论上所有支持 MCP 的客户端都能用包括 Claude Desktop、Cursor、Continue、以及我们今天要重点聊的 LangGraph。这篇文章适合谁看如果你已经在用 LangGraph 搭多 Agent 系统但工具调用还停留在手写tool装饰器的阶段那这篇能帮你把工具层彻底解耦。如果你刚听说 MCP 这个词看了一堆“协议握手”“JSON-RPC”之类的术语一头雾水那我会尽量用人话把整个链路讲清楚。如果你只是好奇“mcp是什么”这个问题看完第一节你应该就有答案了。2. 协议握手MCP 通信的底层逻辑拆解2.1 为什么需要握手而不是直接调用很多人第一次接触 MCP 会有一个疑问不就是调个工具吗为什么还要搞一套“握手”流程我直接发个 HTTP 请求过去不行吗这个问题问得好答案藏在 MCP 的设计目标里。MCP 要解决的不是“一次调用”而是“能力协商”。什么意思假设你是一个客户端你连上了一个 MCP Server你怎么知道这个 Server 支持哪些工具每个工具需要什么参数参数是必填还是可选返回结果是文本还是结构化数据这些信息如果不在调用前先问清楚你就只能靠猜或者靠文档。而文档是会过期的Server 更新了工具但文档没更新调用就挂了。所以 MCP 的握手本质上是一次能力清单交换。客户端连上 Server 后第一件事是发一个initialize请求里面带上自己的协议版本、支持的客户端能力比如是否支持 roots、是否支持 sampling。Server 收到后回一个响应带上自己的协议版本、Server 信息、以及支持的能力比如 tools、resources、prompts。双方确认版本兼容后客户端再发一个initialized通知握手才算完成。这个过程用生活化的类比就是你去一家餐厅吃饭进门先不是直接点菜而是先看菜单。菜单告诉你这家店有什么菜、每道菜什么价、有没有忌口标注。MCP 的握手就是“看菜单”这个动作而且是双方互相看——Server 也想知道客户端能不能处理某些高级特性。2.2 传输层stdio 和 SSE 怎么选握手之上是传输层。MCP 目前主流的传输方式有两种stdio和SSEServer-Sent Events。这两个选择直接决定了你的 Server 怎么部署、怎么调试。stdio 的意思是标准输入输出。客户端把 Server 当做一个子进程启动通过 stdin 发请求通过 stdout 收响应。这种方式的好处是简单、无网络依赖、进程生命周期由客户端管理。缺点是只能本地用没法跨机器。我一开始做本地文件系统 MCP Server 就是用 stdio调试的时候直接在终端里跑日志打到 stderr非常直观。SSE 则是基于 HTTP 的。Server 作为一个独立的 HTTP 服务跑在某个端口上客户端通过 HTTP 连接Server 通过 SSE 推送消息。这种方式适合远程部署、多客户端共享的场景。比如你有一个公司内部的数据库 MCP Server多个同事的 AI 助手都要连那就得用 SSE。但 SSE 的坑在于连接管理和鉴权你得自己处理断线重连、token 校验这些事。我实测下来的经验是本地工具用 stdio团队共享服务用 SSE。不要一上来就追求“远程部署”stdio 的调试体验好太多等本地跑通了再迁移到 SSE 也不迟。2.3 消息格式JSON-RPC 2.0 的约束MCP 的消息格式用的是 JSON-RPC 2.0。如果你之前没接触过简单说就是一种约定好的 JSON 结构包含jsonrpc、id、method、params这几个字段。请求有id响应也带同样的id这样异步场景下才能对上号。这里有个容易踩的坑MCP 对消息的字段名和类型有严格约束。比如id必须是字符串或数字不能是 nullparams如果是对象字段名必须和 Server 声明的 inputSchema 完全一致。我见过有人把参数名写成驼峰但 Server 声明的是下划线结果调用一直报“invalid params”查了半天才发现是命名风格不匹配。还有一个细节是通知notification和请求request的区别。通知没有id也不需要响应。比如initialized就是通知客户端发完就不管了。而tools/call是请求必须等 Server 返回结果。搞混这两个会导致客户端一直等一个永远不会来的响应或者 Server 收到一个不该响应的消息。3. LangGraph 多 Server 调用的架构设计3.1 为什么要在 LangGraph 里接 MCPLangGraph 是 LangChain 团队出的状态机式 Agent 框架核心概念是节点node和边edge通过图结构来编排多步骤的 AI 工作流。它自带的工具调用机制是tool装饰器加ToolNode用起来也不复杂。那为什么还要接 MCP原因有三个。第一是工具复用。你团队里可能已经有一堆 MCP Server 了比如数据库查询、日志检索、文件操作这些没必要在 LangGraph 里重写一遍。第二是动态发现。MCP 的tools/list可以在运行时拉取工具列表这意味着你的 Agent 不需要在代码里硬编码工具而是启动时动态加载。第三是跨框架一致性。同一个 MCP Server今天在 LangGraph 里用明天换到别的框架里也能用工具层和编排层彻底解耦。我自己的项目里LangGraph 负责“什么时候调工具、调完怎么处理结果”MCP 负责“工具本身怎么实现、怎么暴露”。这两层分开之后改工具不用动 Agent 逻辑改 Agent 逻辑不用动工具维护成本直线下降。3.2 多 Server 的连接管理策略单 Server 接入很简单难的是多 Server。你可能有三个 MCP Server一个 stdio 的本地文件服务一个 SSE 的数据库服务一个 SSE 的日志服务。LangGraph 这边怎么管理这些连接我的做法是用一个连接池 一个工具注册表。连接池负责维护每个 Server 的 session启动时并行初始化所有 Server握手完成后把 session 存到一个字典里key 是 Server 名字。工具注册表则把所有 Server 的tools/list结果汇总每个工具记录它属于哪个 Server。这样当 Agent 决定调用某个工具时先查注册表找到对应的 Server再从连接池拿 session 发请求。这里有个关键决策串行初始化还是并行初始化。串行就是一个个连简单但慢并行就是同时连快但错误处理复杂。我建议并行因为 MCP Server 的启动时间差异很大stdio 的可能几十毫秒SSE 的要等网络握手串行的话总时间就是累加。并行初始化用asyncio.gather就行但记得加return_exceptionsTrue否则一个 Server 挂了整个启动流程就崩了。3.3 工具命名冲突的处理多 Server 场景下几乎一定会遇到工具重名。比如文件 Server 有个read工具数据库 Server 也有个read工具。如果直接汇总Agent 调用read的时候根本不知道调哪个。我的解决方案是加前缀。在注册工具时把工具名改成{server_name}__{tool_name}的格式比如filesystem__read、database__read。这样既避免了冲突又让 Agent 从名字就能看出工具来源。前缀用双下划线是因为单下划线在工具名里太常见了容易混淆。但加前缀有个副作用工具描述也得跟着改。因为模型是根据工具名和描述来决定调哪个的如果描述里还写着“读取文件”但名字变成了filesystem__read模型可能会困惑。所以我在注册时会把 Server 名字也拼进描述里比如“从 filesystem 服务读取文件内容”。实测下来这样模型的调用准确率明显更高。4. 实操从零搭一个多 Server 的 LangGraph Agent4.1 环境准备与依赖安装先把环境搭起来。我用的 Python 3.11依赖主要是这几个pip install langgraph langchain-openai mcpmcp是官方 Python SDK提供了 ClientSession 和 stdio/SSE 的传输实现。langgraph提供图编排langchain-openai用来接模型。如果你用的是别的模型换成对应的包就行。这里有个版本坑要提醒mcp SDK 在 0.4 到 0.5 之间有过一次 API 变动ClientSession 的初始化参数改了。如果你照着老教程写发现报错先pip show mcp看版本然后对照官方 README 调整。我踩过这个坑浪费了一个下午。4.2 写一个最小的 MCP Server为了演示我们先写一个最简单的 stdio Server提供一个add工具。实际项目里你可能是文件操作或数据库查询但原理一样。from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent app Server(demo-server) app.list_tools() async def list_tools(): return [ Tool( nameadd, description计算两个数字的和, inputSchema{ type: object, properties: { a: {type: number}, b: {type: number} }, required: [a, b] } ) ] app.call_tool() async def call_tool(name: str, arguments: dict): if name add: result arguments[a] arguments[b] return [TextContent(typetext, textstr(result))] async def main(): async with stdio_server() as (read, write): await app.run(read, write, app.create_initialization_options()) if __name__ __main__: import asyncio asyncio.run(main())这个 Server 的核心就两个装饰器list_tools告诉客户端我有什么工具call_tool处理实际调用。inputSchema用的是 JSON Schema 格式字段类型和必填项都在这里声明。这个 schema 非常关键它直接决定了模型能不能正确构造参数。我建议 schema 写得越详细越好每个字段都加description模型的理解准确率会高很多。4.3 LangGraph 侧的多 Server 客户端封装接下来是重头戏在 LangGraph 里管理多个 MCP Server。我封装了一个MCPManager类核心逻辑如下import asyncio from contextlib import AsyncExitStack from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client class MCPManager: def __init__(self): self.sessions {} self.tools {} self.exit_stack AsyncExitStack() async def connect_stdio(self, name, command, args): params StdioServerParameters(commandcommand, argsargs) 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() self.sessions[name] session await self._register_tools(name, session) async def _register_tools(self, server_name, session): response await session.list_tools() for tool in response.tools: full_name f{server_name}__{tool.name} self.tools[full_name] { server: server_name, original_name: tool.name, schema: tool.inputSchema, description: f[{server_name}] {tool.description} } async def call_tool(self, full_name, arguments): info self.tools[full_name] session self.sessions[info[server]] result await session.call_tool(info[original_name], arguments) return result.content[0].text这段代码有几个设计点值得说。第一用AsyncExitStack统一管理所有连接的清理避免手动 close 漏掉。第二_register_tools里做了前缀拼接和描述改写这就是前面说的冲突处理。第三call_tool通过注册表反查 Server调用方只需要传全名。4.4 把 MCP 工具转成 LangGraph 可用的 ToolNodeLangGraph 的 ToolNode 需要的是 LangChain 的 BaseTool 对象所以得做一层转换。我的做法是动态生成 StructuredToolfrom langchain_core.tools import StructuredTool def build_langchain_tools(mcp_manager): lc_tools [] for full_name, info in mcp_manager.tools.items(): async def _call(_namefull_name, **kwargs): return await mcp_manager.call_tool(_name, kwargs) tool StructuredTool.from_function( coroutine_call, namefull_name, descriptioninfo[description], args_schemainfo[schema] ) lc_tools.append(tool) return lc_tools这里有个 Python 闭包的坑循环里定义函数必须用默认参数绑定变量否则所有函数都会引用最后一个full_name。我一开始没注意结果所有工具调用都指向了同一个 Server排查了半天。_namefull_name这个默认参数就是用来固定当前值的。4.5 组装 LangGraph 图并跑通完整链路最后把工具塞进 ToolNode接上模型组成一个标准的 ReAct 图from langgraph.graph import StateGraph, MessagesState from langgraph.prebuilt import ToolNode from langchain_openai import ChatOpenAI async def build_agent(mcp_manager): tools build_langchain_tools(mcp_manager) model ChatOpenAI(modelgpt-4o).bind_tools(tools) tool_node ToolNode(tools) async def call_model(state): response await model.ainvoke(state[messages]) return {messages: [response]} def should_continue(state): last state[messages][-1] return tools if last.tool_calls else __end__ graph StateGraph(MessagesState) graph.add_node(agent, call_model) graph.add_node(tools, tool_node) graph.set_entry_point(agent) graph.add_conditional_edges(agent, should_continue) graph.add_edge(tools, agent) return graph.compile()跑起来之后你问 Agent“帮我算一下 3 加 5”它会自动调用demo-server__add拿到结果再组织语言回复。整个过程你不需要在 Agent 代码里写任何“加法”的逻辑全部通过 MCP 协议动态发现和调用。5. 常见问题与排查技巧实录5.1 握手失败版本不兼容怎么查最常见的握手失败是协议版本不匹配。MCP 的initialize请求里客户端会带protocolVersion如果 Server 不支持这个版本会返回错误。排查方法是打开 debug 日志在 ClientSession 初始化时传logging_leveldebug然后看 stderr 输出。通常能看到类似“unsupported protocol version”的字样。解决办法有两个升级 Server 的 SDK 版本或者在客户端指定一个 Server 支持的版本。我建议前者因为版本落后往往意味着缺少新特性。5.2 工具调用返回空结果这个问题的表现是 Agent 调了工具但拿到的结果是空字符串然后模型开始胡编。原因通常是返回内容的类型没对上。MCP 的call_tool返回的是content列表每个元素可能是 TextContent、ImageContent 或 EmbeddedResource。如果你直接取result.content当字符串用就会出错。正确的做法是遍历 content根据 type 字段分别处理。文本类取.text图片类取.data资源类取.resource。我封装了一个extract_text函数专门做这件事避免每次调用都手写。5.3 多 Server 并发调用的竞态问题当 Agent 在一轮里同时调用多个工具时LangGraph 的 ToolNode 会并发执行。如果这些工具分属不同的 MCP Server而你的 session 管理没做好线程安全就可能出现“session 被两个协程同时使用”的问题。MCP 的 ClientSession 本身不是线程安全的但它是异步的在同一个事件循环里用asyncio.Lock保护就行。我的做法是给每个 session 配一把锁call_tool时先async with lock。这样虽然牺牲了一点并发度但避免了诡异的随机错误。实测下来工具调用本身耗时远大于锁的开销所以性能影响可以忽略。5.4 常见问题速查表问题现象可能原因排查方向解决方案握手超时Server 启动慢或崩溃看 Server 的 stderr单独跑 Server 确认能启动工具列表为空list_tools 未注册检查装饰器确认 app.list_tools() 存在参数校验失败schema 与传参不匹配对比字段名和类型统一命名风格补全 required调用返回空content 类型未处理打印原始 content按 type 分支提取并发报错session 竞态加日志看调用顺序每个 session 配 asyncio.Lock工具名冲突多 Server 重名列出所有工具名加 server 前缀5.5 几个我踩过的坑和独家技巧第一个坑是stdio Server 的日志输出。如果你在 Server 里用print打日志会污染 stdout导致 JSON-RPC 消息解析失败。正确做法是打到 stderr或者用 logging 模块配置到文件。我一开始不知道Server 里 print 了一句调试信息结果客户端一直报“invalid JSON”查了两小时。第二个技巧是给工具描述加调用示例。模型对“什么时候调这个工具”的判断很大程度上依赖描述。如果你在描述里加一句“例如用户问‘X 加 Y 等于几’时调用此工具”调用准确率会明显提升。这个技巧在工具数量多、功能相近时特别有用。第三个技巧是启动时做一次健康检查。所有 Server 连上后先调一次tools/list确认每个 Server 至少返回一个工具。如果某个 Server 返回空列表说明它可能没正确注册工具这时候就该报警而不是等运行时才发现。6. 关于 MCP 生态的一些个人观察MCP 这个协议从 2024 年底发布到现在生态扩张速度确实快。我关注到的几个方向挺有意思一个是逆向工程工具开始接 MCP比如调试器类的工具通过 MCP 暴露内存读取、断点设置能力让 AI 助手能辅助分析另一个是设计工具接 MCP比如把设计稿的图层信息通过 MCP 暴露出来AI 可以直接读取设计规范生成代码。这些场景的共同点是把原本需要人工在 GUI 里操作的能力变成 AI 可编程调用的接口。LangGraph 这边多 Server 调用目前还没有官方的“开箱即用”方案得自己封装。但我估计很快会有社区包出来因为需求太明确了。如果你现在就要用我上面那套 MCPManager 的代码可以直接抄改改 Server 配置就能跑。最后分享一个我在实际项目里的体会MCP 的价值不在于“能调工具”而在于“工具层的标准化”。以前每接一个新服务都要写适配代码现在只要那个服务有 MCP Server接进来就是几行配置的事。这个边际成本的下降才是它真正改变工作方式的地方。我现在搭新 Agent 的时候第一反应不再是“这个功能怎么用 LangChain 实现”而是“有没有现成的 MCP Server 可以用”。这个思维转变可能比技术本身更重要。