MCP 协议设计与实现

第12章 STDIO 传输:本地进程通信

作者 杨艺韬 · 7,949 字

第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 的核心理由:

  1. 人类可读——cat | less 就能调试
  2. 现有工具兼容——jq、grep、awk 都能用
  3. 流式友好——能写就能读,不需要先知道大小
  4. 与 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 通常能容忍,但保险的做法是显式清理。

决策三:流式缓冲

appendreadMessage 的分离设计支持流式处理——数据可能以任意大小的 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 / LOCALAPPDATAWindows 应用数据
TEMP / TMPDIR临时目录

默认排除的敏感变量(举例):

  • AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY
  • GITHUB_TOKENGITLAB_TOKEN
  • OPENAI_API_KEYANTHROPIC_API_KEY
  • DATABASE_URLREDIS_URL
  • JWT_SECRETSTRIPE_SECRET_KEY
  • SSH_AUTH_SOCK(防止 SSH 代理被滥用)

PATH 必须继承

注意 PATH 是必须继承的——否则子进程将无法找到任何可执行文件。如果 Server 需要调用 gitnpmpython 等命令,没有 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. 关闭 stdinEOF完成当前任务,优雅退出
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.stdinsys.stdout 的默认编码取决于操作系统的区域设置

平台默认编码问题
Linux (en_US.UTF-8)UTF-8
macOSUTF-8
Windows (zh-CN)GBK / CP936❌ 非 UTF-8
Windows (en-US)CP1252❌ 非 UTF-8
被重定向的 stdioASCII❌ 中文爆炸

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可执行文件路径npxpythonnode
args命令行参数数组传递给可执行文件的参数
env额外环境变量与默认安全白名单合并
cwd工作目录可选,默认继承 Client 的 cwd
stderrstderr 模式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 上,SIGTERMSIGKILL 是标准的进程终止信号。但 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 生态的命令(npxnpmyarn)在 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 传输对比:

维度STDIOStreamable 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.ts260StdioClientTransport + 进程派生 + 三级关闭
packages/server/src/server/stdio.ts138StdioServerTransport
合计398

Python SDK——

路径角色
src/mcp/client/stdio.py270StdioClientTransport + 派生
src/mcp/server/stdio.py77StdioServerTransport
src/mcp/os/win32/utilities.py333§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.py57POSIX 进程辅助
src/mcp/os/win32/__init__.py + posix/__init__.py + os/__init__.py3薄壳
合计740

最大的不对称是 Windows 处理——

  1. 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 完全没有对应物
  2. 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 协议栈中最简洁的一层。它的全部逻辑可以归纳为:

  1. 消息帧:换行分隔的 JSON(NDJSON),每条消息一行,以 \n 结尾
  2. 进程管理:Client 通过 spawn 创建 Server 子进程,stdin/stdout 作为通信管道
  3. 环境安全:白名单策略只继承必要的系统变量,敏感凭证通过配置文件显式注入
  4. 优雅关闭:三级降级策略——关闭 stdin → SIGTERM → SIGKILL
  5. stderr 是日志通道——stdout 只能用于协议
  6. 平台兼容:Windows/Unix 进程管理、编码处理、可执行文件查找的差异抽象
  7. 最少机制:不发明新协议层,不引入额外依赖

核心哲学

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。