# 为什么保持 MCP stdout 干净能保护协议边界？

只有当 stdout 上的每个字节都属于协议，并且每个畸形或含义不明确的请求都会在到达操作执行器之前终止时，stdio MCP 边界才值得信任。解析器拒绝垃圾数据，却让一个只被部分理解的请求继续到达 HTTP 或 SSH，这就错过了最危险的环节。

团队经常把 stdout 噪声当成令人烦恼的互操作性问题。这种看法过于宽容。当代理能够触发外部操作时，额外的横幅可能让对话失去同步、掩盖错误响应，或诱使宽松的客户端把错误响应关联到错误请求。正确的测试不只是检查进程是否干净退出，还要证明畸形线路输入不会产生外部效果。

## stdout 契约：每行接受一条 JSON-RPC 消息

Model Context Protocol 的 stdio 传输要求 stdout 中包含 JSON-RPC 消息，消息之间用换行符分隔，且流中不能混入无关输出。反向规则也同样适用：客户端通过 stdin 发送协议消息，不能把这个通道当成日志管道。

这听起来很明显，直到包安装器打印通知、依赖项发出警告，或者有人在启动路径中临时加入一个 `print()`。在人类使用的终端里，这些行没有什么危害。在协议流中，它们却是对端必须解释的字节。对端无法安全判断 `loading credentials` 是横幅、格式错误的结果、响应片段，还是一次被入侵的交互刚刚开始。

传输契约包含三个部分，测试应明确写出：

- 每一行完整内容都必须能解码为 UTF-8，并解析为一个 JSON 值。
- 这个值必须符合当前传输方向允许的 JSON-RPC 请求、响应或通知形状。
- 完整请求通过分帧、JSON、协议、架构和授权检查之前，不能开始任何操作。

第一项用于捕获噪声。第二项用于捕获虽是有效 JSON、却不是 MCP 消息的对象。第三项保护的是最关键的地方：不能把早期解析当成执行许可。

JSON-RPC 2.0 规范将解析错误与无效请求区分开来。如果对端仍然能够写入有边界的响应，无效 JSON 可以返回错误代码 -32700。结构不符合请求要求的 JSON 值属于无效请求，通常使用 -32600。这些代码有助于符合规范的对端诊断故障，但无法告诉你自己的执行器是否始终没有被触碰。测试必须直接回答这个问题。

## 横幅可能破坏已获授权的响应

启动横幅可能在授权成功后破坏一个完全合法的操作。因此，stdout 干净不只是入站验证问题。

想象一个服务器已经接受 initialize 请求，正准备返回工具结果。某个依赖项在响应生命周期的开始和结束之间向 stdout 写入 `warning: configuration missing`。严格客户端无法解析这一行，于是拒绝并断开连接。宽松客户端则跳过它并继续。严格客户端失去可用性，宽松客户端的解析策略则开始接受安全敏感交互中的无主字节。

不要因为宽松客户端看起来方便，就奖励它。客户端一旦开始丢弃任意行，就会面对一长串它无法可靠回答的问题。它丢掉的是诊断信息吗？还是另一个请求的响应？包装器是否重复了一行？能够影响子进程的攻击者是否注入了改变客户端状态的文本？客户端无法从一串游离的字节中推断意图。

请将诊断信息保留在 stderr。为 stderr 设置独立的捕获、保留和脱敏规则，并让进程监管保留两个流的分离。一个非常常见、只在发布版本出现的故障，来自某个包装器将两个流合并，因为开发期间终端输出看起来更整齐。这个包装器会悄悄摧毁协议边界。

在任何客户端库规范化出站流量之前，先把它当作原始字节进行测试。如果库将无效序列转换成异常，并隐藏原始传输记录，就在失败测试的输出中保留这份记录。看到第一行确切的错误内容，可以省下数小时的猜测。

## 拒绝必须传达到操作执行器

只有在执行外部操作的代码完全看不到被拒绝的帧时，拒绝帧才是安全的。返回错误响应很有用，但它不是安全属性。

在最终验证和授权边界之后立即放置一个操作记录器。在真实实现中，这个接缝可能包裹着打开 HTTP 连接或调用 SSH helper 的函数。在测试中，可以使用内存记录器或本地模拟服务。不要把畸形输入测试指向真实端点，再把安全寄托在错误路径上。

这个区别很容易被忽略，因为正常请求要经过很长的路径。它先以字节形式到达，变成 JSON，变成 JSON-RPC 对象，变成 MCP 方法调用，与工具架构匹配，获得授权决定，最后才成为一次操作。开发者可能在这些检查全部完成前添加审计记录或构造请求对象。只有当这两项操作都不能接触外部世界或消耗权限时，这样做才可以接受。

一个有用的不变量是：执行器只接受类型完整且已获授权的操作对象，永远不接受原始 JSON，也不接受只完成部分验证的请求。如果执行器接受通用字典，最终一定会有人过早调用它。由于普通开发中很少出现畸形帧，代码可能连续数月通过正常路径测试。

将拒绝计数与执行计数分开。测试应该能够明确说明：解析器拒绝了一帧，会话已关闭，执行器收到的调用数为零。如果这些事件共用一个笼统的成功或失败计数器，你就无法区分干净的拒绝和启动后才失败的操作。

## 将测试接缝放在解析之后、执行之前

最小但有用的测试工具包含严格的线路验证器和虚假执行器。验证器负责字节和协议形状，虚假执行器记录每次执行工作的尝试。生产适配器可以不同，但两者之间的契约应保持简洁。

下面的 Python 示例刻意保持小巧。将它保存为 `test_stdio_boundary.py`，安装 pytest，然后运行 `pytest -q test_stdio_boundary.py`。将 `Gateway` 替换为你自己的网关适配器，同时保留围绕 `Recorder` 的断言。

```python
import io
import json
import pytest

class Recorder:
    def __init__(self):
        self.calls = []

    def execute(self, action):
        self.calls.append(action)
        return {'ok': True}

class Gateway:
    def __init__(self, executor):
        self.executor = executor
        self.closed = False

    def reject(self, code, reason):
        self.closed = True
        return {'jsonrpc': '2.0', 'id': None,
                'error': {'code': code, 'message': reason}}

    def receive_line(self, raw_line):
        if self.closed:
            return None
        try:
            message = json.loads(raw_line)
        except json.JSONDecodeError:
            return self.reject(-32700, 'parse error')

        if not isinstance(message, dict):
            return self.reject(-32600, 'invalid request')
        if message.get('jsonrpc') != '2.0':
            return self.reject(-32600, 'invalid request')
        if message.get('method') != 'tools/call':
            return self.reject(-32601, 'method not found')
        if not isinstance(message.get('id'), (str, int)) or isinstance(message.get('id'), bool):
            return self.reject(-32600, 'invalid request')

        params = message.get('params')
        if not isinstance(params, dict):
            return self.reject(-32602, 'invalid params')
        if not isinstance(params.get('name'), str):
            return self.reject(-32602, 'invalid params')
        if not isinstance(params.get('arguments', {}), dict):
            return self.reject(-32602, 'invalid params')

        action = {'name': params['name'], 'arguments': params.get('arguments', {})}
        result = self.executor.execute(action)
        return {'jsonrpc': '2.0', 'id': message['id'], 'result': result}

def frame(value):
    return json.dumps(value, separators=(',', ':')) + '\n'


def test_bad_lines_never_execute():
    bad_lines = [
        'debug: entering tool handler\n',
        '<html>gateway unavailable</html>\n',
        '{not json}\n',
        'null\n',
        frame({'jsonrpc': '1.0', 'id': 4, 'method': 'tools/call', 'params': {}}),
        frame({'jsonrpc': '2.0', 'id': 4, 'method': 'tools/call', 'params': 'run'}),
    ]

    for raw_line in bad_lines:
        recorder = Recorder()
        gateway = Gateway(recorder)
        response = gateway.receive_line(raw_line)
        assert response['jsonrpc'] == '2.0'
        assert 'error' in response
        assert gateway.closed
        assert recorder.calls == []

def test_complete_valid_request_executes_once():
    recorder = Recorder()
    gateway = Gateway(recorder)
    request = frame({
        'jsonrpc': '2.0',
        'id': 9,
        'method': 'tools/call',
        'params': {'name': 'safe-test', 'arguments': {'value': 'green'}},
    })

    response = gateway.receive_line(request)
    assert response['result'] == {'ok': True}
    assert recorder.calls == [{'name': 'safe-test', 'arguments': {'value': 'green'}}]
```

这不是完整的 MCP 实现，也不应该把它变成完整实现。它的目的，是让「不执行操作」这一属性可以通过代码验证。你的适配器可以将实际 stdin 字节送入同类测试接缝，并使用真实的请求验证器。不要将这个经过缩略的方法处理逻辑复制到生产服务器中。

请注意，这里选择在入站行畸形或无效后关闭连接。协议并不强制所有实现采用这种会话策略。当网关控制着外部副作用时，这是一种合理的默认设置，因为坏行可能意味着对端已经失去分帧能力。如果你在请求格式清晰但无效后保持会话开放，请单独测试这条路径，并证明下一个有效请求不会继承被拒绝请求的任何状态。

## 可运行的测试工具应该观察字节和效果

上面的单元测试检查了入站拒绝。再增加一个进程级测试，捕获那些出现在你通常测试路径之外的输出。下面的 helper 会检查捕获到的 stdout 传输记录，不依赖客户端库替你解析。

```python
import json
import subprocess

def assert_protocol_stdout(data):
    assert data.endswith(b'\n'), 'stdout ended without a complete frame'
    for number, raw_line in enumerate(data.splitlines(), start=1):
        try:
            line = raw_line.decode('utf-8')
            message = json.loads(line)
        except (UnicodeDecodeError, json.JSONDecodeError) as error:
            raise AssertionError(
                f'non-protocol stdout on line {number}: {raw_line!r}'
            ) from error

        assert isinstance(message, dict), f'line {number} is not an object'
        assert message.get('jsonrpc') == '2.0', f'line {number} lacks JSON-RPC 2.0'
        is_notification = isinstance(message.get('method'), str) and 'id' not in message
        is_response = 'id' in message and ('result' in message or 'error' in message)
        assert is_notification or is_response, f'line {number} has no permitted shape'

def run_server(command, stdin_bytes):
    completed = subprocess.run(
        command,
        input=stdin_bytes,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=False,
    )
    assert_protocol_stdout(completed.stdout)
    return completed

def test_release_command_has_clean_stdout():
    initialize = (
        b'{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",'
        b'\"params\":{\"protocolVersion\":\"2025-03-26\",'
        b'\"capabilities\":{},\"clientInfo\":{\"name\":\"boundary-test\",\"version\":\"1\"}}}\n'
    )
    completed = run_server(['./gateway-under-test'], initialize)
    assert completed.returncode == 0
```

请使用实际的启动命令替换 `./gateway-under-test`。如果它需要运行时、包包装器或环境变量，就使用生产环境中的确切条件。即使进程以非零状态退出，测试也必须检查 stdout。失败的进程仍可能在失败前输出非法横幅。

失败信息应该显示原始字节行。下面这样的输出就很有用：

```text
AssertionError: non-protocol stdout on line 1: b'loading optional extension\n'
```

这个结果会告诉维护者该去哪里查找。像 server did not initialize 这样的笼统消息，会让人去协议代码中寻找问题，而缺陷可能实际位于 shell 脚本、日志配置或依赖项导入中。

请以打包后的形式运行测试工具，而不只是从源码检出目录运行。打包会改变模块解析、环境变量、可执行权限和错误路径。这些恰恰是意外 stdout 写入最容易出现的地方。

## 测试难看的输入，而不是礼貌的错误

边界测试套件需要模拟真实进程流失败的方式，而不只是手写无效对象。让每个案例都经过处理 stdin 的同一个入口，并断言执行器仍为空。

至少使用以下案例：

- 在有效请求前放置一行普通调试信息，再在下一行发送有效请求。
- 有效请求后紧接着在同一行放置横幅，中间没有换行分隔符。
- JSON 对象被截断后遇到文件结束。
- 两个完整 JSON 对象直接拼接，中间没有分隔符。
- JSON 对象有效，方法看起来合理，但参数格式错误。

第一种情况可以捕获悄悄跳过坏行并继续运行的实现。第二种情况可以捕获分帧问题，因为逐行读取器可能会把它误认为一个损坏的请求。截断情况可以捕获这样的代码：对端已经离开后，代码仍试图通过缓冲区修复输入。拼接情况可以捕获这样的解析器配置：在传输规定每行一个帧时，却接受多个顶层 JSON 值。

不要把这套测试缩减成解析器样本集。为每个坏输入配上唯一的虚假操作名称，并验证记录器中没有任何一个名称出现。只有在你记录的策略允许会话保持开放时，才加入一个被拒绝请求之后的有效请求。如果你的策略是关闭会话，就断言第二个请求不会产生操作，因为会话已经关闭。

还要测试上下文中具有危险性的有效数据。将 `method` 设为 `tools/call`、将 `params` 设为字符串的请求虽然是有效 JSON，却不是一次调用。某些语言会把数字 id 当作布尔值处理，这种请求越过了你原本不打算跨越的类型边界。带有未知参数的请求可能会被架构拒绝，但宽松的对象合并器可能意外地将它们传入下游命令构造器。

## 启动噪声通常有普通原因

大多数 stdout 污染来自日常维护工作，而不是有人试图破坏协议。这并不会降低捕获它的必要性。

命令包装器可能打印版本通知。语言运行时可能在环境变化后写入弃用警告。开发者可能在测试未加载的包导入代码中留下调试语句。崩溃处理器可能因为原本为终端应用构建，而将友好消息打印到 stdout。监管程序可能为了收集日志而将 stderr 与 stdout 合并。

把每个来源都作为发布测试案例。设置会让依赖项输出详细日志的环境变量。在没有可选配置的目录中运行可执行文件。强制触发可恢复的启动失败。测试从闲置启动后收到的第一个请求。然后每次都检查原始传输记录。

不要为了让测试通过而关闭所有日志。那只会把运维问题转移到别处。将诊断信息路由到 stderr，为运维人员提供明确的收集方式，并在信息离开进程前脱敏敏感值。只要两个流保持分离，协议正确性和有用的诊断信息完全可以共存。

## 有效 JSON 不等于安全请求

严格的 JSON 解析器可以保护分帧，但它不会决定请求是否可以使用凭据或访问某个主机。

请按顺序处理这些决定。首先，传输读取一个帧。接着，解析器生成一个 JSON 值。然后，协议验证器确认 JSON-RPC 和 MCP 方法形状。架构验证器检查工具参数。只有完成这些阶段后，授权逻辑才应决定请求的操作是否可以运行。执行器应该收到一个带有授权决定的类型化操作，而不是原始方法名和参数字典。

这个顺序可以避免一个隐蔽的故障：验证仍在进行时就开始构造 HTTP 请求。如果后续检查拒绝了调用，但库已经解析主机、打开连接或展开命令模板，那么测试虽然可以报告拒绝，边界却已经泄漏了操作。虚假执行器测试可以发现明显的情况，本地虚假 HTTP 服务或 SSH 测试 helper 则可以在集成测试中发现意外网络活动。

被拒绝的请求仍然可以写入审计记录，但不要让审计记录变成调用操作层的旁路。将拒绝记录为带有原因的拒绝，并使用不会暴露秘密的请求指纹。将外部操作日志与从未通过授权的尝试调用分开。

## 一次意外的 print 可能掩盖危险故障

设想一个网关支持名为 `deploy-preview` 的工具。它的处理器验证部分参数，开始组装出站请求，然后检查调用者是否可以使用选定的凭据。重构期间，开发者添加了一条 stdout print，用来查看选定的目标。

严格的 MCP 客户端看到这条 print，无法将其解析为 JSON-RPC，于是断开连接。开发者看到协议失败并修复 print。这很不方便，但很安全。

宽松客户端跳过这一行，收到错误响应，并告诉运维人员请求被授权逻辑拒绝。与此同时，处理器在授权检查前，已经将组装好的请求交给了会自动重试的 HTTP helper。远程服务收到没有可用凭据的请求，可能返回错误，同时团队得到一份误导性的审计记录：看起来代理被拒绝了，但服务仍然看到了活动。

横幅没有制造授权顺序错误，它只是暴露了为什么宽松的传输恢复策略和过早构造操作会形成危险组合。解决办法不是设计更聪明的跳过规则，而是将授权决定移到请求构造之前，用记录器证明这一点，并让 stdout 保持严格，以便意外输出立即让测试失败。

## 将线路契约设为发布门槛

在每个打包网关命令旁边放置干净 stdout 测试，并在持续集成中运行。让失败信息包含第一段违规字节、启动命令，以及作为独立附件保存的 stderr。维护者应该能够复现失败，而不必重新构造一次代理对话。

将一小组畸形入站帧纳入版本控制。每次出现真实缺陷时，都添加一个案例。不要因为某个客户端发出了奇怪数据，就急于接受这种行为。如果某个实现发送畸形流量，请修复实现，或记录一个有版本控制的兼容边界，并确保该边界不允许执行操作。

Sallyport 通过捆绑的 `sp mcp` stdio shim 将 HTTP 和 SSH 执行置于其后，因此这套测试工具应该放在该 shim 的边界上，并断言被拒绝的帧无法释放外部调用。同样的纪律适用于任何能够执行文本返回以外操作的 MCP 服务器。

发布标准很明确：stdout 只能包含完整的协议消息，被拒绝的输入不能在操作记录器中留下任何记录。只要其中一条断言失败，构建就还没有准备好承载代理流量。
