MCP 协议设计与实现
第12章 STDIO 传输:本地进程通信
前面几章我们分析了 TypeScript 和 Python 两套 SDK 的 Server/Client 实现,但一直跳过了一个基础问题:消息到底是怎么在 Client 和 Server 之间传递的?
MCP 协议定义了两种标准传输机制——STDIO 和 Streamable HTTP。本章聚焦 STDIO,这是本地 MCP Server 最常见的接入方式,规范也要求客户端尽可能支持它(「Clients SHOULD support stdio whenever possible」,docs/specification/2025-11-25/basic/transports.mdx);Claude Desktop、Claude Code 等客户端连接本地 MCP Server 走的也是它。
本章要点
- STDIO = 子进程 + 管道——用操作系统原语解决通信问题
- NDJSON 消息帧:一行一个 JSON,以
\n结尾——简单、易调试 - 环境变量白名单:
sudo风格的最小继承,防止敏感信息泄漏 - 三级优雅关闭:stdin close → SIGTERM → SIGKILL
- stderr 是日志通道——Server 不能污染 stdout
- 平台差异:Windows 无 POSIX 信号、编码陷阱、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 写管道 → Server.stdin<br/>Server.stdout → Client 读管道
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 | 简单 | 分隔符不可见,grep/less 等行式工具不好用 |
规范没有写明选择理由,从效果看 NDJSON 的好处有四点:
- 人类可读——
cat | less就能调试 - 现有工具兼容——jq、grep、awk 都能用
- 流式友好——能写就能读,不需要先知道大小
- 与 JSON-RPC 天然契合——JSON-RPC 本身就是 JSON
代价是规范的那条硬约束:消息以换行分隔,且 MUST NOT 含内嵌换行(docs/specification/2025-11-25/basic/transports.mdx)。JSON 字符串里的换行本来就必须转义成 \n,所以真正要注意的是别做缩进美化输出——JSON.stringify(message) 与 pydantic 的 model_dump_json() 默认都输出紧凑的单行,两个 SDK 都直接用它们序列化。
TypeScript SDK 实现
TypeScript SDK 在 ReadBuffer 类和 serializeMessage 函数中实现了这一协议:
// packages/core/src/shared/stdio.ts(节选:serializeMessage 在 :48-50,ReadBuffer 在 :7-42;
// 省略了 clear() 与 deserializeMessage——后者就是 JSONRPCMessageSchema.parse(JSON.parse(line)),:44-46)
// 序列化: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 来过滤掉这些噪音。注意它只放过「不是 JSON」的行:一行合法 JSON 但不是合法 JSON-RPC 消息(比如 {"hello":"world"})会在 schema 校验时抛出非 SyntaxError 的错误,经 processReadBuffer 报给 onerror——实测 TS 客户端收到一个 ZodError,会话照常继续。
实际场景:
[nodemon] starting `node my-server.js` ← 这行不是 JSON
{"jsonrpc":"2.0","result":{...}} ← 这行才是
[nodemon] restart due to changes... ← 这行也不是
这个容错是 pin 版本前不久才加的(TS SDK 提交 64897f78,#1762,2026-03-27)。在那之前,每一行这样的噪音都会以 SyntaxError 的形式报给 onerror,开发时的 hot reload 场景会刷出一串错误。
决策二:跨平台兼容——处理 Windows 的 \r\n
.replace(/\r$/, '') 处理 Windows 的 \r\n 换行符,确保在所有平台上行为一致。
Windows 的 stdio 在某些模式下会自动把 \n 转成 \r\n(这行代码来自 TS SDK 提交 b2655cdd「Fixing for windows terminal forcing \r\n」)。如果不处理,行尾会多一个 \r——JSON.parse 把它当作 JSON 空白、本身能容忍,显式清理是保险做法。
决策三:流式缓冲
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 中(src/mcp/client/stdio.py:137-162,节选,省略外层 try/async with):
async def stdout_reader():
buffer = ""
async for chunk in TextReceiveStream(process.stdout,
encoding=server.encoding,
errors=server.encoding_error_handler):
lines = (buffer + chunk).split("\n")
buffer = lines.pop() # 最后一个元素是不完整的行,保留到下次
for line in lines:
try:
message = types.jsonrpc_message_adapter.validate_json(
line, by_name=False
)
except Exception as exc:
logger.exception("Failed to parse JSONRPC message from server")
await read_stream_writer.send(exc) # 把异常本身送进读流
continue
session_message = SessionMessage(message)
await read_stream_writer.send(session_message)
# ...(省略外层 try / async with read_stream_writer 与 ClosedResourceError 处理)
Python 版本用字符串的 split("\n") 替代了手动的索引查找,最后一个 pop() 出来的元素就是尚未结束的不完整行。差别在坏行:TS 静默跳过非 JSON 行,Python 不区分「不是 JSON」和「不是合法消息」,一律打一条带堆栈的 logger.exception,再把异常对象送进读流。实测让 Server 往 stdout 打一行 [nodemon] starting ...:Python 客户端在 stderr 打出 Failed to parse JSONRPC message from server 及 pydantic 的 json_invalid 堆栈,ClientSession 把这个 ValidationError 交给 message_handler,随后的 tools/call 照常完成。服务端 stdio_server 的 stdin_reader 同样把异常送进读流(src/mcp/server/stdio.py:53-57),低层 Server 收到后只记一条 logger.error(src/mcp/server/lowlevel/server.py:431-434),实测 stderr 出现 Received exception from stream: ...,不给客户端回任何响应,后面的 initialize 照常处理。
12.3 进程派生与环境变量安全
STDIO 传输的核心操作是 Client 派生 Server 子进程。这个看似简单的操作涉及一个重要的安全决策:子进程应该继承哪些环境变量?
默认情况下,子进程会继承父进程的全部环境变量。但 MCP Client 的环境可能包含敏感信息(API Key、数据库密码等),不应该无差别地暴露给每个 MCP Server。
白名单策略的起源
TypeScript 和 Python SDK 采用了同一套白名单思路,POSIX 列表完全相同,Windows 列表略有出入(TS 多 PROGRAMFILES,Python 多 PATHEXT):
// TypeScript SDK:packages/client/src/client/stdio.ts:46-63
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:src/mcp/client/stdio.py:28-45
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"]
)
TS 源码在 POSIX 列表旁注明「list inspired by the default env inheritance of sudo」:这个白名单的设计灵感来自 Unix sudo 命令的默认环境继承策略——只保留进程正常运行所必需的系统变量。
白名单分类
| 变量 | 用途 | 泄漏风险 |
|---|---|---|
PATH |
命令查找 | 低(但被篡改有风险) |
HOME |
用户目录 | 低 |
USER / LOGNAME |
用户身份 | 低 |
SHELL |
默认 shell | 低 |
TERM |
终端类型 | 极低 |
APPDATA / LOCALAPPDATA |
Windows 应用数据 | 低 |
TEMP |
临时目录(仅 Windows 白名单) | 低 |
被默认排除的敏感变量(举例):
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 漏洞利用的就是这个特性):
# src/mcp/client/stdio.py:51-68(省略 docstring 与覆盖率注释)
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_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx"
}
}
}
}
官方调试文档对 Claude Desktop 的说法是:stdio 启动的 Server 只自动继承一小部分环境变量(具体集合随平台而定),要覆盖或补充就写 env(docs/docs/2025-11-25/tools/debugging.mdx 的 Environment variables 一节,核对日期 2026-09-24)。注意这里的 @modelcontextprotocol/server-github 在 npm 上已标记弃用,开发迁到了 github/github-mcp-server,它读取的变量名是 GITHUB_PERSONAL_ACCESS_TOKEN(npm 包 README,核对日期 2026-09-24)。
在两个 SDK 里,env 参数都会与默认白名单合并,而不是替换它(Python 见 src/mcp/client/stdio.py:125):
// packages/client/src/client/stdio.ts:121-131(节选,省略 windowsHide 与 cwd 两项)
this._process = spawn(command, args, {
env: {
...getDefaultEnvironment(), // 安全的系统变量白名单
...this._serverParams.env // 用户显式配置的变量(覆盖同名项)
},
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
shell: false, // 关键:不通过 shell 启动,防止命令注入
});
这种设计实现了最小权限原则:Server 只能访问明确授权给它的环境变量,而不是 Client 环境中的所有敏感信息。实测在一个设了几十个环境变量的 Linux shell 里,用 TS 客户端显式传入 SECRET_X 启动 Server,子进程看到的只有 HOME,LOGNAME,PATH,SHELL,TERM,USER 加上 SECRET_X;Python Server 进程里还会多出一个 LC_CTYPE——那是 Python 解释器自己在没有区域设置时做的 C locale 强制(PEP 538),不是从父进程继承的。
shell: false 的重要性
注意 shell: false——这不是随便写的。
如果设 shell: true,参数会经过 shell 解析:
spawn("git", ["log", user_input], { shell: true })
// 用户输入 "; rm -rf /"
// 实际执行: sh -c "git log ; rm -rf /" ← 命令注入!
shell: false 时,在 POSIX 上参数数组直接传给 execve 系统调用,不经过 shell,从根本上防止注入。(Windows 上 .cmd/.bat 必须经 cmd.exe 执行,TS SDK 依赖的 cross-spawn 会替你转义参数,见 12.7 节。)
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() 方法实现了三级优雅降级:
// packages/client/src/client/stdio.ts:205-244(简化:改写成提前 return,去掉了 try/catch)
async close(): Promise<void> {
if (!this._process) return;
const processToClose = this._process;
this._process = undefined; // 先摘引用,close() 重入直接短路
const closePromise = new Promise<void>(resolve => {
processToClose.once('close', () => 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');
}
}
// 真实代码里每个 kill()/stdin.end() 都包在 try/catch 里,
// 并在最后调用 this._readBuffer.clear()
为什么是三级而不是一步到位
三级策略符合 MCP 规范定义的 STDIO 关闭序列(docs/specification/2025-11-25/basic/lifecycle.mdx 的 stdio 关闭小节,SHOULD 级别):先礼后兵。每一级都给 Server 一个机会做合理的事:
| 级别 | 信号 | Server 应做的事 |
|---|---|---|
| 1. 关闭 stdin | EOF | 完成当前任务,优雅退出 |
| 2. SIGTERM | 软中止 | 释放资源(关闭 DB、写入缓存)后退出 |
| 3. SIGKILL | 强制杀 | 无法拒绝,内核直接终止进程 |
但绝不容忍 Server 无限期挂起——4 秒是上限。正常情况下根本走不到第二级:实测 TS 客户端 close() 一个 TS Server 用时 15 ms、一个 Python Server 用时 84 ms(中间隔着一层抓包转发进程),都是关掉 stdin 后 Server 自行退出。
.unref() 的细节价值
注意 setTimeout 后面的 .unref() 调用——这确保这些定时器不会阻止 Node.js 进程退出。
一个看似不起眼的细节,但对 CLI 工具的退出速度有直接影响:Server 往往在 stdin 关闭后几十毫秒内就退出,closePromise 赢得竞速,可那个 2 秒定时器还挂着。如果没有 unref(),它会让 Node 事件循环在 close() 返回后继续空转到定时器触发,进程最多晚 2 秒才退出。实测同样的 Promise.race,加 unref() 的脚本 0.04 秒退出,不加的 2.03 秒。(Ctrl+C 不受影响——默认的 SIGINT 处理会直接结束进程,不等定时器。)这两个 unref() 随 TS SDK 提交 ace9b62d「fix hanging stdio servers」(#1200)一起加入。
这就是工程细节决定产品体验的典型——用户永远不会知道这个单行代码,但他们会感知到"这个工具退出很快"。
12.5 服务端实现:从 stdin 读取、向 stdout 写入
服务端的 STDIO 传输要简单得多——因为它不需要管理子进程,只需要读写自己的标准输入输出。
TypeScript 实现
// packages/server/src/server/stdio.ts:19 起——**精简**:只留主干。源码里还有
// _readBuffer / _started / _closed 三个字段(:20-22)与 _ondata / _onerror /
// _onstdouterror 三个箭头函数(:34-46);start() 开头会用 _started 挡住重复启动
// (:52-58);send() 开头先查 _closed(:104-106),并且用一个 settled 标志同时
// 挂 'error' 与 'drain' 两个一次性监听、谁先到谁 resolve/reject(:107-135)。
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)控制:Node 的可写流在内部缓冲超过 highWaterMark 时让 write() 返回 false(数据本身已经收进缓冲),此时等待 drain 事件再 resolve,调用方 await send() 就会自然放慢,防止内存无限增长。
场景:Server 快速生成大量响应,Client 处理慢
↓
Server 往 stdout 猛写
↓
管道写不动,可写流内部缓冲超过 highWaterMark → write() 返回 false
↓
若不处理:数据继续堆在进程内存里,最坏 OOM
↓
正确做法:等 drain 事件 → 消费者赶上来了才继续写
这是典型的"上游跟着下游速度走"的流控模型。
Python 的 UTF-8 强制
Python SDK 的服务端实现同样简洁,但有一个有趣的细节——它显式重新包装了 stdin/stdout 以确保 UTF-8 编码:
# src/mcp/server/stdio.py:32-44(节选,省略参数类型注解与注释)
@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 的默认编码取决于操作系统的区域设置;作为 MCP Server 运行时 stdio 是管道,Windows 上这时用的是系统 ANSI 代码页。
| 平台 | 默认编码 | 问题 |
|---|---|---|
| Linux (en_US.UTF-8) | UTF-8 | ✅ |
| macOS | UTF-8 | ✅ |
| Windows (zh-CN) | GBK / CP936 | ❌ 非 UTF-8 |
| Windows (en-US) | CP1252 | ❌ 非 UTF-8 |
| Linux(C/POSIX locale,且未发生 PEP 538 强制或 UTF-8 模式) | ASCII | ❌ 中文爆炸 |
MCP 的 JSON-RPC 消息必须是 UTF-8 编码的,所以 SDK 绕过了 Python 的默认文本包装,直接操作底层的二进制缓冲区并强制 UTF-8。
这是又一个"规范看起来简单,实现要绕开各种坑"的例子。
12.6 Claude Desktop 配置格式
理解了 STDIO 传输的原理,Claude Desktop 的 MCP Server 配置就完全透明了(示意配置:database 一项的模块名、参数与 DATABASE_READONLY 变量只作演示,实际以各 Server 的 README 为准):
{
"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_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx"
}
}
}
}
配置项与 TS SDK StdioServerParameters(packages/client/src/client/stdio.ts:10-41)的字段一一对应:
| 字段 | 对应参数 | 说明 |
|---|---|---|
command |
可执行文件路径 | 如 npx、python、node |
args |
命令行参数数组 | 传递给可执行文件的参数 |
env |
额外环境变量 | 与默认安全白名单合并 |
cwd |
工作目录 | 可选,默认继承 Client 的 cwd |
stderr |
stderr 模式 | 仅 SDK 参数:inherit(默认)/ pipe / ignore 等;官方文档的 claude_desktop_config.json 示例里出现的字段是 command、args、env、cwd |
Claude Desktop 本身不开源,官方文档只描述了它的配置字段与行为(按 command/args 启动子进程、只继承部分环境变量、Server 写到 stderr 的内容由宿主捕获、日志在 ~/Library/Logs/Claude 等目录,见 docs/docs/2025-11-25/tools/debugging.mdx,核对日期 2026-09-24);机制上它做的就是本章分析的这条路径:派生子进程,再经 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[无 POSIX 信号]
style Unix fill:#dcfce7,stroke:#22c55e
style Windows fill:#fef3c7,stroke:#f59e0b
Windows 进程管理
在 Unix 上,SIGTERM 和 SIGKILL 是标准的进程终止信号。但 Windows 没有 POSIX 那种进程间信号:Node 在 Windows 上执行 kill('SIGTERM') 会直接强制结束进程(Node.js child_process 文档),所以 TS SDK 的第二级在 Windows 上其实已经是硬杀。Python SDK 为此实现了平台特定的进程创建与终止逻辑:
# src/mcp/client/stdio.py:229-252(简化:省略 stderr/cwd 参数)
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 自成一个新会话和进程组,它派生的子孙进程默认继承这个进程组(除非自己再调 setsid/setpgid),需要终止时就能用 killpg 一次性处理整组,而不是只杀死顶层进程(src/mcp/os/posix/utilities.py:13-57)。TS SDK 没有这一步,close() 只对直接子进程发信号(见第 4 章)。
Windows 的 Job Object
Windows 用 Job Object 实现类似效果(Win32 API 示意;Python SDK 通过 pywin32 调用,见 src/mcp/os/win32/utilities.py:232-275):
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 进程再启动了孙子进程(它们默认也归入同一个 Job),关闭 Job 时整棵进程树一起被清理。Python SDK 终止时调用的是 TerminateJobObject(src/mcp/os/win32/utilities.py:291-294)。
可执行文件查找
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 的四扩展搜索级联
打开 src/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
三个工程细节(源码与引入它的提交 e5ee2796「Fixes to stdio_client to support Windows more robustly」#372 都没有写设计理由,以下第 1、3 点是本专栏的解读):
1. 四个扩展的搜索顺序:[".cmd", ".bat", ".exe", ".ps1"] — .cmd 排第一是因为 JavaScript 生态的命令(npx、npm、yarn)在 Windows 上几乎都是 .cmd 脚本;.bat 第二是老式批处理;.exe 第三是原生可执行;.ps1 最后是 PowerShell 脚本(较少见)。可以理解为按常见程度排,第一次命中就返回。
2. OSError 兜底——源码注释原文 "permissions, broken symlinks, etc."。Windows 上 shutil.which 可能因为用户路径里某个目录权限问题抛 OSError。原样返回 command 而非异常传递——把报错推迟到真正创建进程的那一步,由它报出更具体的错误(比如 FileNotFoundError)。
3. shutil.which 的隐式 PATHEXT 处理——Windows 上 shutil.which("npx") 本身就会读 PATHEXT 环境变量自动试 .exe/.cmd/.bat 等扩展。那为什么还要手写 for 循环?合理的解释是 PATHEXT 可能被清空或不完整——手写循环是补丁,在 PATHEXT 异常时仍能找到 .cmd/.bat/.exe。这是面向碎片化 Windows 环境的防御性代码。
12.7.2 create_windows_process 的三级 fallback 阶梯
进程创建函数本身(src/mcp/os/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 都会看到几个黑窗口弹一下、极为破坏体验。subprocess.CREATE_NO_WINDOW只在 Windows 上存在(Python 3.7 起),而 SDK 要求 Python ≥ 3.10(pyproject.toml),所以hasattr判断在 Windows 上实际总为真,只是防御性写法。TS SDK 的对应物是一行 spawn 选项windowsHide: process.platform === 'win32'(packages/client/src/client/stdio.ts:129)。 -
第二级
_create_windows_fallback_process——触发条件是NotImplementedError。源码注释原文:"Windows doesn't support async subprocess creation, e.g., when using the SelectorEventLoop"——Python 3.8 起 Windows 上 asyncio 的默认事件循环是支持子进程的ProactorEventLoop,但SelectorEventLoop不支持 async subprocess;如果宿主应用或它依赖的库把事件循环换成了 SelectorEventLoop,就走这里的 fallback——同步subprocess.Popen+FileReadStream/FileWriteStream手动包装成 async 接口。这条分支来自提交 ecc91651「Fix Windows subprocess NotImplementedError (STDIO clients)」(#596)。 -
第三级 "try again without creation flags"——更多兼容性兜底。源码注释只有一句「Try again without creation flags」:任何非
NotImplementedError的异常都会进这里,去掉CREATE_NO_WINDOW再试一次——代价是可能会弹窗口,但至少进程能起来。注意这个except Exception也会兜住「命令不存在」这类错误,那时重试同样失败、异常照常抛出。"能运行 > 体验完美" 的优先级排序。
FallbackProcess wrapper(src/mcp/os/win32/utilities.py:65-129,包装逻辑在 :74-81)——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 用户不管怎么配都能跑起来"付出的真实工程代价。读懂它们、你就能理解 Windows 上的 STDIO 为什么要多写这么多代码——不是 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 |
| 启动延迟 | 每次连接都要派生进程(冷启动开销取决于运行时,npx/uvx 这类还要先解包) |
只有 HTTP 握手,进程常驻 |
| 并发数 | 每个 Server 一个进程 | 单 Server 多连接 |
| 更新机制 | 重启 Client 或重新 spawn | 滚动升级(无感) |
| 可观测性 | 本地日志 | APM/分布式追踪 |
| 适用场景 | IDE 集成、CLI 工具、桌面应用 | SaaS 服务、远程 API、团队共享 |
STDIO 的三大核心优势
1. 零配置网络
不需要选择端口、不需要处理端口冲突、不需要防火墙规则。对于 Claude Desktop 这样需要同时运行多个 MCP Server 的客户端,这意味着用户不需要关心哪个 Server 占用了哪个端口。
2. 天然的生命周期管理
Client 是 Server 的父进程,Client 一退出,管道的写端随之关闭,Server 读到 stdin EOF 后自行退出。注意操作系统并不会替你杀掉子进程:实测用 kill -9 杀掉 TS 客户端,两个 SDK 写的 Server 都在读到 EOF 后 1 秒内退出;但给 TS Server 额外挂一个 setInterval 后(StdioServerTransport 不监听 stdin 的 end 事件,只靠事件循环清空让进程自然退出),它就被 PID 1 收养、以孤儿进程(不是僵尸进程)继续运行。所以"Server 忘记关闭"的泄漏靠的是 Server 正确处理 EOF,而不是操作系统兜底。即便如此,这也比 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 MUST NOT 往 stdout 写任何不是合法 MCP 消息的内容;stderr 则 MAY 用于任何日志,Client SHOULD NOT 把 stderr 输出当成出错信号(docs/specification/2025-11-25/basic/transports.mdx)。
违反这条规则的后果往往比"解析报错"更隐蔽。实测让 TS Server 在启动时 process.stdout.write('starting...')(没有换行):这段文字和紧随其后的 initialize 响应粘成一行 starting...{"result":...},ReadBuffer 把整行当成非 JSON 静默丢掉,客户端收不到响应,connect() 一直等到超时报 Request timed out。
这也是为什么 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());
});
实测用这种写法,Server 启动时写到 stderr 的第一行也能被收到。Python 的 stdio_client 没有 pipe 选项:它把子进程的 stderr 直接交给 errlog 参数(默认 sys.stderr,src/mcp/client/stdio.py:105、:126),由操作系统直接写过去;要捕获就传一个有真实文件描述符的文件对象。
PassThrough 的精巧时序
这里有一个精巧的实现细节:StdioClientTransport 在构造函数中就创建了 PassThrough 流,而不是在 start() 之后才创建。
这确保了调用方可以在进程启动之前就附加监听器,不会遗漏 Server 启动阶段的早期错误输出:
// 简化自 packages/client/src/client/stdio.ts:97-179
class StdioClientTransport {
private _stderrStream: PassThrough | null = null;
constructor(server: StdioServerParameters) {
// 只有请求了 pipe/overlapped 才建 PassThrough,构造时就建好
if (server.stderr === 'pipe' || server.stderr === 'overlapped') {
this._stderrStream = new PassThrough();
}
}
async start() {
this._process = spawn(...);
if (this._stderrStream && this._process.stderr) {
this._process.stderr.pipe(this._stderrStream);
}
}
// getter:piping 请求过就立刻返回 PassThrough,否则退回子进程自己的 stderr
get stderr(): Stream | null {
if (this._stderrStream) return this._stderrStream;
return this._process?.stderr ?? null;
}
}
// 用户代码可以这样写:
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
实测用这种方式喂给 TS 与 Python 两个 SDK 写的 Server,都会在 stdout 打出一行 initialize 响应,随后 stdin 读到 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
技巧四:调高 Server 的日志级别
日志开关由各个 Server 自己决定,两个 SDK 都没有统一的调试环境变量。Python SDK 的 MCPServer 要在构造时传参:
mcp = MCPServer(name="my-server", log_level="DEBUG") # 日志经 RichHandler 写到 stderr
别指望 MCP_LOG_LEVEL=DEBUG 生效:Settings 虽然声明了 MCP_ 前缀,但 MCPServer.__init__ 把每个字段都显式传给了 Settings(...),构造参数总是压过环境变量(src/mcp/server/mcpserver/server.py:153-162,第 10 章 §10.10 已实测)。要按环境切换,就自己读环境变量再传进去。
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);
});
用 SDK 就不必操心这一点:packages/client/src/client/stdio.ts:142-145 在子进程 close 事件里触发 onclose,packages/core/src/shared/protocol.ts:492-507 随即以 ConnectionClosed 拒绝所有未完成请求。
12.11.1 实测:两 SDK STDIO 实现规模与最关键的不对称
把 STDIO 相关文件按 SDK 实测(git ls-files + wc -l,物理行;TS 表不含 packages/core/src/shared/stdio.ts 里 ReadBuffer/serializeMessage 的 50 行,算上是 448 行)——
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 | stdio_client + 派生 + 分帧 |
src/mcp/server/stdio.py |
77 | stdio_server + 分帧 |
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 + src/mcp/os/posix/__init__.py + src/mcp/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 阶梯"(anyio.open_process+CREATE_NO_WINDOW→NotImplementedError时降级到FallbackProcess(同步Popen包装)→ 其他异常时去掉 creationflags 重试);额外有 Job Object 进程组管理 + 整个进程树终止逻辑——这 333 行在 TS SDK 几乎没有自己的对应代码,唯一的对应物是一行windowsHide: process.platform === 'win32'(对应CREATE_NO_WINDOW) - TS SDK 把这部分外包了——
packages/client/src/client/stdio.ts:8直接import spawn from 'cross-spawn',.cmd/.bat的查找与转义由这个第三方库负责;进程树清理则干脆没做——close()只对直接子进程发信号,也没有 Job Object 或进程组。也就是说 TS SDK 不是"不需要处理 Windows",而是把可执行文件查找挪到了依赖里、把进程树清理省掉了;Python SDK 没用现成的跨平台 spawn 库,这 333 行就落在了自己仓库中(Job Object 部分借助 pywin32,pyproject.toml里只在 win32 平台引入)。
这条不对称的工程含义——跨平台的复杂度不会消失,只会换地方:TS 把它压进 cross-spawn 依赖,Python 把它写进 src/mcp/os/win32/。对读者的实际影响是:调试 Windows 上启动失败的 MCP server 时,Python 侧可以直接读 SDK 源码定位,TS 侧要去读 cross-spawn 的实现。
两 SDK 的 server 端都很小:TS 138 + Python 77——是因为 server 端只需要 process.stdin.on('data') / process.stdout.write() 两条管道——没有 client 端那种复杂的"如何启动子进程"问题。
串联第 13 章 §13.9.1 / 第 15 章 §15.9.4 / 第 16 章 §16.9.1 / 第 17 章 §17.10.1 会看到同一种规律:Streamable HTTP 服务端主文件双 SDK 几乎等大(1056 vs 1038);OAuth 服务端则 Python src/mcp/server/auth/ 1563 行 vs TS server 包 0 行;OAuth 发现逻辑 TS 塞在 auth.ts 尾部的 5 个大函数里、Python 拆成 utils.py 的十几个小函数;Sampling 的"有工具 / 无工具"两种形态 TS 用 Omit 类型运算、Python 直接开两个类——再加本章 STDIO 的 Windows 处理 333 行 vs 几乎为零。结论是:两个 SDK 的一致程度是按功能模块决定的,并不是某一边总落后或总领先。
12.11.2 Python 客户端的关闭顺序:为什么先关 stdin
Python SDK 的 stdio 客户端把关闭顺序写得很直白。src/mcp/client/stdio.py:104-128 先用 StdioServerParameters 里的 command、args、env、cwd 创建子进程;src/mcp/client/stdio.py:137-160 的 stdout_reader 按换行拆 JSON-RPC 消息;src/mcp/client/stdio.py:164-176 的 stdin_writer 把 SessionMessage 序列化成 JSON 后补一个换行写入子进程 stdin。到退出时,关键逻辑在 src/mcp/client/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.