《MCP 协议设计与实现》完整目录
- 前言
- 第1章 为什么需要 MCP
- 第02章 架构总览:Host-Client-Server 模型
- 第03章 JSON-RPC 与消息格式
- 第04章 生命周期与能力协商
- 第05章 Tool:让 Agent 调用世界
- 第6章 Resource:结构化的上下文注入
- 第7章 Prompt:可复用的交互模板
- 第8章 TypeScript Server 实现剖析
- 第09章 TypeScript Client 实现剖析
- 第10章 Python Server 实现剖析
- 第11章 Python Client 实现剖析
- 第12章 STDIO 传输:本地进程通信(当前)
- 第13章 Streamable HTTP:远程流式传输
- 第14章 SSE 与 WebSocket
- 第15章 OAuth 2.1 认证框架
- 第16章 服务发现与客户端注册
- 第17章 sampling
- 第18章 Elicitation、Roots 与配置管理
- 第19章 Claude Code 的 MCP 客户端:12 万行的实战
- 第20章 从零构建一个生产级 MCP Server
- 第21章 设计模式与架构决策
第12章 STDIO 传输:本地进程通信
前面几章我们分析了 TypeScript 和 Python 两套 SDK 的 Server/Client 实现,但一直跳过了一个基础问题:消息到底是怎么在 Client 和 Server 之间传递的?
MCP 协议定义了两种标准传输机制------STDIO 和 Streamable HTTP。本章聚焦 STDIO,这是 MCP 生态中最常用的传输方式,也是 Claude Desktop、Claude Code 等主流客户端与本地 MCP Server 通信的默认选择。
12.1 STDIO 传输的设计直觉
理解 STDIO 传输,只需要一个核心概念:Client 把 Server 当作一个子进程来运行,通过 stdin/stdout 双向传递 JSON-RPC 消息。
这个设计极其简洁。不需要网络端口,不需要 HTTP 服务器,不需要 TLS 证书。操作系统的进程管道就是通信通道。
Server.stdout → Client.stdout Client->>Server: stdin 写入: {"jsonrpc":"2.0","method":"initialize",...}\n Server->>Client: stdout 输出: {"jsonrpc":"2.0","result":{...}}\n Client->>Server: stdin 写入: {"jsonrpc":"2.0","method":"tools/list",...}\n Server->>Client: stdout 输出: {"jsonrpc":"2.0","result":{"tools":[...]}}\n Note over Client,Server: 关闭序列 Client->>Server: 关闭 stdin Server-->>OS: 进程退出
这张图展示了 STDIO 传输的完整生命周期。让我们逐层深入每个环节的实现细节。
12.2 消息帧格式:换行分隔的 JSON
STDIO 传输面临的第一个问题是:stdin/stdout 是连续的字节流,如何从中切分出一条条独立的 JSON-RPC 消息?
MCP 的方案是换行分隔 JSON(Newline-Delimited JSON,NDJSON) :每条消息序列化为一行 JSON,以 \n 结尾。
TypeScript SDK 在 ReadBuffer 类和 serializeMessage 函数中实现了这一协议:
typescript
// 序列化:JSON 末尾加换行符
export function serializeMessage(message: JSONRPCMessage): string {
return JSON.stringify(message) + '\n';
}
// 反序列化:按换行符切分,逐行解析
export class ReadBuffer {
private _buffer?: Buffer;
append(chunk: Buffer): void {
this._buffer = this._buffer
? Buffer.concat([this._buffer, chunk])
: chunk;
}
readMessage(): JSONRPCMessage | null {
while (this._buffer) {
const index = this._buffer.indexOf('\n');
if (index === -1) {
return null; // 没有完整的行,等待更多数据
}
const line = this._buffer.toString('utf8', 0, index)
.replace(/\r$/, ''); // 兼容 Windows 的 \r\n
this._buffer = this._buffer.subarray(index + 1);
try {
return deserializeMessage(line);
} catch (error) {
if (error instanceof SyntaxError) {
continue; // 跳过非 JSON 行(如热重载工具的调试输出)
}
throw error;
}
}
return null;
}
}
这段代码有几个值得注意的设计决策:
1. 容错性 :当 ReadBuffer 遇到无法解析为 JSON 的行时,不会抛出错误,而是静默跳过。这个设计专门应对了一个现实场景------很多 Node.js 开发工具(如 tsx、nodemon)会往 stdout 输出调试信息。如果 Server 通过这类工具启动,这些调试行会混入消息流。ReadBuffer 通过捕获 SyntaxError 来过滤掉这些噪音。
2. 跨平台兼容 :.replace(/\r$/, '') 处理 Windows 的 \r\n 换行符,确保在所有平台上行为一致。
3. 流式缓冲 :append 和 readMessage 的分离设计支持流式处理------数据可能以任意大小的 chunk 到达,ReadBuffer 负责在内部拼接和切分。
Python SDK 采用了不同但等价的实现方式。在客户端的 stdout_reader 中:
python
async def stdout_reader():
buffer = ""
async for chunk in TextReceiveStream(process.stdout,
encoding=server.encoding):
lines = (buffer + chunk).split("\n")
buffer = lines.pop() # 最后一个元素是不完整的行,保留到下次
for line in lines:
message = types.jsonrpc_message_adapter.validate_json(
line, by_name=False
)
session_message = SessionMessage(message)
await read_stream_writer.send(session_message)
Python 版本用字符串的 split("\n") 替代了手动的索引查找,最后一个 pop() 出来的元素就是尚未结束的不完整行。逻辑更 Pythonic,但本质思路完全一致。
12.3 进程派生与环境变量安全
STDIO 传输的核心操作是 Client 派生 Server 子进程。这个看似简单的操作涉及一个重要的安全决策:子进程应该继承哪些环境变量?
默认情况下,子进程会继承父进程的全部环境变量。但 MCP Client 的环境可能包含敏感信息(API Key、数据库密码等),不应该无差别地暴露给每个 MCP Server。
TypeScript 和 Python SDK 都实现了相同的白名单策略:
typescript
// TypeScript SDK
export const DEFAULT_INHERITED_ENV_VARS =
process.platform === 'win32'
? ['APPDATA', 'HOMEDRIVE', 'HOMEPATH', 'LOCALAPPDATA',
'PATH', 'PROCESSOR_ARCHITECTURE', 'SYSTEMDRIVE',
'SYSTEMROOT', 'TEMP', 'USERNAME', 'USERPROFILE',
'PROGRAMFILES']
: ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER'];
python
# Python SDK
DEFAULT_INHERITED_ENV_VARS = (
["APPDATA", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA",
"PATH", "PATHEXT", "PROCESSOR_ARCHITECTURE", "SYSTEMDRIVE",
"SYSTEMROOT", "TEMP", "USERNAME", "USERPROFILE"]
if sys.platform == "win32"
else ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]
)
这个白名单的设计灵感来自 Unix sudo 命令的默认环境继承策略,只保留进程正常运行所必需的系统变量。注意 PATH 是必须继承的------否则子进程将无法找到任何可执行文件。
同时,两个 SDK 都会过滤掉以 () 开头的环境变量值,因为这在某些 Shell 中表示函数定义(如 Bash 的 Shellshock 漏洞利用的就是这个特性)。
那 MCP Server 需要的 API Key 怎么传递?答案是通过配置文件显式指定。以 Claude Desktop 为例:
json
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "ghp_xxxxxxxxxxxx"
}
}
}
}
配置中的 env 字段会与默认环境合并:
typescript
// Client 端 spawn 进程时的环境变量合并
this._process = spawn(command, args, {
env: {
...getDefaultEnvironment(), // 安全的系统变量白名单
...this._serverParams.env // 用户显式配置的变量(覆盖同名项)
},
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
shell: false,
});
这种设计实现了最小权限原则:Server 只能访问明确授权给它的环境变量,而不是 Client 环境中的所有敏感信息。
12.4 客户端实现:进程管理的全生命周期
TypeScript SDK 的 StdioClientTransport 封装了从进程启动到关闭的完整逻辑。
关闭序列是最精妙的部分。close() 方法实现了三级优雅降级:
typescript
async close(): Promise<void> {
// 第一级:关闭 stdin,等待进程自行退出
processToClose.stdin?.end();
await Promise.race([
closePromise,
new Promise(resolve => setTimeout(resolve, 2000).unref())
]);
// 第二级:发送 SIGTERM,给进程清理资源的机会
if (processToClose.exitCode === null) {
processToClose.kill('SIGTERM');
await Promise.race([
closePromise,
new Promise(resolve => setTimeout(resolve, 2000).unref())
]);
}
// 第三级:发送 SIGKILL,强制终止
if (processToClose.exitCode === null) {
processToClose.kill('SIGKILL');
}
}
这三级策略符合 MCP 规范定义的 STDIO 关闭序列:先礼后兵,给 Server 足够的时间释放资源(关闭数据库连接、保存状态等),但绝不容忍 Server 无限期挂起。
注意 setTimeout 后面的 .unref() 调用------这确保这些定时器不会阻止 Node.js 进程退出。一个看似不起眼的细节,但对于 CLI 工具(如 Claude Code)的用户体验至关重要:如果没有 unref(),用户按下 Ctrl+C 后可能要等待 4 秒才能看到进程退出。
12.5 服务端实现:从 stdin 读取、向 stdout 写入
服务端的 STDIO 传输要简单得多------因为它不需要管理子进程,只需要读写自己的标准输入输出。
TypeScript 的 StdioServerTransport:
typescript
export class StdioServerTransport implements Transport {
constructor(
private _stdin: Readable = process.stdin,
private _stdout: Writable = process.stdout
) {}
async start(): Promise<void> {
this._stdin.on('data', this._ondata);
this._stdin.on('error', this._onerror);
this._stdout.on('error', this._onstdouterror);
}
send(message: JSONRPCMessage): Promise<void> {
const json = serializeMessage(message);
if (this._stdout.write(json)) {
resolve();
} else {
this._stdout.once('drain', resolve);
}
}
}
send 方法中的 drain 处理是背压(backpressure)控制:如果 stdout 的内核缓冲区已满,write() 返回 false,此时等待 drain 事件再继续,防止内存无限增长。
Python SDK 的服务端实现同样简洁,但有一个有趣的细节------它显式重新包装了 stdin/stdout 以确保 UTF-8 编码:
python
@asynccontextmanager
async def stdio_server(stdin=None, stdout=None):
if not stdin:
stdin = anyio.wrap_file(
TextIOWrapper(sys.stdin.buffer, encoding="utf-8",
errors="replace")
)
if not stdout:
stdout = anyio.wrap_file(
TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
)
为什么要这样做?因为 Python 的 sys.stdin 和 sys.stdout 的默认编码取决于操作系统的区域设置。在 Windows 上,这可能是 GBK 或 CP936 而非 UTF-8。MCP 的 JSON-RPC 消息必须是 UTF-8 编码的,所以 SDK 绕过了 Python 的默认文本包装,直接操作底层的二进制缓冲区并强制 UTF-8。
12.6 Claude Desktop 配置格式
理解了 STDIO 传输的原理,Claude Desktop 的 MCP Server 配置就完全透明了:
json
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem",
"/Users/yangyitao/Documents"],
"env": {}
},
"database": {
"command": "python",
"args": ["-m", "mcp_server_sqlite",
"--db-path", "/data/app.db"],
"env": {
"DATABASE_READONLY": "true"
}
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "ghp_xxxxxxxxxxxx"
}
}
}
}
每个 Server 配置项直接映射到 StdioServerParameters:
| 配置字段 | 对应参数 | 说明 |
|---|---|---|
command |
可执行文件路径 | 如 npx、python、node |
args |
命令行参数数组 | 传递给可执行文件的参数 |
env |
额外环境变量 | 与默认安全白名单合并 |
Claude Desktop 启动时,会为每个配置的 Server 创建一个 StdioClientTransport 实例,调用 start() 派生子进程,然后通过 stdin/stdout 管道发送 initialize 请求。整个过程就是本章前面分析的代码路径。
12.7 平台兼容性处理
STDIO 传输看似简单,但跨平台兼容是一个需要仔细处理的问题。
Windows 进程管理 :在 Unix 上,SIGTERM 和 SIGKILL 是标准的进程终止信号。但 Windows 没有信号机制。Python SDK 为此实现了平台特定的进程终止逻辑:
python
async def _create_platform_compatible_process(command, args, env, ...):
if sys.platform == "win32":
# Windows: 通过 Job Object 管理子进程树
process = await create_windows_process(command, args, env, ...)
else:
# Unix: 创建新的进程组,便于整体终止
process = await anyio.open_process(
[command, *args],
env=env,
start_new_session=True, # 创建新的 session/process group
)
start_new_session=True 确保 Server 进程及其所有子进程都在同一个进程组中,当需要终止时可以用 killpg 一次性终止整个进程树,而不是只杀死顶层进程。
可执行文件查找 :TypeScript SDK 使用 cross-spawn 库而非 Node.js 原生的 child_process.spawn,专门解决 Windows 上 .cmd、.bat 文件需要通过 cmd.exe 执行的问题。Python SDK 则通过 get_windows_executable_command 做类似的适配。
12.8 STDIO vs HTTP 传输:设计权衡
理解 STDIO 的优势,需要与 HTTP 传输对比:
| 维度 | STDIO | Streamable HTTP |
|---|---|---|
| 部署模型 | Client 派生本地子进程 | Server 独立部署,Client 通过 URL 连接 |
| 安全模型 | 进程隔离 + 环境变量白名单 | TLS + OAuth 2.1 认证 |
| 凭证传递 | 环境变量(简单直接) | HTTP Headers / OAuth Token(复杂但标准) |
| 多用户 | 不支持(一对一绑定) | 天然支持 |
| 网络依赖 | 无(纯本地) | 需要网络连接 |
| 发现机制 | 配置文件显式指定 | DNS / Well-known URL |
| 适用场景 | IDE 集成、CLI 工具、桌面应用 | SaaS 服务、远程 API、团队共享 |
STDIO 传输之所以成为本地场景的首选,核心原因有三:
1. 零配置网络:不需要选择端口、不需要处理端口冲突、不需要防火墙规则。对于 Claude Desktop 这样需要同时运行多个 MCP Server 的客户端,这意味着用户不需要关心哪个 Server 占用了哪个端口。
2. 天然的生命周期管理:Client 是 Server 的父进程,当 Client 退出时,操作系统会自动清理子进程。不存在"Server 忘记关闭"的泄漏问题。相比之下,HTTP Server 需要额外的守护进程管理(systemd、Docker 等)。
3. 简单的安全模型:STDIO 的安全边界就是操作系统的进程隔离。Server 以 Client 同一用户的身份运行,天然拥有与用户相同的文件系统权限------不多也不少。需要额外权限(如 API Key)时通过环境变量显式传递。这比 OAuth 2.1 流程简单一个数量级。
但 STDIO 也有明确的局限:它只能用于本地通信,不支持多用户共享,每次 Client 启动都需要重新派生 Server 进程。当你需要向团队提供共享的 MCP 服务时,Streamable HTTP 才是正确的选择。
12.9 stderr 的角色
在 STDIO 传输中,stdin 和 stdout 被占用于 JSON-RPC 通信,那 Server 的日志和调试信息应该输出到哪里?答案是 stderr。
TypeScript SDK 默认将子进程的 stderr 设为 inherit,即直接输出到父进程的 stderr:
typescript
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit']
但也支持设置为 pipe,此时 Client 可以通过 transport.stderr 属性读取 Server 的错误输出:
typescript
const transport = new StdioClientTransport({
command: "node",
args: ["my-server.js"],
stderr: "pipe"
});
// transport.stderr 是一个 PassThrough 流
// 可以在 start() 之前就附加监听器,不会丢失早期输出
transport.stderr?.on('data', (chunk) => {
console.log('[server stderr]', chunk.toString());
});
这里有一个精巧的实现细节:StdioClientTransport 在构造函数中就创建了 PassThrough 流,而不是在 start() 之后才创建。这确保了调用方可以在进程启动之前就附加监听器,不会遗漏 Server 启动阶段的早期错误输出。
12.10 本章小结
STDIO 传输是 MCP 协议栈中最简洁的一层。它的全部逻辑可以归纳为:
- 消息帧 :换行分隔的 JSON(NDJSON),每条消息一行,以
\n结尾 - 进程管理 :Client 通过
spawn创建 Server 子进程,stdin/stdout 作为通信管道 - 环境安全:白名单策略只继承必要的系统变量,敏感凭证通过配置文件显式注入
- 优雅关闭:三级降级策略------关闭 stdin → SIGTERM → SIGKILL
- 平台兼容:Windows/Unix 进程管理、编码处理、可执行文件查找的差异抽象
STDIO 的设计哲学是用操作系统的原语解决通信问题。不发明新的协议层,不引入额外的依赖,让进程管道承担消息传输,让进程隔离提供安全边界,让父子进程关系管理生命周期。
这种"最少机制原则"使得 STDIO 传输成为本地 MCP 集成的最佳选择。下一章我们将分析 Streamable HTTP 传输------当通信需要跨越网络边界时,事情会复杂得多。