MCP 协议设计与实现
第12章 STDIO 传输:本地进程通信
第12章 STDIO 传输:本地进程通信
前面几章我们分析了 TypeScript 和 Python 两套 SDK 的 Server/Client 实现,但一直跳过了一个基础问题:消息到底是怎么在 Client 和 Server 之间传递的?
MCP 协议定义了两种标准传输机制——STDIO 和 Streamable HTTP。本章聚焦 STDIO,这是 MCP 生态中最常用的传输方式,也是 Claude Desktop、Claude Code 等主流客户端与本地 MCP Server 通信的默认选择。
本章要点
- STDIO = 子进程 + 管道——用操作系统原语解决通信问题
- NDJSON 消息帧:一行一个 JSON,以
\n结尾——简单到无法失败 - 环境变量白名单:
sudo风格的最小继承,防止敏感信息泄漏 - 三级优雅关闭:stdin close → SIGTERM → SIGKILL
- stderr 是日志通道——Server 不能污染 stdout
- 平台差异:Windows 无信号、编码陷阱、cmd/bat 处理
- 最少机制原则:不发明新协议层,不引入额外依赖
12.1 STDIO 传输的设计直觉
理解 STDIO 传输,只需要一个核心概念:Client 把 Server 当作一个子进程来运行,通过 stdin/stdout 双向传递 JSON-RPC 消息。
这个设计极其简洁。不需要网络端口,不需要 HTTP 服务器,不需要 TLS 证书。操作系统的进程管道就是通信通道。
sequenceDiagram
participant Client as MCP Client
participant OS as 操作系统
participant Server as MCP Server (子进程)
Client->>OS: spawn("npx my-mcp-server")
OS-->>Client: 返回子进程句柄
OS-->>Server: 进程启动
Note over Client,Server: 通信通道建立:Client.stdin → Server.stdin<br/>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 是本地通信的最优解
软件工程中有一条很少明说但永远成立的规律:能用操作系统原语解决的,不要发明新的抽象。
STDIO 传输正是这条规律的完美体现:
| 需求 | 常规方案 | STDIO 方案 |
|---|---|---|
| 通信通道 | TCP socket + 端口管理 | 管道(pipe)——OS 自带 |
| 身份认证 | OAuth/API Key | 进程权限继承——OS 自带 |
| 生命周期 | 守护进程 + supervisor | 父子进程关系——OS 自带 |
| 错误隔离 | 容器 / chroot | 进程隔离——OS 自带 |
| 网络依赖 | 需要网络栈 | 零依赖——纯本地 |
STDIO 没有发明任何新东西——它只是重新发现了 Unix 1970 年代就有的智慧:everything is a file, pipes compose small programs into big systems.
12.2 消息帧格式:换行分隔的 JSON
STDIO 传输面临的第一个问题是:stdin/stdout 是连续的字节流,如何从中切分出一条条独立的 JSON-RPC 消息?
MCP 的方案是换行分隔 JSON(Newline-Delimited JSON,NDJSON):每条消息序列化为一行 JSON,以 \n 结尾。
为什么选 NDJSON 而不是其他方案
候选方案对比:
| 方案 | 优势 | 劣势 |
|---|---|---|
| NDJSON(选中) | 人类可读、易调试、实现简单 | 禁止消息内含换行 |
| Length-Prefix(HTTP 风格) | 任意二进制内容 | 需要二进制帧解析 |
| MessagePack | 更紧凑 | 不可读、需要库 |
| Protobuf | 强类型、高性能 | 依赖 schema、不可读 |
| JSON + null terminator | 简单 | 不符合 JSON 规范 |
MCP 选 NDJSON 的核心理由:
- 人类可读——
cat | less就能调试 - 现有工具兼容——jq、grep、awk 都能用
- 流式友好——能写就能读,不需要先知道大小
- 与 JSON-RPC 天然契合——JSON-RPC 本身就是 JSON
唯一的限制是 JSON 字符串内不能包含未转义的 \n——但 JSON 规范本来就要求转义,所以这不是问题。
TypeScript SDK 实现
TypeScript SDK 在 ReadBuffer 类和 serializeMessage 函数中实现了这一协议:
// 序列化: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;
}
}
三个精妙的设计决策
决策一:容错性——静默跳过非 JSON 行
当 ReadBuffer 遇到无法解析为 JSON 的行时,不会抛出错误,而是静默跳过。
这个设计专门应对了一个现实场景——很多 Node.js 开发工具(如 tsx、nodemon)会往 stdout 输出调试信息。如果 Server 通过这类工具启动,这些调试行会混入消息流。ReadBuffer 通过捕获 SyntaxError 来过滤掉这些噪音。
实际场景:
[nodemon] starting `node my-server.js` ← 这行不是 JSON
{"jsonrpc":"2.0","result":{...}} ← 这行才是
[nodemon] restart due to changes... ← 这行也不是
没有这个容错,开发时的 hot reload 场景就会崩溃。
决策二:跨平台兼容——处理 Windows 的 \r\n
.replace(/\r$/, '') 处理 Windows 的 \r\n 换行符,确保在所有平台上行为一致。
Windows 的 stdio 在某些模式下会自动把 \n 转成 \r\n。如果不处理,JSON 解析会在末尾多一个 \r 字符——虽然 JSON.parse 通常能容忍,但保险的做法是显式清理。
决策三:流式缓冲
append 和 readMessage 的分离设计支持流式处理——数据可能以任意大小的 chunk 到达,ReadBuffer 负责在内部拼接和切分。
chunk 1: "{\"jsonrpc\":\"2"
chunk 2: ".0\",\"result\":"
chunk 3: "{\"tools\":[]}}\n{\"jsonrpc\":"
↓
ReadBuffer 内部拼接:
"{\"jsonrpc\":\"2.0\",\"result\":{\"tools\":[]}}\n{\"jsonrpc\":"
↓ readMessage()
返回第一条完整消息,剩余 "{\"jsonrpc\":" 等待后续数据
Python SDK 实现
Python SDK 采用了不同但等价的实现方式。在客户端的 stdout_reader 中:
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 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 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 | 命令查找 | 低(但被篡改有风险) |
HOME | 用户目录 | 低 |
USER / LOGNAME | 用户身份 | 低 |
SHELL | 默认 shell | 低 |
TERM | 终端类型 | 极低 |
APPDATA / LOCALAPPDATA | Windows 应用数据 | 低 |
TEMP / TMPDIR | 临时目录 | 低 |
被默认排除的敏感变量(举例):
AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEYGITHUB_TOKEN、GITLAB_TOKENOPENAI_API_KEY、ANTHROPIC_API_KEYDATABASE_URL、REDIS_URLJWT_SECRET、STRIPE_SECRET_KEYSSH_AUTH_SOCK(防止 SSH 代理被滥用)
PATH 必须继承
注意 PATH 是必须继承的——否则子进程将无法找到任何可执行文件。如果 Server 需要调用 git、npm、python 等命令,没有 PATH 就完全不工作。
防护 Shellshock 变种
同时,两个 SDK 都会过滤掉以 () 开头的环境变量值,因为这在某些 Shell 中表示函数定义(如 Bash 的 Shellshock 漏洞利用的就是这个特性):
def get_default_environment() -> dict[str, str]:
env = {}
for key in DEFAULT_INHERITED_ENV_VARS:
value = os.environ.get(key)
if value is None:
continue
# 过滤 Shellshock 风格的函数定义
if value.startswith("()"):
continue
env[key] = value
return env
这是典型的防御性编程——假设攻击者可能污染环境变量,添加一层简单但有效的过滤。
显式注入敏感信息
那 MCP Server 需要的 API Key 怎么传递?答案是通过配置文件显式指定。以 Claude Desktop 为例:
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "ghp_xxxxxxxxxxxx"
}
}
}
}
配置中的 env 字段会与默认环境合并:
// Client 端 spawn 进程时的环境变量合并
this._process = spawn(command, args, {
env: {
...getDefaultEnvironment(), // 安全的系统变量白名单
...this._serverParams.env // 用户显式配置的变量(覆盖同名项)
},
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
shell: false, // 关键:不通过 shell 启动,防止命令注入
});
这种设计实现了最小权限原则:Server 只能访问明确授权给它的环境变量,而不是 Client 环境中的所有敏感信息。
shell: false 的重要性
注意 shell: false——这不是随便写的。
如果设 shell: true,参数会经过 shell 解析:
spawn("git", ["log", user_input], { shell: true })
// 用户输入 "; rm -rf /"
// 实际执行: sh -c "git log ; rm -rf /" ← 命令注入!
shell: false 时,参数数组直接传给 execve 系统调用,不经过 shell,从根本上防止注入。
12.4 客户端实现:进程管理的全生命周期
TypeScript SDK 的 StdioClientTransport 封装了从进程启动到关闭的完整逻辑。
stateDiagram-v2
[*] --> Created: new StdioClientTransport(params)
Created --> Starting: start()
Starting --> Running: spawn 事件
Starting --> Error: error 事件
Running --> Running: stdin.write() / stdout.on('data')
Running --> Closing: close()
Closing --> StdinClosed: 关闭 stdin
StdinClosed --> WaitExit: 等待进程退出 (2s)
WaitExit --> SIGTERM: 超时未退出
SIGTERM --> WaitTerm: 等待 SIGTERM 响应 (2s)
WaitTerm --> SIGKILL: 仍未退出
SIGKILL --> [*]
WaitExit --> [*]: 正常退出
WaitTerm --> [*]: SIGTERM 后退出
StdinClosed --> [*]: 立即退出
Error --> [*]
三级优雅关闭
关闭序列是最精妙的部分。close() 方法实现了三级优雅降级:
async close(): Promise<void> {
const processToClose = this._process;
if (!processToClose) return;
const closePromise = new Promise<void>(resolve => {
processToClose.once('exit', () => resolve());
});
// 第一级:关闭 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');
}
this._process = undefined;
}
为什么是三级而不是一步到位
三级策略符合 MCP 规范定义的 STDIO 关闭序列:先礼后兵。每一级都给 Server 一个机会做合理的事:
| 级别 | 信号 | Server 应做的事 |
|---|---|---|
| 1. 关闭 stdin | EOF | 完成当前任务,优雅退出 |
| 2. SIGTERM | 软中止 | 释放资源(关闭 DB、写入缓存)后退出 |
| 3. SIGKILL | 强制杀 | 无法拒绝,内核直接终止进程 |
但绝不容忍 Server 无限期挂起——4 秒是上限。
.unref() 的细节价值
注意 setTimeout 后面的 .unref() 调用——这确保这些定时器不会阻止 Node.js 进程退出。
一个看似不起眼的细节,但对于 CLI 工具(如 Claude Code)的用户体验至关重要:如果没有 unref(),用户按下 Ctrl+C 后可能要等待 4 秒才能看到进程退出。
这就是工程细节决定产品体验的典型——用户永远不会知道这个单行代码,但他们会感知到”这个工具退出很快”。
12.5 服务端实现:从 stdin 读取、向 stdout 写入
服务端的 STDIO 传输要简单得多——因为它不需要管理子进程,只需要读写自己的标准输入输出。
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> {
return new Promise((resolve) => {
const json = serializeMessage(message);
if (this._stdout.write(json)) {
resolve();
} else {
this._stdout.once('drain', resolve);
}
});
}
}
背压控制:drain 事件
send 方法中的 drain 处理是背压(backpressure)控制:如果 stdout 的内核缓冲区已满,write() 返回 false,此时等待 drain 事件再继续,防止内存无限增长。
场景:Server 快速生成大量响应,Client 处理慢
↓
Server 往 stdout 猛写
↓
内核缓冲区满 → write() 返回 false
↓
若不处理:Node.js 会把数据排队在 JS 内存里 → OOM
↓
正确做法:等 drain 事件 → 消费者赶上来了才继续写
这是典型的”上游跟着下游速度走”的流控模型。
Python 的 UTF-8 强制
Python SDK 的服务端实现同样简洁,但有一个有趣的细节——它显式重新包装了 stdin/stdout 以确保 UTF-8 编码:
@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")
)
为什么必须强制 UTF-8
Python 的 sys.stdin 和 sys.stdout 的默认编码取决于操作系统的区域设置。
| 平台 | 默认编码 | 问题 |
|---|---|---|
| Linux (en_US.UTF-8) | UTF-8 | ✅ |
| macOS | UTF-8 | ✅ |
| Windows (zh-CN) | GBK / CP936 | ❌ 非 UTF-8 |
| Windows (en-US) | CP1252 | ❌ 非 UTF-8 |
| 被重定向的 stdio | ASCII | ❌ 中文爆炸 |
MCP 的 JSON-RPC 消息必须是 UTF-8 编码的,所以 SDK 绕过了 Python 的默认文本包装,直接操作底层的二进制缓冲区并强制 UTF-8。
这是又一个”规范看起来简单,实现要绕开各种坑”的例子。
12.6 Claude Desktop 配置格式
理解了 STDIO 传输的原理,Claude Desktop 的 MCP Server 配置就完全透明了:
{
"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 | 额外环境变量 | 与默认安全白名单合并 |
cwd | 工作目录 | 可选,默认继承 Client 的 cwd |
stderr | stderr 模式 | inherit / pipe / ignore |
Claude Desktop 启动时,会为每个配置的 Server 创建一个 StdioClientTransport 实例,调用 start() 派生子进程,然后通过 stdin/stdout 管道发送 initialize 请求。整个过程就是本章前面分析的代码路径。
12.7 平台兼容性处理
STDIO 传输看似简单,但跨平台兼容是一个需要仔细处理的问题。
Windows vs Unix 的核心差异
graph TD
STDIO[STDIO 传输]
STDIO --> Unix[Unix/macOS/Linux]
STDIO --> Windows[Windows]
Unix --> U1[真正的 fork/exec]
Unix --> U2[SIGTERM/SIGKILL 信号]
Unix --> U3[process group 管理]
Unix --> U4[UTF-8 默认编码]
Windows --> W1[CreateProcess API]
Windows --> W2[Job Object 进程树管理]
Windows --> W3[.cmd/.bat 需要 cmd.exe]
Windows --> W4[多种编码: GBK/CP1252/UTF-8]
Windows --> W5[无信号机制]
style Unix fill:#dcfce7,stroke:#22c55e
style Windows fill:#fef3c7,stroke:#f59e0b
Windows 进程管理
在 Unix 上,SIGTERM 和 SIGKILL 是标准的进程终止信号。但 Windows 没有信号机制。Python SDK 为此实现了平台特定的进程终止逻辑:
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 一次性终止整个进程树,而不是只杀死顶层进程。
Windows 的 Job Object
Windows 用 Job Object 实现类似效果:
HANDLE job = CreateJobObject(NULL, NULL);
// 配置:当 Job 关闭时,所有进程一起结束
JOBOBJECT_EXTENDED_LIMIT_INFORMATION info = {0};
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info));
// 把新启动的进程加入 Job
AssignProcessToJobObject(job, process);
这样即使 Server 进程 fork 出孙子进程,关闭 Job 时整棵进程树一起被清理。
可执行文件查找
TypeScript SDK 使用 cross-spawn 库而非 Node.js 原生的 child_process.spawn,专门解决 Windows 上 .cmd、.bat 文件需要通过 cmd.exe 执行的问题:
用户配置: { "command": "npx", ... }
↓ 在 Windows 上
实际是: C:\...\npx.cmd
↓ child_process.spawn("npx.cmd") 直接失败!
↓ cross-spawn 会透明处理为:
实际执行: cmd.exe /c npx ...
Python SDK 则通过 get_windows_executable_command 做类似的适配。
12.7.1 get_windows_executable_command 的四扩展搜索级联
打开 mcp/os/win32/utilities.py:34 看 Python SDK 的真实实现:
def get_windows_executable_command(command: str) -> str:
try:
# First check if command exists in PATH as-is
if command_path := shutil.which(command):
return command_path
# Check for Windows-specific extensions
for ext in [".cmd", ".bat", ".exe", ".ps1"]:
ext_version = f"{command}{ext}"
if ext_path := shutil.which(ext_version):
return ext_path
# For regular commands or if we couldn't find special versions
return command
except OSError:
return command
三个真实工程细节:
1. 四个扩展的搜索顺序有意义:[".cmd", ".bat", ".exe", ".ps1"] — .cmd 排第一是因为 JavaScript 生态的命令(npx、npm、yarn)在 Windows 上几乎都是 .cmd 脚本;.bat 第二是老式批处理;.exe 第三是原生可执行;.ps1 最后是 PowerShell 脚本(较少见)。这不是随意列表、是按生产环境命中概率排,第一次 hit 就返回、节省 shutil.which 调用次数。
2. OSError 兜底——源码注释原文 "permissions, broken symlinks, etc."。Windows 上 shutil.which 可能因为用户路径里某个目录权限问题抛 OSError。原样返回 command 而非异常传递——让 subprocess.Popen 自己去 try、由它报更具体的 FileNotFoundError。把底层 OS 异常转成更高层 Python 异常的范式。
3. shutil.which 的隐式 PATHEXT 处理——Windows 上 shutil.which("npx") 本身就会读 PATHEXT 环境变量自动试 .exe/.cmd/.bat 等扩展。那为什么还要手写 for 循环?因为 PATHEXT 在某些 CI 环境、容器镜像里可能被清空或不完整——手写循环是补丁,确保即使用户 shell 配置异常仍能找到正确可执行。这是面向真实碎片化 Windows 生态的防御性代码。
12.7.2 create_windows_process 的三级 fallback 阶梯
进程创建函数本身(win32/utilities.py:137)实现了一个精致的三级降级:
async def create_windows_process(command, args, env, errlog, cwd):
job = _create_job_object()
process = None
try:
# 第一级:异步 + 无窗口
process = await anyio.open_process(
[command, *args],
env=env,
creationflags=subprocess.CREATE_NO_WINDOW
if hasattr(subprocess, "CREATE_NO_WINDOW")
else 0,
stderr=errlog, cwd=cwd,
)
except NotImplementedError:
# 第二级:anyio 异步不支持 → 同步 Popen 包装
process = await _create_windows_fallback_process(command, args, env, errlog, cwd)
except Exception:
# 第三级:creationflags 触发其他异常 → 去掉 flags 再试
process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd)
_maybe_assign_process_to_job(process, job)
return process
三级各自应对不同失败模式:
-
第一级
anyio.open_process + CREATE_NO_WINDOW——理想路径。CREATE_NO_WINDOW这个 flag 至关重要:没它时 Windows 会为每个子进程弹一个 cmd.exe 闪烁的黑窗口——Claude Desktop 这种 GUI 应用如果不屏蔽,用户每次打开 MCP server 都会看到几个黑窗口弹一下、极为破坏体验。hasattr(subprocess, "CREATE_NO_WINDOW")条件判断是因为老 Python 版本没这个常量、降级为0。 -
第二级
_create_windows_fallback_process——触发条件是NotImplementedError。源码注释原文:“Windows doesn’t support async subprocess creation, e.g., when using the SelectorEventLoop”——Python 在 Windows 上默认用SelectorEventLoop(不支持 async subprocess)、要想anyio.open_process工作必须切ProactorEventLoop。如果用户 app 框架(某些 GUI 库)锁定了 SelectorEventLoop、就走这里的 fallback——同步subprocess.Popen+FileReadStream/FileWriteStream手动包装成 async 接口。这是 Python Windows 异步生态最臭名昭著的坑、这行代码是 SDK 为此付出的代价。 -
第三级 “try again without creation flags”——更多兼容性兜底。某些 Windows 版本或运行环境对
creationflags特别挑剔、会抛Exception(不是NotImplementedError)。这时去掉CREATE_NO_WINDOW再试一次——代价是可能会弹窗口,但至少进程能起来。“能运行 > 体验完美” 的优先级排序。
FallbackProcess wrapper(line 65-83)——Popen 只支持同步 I/O、返回的 stdin/stdout 是同步 BinaryIO。SDK 手动用 FileWriteStream(cast(BinaryIO, self.stdin_raw)) 和 FileReadStream(...) 包装成 anyio 异步流——这样对上层 MCP 代码透明,不管底层是异步还是同步 Popen、接口完全一致。代价是多一层 thread-based I/O 开销(anyio 的文件流在内部用 thread 做真正的读写),但对 JSON-RPC 这种低频通信可以忽略。
这三层 fallback 是 MCP SDK 为”让 Python Windows 用户不管怎么配都能跑起来”付出的真实工程代价。读懂它们、你就能理解为什么 MCP 服务器在 Mac/Linux 运行比 Windows 顺畅得多——不是 SDK 偏心、是 Windows 异步生态本身就复杂得多。
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 |
| 启动延迟 | 进程启动时间(50-500ms) | HTTP 握手(10-100ms) |
| 并发数 | 每个 Server 一个进程 | 单 Server 多连接 |
| 更新机制 | 重启 Client 或重新 spawn | 滚动升级(无感) |
| 可观测性 | 本地日志 | APM/分布式追踪 |
| 适用场景 | 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 的明确局限
但 STDIO 也有明确的局限:
- 只能用于本地通信——不支持跨主机
- 不支持多用户共享——一对一绑定
- 每次启动都需要重新派生进程——冷启动有开销
- 服务器端无法独立扩容——绑定到 Client 进程数
- 难以做集中监控——分散在各个用户机器
当你需要向团队提供共享的 MCP 服务时,Streamable HTTP 才是正确的选择。
选型决策
graph TD
Q{需要跨主机?}
Q -->|是| HTTP1[Streamable HTTP]
Q -->|否| Q2{需要多用户共享?}
Q2 -->|是| HTTP2[Streamable HTTP]
Q2 -->|否| Q3{Server 需要独立部署/升级?}
Q3 -->|是| HTTP3[Streamable HTTP]
Q3 -->|否| STDIO1[STDIO ✅]
style STDIO1 fill:#dcfce7,stroke:#22c55e,stroke-width:2px
style HTTP1 fill:#fef3c7,stroke:#f59e0b
style HTTP2 fill:#fef3c7,stroke:#f59e0b
style HTTP3 fill:#fef3c7,stroke:#f59e0b
12.9 stderr 的角色
在 STDIO 传输中,stdin 和 stdout 被占用于 JSON-RPC 通信,那 Server 的日志和调试信息应该输出到哪里?答案是 stderr。
黄金规则:stdout 是协议通道,不要污染
┌─────────────────────────────────────────┐
│ stdin ← JSON-RPC 请求(Client → Server)│
│ stdout → JSON-RPC 响应(Server → Client)│ ← 神圣,不可混入其他内容!
│ stderr → 日志、调试、错误信息 │
└─────────────────────────────────────────┘
违反这条规则的后果:Server 的一行 log 输出污染 stdout,Client 的 JSON 解析失败,整个会话崩溃。
这也是为什么 ReadBuffer 要容忍非 JSON 行(12.2 节)——它是最后一道防线,但本质上 Server 不该往 stdout 写非协议内容。
Client 端配置
TypeScript SDK 默认将子进程的 stderr 设为 inherit,即直接输出到父进程的 stderr:
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit']
但也支持设置为 pipe,此时 Client 可以通过 transport.stderr 属性读取 Server 的错误输出:
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());
});
PassThrough 的精巧时序
这里有一个精巧的实现细节:StdioClientTransport 在构造函数中就创建了 PassThrough 流,而不是在 start() 之后才创建。
这确保了调用方可以在进程启动之前就附加监听器,不会遗漏 Server 启动阶段的早期错误输出:
class StdioClientTransport {
readonly stderr: PassThrough;
constructor(params: StdioServerParameters) {
// 构造时就创建 PassThrough
this.stderr = new PassThrough();
}
async start() {
this._process = spawn(...);
// 把子进程的 stderr 管道到 PassThrough
this._process.stderr?.pipe(this.stderr);
}
}
// 用户代码可以这样写:
const transport = new StdioClientTransport(params);
transport.stderr.on('data', ...); // 在 start 之前就附加
await transport.start(); // 不会丢任何错误
Server 侧的日志库选择
Server 开发者需要选择正确的日志输出方式:
// ❌ 错误:console.log 默认写 stdout
console.log("Starting server..."); // 污染协议!
// ✅ 正确:console.error 写 stderr
console.error("Starting server...");
// ✅ 更好:用专业日志库
import pino from 'pino';
const logger = pino({ transport: { target: 'pino/file', options: { destination: 2 } } });
// destination: 2 = stderr 的文件描述符
logger.info("Starting server");
12.10 调试技巧
STDIO 传输的调试比 HTTP 困难——没有 curl 工具、没有浏览器开发者工具。但有一些实用技巧:
技巧一:MCP Inspector
官方提供的调试工具:
npx @modelcontextprotocol/inspector node my-server.js
它会启动一个 Web UI,让你以图形化方式:
- 查看 Server 能力
- 调用工具并查看参数/响应
- 测试 Resource 读取
- 观察消息流
技巧二:手动喂 JSON
# 启动 Server 并手动输入 initialize 请求
node my-server.js <<EOF
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}
EOF
技巧三:tee 中间人
插入一个 tee 在中间抓包:
# 创建 tee 脚本
cat > debug-server.sh <<'EOF'
#!/bin/bash
tee /tmp/mcp-stdin.log | node my-server.js | tee /tmp/mcp-stdout.log
EOF
chmod +x debug-server.sh
# Client 配置调用 debug-server.sh 而非 my-server.js
# 所有 I/O 都会记录到 /tmp/mcp-*.log
技巧四:环境变量调试
# 很多 MCP Server 支持 DEBUG/LOG_LEVEL
DEBUG=mcp:* node my-server.js
# Python SDK
MCP_LOG_LEVEL=DEBUG python -m my_server
12.11 四个反模式
反模式一:往 stdout 写日志
现象:console.log("Starting...") 写到 stdout。
后果:污染协议,Client 可能卡住或崩溃。
对策:日志走 stderr 或外部文件。
反模式二:shell: true
现象:spawn(cmd, args, { shell: true })。
后果:参数经过 shell 解析,有命令注入风险。
对策:shell: false(默认值,显式写出更清晰)。
反模式三:继承所有环境变量
现象:spawn(cmd, args, { env: process.env })。
后果:所有敏感信息(API Keys)暴露给 Server。
对策:用白名单 + 配置文件显式注入。
反模式四:忘记处理进程崩溃
现象:假设 Server 永远正常运行,不监听 exit/error 事件。
后果:Server 崩溃后 Client 永远等响应。
对策:
this._process.on('exit', (code) => {
this.onerror?.(new Error(`Server exited with code ${code}`));
});
this._process.on('error', (err) => {
this.onerror?.(err);
});
12.11.1 实测:两 SDK STDIO 实现规模与最关键的不对称
把 STDIO 相关文件全部按 SDK 实测——
TS SDK——
| 路径 | 行 | 角色 |
|---|---|---|
packages/client/src/client/stdio.ts | 260 | StdioClientTransport + 进程派生 + 三级关闭 |
packages/server/src/server/stdio.ts | 138 | StdioServerTransport |
| 合计 | 398 | — |
Python SDK——
| 路径 | 行 | 角色 |
|---|---|---|
src/mcp/client/stdio.py | 270 | StdioClientTransport + 派生 |
src/mcp/server/stdio.py | 77 | StdioServerTransport |
src/mcp/os/win32/utilities.py | 333 | §12.7.1 get_windows_executable_command + §12.7.2 create_windows_process + FallbackProcess 类 + _create_windows_fallback_process + _create_job_object + terminate_windows_process_tree + terminate_windows_process——Windows 进程管理的全部工程量 |
src/mcp/os/posix/utilities.py | 57 | POSIX 进程辅助 |
src/mcp/os/win32/__init__.py + posix/__init__.py + os/__init__.py | 3 | 薄壳 |
| 合计 | 740 | — |
最大的不对称是 Windows 处理——
- Python SDK 用 333 行专门处理 Windows——
get_windows_executable_command做 §12.7.1 讨论的”四扩展搜索级联”(按.cmd / .bat / .exe / .ps1顺序找);create_windows_process做 §12.7.2 的”三级 fallback 阶梯”(asyncio subprocess → anyio fallback → FallbackProcess class);额外有 Job Object 进程组管理 + 整个进程树终止逻辑——这 333 行在 TS SDK 完全没有对应物 - TS SDK 没有等价代码——因为 Node.js 的
child_process.spawn内置了完整的跨平台支持:shell: false默认安全、signal选项做超时取消、detached: false默认让父进程退出时杀子进程——Node 标准库本身已经把 §12.7 讨论的所有 Windows 问题处理了;Python 的subprocess在 Windows 上没有这些保证(特别是异步情况下、asyncio.create_subprocess_exec在 Windows 早期版本有 bug 需要 fallback)
这条不对称的工程含义——“用什么语言写 MCP Server 决定了你需要操心多少跨平台问题”——TS/Node.js 几乎免费跨平台、Python 需要为 Windows 写专门的 333 行 fallback 代码。这也部分解释了为什么本章 §12.6 列出的 Claude Desktop 配置示例绝大多数是 Python 写的 server——Python 用户更需要这些跨平台抽象。
两 SDK 的 server 端都极小:TS 138 + Python 77——是因为 server 端只需要 process.stdin.on('data') / process.stdout.write() 两条管道——没有 client 端那种复杂的”如何启动子进程”问题。
串联 §13.9.1 / §15.9.4 / §16.9.1 / §17.10.1 揭示的不对称模式——Streamable HTTP 双 SDK 严格对称(1056 vs 1038)、OAuth Python 服务端 1572 vs TS 0、错误码 7 vs 8、版本协商 5 vs 4——再加本章 STDIO Windows 处理 333 vs 0——MCP spec 同等明确的功能下、两 SDK 实现一致性”按功能模块决定”——并不是某一边总落后或总领先。
12.11.2 Python 客户端的关闭顺序:为什么先关 stdin
Python SDK 的 stdio 客户端把关闭顺序写得很直白。mcp-python-sdk/src/mcp/client/stdio.py:104-128 先用 StdioServerParameters 里的 command、args、env、cwd 创建子进程;stdio.py:137-160 的 stdout_reader 按换行拆 JSON-RPC 消息;stdio.py:164-176 的 stdin_writer 把 SessionMessage 序列化成 JSON 后补一个换行写入子进程 stdin。到退出时,关键逻辑在 stdio.py:180-204:先关闭 stdin,再等待进程自然退出,超时后才走平台相关的进程树终止。
这个顺序比“直接 kill 子进程”可靠。关闭 stdin 等于告诉 server:客户端不会再发送新请求了;一个实现良好的 server 可以借这个 EOF 完成清理、flush 日志、关闭数据库连接。只有它没有退出时,客户端才升级到终止进程树。对本地工具来说,这也是避免脏状态的底线:stdio 是最轻的传输,但不代表生命周期可以粗糙处理。
12.12 本章小结
STDIO 传输是 MCP 协议栈中最简洁的一层。它的全部逻辑可以归纳为:
- 消息帧:换行分隔的 JSON(NDJSON),每条消息一行,以
\n结尾 - 进程管理:Client 通过
spawn创建 Server 子进程,stdin/stdout 作为通信管道 - 环境安全:白名单策略只继承必要的系统变量,敏感凭证通过配置文件显式注入
- 优雅关闭:三级降级策略——关闭 stdin → SIGTERM → SIGKILL
- stderr 是日志通道——stdout 只能用于协议
- 平台兼容:Windows/Unix 进程管理、编码处理、可执行文件查找的差异抽象
- 最少机制:不发明新协议层,不引入额外依赖
核心哲学
STDIO 的设计哲学是用操作系统的原语解决通信问题。不发明新的协议层,不引入额外的依赖,让进程管道承担消息传输,让进程隔离提供安全边界,让父子进程关系管理生命周期。
这种”最少机制原则”使得 STDIO 传输成为本地 MCP 集成的最佳选择。
The best transport is the one the OS already provides.
物理事实:TS SDK STDIO 398 行(client 260 + server 138)vs Python SDK 740 行(client 270 + server 77 + os/win32 333 + os/posix 57);Python 比 TS 多 86% 的代码全在 win32/utilities.py——Node.js child_process 内置跨平台抽象让 TS 免费拿到、Python subprocess 需要 333 行 fallback 补 Windows 不一致;这部分解释了为什么 §12.6 Claude Desktop 配置示例多是 Python server。