
讲讲 MCP 协议。最近两年做 Agent 项目的朋友应该都被这个词刷屏了——Model Context Protocol模型上下文协议。说白了它就是给大模型配上一套标准插座让模型能够以统一接口去调用文件系统、数据库、外部 API、开发工具等各种能力。MCP 的生态已经覆盖了 IDE 插件、浏览器自动化、数据库运维、逆向工程、游戏引擎等多个领域身边越来越多团队开始把 MCP 当作 Agent 基础设施的一部分来对待。这篇文章想分享两个层面的体会一是 MCP 里最容易被忽略、但恰恰是牵一发动全身的协议握手细节二是当工具数量多到一定程度以后如何在 LangGraph 编排框架里同时挂载多个 MCP Server让它们协同工作而不是互相打架。我会结合自己踩过的坑、看过的源码和实际跑通的项目来写适合正在做 Agent 开发的工程师也适合那些刚听说 MCP、想知道它和普通 API 到底有什么区别的同学阅读。1. MCP 的核心价值为什么我们要多此一举1.1 MCP 解决的是“工具侧接口碎片化”问题在 MCP 出现以前让大模型调用外部工具这件事基本是各做各的。今天对接一个数据库可能要写一套 SQL 执行封装明天对接一个文件系统又要写一套路径操作封装再对接一个内部系统又得写一套鉴权和参数映射。每对接一个工具Agent 的代码里就多一层胶水逻辑而且这套胶水只属于当前项目换个项目全部推倒重来。MCP 做的事情是把“工具”本身抽象成标准化的资源服务器端把自己能提供的工具、数据资源和提示词模板暴露出来客户端通过统一的协议去发现和调用。用生活化的类比来说以前每个工具供应商都给你发一条专用遥控器现在 MCP 是统一的红外协议虽然不同遥控器的按键布局可能还不完全一致但信号格式终于通了。协议标准化带来的实际收益是迅捷的。一个 MCP Server 写好以后可以在 Claude Desktop 里用可以在 IDE 插件里用可以在自研的 LangGraph Agent 里用甚至可以在不同的编程语言项目里用。我从实践中感受到这个“一次接入、处处运行”的特性才是 MCP 最核心的吸引力而不是某个具体的 SDK 或框架。1.2 理解 MCP 需要掌握的最小概念集完整掌握 MCP 需要理解的概念不少但真正拦路的核心概念其实是四个Host、Client、Server、Transport。Host 是用户交互所在的应用程序比如 Claude Desktop、IDE或者你自己的 Agent 程序。Client 是协议客户端它与 Server 建立一对一连接负责消息收发。Server 是协议服务端它负责暴露工具、资源、提示词。Transport 则是底层通信方式目前主流是 stdio 和 HTTP SSE一个是进程内管道通信一个是网络通信。这四个角色之间的关系可以记成一句口诀Host 持有 ClientClient 连接 ServerServer 暴露工具。搞清这层关系之后再去理解握手、能力协商、工具调用就会顺畅很多。很多人一开始看 MCP 代码觉得绕其实不是代码难而是没有先把这四个角色放在心上。2. 协议握手MCP 连接的生命线2.1 三次交互的握手流程MCP 的握手过程在协议层面其实非常精简但每一步都有严格的时序要求。我把它拆成三句话先初始化、再通知、然后开始干活。第一步客户端发送 initialize 请求。这个请求里携带三个关键字段protocolVersion 表示客户端支持的协议版本capabilities 声明客户端具备哪些能力clientInfo 描述客户端自身信息。第二步服务器返回 initialize 响应其中 protocolVersion 表示服务器选定的协议版本serverInfo 描述服务器信息capabilities 声明服务器支持的能力。第三步客户端发送 notifications/initialized 通知告诉服务器初始化已经完成之后才可以正式发起请求。有一个细节经常被忽略客户端在收到 initialize 响应之后如果发现服务器选定的协议版本低于自己支持的版本仍然可以继续工作但要以服务器选定的版本为准。也就是说协议版本协商是降级兼容的不是强制对齐最高版本。这个设计对我们做系统集成的人很友好因为 MCP 生态迭代非常快服务器端和客户端版本经常有落差降级兼容意味着我们可以渐进升级而不是一次性强制推平。2.2 能力协商握手之后的关键动作initialize 请求里的 capabilities 字段很容易被当成是协议的一部分走个过场但实际它决定了整个会话期间客户端和服务器各自能做什么。举例来说MCP 的 roots 能力表示客户端愿意向服务器提供文件系统根目录的访问范围sampling 能力表示客户端允许服务器反向请求模型生成结果而 tools 能力则决定服务器是否要暴露工具列表。真实项目中能力协商的坑多半出现在这里服务器声明支持某类能力但实际实现有缺陷或者客户端在握手时没声明某个能力但后续调用却依赖这个能力。我在一个项目里就遇到过服务器答应不支持 roots但工具内部仍然尝试读取客户端根目录的情况结果直接抛错。后来我统一遵循一条原则客户端声明的能力一定是你真正提供的服务器声明的能力一定是你真正实现的宁可少声明不要多声明。握手完成后还有一个容易出现误解的地方tools/list 这类请求并不在握手流程里而是在 initialized 通知之后由客户端主动发起的。它是一个普通的 JSON-RPC 请求服务器返回工具列表客户端再决定如何把工具映射到 Agent 的调用层。不要以为 tools/list 也是握手的一部分实际上它更像是握手成功后的第一个业务请求。2.3 协议版本演进带来的兼容问题MCP 协议还在早期快速演进阶段版本号从 2024-03-31 一路走到了 2024-11-05期间能力字段和传输方式都在变化。我在对接不同 SDK 的时候发现有的 SDK 已经默认启用新版协议有的还停留在旧版两边一碰就容易出现版本不匹配导致握手僵住。遇到这种兼容问题我的排查习惯是先看握手日志里双方声明的 protocolVersion 值确认服务器最终选定的版本是什么。假如服务器返回的是旧版本而客户端强行使用新版本字段请求服务器大概率会直接报错或忽略。这时候可以尝试在客户端初始化参数里手动指定 protocolVersion让它与服务器对齐。有一个容易踩的坑需要特别提醒stdio 传输模式下握手超时往往不是网络问题而是服务器进程启动太慢。比如 npx 首次拉取包可能要几秒甚至十几秒但客户端默认超时时间只有 5 秒。所以你会在日志里看到连接已经建立却没有收到 initialize 响应。这种情况别急着怀疑代码逻辑先检查超时配置和服务器启动耗时就对了。3. LangGraph 多 Server 调用的架构与分工3.1 LangGraph 为什么适合做多 Server 编排LangGraph 的核心设计理念是有状态图执行。它把一个 Agent 任务的执行过程建模成一张有向图每个节点可以是一个模型调用、一个工具调用或者一个条件分支节点之间通过边来定义流转逻辑。相比直接写一个 while 循环调用模型和工具LangGraph 的价值在于状态管理、流程控制和可观测性都内置好了。我们手里的工具数量一旦多起来比如有数据库工具、文件工具、Web 搜索工具、代码执行工具如果全部塞在一个 Agent 的 tools 列表里模型进行工具选择的准确性会明显下降。这时候按功能域拆分多个 MCP Server再通过 LangGraph 编排就能做到类似模块化微服务的效果每个 Server 负责一块能力域Agent 先根据任务决定去哪个 Server 拿工具。我实际体验下来这种架构最大的优势不是性能而是稳定性。拆分之后每个 Server 的失败隔离在各自范围内一个数据库工具挂了不至于导致整个 Agent 不可用同时工具维护也独立了更新某个 Server 的能力不需要重新发布整个 Agent 应用。3.2 多 Server 场景下的连接生命周期管理多个 MCP Server 意味着多个 Client 会话需要管理而 stdio 传输模式下每个 Server 都对应一个子进程。子进程的启停、异常退出、资源回收都是需要关注的问题。刚开始做的时候我用最直接的办法启动时就创建所有会话退出时统一关闭。工具数量少的时候没问题但一旦媒体服务器数量多了启动阶段的时间开销会线性增长一个 Server 启动卡住整个 Agent 就卡住。后来我改成了按需懒加载策略先创建会话管理器只有工具被实际调用时才建立对应 Server 的会话同时设置 idle 超时超过一段时间没有调用就主动关闭会话回收子进程。这套策略让 Agent 的冷启动时间明显下降。但懒加载也带来一个隐患如果 Agent 在首次工具调用时才发现 Server 没启动握手耗时会被计入工具调用延迟模型侧很容易把这解读为工具故障。我的处理办法是在 Agent 进入某个子图之前先做一次轻量的预连接把会话建好但不去调用工具这样既保留了懒加载的资源优势又避免了首次调用的握手延迟。3.3 工具发现与路由设计多个 MCP Server 的工具合并到一起后会遇到工具名冲突的问题。比如文件类 Server 有个 move 工具数据库 Server 可能也有个 move 表数据的工具。MCP 协议本身并没有强制工具名全局唯一实际上工具唯一性是客户端层要解决的问题。我在代码里做了一层工具注册表以 Server 名为前缀构建带命名空间的工具名比如 filesystem_move 和 database_move。模型看到的是带前缀的工具名切换 Server 的逻辑则隐含在工具名里。LangGraph 的预构建 Agent 支持自定义工具列表所以直接传入带前缀的工具即可模型会自主选择以哪个前缀开头的工具。也有一种做法是让 Agent 通过一个 router 工具来中转模型先调用 router 决定使用哪个 Serverrouter 再转发给对应的工具执行器。这种方式对模型更友好因为工具数量可以任意多而不会耗尽模型的上下文窗口缺点是多一层延迟且 router 本身是一个大黄点一旦 router 逻辑出错整个链路都受影响。就我们目前的实践来说直接用命名空间工具名交给模型选择效果已经足够好。4. 实操记录从零搭建 MCP 多 Server LangGraph 环境4.1 环境准备与依赖选型先说明我这次实践使用的技术栈Python 3.11LangGraph 0.2.xMCP SDK 1.8.x模型走 OpenAI 兼容接口。需要注意的是 MCP SDK 与 LangChain 适配器之间的版本兼容不同主版本之间接口变化比较大建议参考官方迁移文档决定依赖版本号。安装依赖时我建议把 mcp、langchain-mcp-adapters、langgraph、langchain-openai 装在一个干净的虚拟环境里。我的实际依赖组合如下pip install mcp1.8.0 langchain-mcp-adapters0.1.0 langgraph0.2.0 langchain-openai0.2.0这个组合经过多次排查相对稳当。如果你用的是旧版本 SDK某些类名和函数签名会对不上整个代码改起来会很痛苦。4.2 编写 MCP Server 注册与连接管理器这个环节的目标是做一个 ServerManager 类负责管理多个 MCP Server 的会话生命周期。核心是传入 Server 标识与启动参数返回一个可以拿到 LangChain 工具列表的会话对象。下面是一段基于常见实践的参考实现不同 SDK 版本的 API 有差异但核心思路是通用的。import anyio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from langchain_mcp_adapters.tools import load_mcp_tools class ServerManager: def __init__(self): self._sessions {} async def get_tools(self, server_name: str, command: str, args: list[str], env: dict | None None): if server_name not in self._sessions: server_params StdioServerParameters( commandcommand, argsargs, envenv, ) read, write anyio.open_process( server_params.command, argsserver_params.args, envserver_params.env, ) async with stdio_client(server_params) as (reader, writer): session await ClientSession(reader, writer).__aenter__() init await session.initialize() self._sessions[server_name] { session: session, read: read, write: write, protocol_version: init.protocolVersion, } return await load_mcp_tools(self._sessions[server_name][session])注意这里我展示了异步上下文管理的用法其中 session 必须在整个生命周期内保持打开。如果会话被垃圾回收底层进程可能直接被关闭。很多诡异的资源类报错排查到最后都是这个原因。4.3 挂载多个 Server 到 LangGraph Agent有了 ServerManager 之后把多个 Server 的工具并入一个 Agent 就变得顺理成章。下面的代码把文件系统和 SQLite 两个 Server 的工具合并到一个工具列表再用 create_react_agent 构建 ReAct Agent。from langgraph.prebuilt import create_react_agent from langchain_openai import ChatOpenAI async def build_agent(): manager ServerManager() fs_tools await manager.get_tools( filesystem, commandnpx, args[-y, modelcontextprotocol/server-filesystem, /tmp], ) db_tools await manager.get_tools( database, commandnpx, args[-y, modelcontextprotocol/server-sqlite, test.db], ) all_tools fs_tools db_tools model ChatOpenAI( modelgpt-4o-mini, temperature0, ) agent create_react_agent(model, all_tools) return agent如果你把这段代码跑起来会看到模型确实能够识别两个来源的工具并调用。不过需要注意 create_react_agent 默认对工具列表是全量加载的如果某个 Server 的工具非常多建议在前置节点做一次工具筛选而不是让所有工具都挤进模型上下文。4.4 应用层路由与子图拆分实践当工具列表在 10 个以内时单 Agent 全量工具是可行方案。工具数量上到几十个以后我建议把 Agent 拆成多个子图一个入口图负责意图判断根据用户任务路由到不同的子 Agent每个子 Agent 只挂对应的 MCP Server 工具。入口节点可以看成一层轻量路由器它不做复杂逻辑只负责输出一个路由决策。下面的代码展示了如何用 LangGraph 的条件边实现按 Server 分组的路由from langgraph.graph import StateGraph, START, END def route(state): task state[task] if 数据库 in task or 查询 in task: return database_node if 文件 in task or 目录 in task: return filesystem_node return fallback_node graph StateGraph(dict) graph.add_node(database_node, db_agent_node) graph.add_node(filesystem_node, fs_agent_node) graph.add_node(fallback_node, fallback_node) graph.add_edge(START, route) graph.add_conditional_edges(route, route, { database_node: database_node, filesystem_node: filesystem_node, fallback_node: fallback_node, }) graph.add_edge(database_node, END) graph.add_edge(filesystem_node, END) graph.add_edge(fallback_node, END)这套结构的优势在于每个子图的上下文窗口只容纳当前 Server 的工具模型需要处理的候选工具数量大幅缩减。从我测试来看多子图路由在任务准确率和响应速度上都优于把所有工具塞进一个 Agent 的做法。5. 常见问题与排查技巧实录5.1 握手失败类问题握手阶段最常见的问题有三种协议版本不匹配、超时、stdio 启动失败。协议版本不匹配的典型特征是 initialize 响应里返回的版本和请求的不一样或者干脆没有响应超时问题常见于 npx 首次下载包太慢stdio 启动失败则往往是命令路径写错或者环境变量缺失。排查手段上我建议先给 MCP 客户端加上协议级日志打印每次 JSON-RPC 请求与响应的原文。很多时候我们以为问题出在协议层面实际是底层进程根本没起来同理你以为进程没起来实际是协议层握手失败后客户端抛了异常。不看日志全凭猜是在浪费时间。一个具体的坑是stdio 启动 Python 写的 Server 时当前虚拟环境的 python 解释器路径不会自动传给子进程。如果你在 Server 里 import 了 mcp 库而 npx 启动的是全局环境那么 import 失败会让进程秒退。解决办法是在 StdioServerParameters 里显式指定 python 可执行文件路径并设置正确的 PATH。5.2 工具调用错误与流式输出问题MCP 的工具调用本身走 JSON-RPC返回体是一个结构化对象可能包含文本内容、图片资源、错误信息等。LangGraph 预构建 Agent 对它没有特殊处理所以如果 Server 返回的数据结构比较特殊比如包含二进制内容模型可能无法直接理解需要写一个自定义的后处理工具。流式输出这个问题是 LangGraph 特有的。MCP ClientSession 的调用本身支持流式响应但 create_react_agent 默认是等完整结果返回后再给模型。如果你做的是对话式 Agent用户会明显感觉到中间没有任何反馈。后来我在工具调用节点里加了流式消息转发逻辑每次收到 Server 的增量内容就往外推送一次体验提升非常明显。5.3 多 Server 会话泄漏问题多 Server 场景下最容易出的隐性问题是会话泄漏。所谓泄漏就是会话建了不关或者关了之后没有从注册表移除导致 Agent 每次运行都会新建一个子进程。运行时间一长机器上堆积了一堆僵尸进程端口或管道被占满整个应用变得卡顿或直接拒绝服务。我的做法是在 ServerManager 里加上生命周期追踪启动时记录进程 PID 和启动时间停止时先发送关闭请求再等待一定时间后强制结束进程。同时设置一个定时清理任务扫描超过 idle 阈值仍未活跃的会话并关闭。这套机制上线以后之前困扰我们的内存增长问题基本消失了。5.4 按调试频率整理的速查参考我把实际开发中最常遇到的问题整理成了下面的速查表适合贴在手边对照排查现象可能原因优先排查项initialize 请求无响应npx 首次下载慢 / 服务器启动失败手动在终端运行启动命令看输出协议版本崩溃客户端与 SDK 版本不一致双方协议日志里的 protocolVersion工具列表为空capabilities 未声明 tools / 工具注册失败检查 initialize capabilities 配置工具名冲突多个 Server 暴露同名工具使用 Server 前缀改名工具调用时无权限服务器未配置访问范围检查 roots 与工具权限配置进程堆积会话未正确关闭排查会话注册表与清理逻辑超时后恢复但报错握手超时之后服务端状态错乱重连会话不要复用旧会话5.5 调试配置的实用建议调试 MCP 项目时我强烈建议在本地开启一个超时长的端口用 HTTP 传输方式临时调试工具逻辑调试通过后再切回 stdio。原因很简单stdio 模式下子进程的输出混杂在协议消息里一旦业务代码打印到 stdout就会污染协议流导致 JSON 解析失败。文字类 Server 的开发中不要用 print 调试要用 logging 输出到 stderr 或者直接写日志文件。我在写 SQLite MCP Server 时就犯过错误业务代码里留下了几个 print结果客户端一直报 JSON 解析错误排查了快一个小时才意识到是 stdout 被污染了。6. 从项目实战中沉淀下来的几点体会6.1 先设计能力边界再动手改代码MCP LangGraph 这个组合最大的诱惑是工具接入变得太容易了容易到我们会忽略能力边界的设计。我见过不少项目两天时间把所有能接的工具全部接上结果 Agent 的准确率和决策速度同时下降。工具不是越多越好模型在大量候选工具中做选择时噪声会造成明显的负面影响。现在我在每个项目启动前都会强制自己先做能力盘点列出真实业务需要哪些工具、每个工具由哪个 Server 暴露、工具之间有没有功能重叠、哪些工具永远不应该同时出现在模型上下文里。这个盘点表看起来简单但对后续架构设计的指导意义远大于一开始闷头写代码。6.2 版本锁定是长期维护的基石MCP 生态还在高速迭代今天用的 API 接口下个月就可能标记废弃。如果你不做版本锁定很可能某天一条代码没动Agent 突然就挂了。这个事我吃过亏所以现在所有 deploy 都用 lock 文件锁定精确版本号升级时在全量测试通过之前不碰生产环境。同理模型侧的版本兼容也要考虑。LangGraph 的 create_react_agent 对工具调用格式有默认约定当模型提供商调整了工具调用格式就可能出现工具参数解析失败的偶发问题。我的建议是把模型供应商抽象到配置层出了问题可以快速切换模型而不是在代码里硬编码。6.3 协议层知识会让你事半功倍做 MCP 相关开发如果只是照着官方示例抄能应付简单场景但一旦遇到深层问题就会卡住。我强烈建议大家把 JSON-RPC 的规范读一遍把 MCP 协议规范里 initialize、tool call、resource 这几个核心流程的字段含义都弄明白。理解了为什么要这样设计调试时就不会被各种报错打乱阵脚。这段实践下来我个人最大的感受是MCP 从来不是一个单纯的接口协议它是一种把整个工具生态串起来的思维框架。不管未来协议本身怎么演理解它的握手设计、能力协商思想、Server 生命周期管理都会让你在做任何 Agent 集成时省下大把时间。