一条JSON-RPC消息如何穿越四层架构完成交付?深入MCP TypeScript SDK源码,拆解从管道字节到协议分发的完整链路。
引言:那条消息经历了什么?
当你在 IDE 中调用一个 MCP 工具------比如让 AI 读一个文件------客户端向服务端发出了一条 tools/call 请求。这条消息从 JS 对象变成字节、穿过管道或网络、在对端被解码、分发、执行、再将结果原路送回。
整个过程跨越了 4 个架构层、3 种帧协议、2 个协议时代,涉及 Buffer 内存管理、SSE 状态机解析、双 Era 编码切换、请求/响应关联等十余个子系统。
本文基于 MCP TypeScript SDK 源码,沿一条消息的完整路径,逐层拆解每个环节的设计决策。
1. 四层架构:一张全景图
MCP SDK 将传输链路上的关注点清晰地切分为四层:
arduino
┌──────────────────────────────────────────────────────┐
│ Role Layer(角色层) │
│ Client / Server / McpServer │
│ → 握手协商、能力声明、业务 API │
├──────────────────────────────────────────────────────┤
│ Protocol Layer(协议层) │
│ Protocol<ContextT>(抽象基类,1883 行) │
│ → 请求/响应关联、通知分发、超时、取消、进度跟踪 │
├──────────────────────────────────────────────────────┤
│ Wire Codec Layer(线格式编码层) │
│ rev2025Codec / rev2026Codec │
│ → 按 Era 编码/解码、方法注册表、物理删除 │
├──────────────────────────────────────────────────────┤
│ Transport Layer(传输层) │
│ Transport 接口 │
│ → 字节流收发、帧格式处理、连接生命周期 │
│ → Stdio / Streamable HTTP / Legacy SSE │
└──────────────────────────────────────────────────────┘
核心原则:上层不感知下层的物理传输细节,下层不理解上层的协议语义。 Transport 只传递 JSONRPCMessage,Protocol 只关心 JSON-RPC 帧逻辑,Wire Codec 只管序列化与时代差异。
这种分层使得同一个 tools/call 请求可以无差别地通过 Stdio 管道、HTTP SSE 流甚至进程内直连(InMemoryTransport)发送------角色层和协议层完全无感。
2. Transport 层:最小契约与八种实现
2.1 接口定义
Transport 是所有传输实现的最小契约------一个双向消息管道:
typescript
export interface Transport {
start(): Promise<void>;
send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void>;
close(): Promise<void>;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: <T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void;
sessionId?: string;
hasPerRequestStream?: boolean;
setProtocolVersion?: (version: string) => void;
}
源文件:transport.ts
为什么这样设计? 接口只暴露消息级 API(send(message)),不暴露字节级 API(write(bytes))。这让上层完全不用关心底层是 NDJSON 还是 SSE 帧------Transport 自己负责帧格式的序列化。
如果换一种设计? 假设 Transport 暴露的是字节流接口(如 Node.js Duplex),那 Protocol 层就需要理解帧格式,导致协议逻辑与传输格式耦合。这正是很多传统 RPC 框架难以同时支持多种传输的原因。
2.2 八种实现
| 实现 | 场景 | 帧格式 | 运行时 |
|---|---|---|---|
StdioServerTransport |
本地进程通信 | NDJSON(\n 分隔) |
Node.js |
StdioClientTransport |
客户端 spawn 子进程 | NDJSON | Node.js |
WebStandardStreamableHTTPServerTransport |
远程服务(推荐) | HTTP + SSE 帧 | Web Standards |
StreamableHTTPClientTransport |
远程客户端(推荐) | fetch + SSE 解析 | Web Standards |
SSEServerTransport |
已废弃 | GET SSE + POST | Node.js http |
SSEClientTransport |
已废弃 | EventSource | Node.js |
PerRequestTransport |
请求级 SSE 升级 | JSON→SSE 动态切换 | Web Standards |
InMemoryTransport |
测试 | 进程内直连 | 任意 |
2.3 发送选项的按传输分派
TransportSendOptions 包含断线重连令牌、取消信号、HTTP 头等高级选项,但只有相关传输会处理它们:
| 选项 | Stdio | Streamable HTTP | Legacy SSE |
|---|---|---|---|
relatedRequestId |
忽略 | 路由到正确的 SSE 流 | 忽略 |
resumptionToken |
忽略 | Last-Event-ID 重连 | 忽略 |
requestSignal |
忽略 | 取消 POST 请求 | 忽略 |
headers |
忽略 | 附加到 POST 请求 | 忽略 |
为什么这样设计? 接口超集 + 按实现选择性处理,避免了为每种传输定义独立接口导致的类型爆炸。Stdio 可以安全地忽略 resumptionToken------因为管道不存在断线重连。
3. Stdio:当消息穿过管道
Stdio 是 MCP 中最基础的传输------客户端作为父进程 spawn 服务端子进程,通过 stdin/stdout 管道双向通信。没有网络,没有 HTTP,只有操作系统的 pipe 和 Node.js 的 Buffer。
3.1 通信模型
帧格式极其简洁------换行分隔的 JSON(NDJSON):
json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}\n
{"jsonrpc":"2.0","method":"notifications/initialized"}\n
没有长度前缀,没有二进制头。每条消息一行 JSON,以 \n 结尾。
3.2 Buffer:管道的数据载体
Node.js 的 Readable 流(process.stdin、child.stdout)的 data 事件默认返回 Buffer。Buffer 的内存不在 V8 堆中,而是通过 C++ 层面的 Slab 分配器在堆外分配:
scss
V8 Heap Off-Heap (C++)
┌─────────────────┐ ┌─────────────────────────────────┐
│ Buffer 实例 │ ──指向──→ │ Slab (8 KB) │
│ { offset, len } │ │ ┌─────┬─────┬─────┬──────────┐ │
│ (JS 对象) │ │ │Buf A│Buf B│Buf C│ 空闲 │ │
└─────────────────┘ │ └─────┴─────┴─────┴──────────┘ │
└─────────────────────────────────┘
小 Buffer(< 4KB)从 8KB 预分配 Slab 切片,分配开销极小。这意味着 Buffer.alloc(100) 不会触发 malloc,只是从已有 Slab 中切一块。
源文件:stdio.ts
3.3 ReadBuffer:跨 chunk 消息缓冲
管道的 data 事件不保证消息边界------一次回调可能收到半条消息,也可能收到三条半。ReadBuffer 正是为了解决这个问题:
typescript
export class ReadBuffer {
private _buffer?: Buffer;
append(chunk: Buffer): void {
this._buffer = this._buffer
? Buffer.concat([this._buffer, chunk]) // 拼接跨 chunk 数据
: chunk;
}
readMessage(): JSONRPCMessage | null {
const index = this._buffer.indexOf('\n'); // 查找行终止符
if (index === -1) return null; // 不完整行,等待更多数据
const line = this._buffer.toString('utf8', 0, index);
this._buffer = this._buffer.subarray(index + 1); // 零拷贝裁剪
return JSONRPCMessageSchema.parse(JSON.parse(line));
}
}
为什么这样设计? 四个 Buffer API 的精准组合解决了所有边界情况:
Buffer.concat()--- 拼接跨 chunk 的不完整 JSON 行indexOf('\n')--- 按换行符分割 NDJSON 消息toString('utf8', 0, index)--- 仅转换需要的一行,避免整块 Buffer 解码subarray()--- 零拷贝裁剪已读数据,不复制内存
如果换一种设计? 可以用 TransformStream 实现类似的分割逻辑,但这会引入 Web Standards 依赖,而 Stdio 是 Node.js 专属场景,直接使用 Buffer 更高效。
3.4 背压与三级关闭
发送端的背压处理 ------当 stdout.write() 返回 false 时,说明内核缓冲区已满:
typescript
if (this._stdout.write(json)) {
resolve(); // 缓冲区有空间
} else {
this._stdout.once('drain', resolve); // 等待 drain 事件
}
客户端的三级关闭策略------确保子进程可靠退出:
lua
stdin.end() → 等待 2s → SIGTERM → 等待 2s → SIGKILL
| 阶段 | 动作 | 说明 |
|---|---|---|
| 1 | stdin.end() |
服务端收到 EOF,应自行退出 |
| 2 | kill('SIGTERM') |
发送终止信号 |
| 3 | kill('SIGKILL') |
强制杀死进程 |
4. SSE:三代架构演进
如果说 Stdio 是"管道里的字节流",那 SSE(Server-Sent Events)就是"HTTP 上的文本事件流"。MCP 中 SSE 经历了三代架构演进,每一代都在解决上一代的痛点。
4.1 三代对比
| 维度 | Legacy SSE | Streamable HTTP | Per-Request Transport |
|---|---|---|---|
| 连接模型 | GET 长连接 + POST 独立请求 | POST 响应可为 SSE + 可选 GET 流 | 每个请求独立 SSE 升级 |
| Session 管理 | sessionId 在 URL query 中 | mcp-session-id HTTP 头 |
无状态 |
| 断线重连 | 无原生支持 | Last-Event-ID + EventStore |
请求级生命周期 |
| 运行时依赖 | Node.js http 模块 |
Web Standards(ReadableStream) |
Web Standards |
| 状态 | 废弃 | 推荐 | 内部使用 |
4.2 Streamable HTTP 双通道模型
当前推荐的传输模式,SSE 作为 POST 响应和 GET 请求的流式载体:
POST 响应可以是一个 SSE 流------服务端在处理请求的过程中,持续推送进度通知,最后发送结果。GET 则是独立的 SSE 流,用于接收服务端主动发起的通知。
4.3 帧写入:从 Node.js 到 Web Standards
Legacy SSE 直接写 Node.js ServerResponse:
typescript
this._sseResponse.write(
`event: message\ndata: ${JSON.stringify(message)}\n\n`
);
Streamable HTTP 使用 ReadableStream + controller.enqueue():
typescript
let eventData = `event: message\n`;
if (eventId) eventData += `id: ${eventId}\n`;
eventData += `data: ${JSON.stringify(message)}\n\n`;
controller.enqueue(encoder.encode(eventData));
为什么这样设计? ReadableStream 是 Web Standards API,可在 Node.js、Bun、Deno、Cloudflare Workers 等运行时工作。这消除了对 Node.js http 模块的硬依赖。
如果换一种设计? 继续用 ServerResponse 写帧在 Node.js 下完全可行,但会锁死运行时,无法部署到 Edge 环境。
4.4 客户端 SSE 解析管道
客户端使用 eventsource-parser 的 TransformStream 构建三步解析管道:
typescript
const reader = stream
.pipeThrough(new TextDecoderStream()) // Uint8Array → string
.pipeThrough(new EventSourceParserStream()) // string → EventSourceMessage
.getReader(); // 逐事件读取
4.5 eventsource-parser 状态机
eventsource-parser 内部是一个高性能的行扫描状态机。几个值得注意的设计:
分片缓冲 :当一条 SSE 行跨越多个 TCP 包时,不完整的片段存入 pendingFragments 数组,直到遇到换行符才拼接处理------避免 O(N²) 的字符串拼接陷阱。
前缀匹配优化 :data: 和 event: 使用手写 charCode 比较而非 startsWith(),快约 20%:
typescript
// 'd'=100, 'a'=97, 't'=116, 'a'=97, ':'=58
function isDataPrefix(chunk, i, firstCharCode) {
return firstCharCode === 100
&& chunk.charCodeAt(i + 1) === 97
&& chunk.charCodeAt(i + 2) === 116
&& chunk.charCodeAt(i + 3) === 97
&& chunk.charCodeAt(i + 4) === 58;
}
id 不重置 :事件分发后 data 和 eventType 被重置,但 id 保留。这是 SSE 规范要求的------一旦收到 id: 字段,后续所有事件继承该 ID,确保 Last-Event-ID 能正确追踪。
4.6 Priming Event 与断线重连
可恢复流的首帧是一个特殊的 Priming Event ,只携带 id 和 retry,不携带数据:
yaml
id: priming-001
retry: 3000
data:
客户端收到 Priming Event 后标记该流可恢复。断线时用 Last-Event-ID 发起 GET 重连,服务端根据 EventStore 重放丢失的事件。
5. Wire Codec 层:双 Era 设计
Transport 把字节变成了 JSONRPCMessage 对象,但不同协议版本的消息结构可能不同。Wire Codec 层解决的问题是:同一个方法在不同协议时代(Era),线上格式可能不一样。
5.1 两个 Era
| Era | 涵盖版本 | Codec | 关键差异 |
|---|---|---|---|
| 2025 Era | 2024-10-07 ~ 2025-11-25 | rev2025Codec |
传统 initialize 握手 |
| 2026 Era | 2026-07-28+ | rev2026Codec |
server/discover 握手、per-request envelope |
源文件:codec.ts
5.2 物理删除原则
这是 Wire Codec 层最精妙的设计之一------不在本 Era 注册表中的方法,直接拒绝:
bash
2025 Era 没有: server/discover, subscriptions/listen, MRTR 方法
→ 入站 → -32601 (Method not found) by absence
→ 出站 → 本地 SdkError,不到 Transport
2026 Era 没有: tasks/*, initialize, ping, server→client 请求
→ 入站 → -32601 by absence
→ 出站 → 本地 SdkError
为什么这样设计? 不是用"黑名单"禁止旧方法,而是用"白名单"只允许本 Era 定义的方法。这确保了每个 Era 的方法集合是封闭的、可推理的------你不可能意外调用一个不属于当前时代的方法,因为它根本不存在于注册表中。
如果换一种设计? 可以维护一个"已删除方法"列表(逻辑删除),但这需要维护两套列表(2025 删除了哪些、2026 删除了哪些),且容易遗漏。物理删除让代码即文档。
5.3 握手前的 Era 钉选
协商完成前,消息已经需要发送了(比如 initialize 请求本身)。此时用 bootstrap 函数根据方法名钉选 Codec:
typescript
function bootstrapOutboundCodec(method: string): WireCodec | undefined {
switch (method) {
case 'initialize':
case 'notifications/initialized':
return codecForVersion(undefined); // → rev2025Codec
case 'server/discover':
return codecForVersion(MODERN_WIRE_REVISION); // → rev2026Codec
}
}
initialize 钉选 2025 Codec,server/discover 钉选 2026 Codec------两条握手路径各自使用正确的编码格式,互不干扰。
6. Protocol 层:消息的三路分发
Protocol 是整个传输链路的大脑------1883 行的抽象基类,管理请求/响应关联、通知分发、超时、取消、Era 选择。
6.1 connect():回调链式安装
当 Client 或 Server 调用 connect(transport) 时,Protocol 安装自己的消息分发函数:
typescript
async connect(transport: Transport): Promise<void> {
const _onmessage = this._transport?.onmessage;
this._transport.onmessage = (message, extra) => {
_onmessage?.(message, extra); // 先调原有回调
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
this._onresponse(message);
} else if (isJSONRPCRequest(message)) {
this._onrequest(message, extra);
} else if (isJSONRPCNotification(message)) {
this._onnotification(message, extra);
}
};
await transport.start();
}
源文件:protocol.ts
为什么链式包装而非直接覆盖? 用户可能在 connect() 之后还想包装回调(比如加日志),链式设计允许这样做而不破坏协议分发。
6.2 消息分发路径
请求处理的完整路径 (_onrequest):
liftWireOnlyMaterial()--- 提取 envelope、inputResponses 等线格式专用字段- Era 校验 --- Edge→Instance handoff check(HTTP 入口分类是否与协商 Era 一致)
- Era 方法门控 --- 不在本 Era 注册表中的方法 → -32601
- Handler 查找 ---
_requestHandlers.get(method) - Envelope 校验 --- 检查 envelope 完整性
buildContext()--- 构建 handler 上下文- 执行 handler →
codec.encodeResult()→transport.send()
6.3 请求发送漏斗
所有出站请求都经过 request() 方法这个漏斗:
scss
request()
│
├─ _resolveOutboundCodec(method) ← Era 选择
├─ _assertOutboundRequestInEra() ← Era 门控
├─ messageId++ ← 生成自增 id
├─ _responseHandlers.set(id, cb) ← 注册响应回调
├─ _setupTimeout(id, 60s) ← 设置超时
├─ _envelopeOutbound(msg) ← 附加 _meta envelope
├─ transport.send(message) ← 发送
└─ 等待 Promise resolve ← 收到响应后 codec.decodeResult()
6.4 取消与超时
双路取消 :对端发送 notifications/cancelled → AbortController.abort(),handler 通过 ctx.mcpReq.signal 感知取消。
智能超时 :收到进度通知时可重置超时计时器,但 maxTotalTimeout 兜底,防止无限续命。
连接关闭时的清理:所有等待中的响应被 reject(ConnectionClosed),所有进行中的请求被 abort,所有超时和进度回调被清理。
7. 版本协商:四种场景
版本协商发生在 Role 层,但贯穿了所有底层。SDK 支持三种协商模式:
7.1 Legacy 握手
最传统的方式------Client 发送 initialize,Server 回复协商后的版本:
arduino
Client ──── initialize (protocolVersion: '2025-11-25') ───→ Server
Client ←─── InitializeResult (protocolVersion: '2025-06-18') ←── Server ← 可能降级
Client ──── notifications/initialized ──────────────────────→ Server
7.2 Modern 协商(auto 模式)
Client 先发送 server/discover 探测:
arduino
Client ──── server/discover ───→ Server
Client ←─── DiscoverResult ────── Server ← 包含 supportedVersions
→ _negotiatedProtocolVersion = '2026-07-28'
→ 进入 Modern Era,无需 initialize
7.3 Probe-Fallback(auto 模式降级)
探测超时或失败时,自动降级到 Legacy 握手:
arduino
Client ──── server/discover ───→ Server
Client ←─── (超时) ←────────────── Server
Client ──── initialize (降级) ───→ Server ← 回退到 Legacy 路径
7.4 Pin 模式
Client 要求特定版本,Server 不支持则直接拒绝,不 fallback:
arduino
Client ──── server/discover ───→ Server
Client ←─── DiscoverResult ────── Server ← supportedVersions 不包含要求的版本
→ UnsupportedProtocolVersionError
→ 不 fallback,直接拒绝
8. 端到端:一条 tools/call 的完整旅程
现在让我们把所有层串起来,看一条 tools/call 请求从发出到收到结果的完整路径:
scss
Client 用户代码
│
│ client.callTool({ name: 'read_file', arguments: { path: '/tmp/x' } })
│
▼ ──── Role Layer ────
Client.request() → 能力校验(server 声明了 tools 能力?)
│
▼ ──── Protocol Layer ────
Protocol.request()
→ _resolveOutboundCodec('tools/call') → codecForVersion('2026-07-28')
→ messageId = 3, 注册 _responseHandlers[3]
→ _setupTimeout(3, 60s)
→ 附加 _meta envelope(protocolVersion, clientCapabilities)
│
▼ ──── Wire Codec Layer ────
rev2026Codec.validateRequest('tools/call', request)
→ Schema 校验请求结构
│
▼ ──── Transport Layer ────
transport.send(message)
│
├── [Stdio 路径]
│ JSON.stringify(message) + '\n'
│ → process.stdin.write()
│ → OS pipe → child.stdin 'data' event
│ → ReadBuffer.append() → indexOf('\n') → JSON.parse()
│
└── [Streamable HTTP 路径]
POST /mcp (Content-Type: application/json)
→ 200 text/event-stream
→ event: message
id: ev-001
data: {...}
→ TextDecoderStream → EventSourceParserStream → JSON.parse()
│
▼ ──── Server 端反向处理 ────
Transport.onmessage() → Protocol._onrequest()
→ liftWireOnlyMaterial() → Era 校验 → handler 查找
→ handler(request, ctx) → 执行工具
→ codec.encodeResult() → transport.send(response)
│
▼ ──── Client 端接收响应 ────
Transport.onmessage() → Protocol._onresponse()
→ _responseHandlers.get(3) → codec.decodeResult()
→ Promise resolves with CallToolResult
9. 可复用的设计模式
9.1 最小接口 + 选项超集
Transport 接口只有 3 个必需方法(start/send/close),高级功能通过 TransportSendOptions 按需传递。Stdio 忽略 resumptionToken,HTTP 充分利用它。
适用场景:当你需要为多种实现定义统一接口,但各实现能力差异大时,避免接口膨胀。
9.2 回调链式包装
Protocol 在覆盖 Transport 回调前保存原有引用,新回调内部先调用旧的再执行自己的。用户可以在 connect() 之后安全地包装回调。
适用场景:任何需要"安装钩子但不破坏已有钩子"的场景。
9.3 物理删除 vs 逻辑删除
Wire Codec 用"只注册本 Era 支持的方法"代替"维护禁止列表"。不在注册表中 = 不存在 = 自动拒绝。
适用场景:API 版本管理、功能开关、多租户权限控制。
9.4 零拷贝裁剪
ReadBuffer 的 subarray() 不会复制内存,只是移动偏移量和长度指针。在处理高频消息流时,避免了大量 Buffer.alloc() + Buffer.copy() 的开销。
适用场景:任何需要从 Buffer 中"消费"前 N 字节并保留剩余部分的场景。
9.5 动态升级
PerRequestTransport 支持将普通 JSON 响应动态升级为 SSE 流------先以普通响应开始,在需要流式推送时才切换:
typescript
private upgradeToSse(): void {
const readable = new ReadableStream<Uint8Array>({ ... });
this.settleResponse(new Response(readable, {
headers: { 'Content-Type': 'text/event-stream' }
}));
}
适用场景:当你不确定响应是简单还是复杂时,延迟决策到最后一刻。
10. 总结
MCP TypeScript SDK 的传输链路是一个精心分层的架构:
- Transport 层用最小接口统一了 8 种传输实现,从管道字节到 HTTP SSE 流
- Wire Codec 层用双 Era 设计优雅地处理了协议版本差异,物理删除让版本边界坚不可摧
- Protocol 层用 1883 行代码构建了消息的三路分发系统,请求/响应关联、取消、超时、去抖一应俱全
- Role 层在 Protocol 之上实现了 4 种版本协商策略,兼容新旧客户端
四个层各司其职,每层只理解相邻层的契约。这种设计让新增一种传输(比如 WebSocket)只需实现 Transport 接口,而不必触碰任何协议逻辑。