MCP 协议设计与实现

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

作者 杨艺韬 · 7,967 字

前面几章我们分析了 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 / LOCALAPPDATA Windows 应用数据
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. 关闭 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.stdinsys.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 可执行文件路径 npxpythonnode
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 上,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 的四扩展搜索级联

打开 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

三个真实工程细节

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 阶梯

进程创建函数本身(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 都会看到几个黑窗口弹一下、极为破坏体验。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 + src/mcp/os/posix/__init__.py + src/mcp/os/__init__.py 3 薄壳
合计 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 客户端把关闭顺序写得很直白。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 协议栈中最简洁的一层。它的全部逻辑可以归纳为:

  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% 的代码全在 src/mcp/os/win32/utilities.py——Node.js child_process 内置跨平台抽象让 TS 免费拿到、Python subprocess 需要 333 行 fallback 补 Windows 不一致;这部分解释了为什么 §12.6 Claude Desktop 配置示例多是 Python server。