1. 为什么值得花时间搞懂 MCP 的握手过程
第一次接触 MCP 是在一个需要把本地工具链接入大模型工作流的场景里。当时的需求很朴素:让模型能调用我本地写好的几个脚本,一个负责查数据库、一个负责读文件、一个负责调内部接口。最开始想的是直接写函数调用,硬编码进去就完事了。但真动手才发现,工具一多、模型一换、调用链一长,整个系统就变成了一团乱麻——每个模型有自己的一套函数描述格式,每接一个新工具就要改一遍胶水代码,调试的时候根本不知道是哪一层出了问题。
MCP 就是在这个背景下进入视野的。它的全称是 Model Context Protocol,翻译过来叫模型上下文协议。你可以把它理解成一套专门给"大模型"和"外部工具"之间设计的通用插座标准。以前每个工具都要为每个模型单独做一个转接头,现在大家都按 MCP 这个标准来,插上就能用。这个类比可能不够精确,但方向是对的:MCP 解决的是标准化对接的问题,而不是某个具体功能的问题。
这篇文章要聊的不是"MCP 是什么"这种科普层面的东西,网上已经有很多了。我想聊的是更实操的两块内容:一是协议握手阶段到底发生了什么,这部分是很多人调不通 MCP 的根源;二是怎么用 LangGraph 同时挂载多个 MCP Server,这是从单工具 Demo 走向真实工作流必须跨过的一道坎。如果你已经跑通过一个最简单的 MCP 示例,但一到多 Server 场景就各种报错、超时、工具名冲突,那这篇内容应该能帮到你。
需要提前说明的是,MCP 生态还在快速演进,不同语言 SDK 的实现细节有差异。我下面讲的内容基于 Python 生态的实践,其他语言的同学可以对照着理解原理,具体 API 名称以你用的 SDK 文档为准。
2. 协议握手:一次连接背后到底交换了什么
2.1 握手不是"打个招呼",而是能力协商
很多人以为 MCP 的握手就是客户端连上服务端,说一句"我来了",服务端回一句"欢迎",然后就完事了。实际远比这个复杂。MCP 的握手本质上是一次双向能力声明,双方要在这几轮消息里把"我支持什么""我需要什么""我的版本是多少"全部对齐。
我用一个生活化的场景来解释。你去一家餐厅吃饭,进门的时候服务员不会直接给你上菜,而是先确认几件事:你有没有预约、你几个人、有没有忌口、今天厨房有什么食材。MCP 握手就是这个过程。客户端(大模型这一侧)要告诉服务端:我支持哪些传输方式、我能处理哪些类型的返回内容、我的协议版本号是多少。服务端则要回应:我提供哪些工具、每个工具的输入参数长什么样、我支持的协议版本是多少。
这里有个关键点:协议版本必须匹配。如果客户端说自己是 2024-11-05 版本,服务端只支持 2024-10-07 版本,握手就会失败。这个失败往往不会给你很明确的提示,可能就是一个连接超时或者一个模糊的 JSON 解析错误。我踩过这个坑,排查了大半天才发现是版本号对不上。
2.2 初始化请求的字段拆解
握手的第一步是客户端发送initialize请求。这个请求里通常包含这么几个核心字段:
protocolVersion:客户端使用的协议版本,字符串格式,通常是日期形式capabilities:客户端支持的能力集合,比如是否支持 roots、是否支持 samplingclientInfo:客户端的名称和版本,用于服务端做兼容性判断
服务端收到之后,会返回一个initialize响应,里面包含:
protocolVersion:服务端实际使用的协议版本,如果和客户端不一致,客户端要决定是否继续capabilities:服务端支持的能力,比如 tools、resources、prompts 各自是否可用serverInfo:服务端的名称和版本
我建议你在调试阶段把这两个消息完整打印出来。很多"工具调不通"的问题,根源就在这一步——服务端根本没声明 tools 能力,或者客户端没声明它需要 tools,结果后面调用工具的时候自然就找不到。
2.3 初始化完成后的通知消息
握手还有第三步,容易被忽略:客户端在收到initialize响应后,要发送一个notifications/initialized通知。注意这是通知,不是请求,服务端不需要回复。它的作用是告诉服务端"我准备好了,你可以开始正常工作了"。
为什么这一步重要?因为有些服务端实现会在收到这个通知之前,拒绝处理任何工具调用请求。如果你跳过这一步直接调工具,就会收到"server not initialized"之类的错误。这个错误信息在不同 SDK 里措辞不一样,但本质都是握手没走完。
下面是一个握手流程的字段对照表,方便你排查问题时逐项核对:
| 阶段 | 消息类型 | 关键字段 | 常见问题 |
|---|---|---|---|
| 第一步 | initialize 请求 | protocolVersion, capabilities, clientInfo | 版本号写错、capabilities 缺 tools |
| 第二步 | initialize 响应 | protocolVersion, capabilities, serverInfo | 服务端未声明 tools 能力 |
| 第三步 | initialized 通知 | 无 | 漏发导致后续调用被拒 |
2.4 传输层:stdio 和 SSE 的取舍
握手消息要通过某种传输方式送出去,MCP 目前主流支持两种:stdio 和 SSE。
stdio 就是标准输入输出。客户端启动服务端进程,通过 stdin 发消息、通过 stdout 收消息。这种方式的好处是简单、无需网络配置、进程生命周期好管理。缺点是服务端必须和客户端在同一台机器上,而且服务端往 stdout 打印任何非协议内容都会污染消息流。我见过有人在自己写的 MCP Server 里加了一句print("server started"),结果整个握手直接崩掉,因为客户端把这句话当成了协议消息去解析。
SSE 是基于 HTTP 的服务端推送。客户端通过 HTTP 连接到服务端,服务端用 SSE 通道往回推消息。这种方式适合服务端部署在远端、多个客户端共享的场景。缺点是网络问题会直接影响握手成功率,而且需要处理重连逻辑。
我的经验是:本地工具用 stdio,远端共享服务用 SSE。不要为了"看起来高级"而强行上 SSE,stdio 的稳定性在本地场景下明显更好。另外,如果你用 stdio,务必确保服务端的所有日志都走 stderr,stdout 只留给协议消息。
3. 单 Server 跑通之后,多 Server 才是真正的考验
3.1 为什么单 Server 顺利不代表多 Server 顺利
单 Server 场景下,你只需要关心一个连接、一套工具列表、一个命名空间。跑通之后很容易产生"我已经掌握 MCP 了"的错觉。但一旦挂上第二个 Server,问题就集中爆发了。
最典型的是工具名冲突。假设 Server A 提供一个叫search的工具,Server B 也提供一个叫search的工具。在单 Server 场景下这完全没问题,但多 Server 场景下,模型发出search调用时,系统根本不知道该路由到哪个 Server。这不是 MCP 协议的缺陷,而是命名空间管理的问题,需要客户端侧来解决。
第二个问题是生命周期管理。每个 Server 可能是一个独立的子进程(stdio 模式),你需要确保它们都正确启动、正确关闭、异常时能重启。如果某个 Server 启动失败,整个工作流是直接报错还是降级跳过?这个策略要提前想清楚。
第三个问题是调用超时。不同 Server 的响应速度差异可能很大,一个查本地文件的 Server 可能 50 毫秒就返回,一个调远端接口的 Server 可能要 5 秒。如果统一用一个超时值,要么误杀快 Server,要么拖死慢 Server。
3.2 LangGraph 里多 Server 的挂载方式
LangGraph 本身是一个编排框架,它不直接管 MCP 连接,而是通过工具节点来调用。常见的做法是先用 MCP 客户端把多个 Server 的工具都拉取下来,转换成一个统一的工具列表,再交给 LangGraph 的 agent 使用。
这里有个设计选择:是每个 Server 一个客户端实例,还是一个客户端管多个 Server。我推荐前者,也就是每个 Server 独立一个客户端连接。原因是隔离性好,一个 Server 挂了不影响其他 Server,而且每个连接可以有自己的超时配置和重试策略。
拉取工具列表的时候,要注意把工具名做前缀处理。比如 Server A 的工具统一加a_前缀,Server B 的加b_前缀。这样既避免了冲突,又让模型在调用时能通过前缀知道该找哪个 Server。代价是工具名变长了,但对模型来说这点长度不算什么。
3.3 工具描述的质量直接决定调用准确率
多 Server 场景下,模型面对的工具数量可能是十几个甚至几十个。这时候工具描述的质量就变得极其关键。我见过太多 MCP Server 的工具描述写得含糊其辞,比如"查询数据"这种,模型根本不知道什么时候该用它。
好的工具描述应该包含三部分:这个工具做什么、什么情况下用、输入参数的具体含义。举个例子,不要写"搜索文件",而要写"在指定目录下按文件名模式搜索文件,支持通配符,返回匹配文件的完整路径列表"。后者虽然长,但模型能准确判断调用时机。
在多 Server 场景下,我还会在工具描述里加上来源标识,比如"(来自文件服务)"。这样模型在多个相似工具之间做选择时,能多一个判断维度。
4. 多 Server 调用的路由与容错设计
4.1 工具路由的三种策略
当模型发出一个工具调用请求时,系统需要决定把它路由到哪个 Server。我实践下来有三种策略,各有适用场景。
第一种是前缀路由。工具名带前缀,直接根据前缀找 Server。这是最简单也最可靠的方案,我大部分场景都用这个。缺点是要求所有工具名都规范加前缀,需要在一开始就定好规则。
第二种是注册表路由。维护一个工具名到 Server 的映射表,调用时查表。这种方式灵活,工具名可以不加前缀,但需要额外维护映射关系,而且映射表本身可能成为单点。
第三种是广播路由。把调用请求发给所有 Server,谁有这个工具谁响应。这种方式最灵活但效率最低,而且如果多个 Server 都有同名工具,会产生歧义。我只在工具数量很少、且确定不会冲突的场景下用过。
实际项目中,我通常是前缀路由为主,注册表路由作为补充。比如核心工具用前缀,一些动态注册的工具用注册表。
4.2 超时与重试的差异化配置
前面提到不同 Server 响应速度差异大,所以超时配置必须差异化。我的做法是给每个 Server 配一个超时值,这个值根据该 Server 的历史响应时间动态调整。
具体来说,初始给一个保守值,比如本地文件类 Server 给 2 秒,远端接口类 Server 给 10 秒。运行一段时间后,统计每个 Server 的 P95 响应时间,把超时值设为 P95 的 1.5 倍左右。这样既能容忍正常波动,又不会让慢请求拖太久。
重试策略也要差异化。对于幂等的查询类工具,可以重试 2 到 3 次。对于有副作用的操作类工具,比如写文件、发请求,重试要极其谨慎,最好不自动重试,而是把失败信息返回给模型让它决定。我踩过一次坑:一个写操作因为超时被自动重试,结果写了两遍,数据重复了。从那以后,所有有副作用的工具我都关掉了自动重试。
4.3 单个 Server 挂掉时的降级处理
多 Server 场景下,某个 Server 挂掉是常态而不是异常。关键是要有降级策略,不能让一个 Server 的问题拖垮整个工作流。
我的做法是在启动阶段做一次健康检查,把不可用的 Server 标记出来,它们的工具不注册到工具列表里。运行阶段如果某个 Server 调用失败,先重试,重试还失败就临时把它标记为不可用,并通知模型"该工具暂时不可用"。模型收到这个信息后,可以选择用其他工具替代,或者告诉用户当前无法完成。
这里有个细节:不要把不可用的工具直接从列表里删掉。因为模型可能已经基于之前的对话历史形成了对这个工具的认知,突然消失会让它困惑。更好的做法是保留工具但标注"当前不可用",让模型自己判断。
5. 实测中那些文档不会告诉你的坑
5.1 stdio 模式下 stdout 被污染
这个坑我在前面提过,但值得单独展开讲,因为它太常见了。stdio 模式下,服务端的 stdout 是协议消息通道,任何额外的输出都会破坏协议。但很多库、很多框架会默认往 stdout 打印日志,比如某些 HTTP 客户端库的调试输出、某些 ORM 的 SQL 日志。
我的解决方案是在服务端启动的最开始,就把 stdout 重定向到一个缓冲区,然后把真正的协议输出写到一个单独的通道。或者更简单粗暴:在服务端代码里全局替换print函数,让它走 stderr。Python 里可以用contextlib.redirect_stdout把标准输出重定向到 stderr,只在需要发协议消息时才写真正的 stdout。
5.2 工具返回内容过大导致截断
MCP 工具返回的内容会作为上下文传给模型。如果某个工具返回了几兆的文本,会直接把上下文撑爆。我遇到过一次:一个查日志的工具返回了完整日志文件,几万行,模型直接报上下文超限。
解决办法是在工具实现层面做结果截断和摘要。比如返回日志时,只返回最后 N 行,或者返回匹配关键字的行。如果确实需要返回大量内容,就返回一个引用 ID,让模型需要时再通过另一个工具去取具体内容。这个设计思路和分页是一个道理。
5.3 并发调用时的连接竞争
LangGraph 的 agent 可能会并发调用多个工具。如果多个调用共享同一个 MCP 客户端连接,就可能出现消息交错、响应错配的问题。我遇到过一次:两个工具调用几乎同时发出,结果响应回来的时候对不上号,A 的响应被当成了 B 的。
解决方式是每个调用用独立的连接,或者在客户端侧做请求 ID 匹配。MCP 协议本身有请求 ID 机制,但有些简易实现没有正确处理。如果你自己写客户端,务必确保每个请求有唯一 ID,响应回来时按 ID 匹配。如果用现成的 SDK,确认它是否支持并发,不支持的话就加锁串行化。
5.4 工具参数校验的缺失
MCP 协议允许服务端声明工具的输入 schema,但很多实现只是声明了,并没有真正校验。结果就是模型传了错误的参数类型,服务端直接崩溃或者返回莫名其妙的错误。
我的做法是在服务端工具实现的最外层加一层参数校验,用 JSON Schema 或者 Pydantic 之类的工具做严格校验。校验失败时返回明确的错误信息,告诉模型哪个参数不对、期望什么类型。这样模型能自我纠正,而不是反复用同样的错误参数重试。
6. 从能跑到好用:几个提升稳定性的实践
6.1 给每个 Server 加健康探针
不要等到调用失败才发现 Server 挂了。我给每个 Server 加了一个轻量的健康探针,定期发一个最简单的请求,比如列出工具列表,看是否正常响应。探针失败达到阈值就把 Server 标记为不健康,暂停路由到它的请求。
探针的频率不用太高,30 秒一次就够了。太频繁会增加负担,太稀疏又起不到及时发现的作用。探针本身也要有超时,避免探针自己卡住。
6.2 记录完整的调用链路日志
多 Server 场景下,出问题时最痛苦的是不知道问题出在哪一层。我现在的做法是给每次工具调用生成一个 trace ID,从模型发出请求、到路由决策、到 Server 处理、到返回结果,每个环节都带上这个 ID 打日志。这样排查时能完整还原一次调用的全过程。
日志里要包含的关键信息:调用的工具名、路由到的 Server、请求参数、响应内容(或摘要)、耗时、是否成功。这些信息在排查超时、错配、参数错误时都用得上。
6.3 工具列表的缓存与刷新
每次对话都重新拉取所有 Server 的工具列表,开销不小。我的做法是启动时拉一次,缓存起来,然后定期刷新。刷新时如果发现工具列表有变化,就更新缓存并通知 agent。
这里要注意缓存失效的处理。如果某个 Server 挂了,刷新时拉不到它的工具,不能直接把它的工具从缓存里删掉,而是标记为不可用。等 Server 恢复后再重新标记为可用。
6.4 用配置驱动而不是硬编码
多 Server 的配置项很多:每个 Server 的启动命令、传输方式、超时、重试策略、前缀。这些千万不要硬编码在代码里。我用一个 YAML 配置文件管理所有 Server 配置,代码只负责读取配置并初始化。这样加一个新 Server 只需要改配置,不用动代码,也方便在不同环境之间切换。
配置文件的格式大概是这样:每个 Server 一个条目,包含 name、transport、command(stdio 模式)、url(SSE 模式)、timeout、prefix 等字段。启动时遍历配置,逐个初始化。
7. 关于这套方案后续还能怎么演进
多 Server 跑通之后,我下一步想解决的是工具的动态发现。现在的做法是启动时确定所有 Server,运行中不变化。但实际场景里,有些工具是临时可用的,比如某个数据处理任务启动后才有的工具。如果能支持运行时动态注册和注销 Server,会更灵活。
另一个方向是工具调用的成本感知路由。同一个功能可能有多个 Server 都能提供,比如本地缓存 Server 和远端 Server 都有查询能力。可以根据响应速度、成本、当前负载来决定路由到哪个。这个在工具数量多、调用频繁的场景下价值很大。
还有一个我觉得很有意思的点是工具组合。现在模型是一次调一个工具,但很多任务需要多个工具配合。如果能定义一些"组合工具",内部编排多个 MCP 调用,对模型来说就是一个工具,会大大降低模型的编排负担。LangGraph 本身擅长做这种编排,把 MCP 工具作为节点,组合逻辑用图来表达,这条路我觉得值得深挖。
最后分享一个我在调试 MCP 时常用的小技巧:先用一个最简单的 echo 工具把整条链路跑通。这个工具不查任何数据、不调任何接口,就是原样返回输入。用它来验证握手、传输、路由、返回这一整条链路是否正常。链路通了之后,再逐个替换成真实工具。这样能把"链路问题"和"工具实现问题"分开排查,效率高很多。我见过太多人一上来就接复杂工具,结果报错之后根本分不清是握手没走通还是工具本身有 bug。