如果你最近在写 AI Agent 或大模型应用,大概率已经被同一个问题磨得头皮发麻:怎么让模型稳定地去调用外部工具、读取外部数据。各家有各家的 function calling 格式,同一个工具接到不同模型平台就得写不同的适配层,接得越多,维护成本就越离谱。MCP(Model Context Protocol,模型上下文协议)就是冲着这个问题来的。它把 AI 应用与外部工具、资源之间的交互抽成一套统一协议,一端是 MCP Server 暴露能力,另一端是 MCP Client 调用能力,两边按协议说话就能完成工具发现、工具调用、资源读取这些动作。这个标题“18.1 MCP协议概述与Client源码解析”,本质上是在讲两件事:协议怎么设计,以及协议在客户端代码里怎么落地。
我建议想真正搞懂 MCP 的同学都走一遍“协议规范 + 官方 SDK 源码 + 最小可用接入”这条路线,只看概念你会觉得很简单,但一碰到超时、握手失败、工具注册不上就完全不知道从哪里下手。这篇文章以官方 TypeScript SDK 的实现为主线,从协议模型讲到 Client 生命周期,再拆到请求链路、消息分帧、版本协商这些实现细节,最后补充我在实际接入和调试中踩过的坑。适合三类人看:刚接触 MCP 想搞懂协议的,准备在自己应用里实现 Client 侧的,以及想把别人写的 Server 代码彻底读明白的。
1. MCP协议的核心模型:一个标准化的AI工具插座
1.1 MCP解决了什么问题,以及它和function calling、Agent Skill不是一回事
在 MCP 普及之前,AI 应用接外部工具基本是“散装组合”。模型平台要接天气 API,写一个 function calling 的 schema;要接数据库,再写一套查询工具定义;要接 Figma、Matlab 这类重型软件,几乎得为每个对象封装一层 HTTP 接口。换一个模型平台,所有定义都可能要重写一遍。这种做法的本质问题是:工具供应商和模型平台之间没有一个双方共同遵守的“连接标准”。
MCP 想做的事,可以类比成给 AI 应用装一个标准化的 USB-C 接口。工具方只要实现一个 MCP Server,暴露自己的能力;AI 应用只要实现 MCP Client,就能发现并调用这些能力。中间不再需要为每一对“模型 + 工具”定制胶水代码。
这里有必要区分几个经常被混在一起的概念。function calling 是模型平台内部的一种函数调用约定,解决的是“模型怎么输出一个结构化调用意图”,它通常只停留在模型、推理框架那一层;MCP 则更偏外圈,解决的是“这个调用意图如何跨进程、跨服务去执行”。Agent Skill 这类概念则更偏高层编排,它可能包含提示词、少样本示例、多个工具的组合策略,管的是“Agent 怎么表现出一种能力”;而 MCP 管的是底层连接通道通不通。你可以有 Skill 但底层不一定用 MCP,也可以只有 MCP 工具但没有任何 Skill。把它们放同一层比较,往往会越比越乱。
1.2 三个角色必须分清:Host、Client、Server
MCP 协议明确区分了三个角色:
- MCP Host:用户直接面对的程序,比如 Claude Desktop、IDE、自定义 Agent 应用。Host 负责整体交互、模型调用、权限控制,但它不会自己跟 Server 一条条收发消息。
- MCP Client:嵌入在 Host 内部的协议客户端组件。一个 Client 与一个 Server 保持一条连接,负责连接的建立、初始化握手、请求发送、响应分发。
- MCP Server:暴露能力的一方,可以是一个本地子进程,也可以是一个远程 HTTP 服务。Server 通过 Tools、Resources、Prompts 三类原语向客户端提供能力。
我经常看到有人把“MCP Server”理解成一个独立跑起来的服务,这没错,但容易忽略一个细节:当你打开某个支持 MCP 的桌面软件,在里面添加一个远程 MCP 地址时,软件内部实际创建的是一个 MCP Client,而不是 Server。Host 管理多个 Client,每个 Client 对应一条独立 Server 连接,这才是更准确的架构图景。
用生活化一点的比喻:Host 是家里的智能中控,Client 是每个电器对应的遥控器协议芯片,Server 是电器本身。中控不会直接拿螺丝刀去拆电器,它交给遥控器去发指令;每个电器有一个专用遥控器,坏了也不影响其他电器。
1.3 JSON-RPC消息模型与两种传输方式
MCP 的消息层选择的是 JSON-RPC 2.0。这个选择很务实,JSON-RPC 足够轻量,天然支持请求、响应、通知三种消息形态,而且几乎所有语言都有现成实现。一个典型的请求消息长这样:
json复制{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {
"city": "Beijing"
}
}
}
对应响应则带同一个 id:
json复制{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{ "type": "text", "text": "晴,26℃" }
],
"isError": false
}
}
传输层有两种主流方式。stdio 方式适合本地场景,Host 启动一个子进程运行 Server 代码,通过标准输入输出传递 JSON-RPC 消息;Streamable HTTP 方式则适合远程 Server,双端通过 HTTP 长连接或 SSE 流式交换消息。需要特别注意的是:stdio 模式下 Server 的 stdout 被协议占用,绝对不能往 stdout 里打业务日志,否则日志会和协议消息混在一起,Client 端解析直接崩掉。日志请一律写给 stderr。这个坑我在后文会再强调。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Client端到底在忙什么:职责边界与生命周期
2.1 Client不是“发HTTP请求的工具”,它的职责有边界
很多初学者会把 MCP Client 想成一个普通的 HTTP 客户端,request 发出去等 response 就行。真正实现过 Client 就知道事情没那么简单。一个合格的 MCP Client 至少要处理四类事情:
第一是连接与生命周期管理。它要知道当前连接处于什么状态:是未连接、正在初始化、已经就绪,还是已经断开。第二是消息关联。因为底层是异步通信,发出请求后不能傻等,必须通过 requestId 把响应和调用方匹配起来。第三是超时与错误处理。Server 可能一直不响应,也可能返回一个协议级错误,Client 得把这种异常转成调用方能理解的 Error。第四是能力协商。初始化阶段记录了 Server 支持哪些 capabilities,后续某些功能能不能用,不是靠猜,而是靠握手结果判断。
还有一点经常被忽略:Client 并不负责“替模型做决策”。它不会判断模型应该调用哪个工具,也不理解工具返回内容的业务含义。它更像一个快递管道,确保请求以正确的格式发出去,再把响应原样交还给上层。真正决定“用哪个工具、怎么用”的,是 Host 里的 Agent 编排层。
2.2 初始化握手:版本和能力的两次协商
MCP Client 建立连接后的第一件事,不是直接去列工具,而是先完成一次 initialize 握手。握手可以拆成四个步骤:
- Client 通过传输层发送
initialize请求,params 里带上自己支持的 protocolVersion、clientInfo、capabilities。 - Server 返回选定的 protocolVersion、自身的 serverInfo 和 capabilities。
- Client 根据返回值记录 Server 的能力集和协议版本。
- Client 再发送一个
notifications/initialized通知,告诉 Server 初始化完成。
为什么第一步要先发 initialize?因为协议版本和能力集合是后续所有调用的前提。早期 MCP 版本和后来的版本在传输细节上有差异,如果 Client 和 Server 对不上版本,后面发的 tools/list、tools/call 都可能语义不一致。通过握手,双端先确认彼此说的是同一个版本的“方言”,再进入正式通信。
协议版本协商不是强制要求 Server 一定接受 Client 声明的版本。Client 发来自己支持的最高版本,Server 有权利选择一个自己兼容的版本返回,甚至返回一个协议错误。所以 Client 源码里一般会维护一个“我可以支持哪些版本”的常量数组,收到 Server 返回的版本后,如果不在自己支持列表里,就要主动报错,不能装作没看见。
2.3 请求关联:requestId如何串起一次完整旅程
JSON-RPC 是异步的,Client 很可能同时发出多个请求。比如 Agent 需要同时调用两个工具,A 请求发出去之后还没回来,B 请求又发出去了,这时候 Server 先后返回两条响应,Client 怎么知道哪条对应哪个调用?答案就是 requestId。
在实现层面,Client 内部通常维护一个自增 id,每发一个请求就把 id 和当前调用方的 Promise resolve/reject 存进一个 Map。响应回来时,解析出消息里的 id,去 Map 里找到对应的处理函数,然后删掉这个挂起项。这个过程在源码里非常清晰:
ts复制// 这是 Protocol 层最核心的逻辑,不是完整源码,但结构是一致的
class Protocol {
private pendingRequests = new Map<number, PendingRequest>();
private nextRequestId = 1;
protected request(method: string, params: unknown): Promise<any> {
const id = this.nextRequestId++;
const message = { jsonrpc: '2.0', id, method, params };
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
this.transport.send(message);
});
}
protected handleMessage(message: any) {
if (message.id !== undefined && this.pendingRequests.has(message.id)) {
const pending = this.pendingRequests.get(message.id)!;
this.pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message));
} else {
pending.resolve(message.result);
}
}
}
}
挂起请求的 Map 必须保证请求结束时删除对应项,否则就会内存泄漏。实际 SDK 还会给每个请求加超时定时器,超过一定时间没响应就自动 reject。这也能解释为什么连接一个不响应 initialize 的 Server,Client 会在几十秒后报 timeout,而不是无限等下去。
2.4 Server反向调用:容易忽略的双向通道
大多数人理解的 MCP 是“Client 请求、Server 响应”的单向模式,实际上协议是双向的。Server 在初始化阶段如果声明了 sampling 等 capabilities,它可以反过来向 Client 发送请求,要求 Client 协助调用大模型完成采样。这种反向请求对源码实现提出了更高要求:Protocol 不仅要处理 response,还要能识别“这个消息是一个发给我的新请求”,并路由到对应的 handler。
双向通道在源码解析时容易被忽视,可一旦你想实现一个完整 Client,就必须考虑它。好消息是绝大多数场景里,普通工具调用只需要 Client 到 Server 的方向,所以即使你不处理反向请求,日常用也不会出问题。我建议读源码时把这一点放在心里,遇到 onrequest 之类的回调就不会懵。
3. 官方TypeScript SDK的Client源码解析
3.1 源码阅读入口:从哪几个文件看起
如果打开官方 TypeScript SDK(GitHub 上的 modelcontextprotocol/typescript-sdk),不要一头扎进去读所有文件。源码的核心集中在 src 下几个模块,阅读顺序我建议这样做:
- types.ts:先看类型定义,了解 ClientCapabilities、ServerCapabilities、Tool、Resource 这些数据长什么样。
- shared/protocol.ts:看 Protocol 基类,这是消息收发的核心,理解了它你就理解了所有请求响应机制。
- client/client.ts:看 Client 类,重点看 connect、listTools、callTool 这三个方法。
- client/stdio.ts 或 client/streamableHttp.ts:看传输层实现,了解消息最终怎么变成字节流发出去。
看源码不是要背每一行,而是带着问题看:Client 怎么完成握手?requestId 存在哪里?超时是哪里触发的?工具调用的返回值长什么样?这几个问题能回答,源码基本就吃透了。
3.2 Protocol基类:处理pending与消息分发
Protocol 是整个 SDK 的通信底座。Client 和 Server 都继承自它,区别只在于上层暴露的业务 API 不同。Protocol 的核心数据结构是 pendingRequests Map,这点前面已经说了;它还有一个职责是消息分发,也就是根据收到的消息形态决定走哪条处理分支。
一个消息从 Server 回来后,Protocol 会先判断消息里有没有 id。有 id,且这个 id 能在 pendingRequests 里找到,那它就是一次请求的响应,直接 resolve 或 reject 对应的 Promise。有 id,但在 pendingRequests 里找不到,这种情况通常是过期响应,直接忽略。没有 id,则说明是一条通知,需要走到对应的事件处理逻辑,比如 notifications/initialized、notifications/tools/list_changed 这类。
Protocol 还负责给 Server 发过来的请求注册 handler。比如 Server 想发起 sampling 反向请求,Client 需要提前用 setRequestHandler 注册一个方法,收到对应 method 时就调用它。这种设计在源码层面把“请求-响应”和“通知-事件”两条路径分得很清楚,读起来并不复杂。
3.3 Client.connect:连接后第一件事是握手
看 Client 类时,最值得关注的是 connect 方法。它做的事情比普通 connect 多得多,不是仅仅把传输层打通:
ts复制async connect(transport: Transport): Promise<void> {
await this.transport.start();
const result = await this.request(
'initialize',
{
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: this.clientCapabilities,
clientInfo: {
name: this.clientName,
version: this.clientVersion
}
}
);
this.serverCapabilities = result.capabilities;
this.serverVersion = result.serverInfo?.version;
this.protocolVersion = result.protocolVersion;
await this.notification('notifications/initialized', {});
}
注意一个容易忽略的细节:SDK 是在 connect 内部完成 initialize 握手,而不是让使用者手动再调一次 initialize。也就是说,await client.connect(transport) 返回以后,Client 已经处于 ready 状态,可以直接 listTools 了。如果你在业务代码里连接完立刻发请求,只要确保前面 await 了 connect,就不会有握手未完成的问题。
有些自己实现的轻量 Client 会简化这个过程,connect 之后把 initialize 交给上层手动控制。这样灵活性更高,但也容易漏掉 notifications/initialized 通知,导致部分 Server 端状态机一直在等待初始化完成,工具调用就卡住。
3.4 callTool链路:从方法名到返回content
阅读源码时,可以把 callTool 当作一条贯穿的线索。调用 client.callTool('get_weather', { city: 'Beijing' }),链路是这样的:
- Client 把方法名、参数封装成 params。
- Protocol.request 分配一个 requestId,生成 JSON-RPC 消息。
- 消息交给 transport,通过 stdout 或 HTTP 发给 Server。
- Server 收到后执行对应工具,返回 result。
- transport 收到响应,Protocol 根据 id 找到挂起请求,resolve。
- Client 的方法拿到 result,也就是包含 content 数组的对象,返回给调用方。
返回值里的 content 类型是数组,每个元素可能是 text、image、resource 等。真正接入模型时,你通常需要自己遍历 content,把 text 类型的内容提取出来再塞回给模型。这里要留意 isError 字段,它表示工具执行是否出错,即使 HTTP 层面成功、JSON-RPC 返回正常,isError 为 true 也说明工具内部抛了异常。
我在源码里学到的最有价值的习惯就是:链路每一层只做自己职责内的事。Client 不解析业务语义,Server 不需要知道上层是哪个模型在调用。这种清晰边界让 MCP 生态能快速发展,也是你后续排查问题的基本框架。
4. 从源码到落地:手写一个最小MCP Client
4.1 准备一个可以用30秒跑起来的样例
源码看得再多,不如亲手跑一个 Client。最简单的验证场景是:本地起一个 MCP Server,让 Client 连接它并调用一个工具。官方维护的 server-everything 包很适合做实验,它暴露了 echo、add 等示例工具。
准备条件很简单,Node.js 18 以上即可。在空目录里做初始化:
bash复制npm init -y
npm install @modelcontextprotocol/sdk
如果不想在本地写 Server 代码,可以直接用 npx 运行现成包。这样我们可以把精力全放在 Client 侧。
4.2 最小Client代码,可复制直接跑
下面这段代码是一个最小可运行的 MCP Client,使用 stdio 传输连接一个本地 Server:
ts复制import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const transport = new StdioClientTransport({
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-everything'],
});
const client = new Client({
name: 'my-minimal-client',
version: '0.1.0',
});
await client.connect(transport);
const tools = await client.listTools();
console.log('可用工具:', tools.tools.map((t) => t.name));
const result = await client.callTool({
name: 'echo',
arguments: { message: 'hello mcp' },
});
console.log('返回内容:', JSON.stringify(result.content));
await transport.close();
这段代码做的事情很清楚:创建 StdioClientTransport 来启动子进程,创建 Client,connect 完成初始化握手,listTools 发现能力,callTool 执行具体工具。整个过程与前面解析的源码完全对应,你可以把它当作一个最小骨架,后续扩展成支持 HTTP Server、多 Server 管理、错误重试等能力。
跑起来以后可以试一试故意把 Server 地址写错,或者把 command 换成一个不存在的命令,观察报错。这比直接看文档更能理解传输层失败和协议层失败的区别。
4.3 进一步想:真实Agent中如何用listTools和callTool配合
手动调用工具只是验证链路,真实 Agent 场景要比这多一层:模型决策。一个典型的接入循环是这样的:
- Client 先 listTools,拿到工具的 name、description、inputSchema。
- 把这些定义拼进系统提示词或通过 function calling 机制传给模型。
- 模型根据用户问题选择要调用的工具,并生成结构化参数。
- Client 调用 callTool 执行工具。
- 把返回的 content 提取成文本,交给模型生成最终回答。
所以 Client 源码保证的是第 1 步和第 4 步的稳定性,而模型能不能选对工具,更多取决于你暴露出去的工具定义质量。如果你定义了模糊的 description、不完整的 inputSchema,模型就会频繁选错参数。这也是为什么不少 MCP Server 的“工具注册不上”最后查出来不是网络问题,而是工具定义写得太差,被模型层忽略或解析失败。
如果你要接多个 Server,一个 Host 里就会有多个 Client。这时要给每个工具加上来源 Server 的前缀,比如 figma_getFile、mysql_query,避免不同 Server 间的工具重名冲突。这类逻辑不在 MCP 协议内,而是 Host 层自己的编排职责,但做 Agent 时一定会碰到。
5. 常见问题与排查经验
5.1 高频问题速查表
我在接入和调试 MCP Client 时整理过一份高频问题表,很多是网上文档不太会细讲的:
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| connect 卡住直到超时 | stdin/HTTP 传输没通;Server 没回 initialize;Server stdout 被日志污染 | 先单独在终端跑 Server,手动发 initialize;检查日志是否写入 stderr |
| tools/list 返回空 | Server capabilities 里没声明 tools;初始化没完成 | 检查 Server 是否实现了 ListToolsRequest;确认 await connect 完成 |
| 工具调用报 method not found | protocol version 不匹配;Server 不支持该工具 | 检查版本协商结果;看 Server 端实现是否注册了该工具 |
| 工具注册不上、时有时无 | Host 缓存旧工具列表;多 Server 工具重名;Codex/Cursor 配置改动后没重启 | 重启 Host;给工具打 Server 前缀;清除缓存后重新扫描 |
| 连接已关闭 / Broken pipe | Server 进程崩溃;Service 端断开了流 | 看 Server stderr;检查本地进程是否还活着 |
| 本地 stdio 模式下日志乱入 | 日志打印到 stdout | 所有调试输出改到 stderr,最好加上日志级别开关 |
| 远程 HTTP Server 鉴权失败 | 缺少 OAuth 或临时令牌;Authorization 头没带 | 检查 Server 要求的鉴权方式,配置好 token 后再连 |
工具注册不上这个问题,我额外说两句。很多人以为注册是一个主动操作,真实情况是大多数 Host 通过 tools/list 拉取工具,然后缓存在本地。比如你在 Codex 或 Cursor 里配了一个 Figma MCP Server,改了 Server 端工具定义,Host 可能还用旧缓存,所以怎么都看不到新工具。解决方式不一定是重装插件,而是先找到 Host 的 MCP 缓存目录,清掉缓存再连一次。
5.2 实用排查方法:直接看消息帧
排查 MCP 问题有个通用思路:不要只盯着上层 API,要下到消息层去看。因为 MCP 是 JSON-RPC,所以只要能看到实际传输的 JSON 消息,问题基本就能定位到是握手失败、方法名错误还是参数格式不对。
最简单的方式是在自定义代码里把 transport 收到的原始 message 打出来。如果你用的是官方 SDK 封装,可以在初始化后手动发一帧 initialize,观察响应结构。网上流传的各种“连不上”“注册不上”问题,最后落到消息层看,通常有三种情况:一是 initialize 响应里的 capabilities 为空,导致 Host 认为 Server 没有可用工具;二是 tools/list 返回的工具名带了特殊前缀,而 Host 端配置没跟上;三是 Server 返回了 isError=true 的执行结果,Host 却只看 HTTP 状态码,导致错误被吞。
实际调试时也可以借助官方 MCP Inspector 这类工具。它本质就是一个现成的 MCP Client,能连接 stdio 和 HTTP Server,展示可用工具、手动调用工具并查看原始响应。我建议出现问题先拿 Inspector 连一次,如果 Inspector 能连通,说明问题在你的 Host 配置或业务代码;如果 Inspector 也连不上,问题大概率在 Server 端。
5.3 源码级排错顺序:从传输层往业务层查
如果你已经把问题定位到自己的 Client 实现上,排错顺序建议严格按照“传输层 -> 协议层 -> 业务层”来。传输层看的是进程能不能起来、网络能不能通、HTTP 状态码是否正常;协议层看的是 JSON-RPC 消息格式、requestId 是否匹配、版本协商结果、capabilities 声明;业务层看的才是工具内部逻辑、权限、参数合法性。
大多数新手会直接从业务层开始查,比如怀疑工具实现有 bug,结果 debug 半天发现根本没有 Server 进程。先确认传输层,再确认协议层,最后看业务层,能省非常多时间。举一个我遇到过的例子:某个远程 MCP Server 接入后时报 “Client closed”,查看传输层发现是 HTTP 长连接超时断开,再看协议层发现 Server 没有按预期发送心跳,最后才知道是那个 Server 实现的内存泄漏导致进程被杀。如果一开始就去检查工具参数,恐怕永远找不到根因。
关于超时设置也有一个心得:不要把所有请求都设同一个超时时间。tools/list 这类轻量元数据请求可以设短一些,比如 5 到 10 秒;tools/call 这类真实执行外部操作的请求则要留足余量,比如 30 秒到几分钟。否则一个耗时的工具调用很容易被客户端误判成超时,造成重复执行。
从一次完整拆解中得到的几点体会
我非常推荐每个做 Agent 应用的人都把 MCP Client 源码完整读一遍,哪怕只是走马观花。对我而言,最大收获不是记住了哪个类名、哪个方法,而是把之前零散的概念串起来了:initialize 为什么必须在前面,requestId 为什么不能省,Server 为什么不能用 stdout 打日志,工具返回为什么要区分 content 和 isError。这些细节在每个单独场景里好像都不起眼,但组合在一起,就是客户端稳定性的关键。
如果你后面要自己写一套 MCP Client,建议从官方 SDK 的最小实现开始改,而不是从零造轮子。先跑通一个本地 stdio Server,然后换 HTTP,再加多 Server 管理、工具缓存、模型决策轮次,一步一步往上叠。等哪一天你打开 Host 的调试日志,看到一条条 JSON-RPC 消息流畅地走完 initialize、tools/list、tools/call,那种感觉是很踏实的。MCP 本身不复杂,复杂的是把它放在真实场景里跑稳,这部分只能靠实际调试一点点积累。
