MCP Client源码解析:从协议握手到连接管理
2026/9/7 20:08:53 网站建设 项目流程

1. 为什么是18.1:MCP在2025年开发者技术栈中的准确位置

如果你对MCP这个缩写还有点陌生,我先把它说透。MCP全称Model Context Protocol,模型上下文协议,是Anthropic在2024年底开源的一套标准化通信协议,它的核心目的是让AI应用(Host)能够以统一的方式接入外部数据源和工具。打个比方,MCP之于AI应用,相当于USB-C之于外设——以前你接一个鼠标要PS/2口,接一个显示器要VGA口,接一个硬盘要SATA口,现在所有设备都能插同一个口,协议层面帮你解决了握手、供电、数据传输的标准化问题。MCP就是AI世界的USB-C。

之所以这篇笔记的标题叫“18.1”,是因为我最近在做的系列文章正好排到了这个编号,这一节的内容定在MCP协议本身以及Client端源码解析。为什么要把Client单独拎出来讲?因为在实际落地过程中,我发现绝大多数教程都在讲“怎么搭一个MCP Server”,官方文档也把Server端的例子写得特别详细,但真正做集成的人——无论是写Agent框架的、写IDE插件的,还是写企业内部AI网关的——几乎都是从Client侧接入别人的Server。Server决定“能提供什么”,Client决定“怎么用起来”,两者解决的问题完全不同,源码的侧重点也完全不同。

在MCP的官方定义里,整个架构涉及三个角色:

  • Host:用户直接交互的AI应用,比如Claude Desktop、Cursor、自研的Agent框架,它是发起会话的进程。
  • Client:在Host进程内部维护与Server的连接,负责协议握手、请求转发、会话状态管理。
  • Server:暴露具体的工具、资源或提示词模板,轻量级程序,可以被任意Client连接。

一个Host里可以同时存在多个Client,每个Client连接一个Server。比如一个IDE插件既可以连本地文件系统的Server,又可以连GitHub的Server,还可以连数据库的Server,它们在Host内部是并存且互不干扰的。

看源码之前,我先把MCP的协议栈给你捋一遍。MCP在设计上分了两层:底层是传输层,负责字节流的搬运;上层是协议层,负责消息语义的解析。传输层目前官方支持三种:基于stdio的本地进程通信、基于Streamable HTTP的跨网络通信,以及老版本的HTTP+SSE。协议层的消息格式统一走JSON-RPC 2.0,也就是每个请求都长这样:{"jsonrpc":"2.0","id":1,"method":"xxx","params":{...}}

JSON-RPC选型的原因很简单——它足够轻量,自带请求/响应/通知三种消息类型,且错误对象是标准化的,这正好满足AI工具调用的场景:大多数操作是短连接式的请求-响应,偶尔有服务端主动推送的状态变化,用notification来承载。

那Client源码里到底有哪些东西值得读?我翻了TypeScript和Python两套官方SDK,核心结构基本一致,我挑几个最关键的部分拆开讲:首先是协议层的初始化握手,也就是initialize请求的完整流程;其次是能力协商(capabilities negotiation),这是MCP最容易踩坑的地方;然后是请求的发送与调度,包括如何处理服务端的异步推送;最后是生命周期管理,从连接到优雅断开。这几个点覆盖了Client端源码80%的核心逻辑,剩下的都是围绕它们展开的辅助类。

2. 一条消息从Client到Server的完整旅程:核心类设计与数据流

读源码的第一步不是逐行读代码,而是先找到数据流的主干。我以一个最简单的“调用工具”请求为例,把一条消息从Client发出到拿到结果的完整路径走一遍,你就能搞清楚源码里每个类是干什么的。

官方TypeScript SDK的包名是@modelcontextprotocol/sdk,Client相关的核心代码在src/client目录下,主要文件有:

文件核心类职责
index.jsMcpClient对外暴露的总入口,组装所有子模块
protocol.jsProtocol协议消息的收发与路由中心
transport.jsStdioClientTransport / StreamableHTTPClientTransport把消息序列化后写入物理通道
session.jsClientSession管理一次Server连接的生命周期
auth.js认证相关处理OAuth 2.0等鉴权流程

实际发请求的时候,调用链路是这样的:

业务代码调用 tool.call("get_weather", {city:"北京"}) → McpClient.request("tools/call", params) → Protocol.request(method, params, resultSchema) → transport.send(message) // JSON序列化后写入stdio管道 / 发起HTTP POST → 等待响应 → transport.onmessage(json) // 收到响应字节 → Protocol.handleMessage(json) → 根据id找到对应的PendingRequest → 解析result或抛出error

这条链路里,Protocol类是整个Client的“心脏”,它维护了一张“待响应请求表”,每个发出的请求都有一个递增的id,响应回来时靠这个id找回对应的回调。这就是JSON-RPC 2.0相对REST的明显优势——并发请求天然支持,不需要像HTTP那样一个连接一次只能等一个响应(HTTP/2虽然解决了多路复用,但JSON-RPC的id关联机制语义更直接,而且对于Server端来说不需要额外处理路由)。

2.1 请求对象的生命周期:从Pending到Resolved

我建议你读源码的时候,优先看Protocol.request这个方法的实现。它做的事情非常清晰:

  1. 生成一个自增的requestId
  2. {method, params, requestId}封装成JSON-RPC请求对象。
  3. this.pendingRequests这个Map里注册一个条目,键是requestId,值是{resolve, reject, timeout}
  4. 调用this.transport.send(message)把请求发出去。
  5. 等待两种终点:要么收到对应id的响应消息,要么超时。

pendingRequests这个Map是整个Client端并发控制的核心。你去看源码时会发现,它的value并不是单纯的回调函数,而是一个包含resolve和reject的对象。为什么这样设计?因为JSON-RPC的响应里除了成功结果,还允许返回标准化的错误对象,比如{"code":-32601,"message":"Method not found"}。当错误对象返回时,Provider层需要走reject路径,把错误抛给上层调用者,而不是静默吞掉。

这里有个非常实用的细节:Protocol.request支持传入一个可选的resultSchema参数。在TypeScript SDK里,这个schema实际上是用来做运行时校验的。如果你显式传入Zod Schema,响应结果返回时SDK会先做一层校验,格式不对直接走reject,这样上层拿到的数据一定是安全的,不会出现“字段为undefined但没报错”这种玄学问题。我强烈建议在实际项目里不要省这一步,尤其是对接第三方Server的时候,鬼知道对方返回的数据结构会不会哪天突然变一下。

2.2 异步推送消息的路由:Server主动开口说话时怎么办

MCP协议里有一种场景,Client并没有主动发请求,但Server需要主动告诉Client一些事情。比如某个资源更新了,或者某个长时间运行的任务进度变了。JSON-RPC把这类消息称为Notification,它有method和params,但没有id,也不需要响应。

Protocol类对Notification的处理走的是一条独立的路:onnotification回调注册机制。SDK在初始化时允许你注册一套handler,根据method名分发到不同的处理函数。TypeScript SDK里典型的写法是:

client.setNotificationHandler("notifications/tools/list_changed", () => { // 刷新工具列表缓存 });

这个机制在源码里是怎么实现的?Protocol.handleMessage收到消息后,会先判断消息里有没有id字段。有id,说明是响应或错误,去pendingRequests里查表;没有id,说明是Notification,去notificationHandlers里查表,找到对应的handler就调用,找不到就静默丢弃。

为什么“静默丢弃”这个设计很重要?因为MCP协议是渐进增强的——新版本的Server可能多推一些你还没适配的消息类型,如果你的Client直接报错,那就没法兼容了。静默丢弃配合日志记录,是处理未知异步消息的最优解。

2.3 transport的抽象边界:协议层与传输层怎么解耦

读MCP源码的时候你会明显感觉到一种设计上的克制,Protocol类完全不关心消息是走stdio还是走HTTP。它只管两件事:send(message)onmessage(callback)。至于消息最终是写进管道还是变成POST请求体,那是transport的事。

这种抽象边界带来的好处太大了。我做集成测试时,可以先用一个InMemoryTransport把两个Protocol实例直接连起来,不经过任何真实通道,单测速度极快。等逻辑验证完,再替换成StdioClientTransport接真实进程。生产环境和测试环境只差一行代码,这就是分层设计的价值。

你去看StdioClientTransport的源码,它的实现也很有趣:它用child_process.spawn拉起Server子进程,然后把子进程的stdout定向为一个ReadableStream,每收到一段完整数据就触发一次onmessage。这里有一个容易踩的坑:stdio管道是流式的,Server发送的一条消息可能被操作系统拆成多个chunk到达,SDK内部会用ReadBuffer做粘包拆包,按Content-Length头来切分消息。有次我调试一个自定义Server时,发现Client收到的JSON被截断了,排查半天,最后还是去读了ReadBuffer的源码,发现协议要求每条消息必须带Content-Length头,而我手写的Server发送时漏了,导致SDK无法正确切分消息边界。这类问题如果不看源码,单靠对协议规范的理解,很难定位。

3. initialize握手与能力协商:Client连上Server时说好的第一句话

MCP的会话建立不是简单地把连接打开就完事了,双方必须先完成一次能力协商,明确“你支持哪些功能,我支持哪些功能”,然后会话才算进入可用状态。这非常像两个人见面先交换名片,谁有什么资源、哪些能共享,得先说清楚,后面合作才不会有歧义。

Client往Server发的第一句话是initialize请求,参数长这样:

{ "protocolVersion": "2025-03-26", "capabilities": { "roots": { "listChanged": true }, "sampling": {} }, "clientInfo": { "name": "my-agent", "version": "1.0.0" } }

Server的响应则是:

{ "protocolVersion": "2025-03-26", "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": true, "listChanged": true }, "prompts": { "listChanged": true }, "logging": {} }, "serverInfo": { "name": "weather-server", "version": "0.2.0" } }

完成握手后,Client还要再发一条notifications/initialized通知,告诉Server“准备就绪,可以开始干活了”。这个通知在源码里有明确的位置,你去找ClientSessioninitialize方法,能看到两个明显阶段:第一阶段发initialize请求等响应,第二阶段发initialized通知。

3.1 能力协商的对称性与非对称性:审慎声明,胆大使用

理解能力协商的关键是抓住它的核心原则:你能做什么和你想做什么,是两件不同的事。Client声明的capabilities是“我作为客户端能提供的服务”,比如sampling(采样),表示Client允许Server反过来请求它调用大模型;roots表示Client允许Server读取本地文件根目录列表。Server声明的capabilities是“我可以提供哪些功能”,比如tools表示我实现了工具调用,resources表示我能暴露资源。

读源码时你会发现,这些capabilities在Client端的处理有一个通用的“反向适配”逻辑。以sampling为例,Server在运行时可能会发一个sampling/createMessage请求过来,要求Client去调用一次大模型生成文本,再把结果返回给Server。这就是MCP协议里“Client主动提供AI能力给Server使用”的场景。在Protocol层面,这同样走的是Notification和新增请求的路由机制,SDK把它称为“client-initiated requests”。

实际开发中,你不需要把所有capabilities都声明成true。只声明你用得到的,少声明不会导致功能缺失,反而是更安全的选择。比如你写一个只读的Agent,那roots其实都可以不声明。很多新手上来把capabilities全开,结果Server当真了,疯狂发起各种回调请求,Client这边没有对应的处理逻辑,反而报一堆错。

3.2 协议版本兼容:一个看着简单但天天出问题的环节

MCP的协议版本字段长得像日期2025-03-26,但这个字段的协商逻辑非常关键。你在源码里会看到,Client发送initialize时会带上自己支持的版本,Server响应时如果Server支持更高版本,它会返回一个自己实际支持的版本。这时双方以响应里的版本为准。

这里有个容易踩的坑:如果你的Client代码写的是protocolVersion: "2024-11-05",而Server响应返回"2025-03-26",此时SDK并不会自动帮你“升级”到新版本。它只做一件事,确认双方能理解同一个版本。如果版本差距过大,SDK可能会在后续请求里报错。

我遇到过一个真实的案例:对接某个云厂商提供的MCP Server时,我的Client一直提示“method not found”,但Server明明实现了工具列表接口。后来抓包看原始响应才发现,对方的Server虽然实现了新协议,但初始化时返回的protocolVersion是旧版,导致Client端按旧协议解析,自然找不到新方法。解决办法是Client端做一次版本向上兼容,在初始化后检测response里的版本号,如果高于自己声明的版本,动态切换到对应版本的解析逻辑。这个逻辑在官方SDK里是通过versioned methods映射表实现的,你去看Protocol构造函数,会发现它内部维护了一张method → 对应版本的实现的表,不同协议版本的相同方法名会落到不同的处理分支。

4. ClientSession的完整生命周期:从连接创建到优雅释放的完整状态机

很多人用MCP SDK,写完client.connect()后就开始调用工具,完全没关心过连接内部的状态管理。直到某天Server的进程崩了,或者网络断了,Client这边一直挂在那里不报错也不超时,才开始怀疑人生。这部分的坑,基本都出在没搞懂ClientSession的生命周期设计上。

先看这个类的构造。它接收一个transport实例,但注意,此时连接还没建立。connect()方法才是真正开始干活的入口。连接建立后,ClientSession内部会依次执行:

  1. 启动transport的消息监听。
  2. 发送initialize握手请求。
  3. 等待initialize响应完成能力协商。
  4. 发送initialized通知。
  5. 把内部状态从connecting切换为connected
  6. 所有后续请求开始正常走Protocol.request

这个过程中,任何一个环节出错,都会走close()逻辑回滚状态。所以你在调用connect()时,一定要做好异常捕获,因为握手失败并不少见——比如Server实现了不兼容的MCP版本,或者Server本身启动就崩溃了。

4.1 连接的建立:什么时候该用长连接,什么时候该连一次就走

这里值得展开的是传输层的连接模式选择。MCP支持两种典型场景:stdio模式通常用于本地进程,Client拉起Server子进程后,连接一直保持,直到Host退出或手动关闭。而HTTP模式则要区分长连接和短连接——如果你的Server是部署在远端、供互联网用户访问的,那Client每次请求都建立一个新连接也并非不可以,因为HTTP传输层本身是有连接的,但MCP协议层的会话状态(比如初始化协商结果)就需要每次重建。

实际项目里我建议:本地工具优先用stdio,远端服务优先用Streamable HTTP长连接。原因很简单,stdio模式下进程间通信不需要考虑网络延迟和断线重连,是延迟最低的方式;而HTTP长连接省去了每次都重新握手、协商的能力开销。

在官方SDK里,StreamableHTTPClientTransport支持一个requestTimeout配置项,默认值是60秒。这个值决定了单个请求的兜底超时时间。有次我们内部做推理型工具的调用,大模型思考时间经常超过60秒,结果每次调用都被Client端超时断开。后来把这个参数调大,问题才解决。

4.2 优雅关闭:别用kill -9解决问题,Close才是正确的道别方式

读源码时你会发现ClientSession.close()做的事情比想象的要多:

  1. 向Server发送terminate通知,告诉对方“我要走了,你可以释放资源了”。
  2. 关闭transport底层连接(stdio模式下kill子进程,HTTP模式下断开连接)。
  3. 清理所有pendingRequests,把这些请求全部reject,避免上层调用者永远等一个永远不会来的响应。
  4. 触发onclose回调,让Host层感知到会话已断开。

前两步好理解,关键是第三步。如果你直接销毁连接但不清理pendingRequests,那上层所有等待响应的异步操作都会永远挂起,内存里积压一堆永远不会执行的Promise,这在长驻进程里就是内存泄漏。官方用了一个很巧妙的设计,close()时会把每个pending请求的reject方法调用一遍,并抛出一个专门的McpError,错误码是-32000,错误消息是“Connection closed, request cancelled”。这样上层调用方的catch分支就能感知到“请求被取消”而非“请求失败”,两者在语义上有本质区别。

4.3 断线重连:官方SDK不帮你做的事,你得自己补齐

官方SDK里没有内建自动重连机制。这是设计决定,不是遗漏,因为不同场景对重连语义的要求差别太大。比如一个IDE插件,用户可能几十个小时不关闭窗口,中间如果Server重启过,Client需要自动重连并恢复会话;而一个CLI工具,跑完就退出,根本不需要重连。

所以如果你在做一个长驻进程(Agent服务、网关等),重连逻辑一定要自己实现。我的经验是实现一个“指数退避重连器”,挂在onclose回调后面。连接意外断开时,等待1秒、2秒、4秒……最多等30秒,重试建立连接。重连成功后再做一次完整的initialize握手,因为旧的会话状态已经作废了。这个逻辑看起来不复杂,但如果不看源码,你很可能忽略一个关键细节:重新连接后,ClientSession是新的实例,旧的Protocol实例里的pendingRequests已经清理干净,不能在旧实例上继续发起请求。

5. 排错实战:MCP Client连接失败的完整排查链路

写代码的时候不怕功能复杂,就怕报错信息晦涩难懂。MCP的报错尤其如此,因为协议层、传输层、业务层三层错误经常混在一起,让人摸不着头脑。这一节我把自己实际排查过的几个典型案例完整复盘一下,包括我的思考路径和最终定位到的根因,供大家少走弯路。

先说你最可能遇见的第一个错误:“Cannot read properties of undefined (reading 'send')”。这种报错一般发生在你还未调用await client.connect()就直接调用了client.callTool()。原因很直接,transport此时是undefined,连接还没有初始化。排查思路也很简单——回到源码里看McpClient的构造逻辑,connect()里才会给transportProtocol做关联。没连接就发请求,自然拿不到transport实例。

第二个高频错误:“Server sent an invalid response”。这个报错通常意味着你对端的Server返回的数据结构不符合MCP规范。很多情况下不是协议版本的问题,而是Server端在实现tools/call时返回了一个非法的result结构,比如直接把整个工具输出当字符串拼进result,而协议要求必须是一个包含content数组的结构化对象。这个排查起来要抓Server端的原始输出,我在实践里最喜欢的办法是给transport加一层日志中间件,把所有收到的原始JSON打出来。你不需要改动SDK源码,只需要在onmessage回调外层套一层打印。

第三个隐蔽错误:服务端能力声明与实现不一致。这个错误最恶心,因为它的报错信息跟实际问题毫无关系。有次我连一个数据库MCP Server,initialize握手返回的能力里明确写着没有resources能力,但当我请求某个资源时,Server却端返回了资源内容。从协议角度来说,这就是Server违反了自己的能力声明,Client端可以拒绝处理,但实际很多Server的实现在这块非常粗糙。面对这种情况,我采取的思路是在代码里对能力做“白名单校验”:

// 伪代码:检查Server是否声明了某能力 if (!serverCapabilities.tools) { throw new Error("Server does not support tools"); }

这个检查看起来多此一举,但它能让你在问题最初阶段就知道“不是Client的问题,是Server没按协议办事”,省去后面大量的无效调试时间。

最后一个常犯错误是stdio模式下忘关父进程的stdin。如果你是自己实现的transport而不是直接用官方SDK的StdioClientTransport,一定要记得用完后把子进程的stdin关闭,否则Server进程会一直等输入,永远不会退出。这个坑在官方SDK里其实已经处理了,但一旦你写自定义的transport就会暴露出来。

6. 从读源码到进阶:三个只有看Client源码才能悟到的设计经验

读源码如果只停留在“我能跑通”,那是浪费。MCP Client源码里藏着几处非常值得借鉴的设计哲学,我用最后一段篇幅展开说。

第一处是Protocol类的请求超时管理。它的超时不是给每个请求单独开一个setTimeout,而是在每个pending请求里记一个时间戳,然后在收到任何消息时顺带检查一遍所有pending请求是否已经过期,过期的就直接reject。这种“懒清理”策略有一个明显好处——不额外占用定时器资源,尤其在大量并发请求的场景下,不会因为每个请求都开一个定时器导致事件循环被塞满。这里我们可以借鉴到自己的网络库设计中:对于超时精度要求没那么高的场景,不需要用setTimeout精确到毫秒级,用时间戳加轮询检查就够了。

第二处是能力协商之后的运行时校验。前面提到request可以传resultSchema,这套机制在MCP协议层其实扮演的是“运行时类型安全”的角色。为什么协议已经规定了数据结构,还要在代码层再做一遍校验?因为协议文本只是“纸面约定”,实际的对端Server是别人写的,不知道会返回什么奇怪的东西。SDK这一步等于给整个系统上了一道保险。放到更大的分布式系统设计里,这其实就是“契约测试”的思想——不要相信任何外部系统的输出,永远在自己这一侧做验证。

第三处是错误码体系的语义化。JSON-RPC错误码预留了三段区间:-32700到-32000是协议层错误,-32000到-32099是服务端错误,而32700以上是应用自定义错误。MCP官方SDK在保留这些语义的基础上又扩展了McpError,让上层异常处理能分清“协议错误”“连接错误”“业务错误”。我强烈建议你在自己的项目里也建立类似的错误码体系,这会让排错效率提升一个量级。

最后分享一个小技巧。源码里的src/types.ts是MCP协议的所有TypeScript类型定义,包括各个请求的params结构、capabilities结构、错误码枚举等。当你不确定某个字段名是tool还是tools时,别去查文档,直接看这个文件,里面的类型定义就是最准确的“活文档”。我每次对接新版本的Server,第一件事就是去翻SDK的types文件,比看协议规范原文快得多。

以上,就是我从MCP Client源码里读到的核心内容。从协议栈的抽象边界,到握手协商的版本适配,再到生命周期管理的每一条细节,每一步都踩过坑,也都有实实在在的解决办法。希望这份笔记能帮你在接入MCP时少走弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询