
前阵子接了个需求要把内部订单查询系统和一套运维文档知识库同时接进 Agent一开始自然想到的是给每个系统单独写 HTTP 封装、自己做鉴权、再手工整理一份 OpenAI Function 描述。磨合到一半我就放弃了每个系统都得重复一遍鉴权、文档解析、错误重试、工具注册的流程太像早期的接口时代。后面我把这两个系统都改成了 MCP ServerAgent 这边用一个统一客户端拿工具、调工具配合 LangGraph 做编排整个集成成本一下子降下来了。这篇文章就从协议握手的细节讲起落到 LangGraph 里怎么组织多 Server 调用。内容偏实战适合已经在用 LLM 做 Agent、但还没系统接触过 MCP 的开发者也适合那种见过工具调用、但没搞懂协议层到底发生了什么的人。1. 为什么要关心 MCP从写胶水代码到接标准插头先聊一个很现实的问题Agent 接外部能力最常见的方式到底是什么我见过不少团队的项目都是这样起步的——用 FastAPI 包一层 HTTP 接口把查询逻辑暴露出来然后在 System Prompt 里写清楚调这个接口需要什么参数再把函数签名喂给模型。跑一两个服务还行一旦超过三五个问题就绷不住了每个服务都得自己定义一套工具描述格式有的用 JSON Schema有的直接甩一段自然语言模型经常理解偏。鉴权方式五花八门有 API Key 的有签名算法的有 OAUTH 的Agent 代码里塞了一堆 if-else。错误处理更是灾难A 系统超时返回 504B 系统返回一堆堆栈C 系统干脆静默失败LLM 拿到这些结果也判断不出到底要不要重试。MCP 做的事情本质上是把这些散落的集成工作收敛成一套标准协议。它不关心你内部是 Python 还是 Node不关心你数据存在 MySQL 还是 ClickHouse它只定义了三类原语Tool可执行的函数模型能看见、能调用。Resource可读的数据源类似把某份文档发给模型当上下文的能力。Prompt可复用的提示模板由服务端定义。一旦两边都遵守这个协议集成就变成了插头对插座系统方写一个 MCP Server 暴露能力Agent 方用 MCP Client 获取工具列表、发起调用剩下的鉴权、传输、重试这些破事被协议和 SDK 挡在下面。这里有个心态上的转变我觉得挺重要的。之前我们写 Agent思考的是我该怎么为这个 API 写描述、做参数校验、处理返回结果有了 MCP 之后思考变成了我该以什么粒度暴露我的能力、这个工具描述怎么写模型才不会误解。前者是给机器写适配层后者是给模型设计接口完全是两个层级的活。你可能会问既然说了这么多好处那我直接用 FastAPI function calling 不也能达到类似效果吗答案是能但代价是每次都要从零维护一套半私有协议。而 MCP 的生态正在快速统一像 LangGraph、LangChain 这些编排框架已经原生支持 MCP 工具注入Dify 这类低代码平台也在跟进。基于标题热搜词里的趋势也能看出来MCP 已经不是概念预热期而是实打实进入项目落地阶段了。2. 握手不是黑魔法MCP 协议握手的请求链路拆解很多教程上来就教你写 Client、注册工具但把协议层的握手过程直接跳过了。我觉得这不太好——你调试 MCP Server 时百分之八十的疑难杂症都出在握手没走通或者 capabilities 协商跟预期不一致。所以这一节我们把握手拆开看。2.1 传输层stdio 与 Streamable HTTPMCP 的传输层有两种主流形态。第一种是 stdio也就是通过标准输入输出跑 JSON-RPC 消息适合本地进程这种场景。你在命令行跑一个 Python 脚本脚本通过 stdin 接收请求、通过 stdout 写回响应这就是一个最简单的 stdio MCP Server。第二种是 HTTP早期规范是 SSEServer-Sent Events后来更新成了 Streamable HTTP请求和响应都走普通的 HTTP 通道。远程部署、跨机器调用走这个。无论哪种传输方式协议消息本身都是 JSON-RPC 2.0。看一个例子就明白了{ jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2025-03-26, capabilities: {}, clientInfo: { name: my-agent, version: 1.0.0 } } }2.2 初始化握手client 与 server 的第一次对话MCP 的握手比 HTTP 的 TCP 握手多一层用意——它要协商的不是连接而是我们互相能做什么、用哪个版本的协议对话。完整流程分三步客户端发送initialize请求带上自己支持的协议版本和 clientInfo。服务端返回自身支持的协议版本、服务端能力声明capabilities以及 serverInfo。客户端发送notifications/initialized通知握手结束。服务端返回的响应大概长这样{ jsonrpc: 2.0, id: 1, result: { protocolVersion: 2025-03-26, capabilities: { tools: { listChanged: true }, resources: { subscribe: true } }, serverInfo: { name: order-query-server, version: 0.3.0 } } }协议版本协商这块有一个值得注意的点如果服务端不支持客户端发的protocolVersion服务端会返回它自己支持的最新版本客户端收到后需要判断是否兼容。现实里大部分 SDK 都已经帮你处理了版本降级逻辑你不需要手工重发请求但理解这个机制对你排查两边版本一堆莫名其妙的报错非常有帮助。capabilities 更是关键。它声明了这个 Server 到底支持什么——tools支持工具调用resources支持资源读取prompts支持模板提示。如果 Server 没声明某个 capability客户端就不该去调用对应的方法。你们项目里如果出现工具列表拉到了但一调用就报 Method Not Found先回头检查 capabilities 是不是没声明而不是怀疑协议被破坏。握手完成之后正常的 RPC 调用就开始了。Agent 想拿到可用工具就发一个tools/list{ jsonrpc: 2.0, id: 2, method: tools/list, params: {} }服务端返回工具数组每个工具包含名称、描述和输入 Schema。模型就是靠这个描述来决定要不要调用、怎么传参的所以你会发现 MCP 工具描述的写作质量直接影响 Agent 的能力上限。工具描述写得稀烂模型就会在多个相似工具之间犹豫不决。真正执行工具时发tools/call{ jsonrpc: 2.0, id: 3, method: tools/call, params: { name: query_order, arguments: { order_id: 20250101ABC } } }服务端执行完返回包含内容content和是否出错isError的结果。这里有个细节MCP 的结果统一用content数组承载字段本身还有text、image等类型所以返回一张图片给模型看这类跨模态传递也是天然支持的。2.3 为什么你平时感觉不到握手的存在看到这里你可能觉得MCP 握手流程好繁琐真要每一步都手写项目开发效率得打骨折。没错所以官方的 Python SDK 和 TypeScript SDK 把这些细节都封装掉了。你用mcp这个 Python 包写客户端时通常只需要from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): params StdioServerParameters(commandpython, args[server.py]) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools await session.list_tools()你看到了session.initialize()这一行背后就是刚才讲的握手流程。工具函数返回的tools对象里每个工具都有.name、.description、.inputSchema三个核心字段。站在使用者的角度协议细节可以全权交给 SDK 处理但一旦出现问题你就得靠上面那些协议知识去排查。一个我在实际项目里测出来的经验MCP Server 启动阶段如果报错比如依赖缺失、环境变量没配上stdio transport 模式下 server 进程一开始就挂了client 那边表现出的症状不是握手超时而是发完 initialize 后完全没有响应。这种问题先去 server 的 stderr 看日志比在客户端反复重试有效得多。HTTP transport 模式下症状又会变成 HTTP 层 404那就是 Server 的 HTTP 路由压根没暴露出来。3. 在 LangGraph 里把 MCP 工具翻译成 Agent 能用的东西LangGraph 对 MCP 的支持并不是说这两个东西谁替代谁——它们本来就在解决不同层面的事。MCP 解决的是Agent 如何外接工具LangGraph 解决的是Agent 如何编排这些工具的执行流程。把两者拼起来才算一个完整的 Agent 应用。3.1 LangGraph 的核心概念与 MCP 对接点LangGraph 的核心建模方式总结起来就三个概念节点Node、边Edge、状态State。State贯穿整个图执行的共享数据LLM 的中间输出、工具的执行结果、最终回复都往这里塞。Node一个执行单元接受 State 输入、经过逻辑处理、输出新的 State。Edge节点之间的流转路径可以带条件也就是所谓的条件边。Agent 最常见的一种图结构是Agent 节点 → 工具节点 → Agent 节点……循环直到模型认为不需要再调用工具才输出最终答案。这里面Agent 节点负责推理和决策工具节点负责真正执行外部函数。MCP 工具要接进来面临一个翻译问题MCP 协议的工具格式跟 LangGraph 里的 ToolNode 所期望的格式不是一回事。MCP 返回的工具是名称 描述 inputSchema这样的结构而 LangGraph 里的工具节点通常要求 OpenAI function-calling 风格的完整工具定义。翻译的核心路径是把 MCP 返回的每个工具重映射成{type: function, function: {name, description, parameters}}结构。巧的是LangChain 官方出的langchain-mcp-adapters库就是专门干这件事的里面有现成的转换函数比如convert_to_openai_tools。不过我也建议你亲自动手写一次转换逻辑哪怕之后还是用现成库。写一次你就会明白转换过程不只是字段重命名还涉及inputSchema里 properties 的必填项、组合 schema 的处理这些细节一旦要自定义扩展就得自己动手。3.2 Agent 节点怎么绑定工具列表在 LangGraph 里Agent 节点的典型实现是用一个 LLM 实例把tools参数传进去from langchain_openai import ChatOpenAI llm ChatOpenAI(modelgpt-4o, temperature0) # openai_tools 就是从 MCP 工具转换过来的一组工具描述 agent_node llm.bind_tools(openai_tools)这一步做完LLM 在推理时就能看到这些工具并在需要时输出结构化的 tool_calls。注意bind_tools不会真的执行工具它只是把工具描述塞进请求里让模型知道有这些工具可用。真正执行工具的地方是 ToolNode。你可以这样理解Agent 节点负责决定调哪个工具ToolNode 负责真的去调。LangGraph 会把 LLM 输出的 tool_calls 从 State 里读出来逐个匹配到对应的工具函数上执行。因此你需要在图里挂一个工具执行节点并把可分发的工具列表传给它。多 Server 场景下这个环节会出现一个常见的架构选择要么把 A Server 的所有工具和 B Server 的所有工具合并成一个工具池统一挂在同一个 ToolNode 下要么按 Server 拆分多个 ToolNode再用路由策略决定哪个 Agent 节点去碰哪个工具池。这是下一节要展开的重点这里先不深入。3.3 一个最小可跑的集成骨架我把一个最小实现的结构放在这里它包含三个元素MCP 客户端获取工具、把工具转成 OpenAI 格式、在 LangGraph 图中挂一个 ToolNode。from langchain_mcp_adapters.tools import convert_to_openai_tools from langgraph.prebuilt import ToolNode # tools 来自 MCP session.list_tools() 的结果 openai_tools convert_to_openai_tools(tools) # 把转换后的工具交给 ToolNode tool_node ToolNode(openai_tools)接着就是常规的 LangGraph 构建流程定义 StateGraph加入 Agent 节点和 tool_node用条件边判断如果 LLM 输出里有 tool_calls 就去 tool_node否则直接生成最终回复。跑通这个最小结构之后你会发现整套流程跟直接用 OpenAI function calling 没有任何区别——模型的决策逻辑、工具的返回格式、循环次数控制都是一样的。差别只在底层以前工具定义是手写的现在是从 MCP Server 动态拉取的以前工具执行是调自己封装的 Python 函数现在是经过 MCP 协议通道到达对端服务。这带来的直接好处是新增能力时你不需要改 Agent 代码。Server 端把新工具注册好客户端重新拉一次tools/listAgent 立刻就能感知。这种动态发现工具的能力是我认为 MCP 最有杀伤力的特性。4. 多 Server 调用的真实形态共享指令流还是路由分发单个 MCP Server 的集成其实很简单跟接一个普通工具库差不多。真正考验架构能力的是多个 Server 同时接入。我这里说的多 Server指的是一个 Agent 应用需要同时连接 N 个异构系统比如数据库查询服务、文档检索服务、网页抓取服务各由独立的 MCP Server 提供。4.1 共享池模式所有工具一股脑塞给一个 Agent最简单粗暴的做法是客户端同时连接多个 Server把每个 Server 的工具列表全部拉下来合并在一个数组里再统一传给 LLM。我开头讲的订单查询 运维文档场景最初就是这种形态。伪代码大概长这样class MCPGateway: def __init__(self): self.sessions [] self.tool_map {} async def connect(self, server_configs): for name, cmd, args in server_configs: session await self._create_session(cmd, args) tools await session.list_tools() for tool in tools.tools: tool_name f{name}__{tool.name} self.tool_map[tool_name] (session, tool) self.all_tools.append({ type: function, function: { name: tool_name, description: tool.description, parameters: tool.inputSchema } })合并后的工具数组直接丢给 Agent 节点。模型看到 A 系统的订单查询、B 系统的文档搜索、C 系统的网页内容抓取自行判断此刻该调哪个。共享池模式的好处是灵活模型可以自由组合不同 Server 的工具完成查订单 → 查文档 → 汇总生成报告这种跨系统链路。坏处也很明显工具一多prompt 里的工具描述就非常占上下文而且模型在几十个工具之间做选择时选错工具的几率会上升。我的经验是超过 20 个工具时这种模式就开始变得不可控你得开始思考路由分发。4.2 路由分发模式让专门的 Agent 处理专门的事路由分发模式的思路是分层。外层一个主 Agent 不持有任何具体工具只负责理解用户意图、判断该把任务交给哪个子 Agent内层每个子 Agent 各连接一个 MCP Server只看到属于自己领域的工具。在 LangGraph 里这可以用多 Agent 图实现。主 Agent 输出一个结构化的路由决定条件边根据这个决定把流程分发到不同的子图。每个子图内部又包含自己的 Agent 节点和 ToolNode跟前面讲的最小结构一样。这种模式的优势是上下文干净子 Agent 只看到自己领域的工具不会被无关的工具描述干扰主 Agent 不需要看任何工具描述只要做好任务分析。劣势同样存在——路由决策本身有误差如果主 Agent 判断错了把本该去文档检索的任务发给了数据库查询子 Agent后续流程就会走入死胡同需要额外的纠错机制。另外子图的引入让整个链路变长一次普通问答可能要经过主 Agent 和子 Agent 两轮推理时延会翻倍。4.3 两种模式的选型建议我用一个表格总结下我的选择逻辑判断维度共享池模式路由分发模式工具数量适合 20 个以内适合 20 个以上甚至几百个任务复杂度链路简单模型可自主规划链路复杂需要显式分工上下文预算宽松能容纳工具描述紧张需为子任务保留空间时延敏感度低一次推理就行高主 Agent 子 Agent 多轮路由容错没有路由不担心路由错误要有重路由或兜底设计我自己在实际项目中一般会用共享池模式起步简单直接当工具数量膨胀、模型开始乱点工具时再拆成路由分发。这个演进顺序比较自然不必一开始就上复杂架构。4.4 多 Server 连接的生命周期管理多 Server 场景还有一个很容易被忽略的工程问题连接生命周期。每个 MCP Server 都需要一条独立的连接。stdio 模式下每条连接就是一个子进程HTTP 模式下是一条可复用的 HTTP 通道。如果你在 Agent 每次请求时都重新建连开销会非常大——子进程启停、握手来回都要时间。所以要做连接池或者至少在应用层面缓存 Session。我的做法是做一个 ConnectionManager维护一份Server 名称到 Session 的映射Agent 启动时统一建立连接之后所有请求复用。连接断了再按需重建并做好重试和超时。因为 MCP SDK 的 Session 不是线程安全的如果你做并发请求进程里要为每个 Server 维护独立的 Session避免共享同一个 Session 导致消息 ID 冲突。LangGraph 的并行节点在这个阶段会帮上忙。比如一个任务需要同时调用文档检索和数据库查询它们互相没有依赖可以在图上用并行分发的方式同时执行而不是串行跑。这里要特别提醒一点MCP 的单个 Session 内部是串行处理请求的你用同一个 Session 并发调用两个工具后到的请求会被阻塞。并发场景下要么给每个并发分支建独立连接要么接受串行的代价、在业务上做取舍。5. 踩坑实录多 Server 场景下的三个典型问题再多的架构理论不踩坑等于白讲。这里分享三个我实际遇到、而且网上不太容易查到的坑每个都附上完整排查链路。5.1 工具重名模型悄无声息地点错工具第一个坑出现在两个 Server 都定义了query这个工具名的场景。一个查订单一个搜文档名字一模一样。合并工具池后工具列表里出现两条query记录LLM 调度时随机选中一个返回的数据完全驴唇不对马嘴而且不会报任何错误——因为从模型视角看它调用query是合法的只是查错了系统。排查链路先打印 LLM 实际决策出的 tool_calls确认它选的是哪个工具再对比 MCP Server 返回的工具列表发现两个 Server 的工具名发生碰撞进一步确认tools/list返回的名字本身没有命名空间隔离需要客户端自行处理。解决方式合并前给工具名加前缀我用的是{server_name}__{tool_name}这种分隔方式既保证唯一又不至于太拗口。同时更新工具描述的文本把归属信息写进 description 里帮模型做更精准的判断。这个改动很小但能省掉后期一大批调用结果对不上的排查时间。5.2 串行阻塞一个慢工具拖垮整条 Agent 链路第二个坑是一次性能测试时暴露的。当时有一个数据库查询 Server某些查询要跑十几秒才能返回。监控数据发现在这十几秒里Agent 完全没法调用其他 Server 的工具整条链路的吞吐量被单点拖死。排查链路先从 Agent 日志看工具调用记录的时间戳间隔异常再看 MCP Server 侧发现 Server 进程全程阻塞在一条请求上最后翻 MCP 协议文档确认单个 session 在同一时刻只能处理一个请求——JSON-RPC 的 id 匹配机制天然是串行的。解决方式给耗时工具设置超时超过阈值直接返回查询超时让模型决定是否换一种方式把不同 Server 拆到不同 Session让 A Server 的慢查询不要阻塞 B Server 的调用如果同一 Server 内部也有高并发需求就为它建立多个连接做一个简单的连接池分配。实际上多数 Agent 场景不需要刻意追求全并行给关键路径建独立连接就够了。5.3 权限边界Agent 调用了不该调的危险工具第三个坑更具隐蔽性。一个 MCP Server 暴露了文件写入类工具本意是供受控场景使用。一次线上任务里Agent 依据模型推理自行调用了这个工具覆盖了一个本不该动的文件。工具执行成功数据也回滚了好几个小时。排查链路先看 Agent 的完整执行轨迹确认是模型自主决策还是用户触发再看 MCP Server 的权限设计发现工具本身没有任何权限分级最终确认MCP 协议层没有原生的工具级权限控制谁拿到连接就能调所有工具。解决方式我后续做了三层防御。第一层在 Client 侧做工具过滤根据工具的 name 或 description 打标把只读工具和写工具分开第二层让 MCP Server 本身对危险工具加确认参数比如要求额外传一个confirm: true才能执行第三层在 Agent 的 System Prompt 里明确规则把不得主动调用写操作写死并配合工具描述里的 warning 提示。三层叠加后误操作概率大幅下降。这套排查链路给我们的启示很直接协议本身只保证能通不保证安全。接入外部 Server 时权限边界要假设对方是不可信的风险控制必须做在 Client 侧。6. 按需选择什么时候真的需要 MCP什么时候不用硬套最后泼一盆冷水。MCP 确实好但它不是银弹。我见过有人把内部两个函数之间的调用也包一层 MCP纯粹为了跟上技术潮流这属于过度设计。什么时候不建议用 MCP工具数量少、且只在进程内调用时直接用 Python 函数就好。模型通过普通 function calling 就能调用不用引入进程通信和协议层开销。内部系统之间的高频调用比如毫秒级的热路径查询MCP 的进程通信成本反而会成为性能瓶颈。什么时候值得用跨系统、跨团队、异构技术栈之间的工具互通以及需要动态发现工具的场景。一句话集成成本高、对方系统你改不动、或你不希望每次加工具都改一遍 Agent 代码这时候 MCP 的价值才真正体现出来。项目初期的搭建建议我一般这么排序先是单 Server LangGraph 最小链路跑通验证模型决策和工具执行闭环再扩展多 Server从共享池模式开始等到工具数量失控或路由错误频发再考虑拆分路由分发架构。每一步都有明确的触发条件不要一上来就搭分布式。最后分享一个很久之后才悟出来的小技巧给每个 MCP Server 配对客户端时把鉴权、重试、超时、日志这些横切逻辑统一封装成一个装饰器或基类而不是在每个连接代码里重复写。因为你永远预测不到项目后期会接入多少 Server统一的横切封装能让后续每个 Server 都保持相同的代码风格和失败处理模式。这点在你接手别人留下的、连接逻辑千奇百怪的代码时会尤其感激当时的自己。