Skip to content

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

前面几章我们分析了 TypeScript 和 Python 两套 SDK 的 Server/Client 实现,但一直跳过了一个基础问题:消息到底是怎么在 Client 和 Server 之间传递的?

MCP 协议定义了两种标准传输机制——STDIO 和 Streamable HTTP。本章聚焦 STDIO,这是 MCP 生态中最常用的传输方式,也是 Claude Desktop、Claude Code 等主流客户端与本地 MCP Server 通信的默认选择。

12.1 STDIO 传输的设计直觉

理解 STDIO 传输,只需要一个核心概念:Client 把 Server 当作一个子进程来运行,通过 stdin/stdout 双向传递 JSON-RPC 消息。

这个设计极其简洁。不需要网络端口,不需要 HTTP 服务器,不需要 TLS 证书。操作系统的进程管道就是通信通道。

这张图展示了 STDIO 传输的完整生命周期。让我们逐层深入每个环节的实现细节。

12.2 消息帧格式:换行分隔的 JSON

STDIO 传输面临的第一个问题是:stdin/stdout 是连续的字节流,如何从中切分出一条条独立的 JSON-RPC 消息?

MCP 的方案是换行分隔 JSON(Newline-Delimited JSON,NDJSON):每条消息序列化为一行 JSON,以 \n 结尾。

TypeScript SDK 在 ReadBuffer 类和 serializeMessage 函数中实现了这一协议:

typescript
// 序列化:JSON 末尾加换行符
export function serializeMessage(message: JSONRPCMessage): string {
    return JSON.stringify(message) + '\n';
}

// 反序列化:按换行符切分,逐行解析
export class ReadBuffer {
    private _buffer?: Buffer;

    append(chunk: Buffer): void {
        this._buffer = this._buffer
            ? Buffer.concat([this._buffer, chunk])
            : chunk;
    }

    readMessage(): JSONRPCMessage | null {
        while (this._buffer) {
            const index = this._buffer.indexOf('\n');
            if (index === -1) {
                return null; // 没有完整的行,等待更多数据
            }

            const line = this._buffer.toString('utf8', 0, index)
                .replace(/\r$/, ''); // 兼容 Windows 的 \r\n
            this._buffer = this._buffer.subarray(index + 1);

            try {
                return deserializeMessage(line);
            } catch (error) {
                if (error instanceof SyntaxError) {
                    continue; // 跳过非 JSON 行(如热重载工具的调试输出)
                }
                throw error;
            }
        }
        return null;
    }
}

这段代码有几个值得注意的设计决策:

1. 容错性:当 ReadBuffer 遇到无法解析为 JSON 的行时,不会抛出错误,而是静默跳过。这个设计专门应对了一个现实场景——很多 Node.js 开发工具(如 tsx、nodemon)会往 stdout 输出调试信息。如果 Server 通过这类工具启动,这些调试行会混入消息流。ReadBuffer 通过捕获 SyntaxError 来过滤掉这些噪音。

2. 跨平台兼容.replace(/\r$/, '') 处理 Windows 的 \r\n 换行符,确保在所有平台上行为一致。

3. 流式缓冲appendreadMessage 的分离设计支持流式处理——数据可能以任意大小的 chunk 到达,ReadBuffer 负责在内部拼接和切分。

Python SDK 采用了不同但等价的实现方式。在客户端的 stdout_reader 中:

python
async def stdout_reader():
    buffer = ""
    async for chunk in TextReceiveStream(process.stdout,
                                          encoding=server.encoding):
        lines = (buffer + chunk).split("\n")
        buffer = lines.pop()  # 最后一个元素是不完整的行,保留到下次

        for line in lines:
            message = types.jsonrpc_message_adapter.validate_json(
                line, by_name=False
            )
            session_message = SessionMessage(message)
            await read_stream_writer.send(session_message)

Python 版本用字符串的 split("\n") 替代了手动的索引查找,最后一个 pop() 出来的元素就是尚未结束的不完整行。逻辑更 Pythonic,但本质思路完全一致。

12.3 进程派生与环境变量安全

STDIO 传输的核心操作是 Client 派生 Server 子进程。这个看似简单的操作涉及一个重要的安全决策:子进程应该继承哪些环境变量?

默认情况下,子进程会继承父进程的全部环境变量。但 MCP Client 的环境可能包含敏感信息(API Key、数据库密码等),不应该无差别地暴露给每个 MCP Server。

TypeScript 和 Python SDK 都实现了相同的白名单策略:

typescript
// TypeScript SDK
export const DEFAULT_INHERITED_ENV_VARS =
    process.platform === 'win32'
        ? ['APPDATA', 'HOMEDRIVE', 'HOMEPATH', 'LOCALAPPDATA',
           'PATH', 'PROCESSOR_ARCHITECTURE', 'SYSTEMDRIVE',
           'SYSTEMROOT', 'TEMP', 'USERNAME', 'USERPROFILE',
           'PROGRAMFILES']
        : ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER'];
python
# Python SDK
DEFAULT_INHERITED_ENV_VARS = (
    ["APPDATA", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA",
     "PATH", "PATHEXT", "PROCESSOR_ARCHITECTURE", "SYSTEMDRIVE",
     "SYSTEMROOT", "TEMP", "USERNAME", "USERPROFILE"]
    if sys.platform == "win32"
    else ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]
)

这个白名单的设计灵感来自 Unix sudo 命令的默认环境继承策略,只保留进程正常运行所必需的系统变量。注意 PATH 是必须继承的——否则子进程将无法找到任何可执行文件。

同时,两个 SDK 都会过滤掉以 () 开头的环境变量值,因为这在某些 Shell 中表示函数定义(如 Bash 的 Shellshock 漏洞利用的就是这个特性)。

那 MCP Server 需要的 API Key 怎么传递?答案是通过配置文件显式指定。以 Claude Desktop 为例:

json
{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_TOKEN": "ghp_xxxxxxxxxxxx"
      }
    }
  }
}

配置中的 env 字段会与默认环境合并:

typescript
// Client 端 spawn 进程时的环境变量合并
this._process = spawn(command, args, {
    env: {
        ...getDefaultEnvironment(),  // 安全的系统变量白名单
        ...this._serverParams.env     // 用户显式配置的变量(覆盖同名项)
    },
    stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
    shell: false,
});

这种设计实现了最小权限原则:Server 只能访问明确授权给它的环境变量,而不是 Client 环境中的所有敏感信息。

12.4 客户端实现:进程管理的全生命周期

TypeScript SDK 的 StdioClientTransport 封装了从进程启动到关闭的完整逻辑。

关闭序列是最精妙的部分。close() 方法实现了三级优雅降级:

typescript
async close(): Promise<void> {
    // 第一级:关闭 stdin,等待进程自行退出
    processToClose.stdin?.end();
    await Promise.race([
        closePromise,
        new Promise(resolve => setTimeout(resolve, 2000).unref())
    ]);

    // 第二级:发送 SIGTERM,给进程清理资源的机会
    if (processToClose.exitCode === null) {
        processToClose.kill('SIGTERM');
        await Promise.race([
            closePromise,
            new Promise(resolve => setTimeout(resolve, 2000).unref())
        ]);
    }

    // 第三级:发送 SIGKILL,强制终止
    if (processToClose.exitCode === null) {
        processToClose.kill('SIGKILL');
    }
}

这三级策略符合 MCP 规范定义的 STDIO 关闭序列:先礼后兵,给 Server 足够的时间释放资源(关闭数据库连接、保存状态等),但绝不容忍 Server 无限期挂起。

注意 setTimeout 后面的 .unref() 调用——这确保这些定时器不会阻止 Node.js 进程退出。一个看似不起眼的细节,但对于 CLI 工具(如 Claude Code)的用户体验至关重要:如果没有 unref(),用户按下 Ctrl+C 后可能要等待 4 秒才能看到进程退出。

12.5 服务端实现:从 stdin 读取、向 stdout 写入

服务端的 STDIO 传输要简单得多——因为它不需要管理子进程,只需要读写自己的标准输入输出。

TypeScript 的 StdioServerTransport

typescript
export class StdioServerTransport implements Transport {
    constructor(
        private _stdin: Readable = process.stdin,
        private _stdout: Writable = process.stdout
    ) {}

    async start(): Promise<void> {
        this._stdin.on('data', this._ondata);
        this._stdin.on('error', this._onerror);
        this._stdout.on('error', this._onstdouterror);
    }

    send(message: JSONRPCMessage): Promise<void> {
        const json = serializeMessage(message);
        if (this._stdout.write(json)) {
            resolve();
        } else {
            this._stdout.once('drain', resolve);
        }
    }
}

send 方法中的 drain 处理是背压(backpressure)控制:如果 stdout 的内核缓冲区已满,write() 返回 false,此时等待 drain 事件再继续,防止内存无限增长。

Python SDK 的服务端实现同样简洁,但有一个有趣的细节——它显式重新包装了 stdin/stdout 以确保 UTF-8 编码:

python
@asynccontextmanager
async def stdio_server(stdin=None, stdout=None):
    if not stdin:
        stdin = anyio.wrap_file(
            TextIOWrapper(sys.stdin.buffer, encoding="utf-8",
                          errors="replace")
        )
    if not stdout:
        stdout = anyio.wrap_file(
            TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
        )

为什么要这样做?因为 Python 的 sys.stdinsys.stdout 的默认编码取决于操作系统的区域设置。在 Windows 上,这可能是 GBK 或 CP936 而非 UTF-8。MCP 的 JSON-RPC 消息必须是 UTF-8 编码的,所以 SDK 绕过了 Python 的默认文本包装,直接操作底层的二进制缓冲区并强制 UTF-8。

12.6 Claude Desktop 配置格式

理解了 STDIO 传输的原理,Claude Desktop 的 MCP Server 配置就完全透明了:

json
{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem",
               "/Users/yangyitao/Documents"],
      "env": {}
    },
    "database": {
      "command": "python",
      "args": ["-m", "mcp_server_sqlite",
               "--db-path", "/data/app.db"],
      "env": {
        "DATABASE_READONLY": "true"
      }
    },
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_TOKEN": "ghp_xxxxxxxxxxxx"
      }
    }
  }
}

每个 Server 配置项直接映射到 StdioServerParameters

配置字段对应参数说明
command可执行文件路径npxpythonnode
args命令行参数数组传递给可执行文件的参数
env额外环境变量与默认安全白名单合并

Claude Desktop 启动时,会为每个配置的 Server 创建一个 StdioClientTransport 实例,调用 start() 派生子进程,然后通过 stdin/stdout 管道发送 initialize 请求。整个过程就是本章前面分析的代码路径。

12.7 平台兼容性处理

STDIO 传输看似简单,但跨平台兼容是一个需要仔细处理的问题。

Windows 进程管理:在 Unix 上,SIGTERMSIGKILL 是标准的进程终止信号。但 Windows 没有信号机制。Python SDK 为此实现了平台特定的进程终止逻辑:

python
async def _create_platform_compatible_process(command, args, env, ...):
    if sys.platform == "win32":
        # Windows: 通过 Job Object 管理子进程树
        process = await create_windows_process(command, args, env, ...)
    else:
        # Unix: 创建新的进程组,便于整体终止
        process = await anyio.open_process(
            [command, *args],
            env=env,
            start_new_session=True,  # 创建新的 session/process group
        )

start_new_session=True 确保 Server 进程及其所有子进程都在同一个进程组中,当需要终止时可以用 killpg 一次性终止整个进程树,而不是只杀死顶层进程。

可执行文件查找:TypeScript SDK 使用 cross-spawn 库而非 Node.js 原生的 child_process.spawn,专门解决 Windows 上 .cmd.bat 文件需要通过 cmd.exe 执行的问题。Python SDK 则通过 get_windows_executable_command 做类似的适配。

12.8 STDIO vs HTTP 传输:设计权衡

理解 STDIO 的优势,需要与 HTTP 传输对比:

维度STDIOStreamable HTTP
部署模型Client 派生本地子进程Server 独立部署,Client 通过 URL 连接
安全模型进程隔离 + 环境变量白名单TLS + OAuth 2.1 认证
凭证传递环境变量(简单直接)HTTP Headers / OAuth Token(复杂但标准)
多用户不支持(一对一绑定)天然支持
网络依赖无(纯本地)需要网络连接
发现机制配置文件显式指定DNS / Well-known URL
适用场景IDE 集成、CLI 工具、桌面应用SaaS 服务、远程 API、团队共享

STDIO 传输之所以成为本地场景的首选,核心原因有三:

1. 零配置网络:不需要选择端口、不需要处理端口冲突、不需要防火墙规则。对于 Claude Desktop 这样需要同时运行多个 MCP Server 的客户端,这意味着用户不需要关心哪个 Server 占用了哪个端口。

2. 天然的生命周期管理:Client 是 Server 的父进程,当 Client 退出时,操作系统会自动清理子进程。不存在"Server 忘记关闭"的泄漏问题。相比之下,HTTP Server 需要额外的守护进程管理(systemd、Docker 等)。

3. 简单的安全模型:STDIO 的安全边界就是操作系统的进程隔离。Server 以 Client 同一用户的身份运行,天然拥有与用户相同的文件系统权限——不多也不少。需要额外权限(如 API Key)时通过环境变量显式传递。这比 OAuth 2.1 流程简单一个数量级。

但 STDIO 也有明确的局限:它只能用于本地通信,不支持多用户共享,每次 Client 启动都需要重新派生 Server 进程。当你需要向团队提供共享的 MCP 服务时,Streamable HTTP 才是正确的选择。

12.9 stderr 的角色

在 STDIO 传输中,stdin 和 stdout 被占用于 JSON-RPC 通信,那 Server 的日志和调试信息应该输出到哪里?答案是 stderr

TypeScript SDK 默认将子进程的 stderr 设为 inherit,即直接输出到父进程的 stderr:

typescript
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit']

但也支持设置为 pipe,此时 Client 可以通过 transport.stderr 属性读取 Server 的错误输出:

typescript
const transport = new StdioClientTransport({
    command: "node",
    args: ["my-server.js"],
    stderr: "pipe"
});

// transport.stderr 是一个 PassThrough 流
// 可以在 start() 之前就附加监听器,不会丢失早期输出
transport.stderr?.on('data', (chunk) => {
    console.log('[server stderr]', chunk.toString());
});

这里有一个精巧的实现细节:StdioClientTransport 在构造函数中就创建了 PassThrough 流,而不是在 start() 之后才创建。这确保了调用方可以在进程启动之前就附加监听器,不会遗漏 Server 启动阶段的早期错误输出。

12.10 本章小结

STDIO 传输是 MCP 协议栈中最简洁的一层。它的全部逻辑可以归纳为:

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

STDIO 的设计哲学是用操作系统的原语解决通信问题。不发明新的协议层,不引入额外的依赖,让进程管道承担消息传输,让进程隔离提供安全边界,让父子进程关系管理生命周期。

这种"最少机制原则"使得 STDIO 传输成为本地 MCP 集成的最佳选择。下一章我们将分析 Streamable HTTP 传输——当通信需要跨越网络边界时,事情会复杂得多。

基于 VitePress 构建