MCP 传输链路全链路拆解:从字节流到协议栈的四层架构之旅

一条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 通信模型

graph LR A[&#34;Client 父进程&#34;] -->|stdin 写入| B[&#34;OS Pipe 内核缓冲区&#34;] B -->|stdin 读取| C[&#34;Server 子进程&#34;] C -->|stdout 写入| D[&#34;OS Pipe 内核缓冲区&#34;] D -->|stdout 读取| A

帧格式极其简洁------换行分隔的 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.stdinchild.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 请求的流式载体:

graph TB A[&#34;Client&#34;] -->|POST /mcp| B[&#34;Server&#34;] B -->|200 text/event-stream| A A -->|GET /mcp| B B -->|SSE 独立流| A

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-parserTransformStream 构建三步解析管道:

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 不重置 :事件分发后 dataeventType 被重置,但 id 保留。这是 SSE 规范要求的------一旦收到 id: 字段,后续所有事件继承该 ID,确保 Last-Event-ID 能正确追踪。

4.6 Priming Event 与断线重连

可恢复流的首帧是一个特殊的 Priming Event ,只携带 idretry,不携带数据:

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 消息分发路径

graph TB A[&#34;Transport.onmessage&#34;] --> B{消息类型?} B -->|Response| C[&#34;_onresponse: 按 id 匹配 resolve/reject&#34;] B -->|Request| D[&#34;_onrequest: Era校验 → handler查找 → 执行 → 编码结果&#34;] B -->|Notification| E[&#34;_onnotification: Era校验 → handler查找 → 执行&#34;]

请求处理的完整路径_onrequest):

  1. liftWireOnlyMaterial() --- 提取 envelope、inputResponses 等线格式专用字段
  2. Era 校验 --- Edge→Instance handoff check(HTTP 入口分类是否与协商 Era 一致)
  3. Era 方法门控 --- 不在本 Era 注册表中的方法 → -32601
  4. Handler 查找 --- _requestHandlers.get(method)
  5. Envelope 校验 --- 检查 envelope 完整性
  6. buildContext() --- 构建 handler 上下文
  7. 执行 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/cancelledAbortController.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 接口,而不必触碰任何协议逻辑。

相关推荐
魏祖潇1 小时前
DDD 完整指南——AI 时代工程师的第一道秩序分水岭
人工智能·后端
Mark0802031 小时前
散户做信息整理和研究记录时,不同AI工具更适合哪些环节
大数据·人工智能
L3S1 小时前
Agent为什么会死循环?
人工智能·agent
Z-D-K1 小时前
考验AI的“自我“-AI对《红楼梦》后40回的改写(32)
人工智能·ai·aigc·交互·agi
触底反弹1 小时前
AI Tool Use 深度解析:大模型是如何"突破物理限制"调用外部工具的?
javascript·人工智能·后端
delishcomcn1 小时前
从“一刀切”到动态预测:AI优化烫金箔张力控制
人工智能
明志数科1 小时前
具身智能“数据工厂“的标准化产线设计——从多模态采集到VLA-ready数据集的全链路工程解析
人工智能
云烟成雨TD1 小时前
LangFlow 1.x 系列【3】入门案例
人工智能·python·agent
Wireless_wifi62 小时前
Why Choose IPQ9574 for Your WiFi 7 Solution
linux·人工智能·5g