给 MCP 做真实 stdio 契约回归

1. 先把可复现范围写清楚

一个工具在 Python 中返回了正确字典,距离客户端真正收到正确结果,还有进程启动、初始化、工具发现、消息编码和关闭几道边界。本篇用官方 Python MCP SDK 启动真实本地 stdio 子进程,把这些边界一起放进回归测试。实验不接模型、不使用 API key,也不访问真实 MCP 服务。

本文是 mcp==1.29.0 的冻结兼容性实验,不把它当作最新版本推荐。实测环境为 Linux、Python 3.12.14,协商结果是协议 2025-11-25。已有服务端入门篇使用另一套 SDK 版本;请将本篇文件放进独立目录,不要把两篇的导入和字段拼在一起。

2026 年 10 月 7 日,本篇使用环境中已安装的依赖完成真实子进程运行。没有重新安装依赖,因此下文的全新虚拟环境安装步骤属于读者复现流程,不能算作已经完成的干净安装验收。实际检查覆盖握手、schema、两种结果、失败分类、超时、显式取消和关闭;没有覆盖桌面宿主、HTTP、OAuth、Windows 进程回收或网络断连。

三个工具只使用合成数据。read_station 返回 ALPHA=7 或 BETA=0;MISSING 是合法输入但没有记录。station_note 返回固定文本。wait_sample 只执行异步等待,便于观察取消。服务端不写文件;客户端会在本实验目录生成日志,重复运行会覆盖这些证据文件。

2. 用独立预期约束真实通信

客户端与独立服务端通过stdin和stdout连接,预期与证据分开保存。
Tap 记录 SDK 已解析或待发送的消息,底层仍由官方 stdio transport 读写真实管道。

测试客户端不导入 server.py。工具名称、输入与输出 schema、样例输入、结果数值和错误码均写进 cases.json,由人维护。这避免测试直接调用服务端函数,也避免从实际返回值自动生成“正确答案”。

本例使用 low-level Server,schema 本来就是显式声明的字典,所以可以核对完整对象。换成自动生成 schema 的框架时,title、$defs 和引用位置可能改变;应先确定团队需要保护哪些语义,再决定是否做规范化。不要在失败之后随手刷新预期。

日志中的 messages.jsonl 是消息层记录,经过 SDK 的 Pydantic 序列化;它并非逐字节抓包,不能用来证明空格、编码或每个原始字节完全一致。Tap 不生成响应、不替换 SDK,也不绕过真实进程。若需要验证启动横幅污染,应另加独立故障用例,不能从本篇成功记录推断所有启动脚本都安全。

cases.json
{
  "note": "Handwritten expectations, independent of server.py; never regenerated from actual results.",
  "protocol": "2025-11-25",
  "tool_names": [
    "read_station",
    "station_note",
    "wait_sample"
  ],
  "input_schema": {
    "type": "object",
    "properties": {
      "station": {
        "type": "string",
        "enum": [
          "ALPHA",
          "BETA",
          "MISSING"
        ]
      }
    },
    "required": [
      "station"
    ],
    "additionalProperties": false
  },
  "successes": [
    {
      "input": {
        "station": "ALPHA"
      },
      "expected": {
        "station": "ALPHA",
        "reading": 7,
        "source": "synthetic-v1"
      }
    },
    {
      "input": {
        "station": "BETA"
      },
      "expected": {
        "station": "BETA",
        "reading": 0,
        "source": "synthetic-v1"
      }
    }
  ],
  "invalid_inputs": [
    {},
    {
      "station": 7
    },
    {
      "station": "alpha"
    },
    {
      "station": "ALPHA",
      "extra": true
    }
  ],
  "missing_input": {
    "station": "MISSING"
  },
  "missing_message": "No synthetic station record",
  "text_message": "Synthetic readings only",
  "protocol_error": -32601,
  "timeout_error": 408,
  "cancel_error": 0,
  "output_schema": {
    "type": "object",
    "properties": {
      "station": {
        "type": "string"
      },
      "reading": {
        "type": "integer"
      },
      "source": {
        "type": "string"
      }
    },
    "required": [
      "station",
      "reading",
      "source"
    ],
    "additionalProperties": false
  }
}

3. 准备依赖和目录

把下面公开给出的 server.py、probe.py、audit.py、cases.json 与 requirements.txt 放在同一目录。运行 probe.py 即会生成 evidence/。文章包含全部文件,不需要依赖另一个下载地址才能开始。

requirements.txt
mcp==1.29.0
pydantic==2.13.4
anyio==4.14.2
jsonschema==4.26.0
httpx==0.28.1

requirements.txt 固定本次运行中关注的五个包,不是所有传递依赖的完整锁文件。版本页可在PyPI 的 1.29.0 条目核对。需要长期保存可重复环境的团队,应在目标系统安装成功后生成自己的完整锁定记录,并记录 Python 与操作系统版本。

macOS / Linux · 在新建实验目录执行
python3.12 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python -m pip check
.venv/bin/python probe.py > run.log 2> client.stderr.log
.venv/bin/python audit.py > audit.log

Windows 可将解释器路径换为 .venv\Scripts\python.exe。脚本在非 POSIX 平台跳过 PID 存在性检查,并打印 SKIP;这不算完成 Windows 进程回收验收。若受运行环境限制无法启动子进程,应保留失败输出并停止该项,不要把语法检查或模拟对象测试算成 stdio 实测。

4. 服务端只保留实验需要的能力

这里使用 Server 而非高层装饰器框架,是为了让公开的工具 schema 和方法注册情况一眼可见。只注册 list_tools 和 call_tool,故意不提供资源接口。稍后 resources/list 的负例会据此得到方法未找到,而不是拿未知工具名猜测协议层行为。

正常查询直接返回字典,让该版 SDK 构造 structuredContent 和 JSON 文本内容。只有业务无记录时直接构造 isError=True 的 CallToolResult。该版 low-level handler 对显式 CallToolResult 有直接返回分支;本例不借这个分支跳过成功输出校验。行为依据见固定标签的服务端源码。

所有诊断写入 stderr,stdout 留给协议。只读注解是供客户端参考的声明,不是系统权限控制。代码没有真实数据入口、任意路径参数或外部 URL;它的只读范围来自实现本身。

server.py
"""Original synthetic, read-only stdio server. MCP SDK 1.29.0 only."""
import asyncio
import json
import os
import sys
from importlib.metadata import version

from mcp import types
from mcp.server.lowlevel import Server
from mcp.server.stdio import stdio_server

app = Server("contract-lab", version="1.0.0")
INPUT = {"type": "object", "properties": {
    "station": {"type": "string", "enum": ["ALPHA", "BETA", "MISSING"]}},
    "required": ["station"], "additionalProperties": False}
OUTPUT = {"type": "object", "properties": {
    "station": {"type": "string"}, "reading": {"type": "integer"},
    "source": {"type": "string"}},
    "required": ["station", "reading", "source"], "additionalProperties": False}


def diagnostic(event, **fields):
    print(json.dumps({"event": event, **fields}, sort_keys=True), file=sys.stderr, flush=True)


@app.list_tools()
async def list_tools():
    return [
        types.Tool(name="read_station", description="Read a synthetic station; no real sensors.",
                   inputSchema=INPUT, outputSchema=OUTPUT,
                   annotations=types.ToolAnnotations(readOnlyHint=True, openWorldHint=False)),
        types.Tool(name="station_note", description="Return a fixed synthetic text note.",
                   inputSchema={"type": "object", "properties": {}, "additionalProperties": False}),
        types.Tool(name="wait_sample", description="Wait without external effects for timeout tests.",
                   inputSchema={"type": "object", "properties": {
                       "seconds": {"type": "number", "minimum": 0, "maximum": 30}},
                       "required": ["seconds"], "additionalProperties": False}),
    ]


@app.call_tool()
async def call_tool(name, arguments):
    if name == "read_station":
        station = arguments["station"]
        if station == "MISSING":
            return types.CallToolResult(isError=True, content=[
                types.TextContent(type="text", text="No synthetic station record")])
        return {"station": station, "reading": {"ALPHA": 7, "BETA": 0}[station],
                "source": "synthetic-v1"}
    if name == "station_note":
        return [types.TextContent(type="text", text="Synthetic readings only")]
    if name == "wait_sample":
        request_id = app.request_context.request_id
        diagnostic("wait_started", request_id=request_id)
        try:
            await asyncio.sleep(arguments["seconds"])
        except asyncio.CancelledError:
            diagnostic("wait_cancelled", request_id=request_id)
            raise
        diagnostic("wait_finished", request_id=request_id)
        return [types.TextContent(type="text", text="Sample ready")]
    return types.CallToolResult(isError=True, content=[
        types.TextContent(type="text", text="Unknown synthetic tool")])


async def main():
    if version("mcp") != "1.29.0":
        raise SystemExit("This frozen lab requires mcp==1.29.0")
    diagnostic("server_started", pid=os.getpid())
    try:
        async with stdio_server() as (read, write):
            await app.run(read, write, app.create_initialization_options())
    finally:
        diagnostic("server_closed")


if __name__ == "__main__":
    asyncio.run(main())

5. 客户端逐项验收并保留证据

probe.py 通过当前 Python 的绝对解释器路径运行 server.py。env={} 传给 SDK,SDK 仍会合并自己的默认环境白名单,因此不能把它描述为完全空环境或系统级沙箱。客户端启动后显式执行 initialize,再发现工具。

初始化结果要核对版本、服务名和 tools 能力,同时检查 initialized 通知已发送。随后分别核对 ALPHA=7、BETA=0、文本结果和业务无记录。零值是成功;缺失记录才是本例的业务失败。客户端还用四组错误输入确认 schema 验证确实发生在真实调用路径上。

取消请求所用的 ID 从实际发出的消息中读取,没有依赖 SDK 私有计数器。发送取消之前,先等服务端的 wait_started 诊断,避免把“还没开始的请求”误当成“运行中被取消”。等待使用事件条件并设上限,而不是睡固定时间后猜测处理进度。

probe.py
"""Real SDK transport probe; no imports from the server and no fake SDK objects."""
import asyncio
from datetime import timedelta
from importlib.metadata import version
import json
import os
from pathlib import Path
import platform
import sys
import time

from mcp import ClientSession, StdioServerParameters, types
from mcp.client.stdio import stdio_client
from mcp.shared.exceptions import McpError

ROOT = Path(__file__).resolve().parent


class Tap:
    """SDK message-stream recorder with fail-fast parse errors; no fabricated messages."""
    def __init__(self, stream, direction, journal):
        self.stream, self.direction, self.journal = stream, direction, journal

    async def __aenter__(self):
        await self.stream.__aenter__()
        return self

    async def __aexit__(self, *args):
        return await self.stream.__aexit__(*args)

    def __aiter__(self):
        return self

    async def __anext__(self):
        item = await self.stream.__anext__()
        self.record(item)
        return item

    def record(self, item):
        if isinstance(item, Exception):
            raise item
        self.journal.append({"direction": self.direction,
                             "message": item.message.model_dump(mode="json", by_alias=True, exclude_none=True)})

    async def send(self, item):
        self.record(item)
        await self.stream.send(item)

    async def aclose(self):
        await self.stream.aclose()


def check(condition, label):
    if not condition:
        raise AssertionError(label)
    print("PASS " + label, flush=True)


def events(path):
    return [json.loads(line) for line in path.read_text().splitlines() if line.startswith('{"event":')]


async def wait_event(path, event, request_id, timeout=5):
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if any(e.get("event") == event and e.get("request_id") == request_id for e in events(path)):
            return
        await asyncio.sleep(0.02)
    raise AssertionError(f"Missing diagnostic {event} for {request_id}")


def last_call_id(journal):
    return next(e["message"]["id"] for e in reversed(journal)
                if e["direction"] == "client_to_server" and e["message"].get("method") == "tools/call")


async def main():
    check(version("mcp") == "1.29.0", "frozen SDK 1.29.0")
    print(json.dumps({"python": platform.python_version(), "platform": platform.system(),
                      "packages": {p: version(p) for p in ("mcp", "pydantic", "anyio", "jsonschema", "httpx")}}, sort_keys=True))
    fixture = json.loads((ROOT / "cases.json").read_text())
    # This probe writes only its own evidence files. The server does not write files.
    evidence = ROOT / "evidence"
    evidence.mkdir(exist_ok=True)
    stderr_path = evidence / "server.stderr.log"
    journal = []
    parameters = StdioServerParameters(command=sys.executable, args=[str(ROOT / "server.py")], env={})
    try:
        with stderr_path.open("w") as errlog:
            async with stdio_client(parameters, errlog=errlog) as (read, write):
                async with ClientSession(Tap(read, "server_to_client", journal),
                                         Tap(write, "client_to_server", journal),
                                         read_timeout_seconds=timedelta(seconds=5)) as session:
                    initialized = await session.initialize()
                    check(initialized.protocolVersion == fixture["protocol"], "initialize protocol version")
                    check(initialized.serverInfo.name == "contract-lab" and initialized.capabilities.tools is not None,
                          "initialize server identity and tools capability")
                    check(any(e["message"].get("method") == "notifications/initialized" for e in journal),
                          "initialized notification sent")
                    listed = await session.list_tools()
                    check(sorted(t.name for t in listed.tools) == fixture["tool_names"], "tool names")
                    tool = next(t for t in listed.tools if t.name == "read_station")
                    check(tool.inputSchema == fixture["input_schema"], "inputSchema exact authored contract")
                    check(tool.outputSchema == fixture["output_schema"] and tool.annotations.readOnlyHint is True, "outputSchema exact authored contract and readOnlyHint")
                    for case in fixture["successes"]:
                        result = await session.call_tool("read_station", case["input"])
                        check(result.isError is False and result.structuredContent == case["expected"],
                              "structured result " + case["input"]["station"])
                        check(len(result.content) == 1 and isinstance(result.content[0], types.TextContent)
                              and json.loads(result.content[0].text) == case["expected"],
                              "text JSON matches structured result " + case["input"]["station"])
                    note = await session.call_tool("station_note", {})
                    check(not note.isError and note.structuredContent is None and note.content[0].text == fixture["text_message"],
                          "text-only tool result")
                    missing = await session.call_tool("read_station", fixture["missing_input"])
                    check(missing.isError and missing.structuredContent is None
                          and missing.content[0].text == fixture["missing_message"], "business failure isError")
                    for index, invalid in enumerate(fixture["invalid_inputs"]):
                        result = await session.call_tool("read_station", invalid)
                        check(result.isError and result.content[0].text.startswith("Input validation error:"),
                              f"invalid input {index + 1} is tool error")
                    try:
                        await session.list_resources()
                    except McpError as exc:
                        check(exc.error.code == fixture["protocol_error"], "unregistered resources/list is protocol error -32601")
                    else:
                        raise AssertionError("Expected protocol error")
                    try:
                        await session.call_tool("wait_sample", {"seconds": 0.6},
                                                read_timeout_seconds=timedelta(seconds=0.1))
                    except McpError as exc:
                        check(exc.error.code == fixture["timeout_error"], "local read timeout is McpError 408")
                    else:
                        raise AssertionError("Expected read timeout")
                    timed_id = last_call_id(journal)
                    await wait_event(stderr_path, "wait_finished", timed_id)
                    check(not any(e["message"].get("method") == "notifications/cancelled" for e in journal),
                          "read timeout did not send cancellation")
                    check(not (await session.call_tool("station_note", {})).isError, "session usable after timed-out work finished")
                    previous_id = last_call_id(journal)
                    pending = asyncio.create_task(session.call_tool("wait_sample", {"seconds": 30}))
                    # Wait until this request appears on the SDK's outgoing stream, then until server entry.
                    deadline = time.monotonic() + 5
                    while last_call_id(journal) == previous_id:
                        if time.monotonic() >= deadline:
                            raise AssertionError("Cancellation request did not start")
                        await asyncio.sleep(0.01)
                    cancel_id = last_call_id(journal)
                    await wait_event(stderr_path, "wait_started", cancel_id)
                    await session.send_notification(types.ClientNotification(types.CancelledNotification(
                        params=types.CancelledNotificationParams(requestId=cancel_id, reason="synthetic contract test"))))
                    try:
                        await pending
                    except McpError as exc:
                        check(exc.error.code == fixture["cancel_error"] and exc.error.message == "Request cancelled",
                              "explicit cancellation returns SDK-specific error 0")
                    else:
                        raise AssertionError("Expected cancellation error")
                    await wait_event(stderr_path, "wait_cancelled", cancel_id)
                    check(not any(e.get("event") == "wait_finished" and e.get("request_id") == cancel_id for e in events(stderr_path)),
                          "cancelled handler did not finish normally")
                    check(not (await session.call_tool("station_note", {})).isError, "session usable after explicit cancellation")
    finally:
        (evidence / "messages.jsonl").write_text("".join(json.dumps(e, sort_keys=True) + "\n" for e in journal))
    records = events(stderr_path)
    pid = next(e["pid"] for e in records if e["event"] == "server_started")
    check(any(e["event"] == "server_closed" for e in records), "server reached closing finally block")
    if os.name == "posix":
        try:
            os.kill(pid, 0)  # Existence probe only; sends no signal.
        except ProcessLookupError:
            check(True, "child PID absent after transport context exit")
        else:
            raise AssertionError("Child PID still present, or PID reused")
    else:
        print("SKIP child PID existence probe: POSIX-only; context exit still checked")
    print(f"PASS saved {len(journal)} SDK message records; all contract checks completed")


if __name__ == "__main__":
    asyncio.run(main())

6. 三种失败要看各自的证据

业务和输入错误在result里,未注册方法返回error,超时408由客户端本地产生。
异常类名不足以说明错误来源。408 是本地超时,-32601 是收到的协议错误。

MISSING 输入合法,服务端返回 result,里面的 isError 为 true。schema 不匹配的四个输入,在这个固定版本的 low-level handler 中也变成工具错误结果。业务失败的完整消息由本例规定;参数错误只检查稳定前缀,不把 jsonschema 的整段措辞当成跨版本 API。

resources/list 请求是有效的协议方法,但这个实验没有注册相应处理器。实际响应包含 error.code=-32601,ClientSession 将它变成 McpError。这个测试不代表任何未知字符串方法都会得到同样错误;参数模型验证失败可能发生在更早阶段。客户端异常与服务端 dispatch 可分别对照client/session.py和上节服务端源码。

短等待调用设为 0.6 秒,读取超时设为 0.1 秒。客户端抛出的 McpError.code=408 是 SDK 本地构造的错误,并不是 stdio 服务端发回 HTTP 响应。messages.jsonl 中能看到同一请求稍后返回 Sample ready;server.stderr.log 也记录了 wait_finished。

7. 超时之后,处理器可能仍在工作

短等待超时后继续完成;长等待明确收到取消通知后终止,最后检查关闭。
两次等待请求分别检验读取超时和显式取消。示意图不使用真实耗时比例。

1.29.0 的 send_request 在超时时移除等待响应的本地登记,并关闭相应流;它没有在这个分支自动发送 notifications/cancelled。本实验明确检查没有发出该通知,再等待服务端正常完成。这是该版本的实际行为,不能推广为所有 MCP 客户端都如此。参考shared/session.py。

第二次等待设为 30 秒。客户端确认处理器已经开始,发送携带请求 ID 的取消通知,随后等待原调用结束。此版本返回 code=0、message=Request cancelled;服务端记录 wait_cancelled,并且没有 wait_finished。这个 code=0 是本实验固定 SDK 的实现结果,不应写成 MCP 规范规定的通用取消码。2025-11-25 规范建议接收方不回复被取消的请求,发送方忽略取消后到达的响应;本文刻意记录此 SDK 的实际行为,用于版本回归,不把它当成规范要求。

规范允许取消存在时序竞争:通知到达前,任务可能已经完成。取消也不能撤回已经发生的外部副作用。本例的异步等待没有外部副作用,且在开始后马上取消,所以能得到稳定的取消证据。生产系统仍需定义任务状态、幂等性与可查询结果,不能把超时或取消通知等同于“什么也没发生”。见2025-11-25 取消规范。

读取超时和显式取消这两项实验后,都再次调用 station_note。这个检查证明同一会话仍能继续工作,不能只看到预期异常就结束测试。它也不表示真实服务的所有后续请求都一定安全。

8. 关闭也要有检查点

客户端先退出 ClientSession,再退出 stdio_client 上下文。测试要求 server_closed 诊断存在,并在 Linux 上用 os.kill(pid, 0) 检查子进程已不存在;信号 0 只做存在性查询,不会向进程发送实际信号。若极短时间内 PID 被其他进程复用,会使检查保守失败,此时应进一步核对进程身份。

server_closed 表示服务端执行到了 finally,不单独证明进程已消失;PID 检查补上另一个观察点。反过来,仅发现 PID 不在也无法知道是否曾发生强制终止。本文验证的是这次正常退出路径,没有注入拒绝退出的进程,也没有验收 SIGTERM/SIGKILL 兜底分支。生命周期和 stdio 关闭顺序可对照对应协议版本的生命周期规范。

如果 probe.py 抛出异常,finally 仍会写下已收集的消息,帮助定位最后到达的阶段。不要在 CI 中用忽略退出码的命令掩盖错误;只有 probe.py 与 audit.py 都以 0 退出,本轮证据才算完整。

9. 再用独立脚本核对日志

audit.py 只使用标准库,不导入服务端和测试客户端。它从日志重新检查握手顺序、响应的 result/error 二选一、两个等待请求和取消目标,确认迟到结果真的经过 stdio。它无法从线上日志推导出本地 408 异常,因此把这一项明确留给 probe.py 的断言。

2026-10-20 修正:原审计先按响应 ID 建字典,重复响应会被后面的同 ID 记录覆盖;它也没有核对每个请求都有且只有一个响应。实测删除 ALPHA 响应、插入带 result 和 error 的重复响应后再保留合法响应、或追加孤立响应,旧脚本都错误地退出 0。下面先检查请求 ID 唯一、响应 ID 唯一以及两边 ID 集合一致,再建立索引;正常日志的独立检查从 24 项增加到 27 项。

这三项只约束本篇的有限夹具:客户端发请求,服务端发响应。它不要求并发响应与请求同序,也不是面向所有 MCP 通信的完整校验器。仍保留本版本被取消请求实际收到 code=0 响应的实验事实,不把“本例每个请求都有响应”推广成取消规范要求。

audit.py
"""Independent log audit; standard library only, no server or probe imports."""
import json
from pathlib import Path

ROOT = Path(__file__).resolve().parent
rows = [json.loads(s) for s in (ROOT / "evidence/messages.jsonl").read_text().splitlines()]
events = [json.loads(s) for s in (ROOT / "evidence/server.stderr.log").read_text().splitlines()
          if s.startswith('{"event":')]
calls = [r["message"] for r in rows if r["direction"] == "client_to_server"]
responses = [r["message"] for r in rows if r["direction"] == "server_to_client"]


def require(value, description):
    if not value:
        raise AssertionError(description)
    print("PASS audit " + description)


# This frozen fixture has client requests and server responses only.
# Check list multiplicity before converting responses to a dictionary.
request_ids = [m["id"] for m in calls if "id" in m]
response_ids = [m["id"] for m in responses]
require(len(set(request_ids)) == len(request_ids), "distinct request IDs")
require(len(set(response_ids)) == len(response_ids), "distinct response IDs")
require(set(response_ids) == set(request_ids), "response IDs match every request exactly")
replies = {m["id"]: m for m in responses}


require([m.get("method") for m in calls[:3]] ==
        ["initialize", "notifications/initialized", "tools/list"], "handshake order")
for response in replies.values():
    require(("result" in response) != ("error" in response), f"response {response['id']} has result XOR error")
waits = [m for m in calls if m.get("params", {}).get("name") == "wait_sample"]
require(len(waits) == 2, "two independent wait requests")
short = next(m["id"] for m in waits if m["params"]["arguments"]["seconds"] == 0.6)
long = next(m["id"] for m in waits if m["params"]["arguments"]["seconds"] == 30)
cancellations = [m["params"]["requestId"] for m in calls if m.get("method") == "notifications/cancelled"]
require(cancellations == [long], "only long request explicitly cancelled")
require(replies[short]["result"]["content"][0]["text"] == "Sample ready", "late short result arrived over stdio")
require(replies[long]["error"] == {"code": 0, "message": "Request cancelled"}, "long request error matches frozen SDK")
require(any(e.get("event") == "wait_finished" and e.get("request_id") == short for e in events), "short handler completed")
require(any(e.get("event") == "wait_cancelled" and e.get("request_id") == long for e in events), "long handler observed cancellation")
require(not any(e.get("event") == "wait_finished" and e.get("request_id") == long for e in events), "long handler did not complete")
require(events[-1]["event"] == "server_closed", "server close diagnostic present")
print("Audit completed; local timeout 408 is checked by probe.py, not inferred from a wire response")

把下面的 audit_regression.py 和前文文件放在同一目录,先运行 probe.py 生成真实证据,再运行此脚本。它接受未改动日志,并在临时副本里分别删除响应、插入被覆盖的坏重复响应、追加孤立响应及重复请求。每个负例都必须以 1 退出,并以指定 AssertionError 文本结束;语法错误、导入错误或其他崩溃不能冒充预期拒绝。原 evidence/ 不被修改。

audit_regression.py
"""Test audit acceptance and four precise log corruptions; stdlib only."""
import copy
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile

ROOT = Path(__file__).resolve().parent
rows = [json.loads(line) for line in
        (ROOT / "evidence/messages.jsonl").read_text().splitlines()]
request = next(row for row in rows if row["direction"] == "client_to_server"
               and row["message"].get("method") == "tools/call"
               and row["message"].get("params") == {
                   "name": "read_station", "arguments": {"station": "ALPHA"}})
response = next(row for row in rows if row["direction"] == "server_to_client"
                and row["message"]["id"] == request["message"]["id"])
position = rows.index(response)
bad_duplicate = copy.deepcopy(response)
bad_duplicate["message"]["error"] = {"code": -32603, "message": "synthetic duplicate"}
orphan = copy.deepcopy(response)
orphan["message"]["id"] = "audit-orphan"
if any(row["message"].get("id") == "audit-orphan" for row in rows):
    raise AssertionError("Orphan fixture ID already exists")
cases = [
    ("baseline", rows, None),
    ("missing_reply", [row for row in rows if row is not response],
     "response IDs match every request exactly"),
    ("duplicate_hides_bad_response", rows[:position] + [bad_duplicate] + rows[position:],
     "distinct response IDs"),
    ("orphan_reply", rows + [orphan], "response IDs match every request exactly"),
    ("duplicate_request", rows + [copy.deepcopy(request)], "distinct request IDs"),
]
flags = ["-B"] + (["-O"] if sys.flags.optimize else [])
for name, changed, expected_error in cases:
    with tempfile.TemporaryDirectory(prefix="stdio-audit-check-") as directory:
        folder = Path(directory)
        (folder / "evidence").mkdir()
        shutil.copyfile(ROOT / "audit.py", folder / "audit.py")
        shutil.copyfile(ROOT / "evidence/server.stderr.log",
                        folder / "evidence/server.stderr.log")
        (folder / "evidence/messages.jsonl").write_text(
            "".join(json.dumps(row) + "\n" for row in changed))
        result = subprocess.run([sys.executable, *flags, str(folder / "audit.py")],
                                capture_output=True, text=True, timeout=10)
        if expected_error is None:
            if result.returncode != 0 or result.stderr or result.stdout.count("PASS audit ") != 27:
                raise AssertionError((name, result.returncode, result.stdout, result.stderr))
        elif result.returncode != 1 or result.stderr.splitlines()[-1:] != [
                "AssertionError: " + expected_error]:
            raise AssertionError((name, result.returncode, result.stderr))
    print("PASS audit-regression " + name)
print("PASS: baseline accepted; 4 precise evidence corruptions rejected")
在原始实验目录执行
python -B audit_regression.py
python -B -O audit_regression.py

2026-10-20 在上述已安装依赖的 Linux 环境实跑:正常模式和 -O 模式均接受基线并拒绝四种指定的证据破坏,完整输出一致。此测试针对日志审计误放行,不代表已经测试网络损坏、真实宿主或新的传输故障。

audit-regression.log
PASS audit-regression baseline
PASS audit-regression missing_reply
PASS audit-regression duplicate_hides_bad_response
PASS audit-regression orphan_reply
PASS audit-regression duplicate_request
PASS: baseline accepted; 4 precise evidence corruptions rejected

10. 本次实际运行记录

原 probe.py 的 26 项断言和 32 条 SDK 消息不变,最后一行是汇总,不另算一项测试。2026-10-20 补强后的 audit.py 完成 27 项日志检查;当天在相同固定版本的已安装依赖环境重跑主探针与审计,正常模式和 -O 模式均以 0 退出,完整 stdout 一致且客户端 stderr 为空。下面更新审计输出;原探针输出、历史服务端 PID 示例和完整消息层日志仍保留。

run.log
PASS frozen SDK 1.29.0
{"packages": {"anyio": "4.14.2", "httpx": "0.28.1", "jsonschema": "4.26.0", "mcp": "1.29.0", "pydantic": "2.13.4"}, "platform": "Linux", "python": "3.12.14"}
PASS initialize protocol version
PASS initialize server identity and tools capability
PASS initialized notification sent
PASS tool names
PASS inputSchema exact authored contract
PASS outputSchema exact authored contract and readOnlyHint
PASS structured result ALPHA
PASS text JSON matches structured result ALPHA
PASS structured result BETA
PASS text JSON matches structured result BETA
PASS text-only tool result
PASS business failure isError
PASS invalid input 1 is tool error
PASS invalid input 2 is tool error
PASS invalid input 3 is tool error
PASS invalid input 4 is tool error
PASS unregistered resources/list is protocol error -32601
PASS local read timeout is McpError 408
PASS read timeout did not send cancellation
PASS session usable after timed-out work finished
PASS explicit cancellation returns SDK-specific error 0
PASS cancelled handler did not finish normally
PASS session usable after explicit cancellation
PASS server reached closing finally block
PASS child PID absent after transport context exit
PASS saved 32 SDK message records; all contract checks completed
audit.log
PASS audit distinct request IDs
PASS audit distinct response IDs
PASS audit response IDs match every request exactly
PASS audit handshake order
PASS audit response 0 has result XOR error
PASS audit response 1 has result XOR error
PASS audit response 2 has result XOR error
PASS audit response 3 has result XOR error
PASS audit response 4 has result XOR error
PASS audit response 5 has result XOR error
PASS audit response 6 has result XOR error
PASS audit response 7 has result XOR error
PASS audit response 8 has result XOR error
PASS audit response 9 has result XOR error
PASS audit response 10 has result XOR error
PASS audit response 11 has result XOR error
PASS audit response 12 has result XOR error
PASS audit response 13 has result XOR error
PASS audit response 14 has result XOR error
PASS audit two independent wait requests
PASS audit only long request explicitly cancelled
PASS audit late short result arrived over stdio
PASS audit long request error matches frozen SDK
PASS audit short handler completed
PASS audit long handler observed cancellation
PASS audit long handler did not complete
PASS audit server close diagnostic present
Audit completed; local timeout 408 is checked by probe.py, not inferred from a wire response

服务端诊断中的 PID 和消息里的请求 ID 是一次运行的观测值;重新运行时 PID 可以不同。测试按实际发出的消息找到等待请求,没有把示例 ID 写进判断。

evidence/server.stderr.log
{"event": "server_started", "pid": 9}
{"event": "wait_started", "request_id": 11}
{"event": "wait_finished", "request_id": 11}
{"event": "wait_started", "request_id": 13}
{"event": "wait_cancelled", "request_id": 13}
{"event": "server_closed"}
展开本次完整消息层日志
evidence/messages.jsonl
{"direction": "client_to_server", "message": {"id": 0, "jsonrpc": "2.0", "method": "initialize", "params": {"capabilities": {}, "clientInfo": {"name": "mcp", "version": "0.1.0"}, "protocolVersion": "2025-11-25"}}}
{"direction": "server_to_client", "message": {"id": 0, "jsonrpc": "2.0", "result": {"capabilities": {"experimental": {}, "tools": {"listChanged": false}}, "protocolVersion": "2025-11-25", "serverInfo": {"name": "contract-lab", "version": "1.0.0"}}}}
{"direction": "client_to_server", "message": {"jsonrpc": "2.0", "method": "notifications/initialized"}}
{"direction": "client_to_server", "message": {"id": 1, "jsonrpc": "2.0", "method": "tools/list"}}
{"direction": "server_to_client", "message": {"id": 1, "jsonrpc": "2.0", "result": {"tools": [{"annotations": {"openWorldHint": false, "readOnlyHint": true}, "description": "Read a synthetic station; no real sensors.", "inputSchema": {"additionalProperties": false, "properties": {"station": {"enum": ["ALPHA", "BETA", "MISSING"], "type": "string"}}, "required": ["station"], "type": "object"}, "name": "read_station", "outputSchema": {"additionalProperties": false, "properties": {"reading": {"type": "integer"}, "source": {"type": "string"}, "station": {"type": "string"}}, "required": ["station", "reading", "source"], "type": "object"}}, {"description": "Return a fixed synthetic text note.", "inputSchema": {"additionalProperties": false, "properties": {}, "type": "object"}, "name": "station_note"}, {"description": "Wait without external effects for timeout tests.", "inputSchema": {"additionalProperties": false, "properties": {"seconds": {"maximum": 30, "minimum": 0, "type": "number"}}, "required": ["seconds"], "type": "object"}, "name": "wait_sample"}]}}}
{"direction": "client_to_server", "message": {"id": 2, "jsonrpc": "2.0", "method": "tools/call", "params": {"arguments": {"station": "ALPHA"}, "name": "read_station"}}}
{"direction": "server_to_client", "message": {"id": 2, "jsonrpc": "2.0", "result": {"content": [{"text": "{\n  \"station\": \"ALPHA\",\n  \"reading\": 7,\n  \"source\": \"synthetic-v1\"\n}", "type": "text"}], "isError": false, "structuredContent": {"reading": 7, "source": "synthetic-v1", "station": "ALPHA"}}}}
{"direction": "client_to_server", "message": {"id": 3, "jsonrpc": "2.0", "method": "tools/call", "params": {"arguments": {"station": "BETA"}, "name": "read_station"}}}
{"direction": "server_to_client", "message": {"id": 3, "jsonrpc": "2.0", "result": {"content": [{"text": "{\n  \"station\": \"BETA\",\n  \"reading\": 0,\n  \"source\": \"synthetic-v1\"\n}", "type": "text"}], "isError": false, "structuredContent": {"reading": 0, "source": "synthetic-v1", "station": "BETA"}}}}
{"direction": "client_to_server", "message": {"id": 4, "jsonrpc": "2.0", "method": "tools/call", "params": {"arguments": {}, "name": "station_note"}}}
{"direction": "server_to_client", "message": {"id": 4, "jsonrpc": "2.0", "result": {"content": [{"text": "Synthetic readings only", "type": "text"}], "isError": false}}}
{"direction": "client_to_server", "message": {"id": 5, "jsonrpc": "2.0", "method": "tools/call", "params": {"arguments": {"station": "MISSING"}, "name": "read_station"}}}
{"direction": "server_to_client", "message": {"id": 5, "jsonrpc": "2.0", "result": {"content": [{"text": "No synthetic station record", "type": "text"}], "isError": true}}}
{"direction": "client_to_server", "message": {"id": 6, "jsonrpc": "2.0", "method": "tools/call", "params": {"arguments": {}, "name": "read_station"}}}
{"direction": "server_to_client", "message": {"id": 6, "jsonrpc": "2.0", "result": {"content": [{"text": "Input validation error: 'station' is a required property", "type": "text"}], "isError": true}}}
{"direction": "client_to_server", "message": {"id": 7, "jsonrpc": "2.0", "method": "tools/call", "params": {"arguments": {"station": 7}, "name": "read_station"}}}
{"direction": "server_to_client", "message": {"id": 7, "jsonrpc": "2.0", "result": {"content": [{"text": "Input validation error: 7 is not of type 'string'", "type": "text"}], "isError": true}}}
{"direction": "client_to_server", "message": {"id": 8, "jsonrpc": "2.0", "method": "tools/call", "params": {"arguments": {"station": "alpha"}, "name": "read_station"}}}
{"direction": "server_to_client", "message": {"id": 8, "jsonrpc": "2.0", "result": {"content": [{"text": "Input validation error: 'alpha' is not one of ['ALPHA', 'BETA', 'MISSING']", "type": "text"}], "isError": true}}}
{"direction": "client_to_server", "message": {"id": 9, "jsonrpc": "2.0", "method": "tools/call", "params": {"arguments": {"extra": true, "station": "ALPHA"}, "name": "read_station"}}}
{"direction": "server_to_client", "message": {"id": 9, "jsonrpc": "2.0", "result": {"content": [{"text": "Input validation error: Additional properties are not allowed ('extra' was unexpected)", "type": "text"}], "isError": true}}}
{"direction": "client_to_server", "message": {"id": 10, "jsonrpc": "2.0", "method": "resources/list"}}
{"direction": "server_to_client", "message": {"error": {"code": -32601, "message": "Method not found"}, "id": 10, "jsonrpc": "2.0"}}
{"direction": "client_to_server", "message": {"id": 11, "jsonrpc": "2.0", "method": "tools/call", "params": {"arguments": {"seconds": 0.6}, "name": "wait_sample"}}}
{"direction": "server_to_client", "message": {"id": 11, "jsonrpc": "2.0", "result": {"content": [{"text": "Sample ready", "type": "text"}], "isError": false}}}
{"direction": "client_to_server", "message": {"id": 12, "jsonrpc": "2.0", "method": "tools/call", "params": {"arguments": {}, "name": "station_note"}}}
{"direction": "server_to_client", "message": {"id": 12, "jsonrpc": "2.0", "result": {"content": [{"text": "Synthetic readings only", "type": "text"}], "isError": false}}}
{"direction": "client_to_server", "message": {"id": 13, "jsonrpc": "2.0", "method": "tools/call", "params": {"arguments": {"seconds": 30}, "name": "wait_sample"}}}
{"direction": "client_to_server", "message": {"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"reason": "synthetic contract test", "requestId": 13}}}
{"direction": "server_to_client", "message": {"error": {"code": 0, "message": "Request cancelled"}, "id": 13, "jsonrpc": "2.0"}}
{"direction": "client_to_server", "message": {"id": 14, "jsonrpc": "2.0", "method": "tools/call", "params": {"arguments": {}, "name": "station_note"}}}
{"direction": "server_to_client", "message": {"id": 14, "jsonrpc": "2.0", "result": {"content": [{"text": "Synthetic readings only", "type": "text"}], "isError": false}}}

11. 把这组回归带回自己的项目

先替换合成业务数据和相应的手写预期,保留真实子进程路径。增加新工具时,同时检查 tools/list 的变化、有效输入、拒绝输入、输出 schema 和失败位置。升级 SDK 时先运行原预期;若出现差异,找到对应版本源码和发布说明,再决定修改服务端还是有意更新契约。

不要把依赖版本、握手协议版本和业务服务版本混为一谈。本例分别是 1.29.0、2025-11-25 和 1.0.0。初始化消息中 SDK 默认 clientInfo.version 也不一定等于安装包版本;依赖版本以 importlib.metadata 的运行记录为准。

如果只有真实宿主中出错,应继续检查宿主实际使用的解释器、工作目录、权限与工具调用记录。本篇通过的是一个本地官方 SDK 客户端,不等于 Codex 或任意桌面应用已接入成功。也不能用这些 stdio 结果替代远程身份认证或租户隔离测试。

12. 补充:工具返回 7,启动流仍可能已经出错

前文验收了握手、工具结果、超时和取消,启动时 stdout 被普通日志污染这一项还需要独立输入。原 probe.py 的 Tap 已经在收到 Exception 项时抛出异常;本补充保留它,不把已有代码说成“忽略解析错误”。下面另建一份实验,比较只记录异常的 observe 模式与检查已观察错误的 gate 模式,不更换原来的 26 项断言。

固定环境仍是 Linux、Python 3.12.14、mcp 1.29.0、Pydantic 2.13.4、AnyIO 4.14.2,协商协议 2025-11-25。合成服务在启动时先输出一行 banner,再正常运行。文本 FIXTURE READY 不是 JSON;另一行 {"level":"info","message":"fixture ready"} 虽然是合法 JSON,却没有 JSON-RPC 消息封套。两种输入都由本次 SDK 解析为一个 ValidationError。

实测 observe 模式仍完成初始化并收到工具文本 7。单独用没有包装器的 ClientSession 复跑这两种输入,也得到相同结果。因此只看工具返回值,不能判断启动 stdout 是否符合协议。2025-11-25 传输规范要求 stdout 只承载有效 MCP 消息,允许在 stderr 写日志;stderr 有内容本身不等于失败。

这是规范与所测客户端处理策略的区别。v1.29.0 的 stdio 解析器在解析失败后记录诊断,将异常作为流中的一个项交出,再继续读下一行。基础会话接收循环把这种项交给 incoming handler;客户端默认 handler没有在这里把会话判为失败。本次复现不代表其他版本或其他宿主也这样处理。

stdout 启动 banner 产生解析异常;观察模式仍得到七,已观察错误闸门则不发送工具调用
图 4:初始化、启动流验收与工具执行是不同的检查点。原创合成实验示意。

13. 先把原始字节、服务端日志和客户端诊断分开

在新的实验目录下建立 source/、expected/ 两个子目录。不要覆盖前文的同名 server.py、probe.py。下面三份完整程序组成新的链路:官方 stdio_client → 字节观察器 relay.py → 固定合成服务 server.py。只有本实验程序会写输出目录;工具 read_answer 只返回固定文本 7,不访问真实文件、账户或网络。

source/server.py
"""Fixed, synthetic read-only MCP server. No network, account or model."""
import asyncio
import json
import sys
from mcp import types
from mcp.server.lowlevel import Server
from mcp.server.stdio import stdio_server

MODE = sys.argv[1]
if MODE == 'text':
    print('FIXTURE READY', flush=True)
elif MODE == 'json':
    print('{"level":"info","message":"fixture ready"}', flush=True)
elif MODE == 'stderr':
    print('FIXTURE READY', file=sys.stderr, flush=True)
elif MODE != 'none':
    raise ValueError('unknown fixture mode')

server = Server('startup-stream-fixture', version='1')

@server.list_tools()
async def list_tools():
    return [types.Tool(name='read_answer', inputSchema={'type':'object', 'properties':{}})]

@server.call_tool()
async def call_tool(name, arguments):
    if name != 'read_answer':
        raise ValueError('unknown fixture tool')
    return [types.TextContent(type='text', text='7')]

async def main():
    async with stdio_server() as (read, write):
        await server.run(read, write, server.create_initialization_options())

if __name__ == '__main__':
    asyncio.run(main())
source/relay.py
"""Byte observer for this one finite fixture, NOT a general MCP proxy."""
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
ROOT = Path(__file__).resolve().parent
mode, output = (sys.argv[1], Path(sys.argv[2]))
if not mode in ('none', 'text', 'json', 'stderr'):
    raise AssertionError("Fixture invariant: mode in ('none', 'text', 'json', 'stderr')")
if not output.is_dir():
    raise AssertionError('Fixture invariant: output.is_dir()')
child = subprocess.Popen([sys.executable, '-u', str(ROOT / 'server.py'), mode], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0)
errors = []

def write_all(fd, chunk):
    while chunk:
        count = os.write(fd, chunk)
        if count <= 0:
            raise OSError('zero-byte pipe write')
        chunk = chunk[count:]

def pump(source, destination, filename, close=None):
    try:
        with (output / filename).open('wb') as raw:
            while True:
                chunk = os.read(source, 4096)
                if not chunk:
                    break
                raw.write(chunk)
                raw.flush()
                write_all(destination, chunk)
    except BaseException as exc:
        errors.append({'stream': filename, 'error': type(exc).__name__})
    finally:
        if close is not None:
            close.close()
if not (child.stdin and child.stdout and child.stderr):
    raise AssertionError('Fixture invariant: child.stdin and child.stdout and child.stderr')
incoming = threading.Thread(target=pump, args=(0, child.stdin.fileno(), 'client-stdin.bin', child.stdin))
diagnostic = threading.Thread(target=pump, args=(child.stderr.fileno(), 2, 'server-stderr.bin'))
incoming.start()
diagnostic.start()
pump(child.stdout.fileno(), 1, 'server-stdout.bin')
returncode = child.wait()
incoming.join()
diagnostic.join()
child.stdout.close()
child.stderr.close()
(output / 'relay-result.json').write_text(json.dumps({'server_returncode': returncode, 'relay_errors': errors, 'threads_finished': not incoming.is_alive() and (not diagnostic.is_alive())}, indent=2) + '\n')
sys.exit(returncode if returncode else int(bool(errors)))
source/probe.py
"""Official SDK stdio transport; message observer leaves all items unchanged."""
from datetime import timedelta
from importlib.metadata import version
import json
import logging
from pathlib import Path
import platform
import sys
import anyio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.shared.exceptions import McpError
ROOT = Path(__file__).resolve().parent

class DiagnosticFormatter(logging.Formatter):
    """Keep the entire SDK error text, without machine-specific stack paths."""

    def formatException(self, exc_info):
        return f'{exc_info[0].__name__}: {exc_info[1]}'

class ObserveRead:

    def __init__(self, stream):
        self.stream = stream
        self.errors = []
        self.events = []

    async def __aenter__(self):
        await self.stream.__aenter__()
        return self

    async def __aexit__(self, *args):
        return await self.stream.__aexit__(*args)

    def __aiter__(self):
        return self

    async def __anext__(self):
        item = await self.stream.__anext__()
        if isinstance(item, Exception):
            self.errors.append(type(item).__name__)
            self.events.append('parse_error:' + type(item).__name__)
        else:
            message = item.message.model_dump(mode='json', by_alias=True, exclude_none=True)
            self.events.append({'message': message})
        return item

    async def aclose(self):
        await self.stream.aclose()

async def run_case(name, folder):
    condition, policy = name.rsplit('_', 1)
    mode = 'none' if condition == 'rpc_error' else condition
    error_code, answer, outcome = (None, None, 'startup_stream_rejected')
    handler = logging.FileHandler(folder / 'client-sdk.log', encoding='utf-8')
    handler.setFormatter(DiagnosticFormatter())
    logging.getLogger().addHandler(handler)
    try:
        parameters = StdioServerParameters(command=sys.executable, args=['-u', str(ROOT / 'relay.py'), mode, str(folder)], cwd=str(ROOT))
        with (folder / 'relay-stderr.log').open('w') as diagnostics:
            async with stdio_client(parameters, errlog=diagnostics) as (read, write):
                observer = ObserveRead(read)
                async with ClientSession(observer, write, read_timeout_seconds=timedelta(seconds=5)) as session:
                    initialized = await session.initialize()
                    if not initialized.protocolVersion == '2025-11-25':
                        raise AssertionError("Fixture invariant: initialized.protocolVersion == '2025-11-25'")
                    if not initialized.serverInfo.name == 'startup-stream-fixture':
                        raise AssertionError("Fixture invariant: initialized.serverInfo.name == 'startup-stream-fixture'")
                    (folder / 'startup-observation.json').write_text(json.dumps({'initialize_completed': True, 'events': observer.events.copy(), 'errors': observer.errors.copy()}, indent=2) + '\n')
                    if policy != 'gate' or not observer.errors:
                        if condition == 'rpc_error':
                            try:
                                await session.list_resources()
                            except McpError as exc:
                                error_code = exc.error.code
                            else:
                                raise AssertionError('Expected Method not found')
                        result = await session.call_tool('read_answer', {})
                        if not (result.isError is False and len(result.content) == 1):
                            raise AssertionError('Fixture invariant: result.isError is False and len(result.content) == 1')
                        answer = result.content[0].text
                        outcome = 'tool_returned'
    finally:
        logging.getLogger().removeHandler(handler)
        handler.close()
    sent = [json.loads(row) for row in (folder / 'client-stdin.bin').read_bytes().split(b'\n') if row]
    actual = {'errors': len(observer.errors), 'tool_calls': sum((row.get('method') == 'tools/call' for row in sent)), 'answer': answer, 'outcome': outcome, 'protocol_error': error_code}
    cleanup = json.loads((folder / 'relay-result.json').read_text())
    if not cleanup == {'server_returncode': 0, 'relay_errors': [], 'threads_finished': True}:
        raise AssertionError("Fixture invariant: cleanup == {'server_returncode': 0, 'relay_errors': [], 'threads_finished': True}")
    (folder / 'observed.json').write_text(json.dumps(actual, indent=2) + '\n')
    return actual

async def main():
    versions = {'python': platform.python_version(), **{x: version(x) for x in ['mcp', 'pydantic', 'anyio']}}
    if not versions == {'python': '3.12.14', 'mcp': '1.29.0', 'pydantic': '2.13.4', 'anyio': '4.14.2'}:
        raise AssertionError(versions)
    expected = json.loads((ROOT.parent / 'expected/cases.json').read_text())
    output = Path(sys.argv[1]).resolve()
    output.mkdir(exist_ok=False)
    results = {}
    for name, wanted in expected.items():
        folder = output / name
        folder.mkdir()
        with anyio.fail_after(15):
            actual = await run_case(name, folder)
        if not actual == wanted:
            raise AssertionError((name, actual, wanted))
        results[name] = actual
    (output / 'results.json').write_text(json.dumps({'versions': versions, 'cases': results}, indent=2) + '\n')
    print(json.dumps({'passed': len(results), 'sdk': '1.29.0', 'protocol': '2025-11-25'}, sort_keys=True))
if __name__ == '__main__':
    anyio.run(main)

relay.py 不解析、删除或改写消息。它在三个独立方向保存字节并转发,write_all 处理一次写入未写完的情况;stdin 结束后关闭子进程输入,等待 child.wait(),再汇合本例的有限泵线程。server_returncode 是 wait() 实际取得的子进程退出码,不是根据“看起来结束”猜测。正常用例要求它为 0、relay_errors 为空、线程已结束。

client-stdin.bin 保存发给服务的原始字节,server-stdout.bin 保存服务发出的原始字节,server-stderr.bin 保存服务日志。relay-stderr.log 是交给官方客户端 errlog 的转发副本;client-sdk.log 才是客户端解析器的诊断。后者使用显式日志格式器,保留异常类型和完整错误文字,省略机器相关调用栈路径,所以不能把它当作原始 stderr 字节。

字节观察器只适用于这份固定、有限、可信的夹具。没有为任意子进程、无限输出、磁盘写失败、管道背压或挂起线程建立完备回收策略;超时只是让本实验暴露卡住,不是生产级取消实现。不要把它当作可部署的 MCP 代理。

14. 十项固定预期,分别约束污染、控制和协议错误

五种条件分别运行 observe、gate,共十项真实 stdio 用例。none 是干净控制;stderr 把同一行文本只写到 stderr;text 与 json 把两种非法 MCP banner 写到 stdout;rpc_error 则保持 stdout 干净,在初始化后调用未注册的 resources/list,收到合法 JSON-RPC error 响应,再继续调用 read_answer。

expected/cases.json
{
  "none_observe":{"errors":0,"tool_calls":1,"answer":"7","outcome":"tool_returned","protocol_error":null},
  "none_gate":{"errors":0,"tool_calls":1,"answer":"7","outcome":"tool_returned","protocol_error":null},
  "stderr_observe":{"errors":0,"tool_calls":1,"answer":"7","outcome":"tool_returned","protocol_error":null},
  "stderr_gate":{"errors":0,"tool_calls":1,"answer":"7","outcome":"tool_returned","protocol_error":null},
  "text_observe":{"errors":1,"tool_calls":1,"answer":"7","outcome":"tool_returned","protocol_error":null},
  "text_gate":{"errors":1,"tool_calls":0,"answer":null,"outcome":"startup_stream_rejected","protocol_error":null},
  "json_observe":{"errors":1,"tool_calls":1,"answer":"7","outcome":"tool_returned","protocol_error":null},
  "json_gate":{"errors":1,"tool_calls":0,"answer":null,"outcome":"startup_stream_rejected","protocol_error":null},
  "rpc_error_observe":{"errors":0,"tool_calls":1,"answer":"7","outcome":"tool_returned","protocol_error":-32601},
  "rpc_error_gate":{"errors":0,"tool_calls":1,"answer":"7","outcome":"tool_returned","protocol_error":-32601}
}

这份预期由实验目标单独写定,SHA-256 为 85cf4d92cfe69ebfff06e390eff81c54a0585e607fdfea9b23f9453d1e6465df。运行代码只读取它,不从观测结果生成或更新。errors 是观察器收到的解析异常项数量;protocol_error 是客户端捕获到的协议错误码,二者不是同一个通道。

两个受污染的 observe 用例各记录一次异常,却发送一次 tools/call 并返回 7。对应 gate 用例也完成了初始化,但返回 startup_stream_rejected,工具调用数是 0、answer 为 null。其余六项控制均得到 7;rpc_error 两项另外得到 -32601,观察器的解析异常计数仍为 0。

下面是十项运行的完整汇总。它是观测结果,不作为测试预期来源。

observed/matrix/results.json
{
  "versions": {
    "python": "3.12.14",
    "mcp": "1.29.0",
    "pydantic": "2.13.4",
    "anyio": "4.14.2"
  },
  "cases": {
    "none_observe": {
      "errors": 0,
      "tool_calls": 1,
      "answer": "7",
      "outcome": "tool_returned",
      "protocol_error": null
    },
    "none_gate": {
      "errors": 0,
      "tool_calls": 1,
      "answer": "7",
      "outcome": "tool_returned",
      "protocol_error": null
    },
    "stderr_observe": {
      "errors": 0,
      "tool_calls": 1,
      "answer": "7",
      "outcome": "tool_returned",
      "protocol_error": null
    },
    "stderr_gate": {
      "errors": 0,
      "tool_calls": 1,
      "answer": "7",
      "outcome": "tool_returned",
      "protocol_error": null
    },
    "text_observe": {
      "errors": 1,
      "tool_calls": 1,
      "answer": "7",
      "outcome": "tool_returned",
      "protocol_error": null
    },
    "text_gate": {
      "errors": 1,
      "tool_calls": 0,
      "answer": null,
      "outcome": "startup_stream_rejected",
      "protocol_error": null
    },
    "json_observe": {
      "errors": 1,
      "tool_calls": 1,
      "answer": "7",
      "outcome": "tool_returned",
      "protocol_error": null
    },
    "json_gate": {
      "errors": 1,
      "tool_calls": 0,
      "answer": null,
      "outcome": "startup_stream_rejected",
      "protocol_error": null
    },
    "rpc_error_observe": {
      "errors": 0,
      "tool_calls": 1,
      "answer": "7",
      "outcome": "tool_returned",
      "protocol_error": -32601
    },
    "rpc_error_gate": {
      "errors": 0,
      "tool_calls": 1,
      "answer": "7",
      "outcome": "tool_returned",
      "protocol_error": -32601
    }
  }
}

为了让“默认会话仍继续”有独立于 ObserveRead 的实测,下面另跑两个没有流包装器、没有自定义会话 handler 的控制。仅复用诊断格式器,不复用门控判断。它们不计入上面的十项矩阵。

source/direct_baseline.py
"""Two unwrapped ClientSession controls, independent of ObserveRead and gate."""
from datetime import timedelta
import json
import logging
from pathlib import Path
import sys
import anyio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from probe import DiagnosticFormatter

ROOT = Path(__file__).resolve().parent

def require(condition, label):
    if not condition:
        raise AssertionError(label)

async def main():
    output = Path(sys.argv[1]).resolve()
    output.mkdir(exist_ok=False)
    results = {}
    for mode in ('text', 'json'):
        folder = output / mode
        folder.mkdir()
        handler = logging.FileHandler(folder / 'client-sdk.log', encoding='utf-8')
        handler.setFormatter(DiagnosticFormatter())
        logging.getLogger().addHandler(handler)
        try:
            parameters = StdioServerParameters(command=sys.executable,
                args=['-u', str(ROOT / 'relay.py'), mode, str(folder)], cwd=str(ROOT))
            with (folder / 'relay-stderr.log').open('w') as diagnostics:
                async with stdio_client(parameters, errlog=diagnostics) as (read, write):
                    # No stream wrapper, custom handler, or error gate here.
                    async with ClientSession(read, write,
                            read_timeout_seconds=timedelta(seconds=5)) as session:
                        initialized = await session.initialize()
                        require(initialized.protocolVersion == '2025-11-25', 'Protocol')
                        result = await session.call_tool('read_answer', {})
                        require(result.isError is False, 'Tool isError')
                        require([item.text for item in result.content] == ['7'], 'Answer')
        finally:
            logging.getLogger().removeHandler(handler)
            handler.close()
        messages = [json.loads(line) for line in
                    (folder / 'client-stdin.bin').read_bytes().splitlines()]
        require(sum(row.get('method') == 'tools/call' for row in messages) == 1, 'Call count')
        require((folder / 'client-sdk.log').read_text().count(
            'Failed to parse JSONRPC message from server') == 1, 'SDK diagnostic')
        require(json.loads((folder / 'relay-result.json').read_text()) == {
            'server_returncode': 0, 'relay_errors': [], 'threads_finished': True}, 'Cleanup')
        results[mode] = {'initialized': True, 'answer': '7', 'tool_calls': 1,
                         'sdk_parse_diagnostics': 1}
    (output / 'results.json').write_text(json.dumps(results, indent=2) + '\n')
    print('PASS: 2 unwrapped default-session controls returned 7 with parser diagnostics')

if __name__ == '__main__':
    anyio.run(main)
observed/direct/results.json
{
  "text": {
    "initialized": true,
    "answer": "7",
    "tool_calls": 1,
    "sdk_parse_diagnostics": 1
  },
  "json": {
    "initialized": true,
    "answer": "7",
    "tool_calls": 1,
    "sdk_parse_diagnostics": 1
  }
}

15. 门控点在初始化之后,业务调用之前

本例先 await session.initialize(),随后检查 observer.errors,只有未观察到启动解析错误才进入业务步骤。这个判断不宣称初始化没有完成:text_gate 和 json_gate 已经发送 notifications/initialized。要验收的是业务请求尚未放行。

这一顺序有具体输入作保证:banner 在同一个 stdout 中位于 initialize 响应之前,观察器按接收流顺序记录异常和正常响应。startup-observation.json 验收“先异常、后初始化响应”;原始客户端字节只包含 initialize 和 notifications/initialized,没有 tools/call,也没有 tools/list。不要把 stdout 和 stderr 两条独立管道推断成一个全局时间序列。

observed/matrix/text_gate/startup-observation.json
{
  "initialize_completed": true,
  "events": [
    "parse_error:ValidationError",
    {
      "message": {
        "jsonrpc": "2.0",
        "id": 0,
        "result": {
          "protocolVersion": "2025-11-25",
          "capabilities": {
            "experimental": {},
            "tools": {
              "listChanged": false
            }
          },
          "serverInfo": {
            "name": "startup-stream-fixture",
            "version": "1"
          }
        }
      }
    }
  ],
  "errors": [
    "ValidationError"
  ]
}
observed/matrix/text_gate/client-stdin.bin
{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
{"method":"notifications/initialized","jsonrpc":"2.0"}

正常调用路径中,固定 SDK 的 call_tool 实现还会为结果校验取得工具 schema,因此原始方法顺序中 tools/list 出现在 tools/call 后。本实验把这条实际顺序也写入独立审计。不要只检查自己显式调用过的 API 名称,就断定线路上没有其他请求。

-32601 是服务端针对 resources/list 返回的有效协议错误响应,不是 stdout 解析异常。客户端捕获这个 McpError 后,在同一个会话继续调用合成工具,得到 7。它既不同于启动 banner,也不同于前文工具结果中的 isError,更不能与本地产生的读取超时混成一种错误。

下面逐项提供完整字节记录、格式化诊断、初始化观察和关闭结果。空的 code 块表示该文件确实为零字节;没有把它替换成说明文字。行尾 LF 保存在文件和离线包中。

none_observe:完整证据
observed/matrix/none_observe/client-stdin.bin
{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
{"method":"notifications/initialized","jsonrpc":"2.0"}
{"method":"tools/call","params":{"name":"read_answer","arguments":{}},"jsonrpc":"2.0","id":1}
{"method":"tools/list","jsonrpc":"2.0","id":2}
observed/matrix/none_observe/server-stdout.bin
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"tools":{"listChanged":false}},"serverInfo":{"name":"startup-stream-fixture","version":"1"}}}
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"7"}],"isError":false}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"read_answer","inputSchema":{"type":"object","properties":{}}}]}}
observed/matrix/none_observe/server-stderr.bin
observed/matrix/none_observe/relay-stderr.log
observed/matrix/none_observe/client-sdk.log
observed/matrix/none_observe/startup-observation.json
{
  "initialize_completed": true,
  "events": [
    {
      "message": {
        "jsonrpc": "2.0",
        "id": 0,
        "result": {
          "protocolVersion": "2025-11-25",
          "capabilities": {
            "experimental": {},
            "tools": {
              "listChanged": false
            }
          },
          "serverInfo": {
            "name": "startup-stream-fixture",
            "version": "1"
          }
        }
      }
    }
  ],
  "errors": []
}
observed/matrix/none_observe/relay-result.json
{
  "server_returncode": 0,
  "relay_errors": [],
  "threads_finished": true
}
observed/matrix/none_observe/observed.json
{
  "errors": 0,
  "tool_calls": 1,
  "answer": "7",
  "outcome": "tool_returned",
  "protocol_error": null
}
none_gate:完整证据
observed/matrix/none_gate/client-stdin.bin
{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
{"method":"notifications/initialized","jsonrpc":"2.0"}
{"method":"tools/call","params":{"name":"read_answer","arguments":{}},"jsonrpc":"2.0","id":1}
{"method":"tools/list","jsonrpc":"2.0","id":2}
observed/matrix/none_gate/server-stdout.bin
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"tools":{"listChanged":false}},"serverInfo":{"name":"startup-stream-fixture","version":"1"}}}
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"7"}],"isError":false}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"read_answer","inputSchema":{"type":"object","properties":{}}}]}}
observed/matrix/none_gate/server-stderr.bin
observed/matrix/none_gate/relay-stderr.log
observed/matrix/none_gate/client-sdk.log
observed/matrix/none_gate/startup-observation.json
{
  "initialize_completed": true,
  "events": [
    {
      "message": {
        "jsonrpc": "2.0",
        "id": 0,
        "result": {
          "protocolVersion": "2025-11-25",
          "capabilities": {
            "experimental": {},
            "tools": {
              "listChanged": false
            }
          },
          "serverInfo": {
            "name": "startup-stream-fixture",
            "version": "1"
          }
        }
      }
    }
  ],
  "errors": []
}
observed/matrix/none_gate/relay-result.json
{
  "server_returncode": 0,
  "relay_errors": [],
  "threads_finished": true
}
observed/matrix/none_gate/observed.json
{
  "errors": 0,
  "tool_calls": 1,
  "answer": "7",
  "outcome": "tool_returned",
  "protocol_error": null
}
stderr_observe:完整证据
observed/matrix/stderr_observe/client-stdin.bin
{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
{"method":"notifications/initialized","jsonrpc":"2.0"}
{"method":"tools/call","params":{"name":"read_answer","arguments":{}},"jsonrpc":"2.0","id":1}
{"method":"tools/list","jsonrpc":"2.0","id":2}
observed/matrix/stderr_observe/server-stdout.bin
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"tools":{"listChanged":false}},"serverInfo":{"name":"startup-stream-fixture","version":"1"}}}
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"7"}],"isError":false}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"read_answer","inputSchema":{"type":"object","properties":{}}}]}}
observed/matrix/stderr_observe/server-stderr.bin
FIXTURE READY
observed/matrix/stderr_observe/relay-stderr.log
FIXTURE READY
observed/matrix/stderr_observe/client-sdk.log
observed/matrix/stderr_observe/startup-observation.json
{
  "initialize_completed": true,
  "events": [
    {
      "message": {
        "jsonrpc": "2.0",
        "id": 0,
        "result": {
          "protocolVersion": "2025-11-25",
          "capabilities": {
            "experimental": {},
            "tools": {
              "listChanged": false
            }
          },
          "serverInfo": {
            "name": "startup-stream-fixture",
            "version": "1"
          }
        }
      }
    }
  ],
  "errors": []
}
observed/matrix/stderr_observe/relay-result.json
{
  "server_returncode": 0,
  "relay_errors": [],
  "threads_finished": true
}
observed/matrix/stderr_observe/observed.json
{
  "errors": 0,
  "tool_calls": 1,
  "answer": "7",
  "outcome": "tool_returned",
  "protocol_error": null
}
stderr_gate:完整证据
observed/matrix/stderr_gate/client-stdin.bin
{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
{"method":"notifications/initialized","jsonrpc":"2.0"}
{"method":"tools/call","params":{"name":"read_answer","arguments":{}},"jsonrpc":"2.0","id":1}
{"method":"tools/list","jsonrpc":"2.0","id":2}
observed/matrix/stderr_gate/server-stdout.bin
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"tools":{"listChanged":false}},"serverInfo":{"name":"startup-stream-fixture","version":"1"}}}
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"7"}],"isError":false}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"read_answer","inputSchema":{"type":"object","properties":{}}}]}}
observed/matrix/stderr_gate/server-stderr.bin
FIXTURE READY
observed/matrix/stderr_gate/relay-stderr.log
FIXTURE READY
observed/matrix/stderr_gate/client-sdk.log
observed/matrix/stderr_gate/startup-observation.json
{
  "initialize_completed": true,
  "events": [
    {
      "message": {
        "jsonrpc": "2.0",
        "id": 0,
        "result": {
          "protocolVersion": "2025-11-25",
          "capabilities": {
            "experimental": {},
            "tools": {
              "listChanged": false
            }
          },
          "serverInfo": {
            "name": "startup-stream-fixture",
            "version": "1"
          }
        }
      }
    }
  ],
  "errors": []
}
observed/matrix/stderr_gate/relay-result.json
{
  "server_returncode": 0,
  "relay_errors": [],
  "threads_finished": true
}
observed/matrix/stderr_gate/observed.json
{
  "errors": 0,
  "tool_calls": 1,
  "answer": "7",
  "outcome": "tool_returned",
  "protocol_error": null
}
text_observe:完整证据
observed/matrix/text_observe/client-stdin.bin
{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
{"method":"notifications/initialized","jsonrpc":"2.0"}
{"method":"tools/call","params":{"name":"read_answer","arguments":{}},"jsonrpc":"2.0","id":1}
{"method":"tools/list","jsonrpc":"2.0","id":2}
observed/matrix/text_observe/server-stdout.bin
FIXTURE READY
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"tools":{"listChanged":false}},"serverInfo":{"name":"startup-stream-fixture","version":"1"}}}
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"7"}],"isError":false}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"read_answer","inputSchema":{"type":"object","properties":{}}}]}}
observed/matrix/text_observe/server-stderr.bin
observed/matrix/text_observe/relay-stderr.log
observed/matrix/text_observe/client-sdk.log
Failed to parse JSONRPC message from server
ValidationError: 1 validation error for JSONRPCMessage
  Invalid JSON: expected value at line 1 column 1 [type=json_invalid, input_value='FIXTURE READY', input_type=str]
    For further information visit https://errors.pydantic.dev/2.13/v/json_invalid
observed/matrix/text_observe/startup-observation.json
{
  "initialize_completed": true,
  "events": [
    "parse_error:ValidationError",
    {
      "message": {
        "jsonrpc": "2.0",
        "id": 0,
        "result": {
          "protocolVersion": "2025-11-25",
          "capabilities": {
            "experimental": {},
            "tools": {
              "listChanged": false
            }
          },
          "serverInfo": {
            "name": "startup-stream-fixture",
            "version": "1"
          }
        }
      }
    }
  ],
  "errors": [
    "ValidationError"
  ]
}
observed/matrix/text_observe/relay-result.json
{
  "server_returncode": 0,
  "relay_errors": [],
  "threads_finished": true
}
observed/matrix/text_observe/observed.json
{
  "errors": 1,
  "tool_calls": 1,
  "answer": "7",
  "outcome": "tool_returned",
  "protocol_error": null
}
text_gate:完整证据
observed/matrix/text_gate/client-stdin.bin
{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
{"method":"notifications/initialized","jsonrpc":"2.0"}
observed/matrix/text_gate/server-stdout.bin
FIXTURE READY
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"tools":{"listChanged":false}},"serverInfo":{"name":"startup-stream-fixture","version":"1"}}}
observed/matrix/text_gate/server-stderr.bin
observed/matrix/text_gate/relay-stderr.log
observed/matrix/text_gate/client-sdk.log
Failed to parse JSONRPC message from server
ValidationError: 1 validation error for JSONRPCMessage
  Invalid JSON: expected value at line 1 column 1 [type=json_invalid, input_value='FIXTURE READY', input_type=str]
    For further information visit https://errors.pydantic.dev/2.13/v/json_invalid
observed/matrix/text_gate/startup-observation.json
{
  "initialize_completed": true,
  "events": [
    "parse_error:ValidationError",
    {
      "message": {
        "jsonrpc": "2.0",
        "id": 0,
        "result": {
          "protocolVersion": "2025-11-25",
          "capabilities": {
            "experimental": {},
            "tools": {
              "listChanged": false
            }
          },
          "serverInfo": {
            "name": "startup-stream-fixture",
            "version": "1"
          }
        }
      }
    }
  ],
  "errors": [
    "ValidationError"
  ]
}
observed/matrix/text_gate/relay-result.json
{
  "server_returncode": 0,
  "relay_errors": [],
  "threads_finished": true
}
observed/matrix/text_gate/observed.json
{
  "errors": 1,
  "tool_calls": 0,
  "answer": null,
  "outcome": "startup_stream_rejected",
  "protocol_error": null
}
json_observe:完整证据
observed/matrix/json_observe/client-stdin.bin
{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
{"method":"notifications/initialized","jsonrpc":"2.0"}
{"method":"tools/call","params":{"name":"read_answer","arguments":{}},"jsonrpc":"2.0","id":1}
{"method":"tools/list","jsonrpc":"2.0","id":2}
observed/matrix/json_observe/server-stdout.bin
{"level":"info","message":"fixture ready"}
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"tools":{"listChanged":false}},"serverInfo":{"name":"startup-stream-fixture","version":"1"}}}
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"7"}],"isError":false}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"read_answer","inputSchema":{"type":"object","properties":{}}}]}}
observed/matrix/json_observe/server-stderr.bin
observed/matrix/json_observe/relay-stderr.log
observed/matrix/json_observe/client-sdk.log
Failed to parse JSONRPC message from server
ValidationError: 11 validation errors for JSONRPCMessage
JSONRPCRequest.method
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCRequest.jsonrpc
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCRequest.id
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCNotification.method
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCNotification.jsonrpc
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCResponse.jsonrpc
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCResponse.id
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCResponse.result
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCError.jsonrpc
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCError.id
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCError.error
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
observed/matrix/json_observe/startup-observation.json
{
  "initialize_completed": true,
  "events": [
    "parse_error:ValidationError",
    {
      "message": {
        "jsonrpc": "2.0",
        "id": 0,
        "result": {
          "protocolVersion": "2025-11-25",
          "capabilities": {
            "experimental": {},
            "tools": {
              "listChanged": false
            }
          },
          "serverInfo": {
            "name": "startup-stream-fixture",
            "version": "1"
          }
        }
      }
    }
  ],
  "errors": [
    "ValidationError"
  ]
}
observed/matrix/json_observe/relay-result.json
{
  "server_returncode": 0,
  "relay_errors": [],
  "threads_finished": true
}
observed/matrix/json_observe/observed.json
{
  "errors": 1,
  "tool_calls": 1,
  "answer": "7",
  "outcome": "tool_returned",
  "protocol_error": null
}
json_gate:完整证据
observed/matrix/json_gate/client-stdin.bin
{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
{"method":"notifications/initialized","jsonrpc":"2.0"}
observed/matrix/json_gate/server-stdout.bin
{"level":"info","message":"fixture ready"}
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"tools":{"listChanged":false}},"serverInfo":{"name":"startup-stream-fixture","version":"1"}}}
observed/matrix/json_gate/server-stderr.bin
observed/matrix/json_gate/relay-stderr.log
observed/matrix/json_gate/client-sdk.log
Failed to parse JSONRPC message from server
ValidationError: 11 validation errors for JSONRPCMessage
JSONRPCRequest.method
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCRequest.jsonrpc
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCRequest.id
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCNotification.method
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCNotification.jsonrpc
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCResponse.jsonrpc
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCResponse.id
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCResponse.result
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCError.jsonrpc
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCError.id
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
JSONRPCError.error
  Field required [type=missing, input_value={'level': 'info', 'message': 'fixture ready'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing
observed/matrix/json_gate/startup-observation.json
{
  "initialize_completed": true,
  "events": [
    "parse_error:ValidationError",
    {
      "message": {
        "jsonrpc": "2.0",
        "id": 0,
        "result": {
          "protocolVersion": "2025-11-25",
          "capabilities": {
            "experimental": {},
            "tools": {
              "listChanged": false
            }
          },
          "serverInfo": {
            "name": "startup-stream-fixture",
            "version": "1"
          }
        }
      }
    }
  ],
  "errors": [
    "ValidationError"
  ]
}
observed/matrix/json_gate/relay-result.json
{
  "server_returncode": 0,
  "relay_errors": [],
  "threads_finished": true
}
observed/matrix/json_gate/observed.json
{
  "errors": 1,
  "tool_calls": 0,
  "answer": null,
  "outcome": "startup_stream_rejected",
  "protocol_error": null
}
rpc_error_observe:完整证据
observed/matrix/rpc_error_observe/client-stdin.bin
{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
{"method":"notifications/initialized","jsonrpc":"2.0"}
{"method":"resources/list","jsonrpc":"2.0","id":1}
{"method":"tools/call","params":{"name":"read_answer","arguments":{}},"jsonrpc":"2.0","id":2}
{"method":"tools/list","jsonrpc":"2.0","id":3}
observed/matrix/rpc_error_observe/server-stdout.bin
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"tools":{"listChanged":false}},"serverInfo":{"name":"startup-stream-fixture","version":"1"}}}
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"7"}],"isError":false}}
{"jsonrpc":"2.0","id":3,"result":{"tools":[{"name":"read_answer","inputSchema":{"type":"object","properties":{}}}]}}
observed/matrix/rpc_error_observe/server-stderr.bin
observed/matrix/rpc_error_observe/relay-stderr.log
observed/matrix/rpc_error_observe/client-sdk.log
observed/matrix/rpc_error_observe/startup-observation.json
{
  "initialize_completed": true,
  "events": [
    {
      "message": {
        "jsonrpc": "2.0",
        "id": 0,
        "result": {
          "protocolVersion": "2025-11-25",
          "capabilities": {
            "experimental": {},
            "tools": {
              "listChanged": false
            }
          },
          "serverInfo": {
            "name": "startup-stream-fixture",
            "version": "1"
          }
        }
      }
    }
  ],
  "errors": []
}
observed/matrix/rpc_error_observe/relay-result.json
{
  "server_returncode": 0,
  "relay_errors": [],
  "threads_finished": true
}
observed/matrix/rpc_error_observe/observed.json
{
  "errors": 0,
  "tool_calls": 1,
  "answer": "7",
  "outcome": "tool_returned",
  "protocol_error": -32601
}
rpc_error_gate:完整证据
observed/matrix/rpc_error_gate/client-stdin.bin
{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
{"method":"notifications/initialized","jsonrpc":"2.0"}
{"method":"resources/list","jsonrpc":"2.0","id":1}
{"method":"tools/call","params":{"name":"read_answer","arguments":{}},"jsonrpc":"2.0","id":2}
{"method":"tools/list","jsonrpc":"2.0","id":3}
observed/matrix/rpc_error_gate/server-stdout.bin
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"tools":{"listChanged":false}},"serverInfo":{"name":"startup-stream-fixture","version":"1"}}}
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"7"}],"isError":false}}
{"jsonrpc":"2.0","id":3,"result":{"tools":[{"name":"read_answer","inputSchema":{"type":"object","properties":{}}}]}}
observed/matrix/rpc_error_gate/server-stderr.bin
observed/matrix/rpc_error_gate/relay-stderr.log
observed/matrix/rpc_error_gate/client-sdk.log
observed/matrix/rpc_error_gate/startup-observation.json
{
  "initialize_completed": true,
  "events": [
    {
      "message": {
        "jsonrpc": "2.0",
        "id": 0,
        "result": {
          "protocolVersion": "2025-11-25",
          "capabilities": {
            "experimental": {},
            "tools": {
              "listChanged": false
            }
          },
          "serverInfo": {
            "name": "startup-stream-fixture",
            "version": "1"
          }
        }
      }
    }
  ],
  "errors": []
}
observed/matrix/rpc_error_gate/relay-result.json
{
  "server_returncode": 0,
  "relay_errors": [],
  "threads_finished": true
}
observed/matrix/rpc_error_gate/observed.json
{
  "errors": 0,
  "tool_calls": 1,
  "answer": "7",
  "outcome": "tool_returned",
  "protocol_error": -32601
}

16. 独立审计,再故意改坏判断验证它会失败

audit.py 只使用标准库和原始证据,不导入服务端、观察器,也不读取 expected/cases.json 或 observed.json。它另用手写常量检查精确 banner 字节、方法顺序、响应 ID、result 与 error 的互斥、协议版本、工具输入输出、-32601 响应、三种日志的归属,以及初始化检查点的顺序。

source/audit.py
"""Independent oracle: stdlib, captured bytes, and handwritten constants only."""
import json
from pathlib import Path
import sys


def require(condition, label):
    if not condition:
        raise AssertionError(label)


root = Path(sys.argv[1])
expected_names = {f'{mode}_{policy}'
    for mode in ('none', 'stderr', 'text', 'json', 'rpc_error')
    for policy in ('observe', 'gate')}
require({p.name for p in root.iterdir() if p.is_dir()} == expected_names, 'Case set')
findings = {}
for name in sorted(expected_names):
    condition, policy = name.rsplit('_', 1)
    folder = root / name
    client_raw = (folder / 'client-stdin.bin').read_bytes()
    server_raw = (folder / 'server-stdout.bin').read_bytes()
    require(client_raw.endswith(b'\n') and server_raw.endswith(b'\n'), 'Final LF')
    client = [json.loads(row) for row in client_raw[:-1].split(b'\n')]
    raw_lines = server_raw[:-1].split(b'\n')
    bad_kind = None
    if condition == 'text':
        require(raw_lines.pop(0) == b'FIXTURE READY', 'Exact text banner')
        bad_kind = 'invalid_json'
    elif condition == 'json':
        banner = raw_lines.pop(0)
        require(banner == b'{"level":"info","message":"fixture ready"}', 'JSON banner')
        require(json.loads(banner) == {'level': 'info', 'message': 'fixture ready'},
                'Valid JSON, without a JSON-RPC envelope')
        bad_kind = 'json_without_jsonrpc_envelope'
    server = [json.loads(row) for row in raw_lines]
    require(all(type(row) is dict and row.get('jsonrpc') == '2.0'
                for row in client + server), 'Fixture envelopes')
    requests = [row for row in client if 'id' in row]
    require(len({row['id'] for row in requests}) == len(requests), 'Distinct request IDs')
    require(len(server) == len(requests), 'Response count')
    require([row['id'] for row in server] == [row['id'] for row in requests], 'Response IDs')
    require(all(('result' in row) != ('error' in row) for row in server), 'Result XOR error')
    require(server[0]['result']['protocolVersion'] == '2025-11-25', 'Protocol')
    require(server[0]['result']['serverInfo'] == {
        'name': 'startup-stream-fixture', 'version': '1'}, 'Server identity')
    wanted_tools = 0 if condition in ('text', 'json') and policy == 'gate' else 1
    methods = ['initialize', 'notifications/initialized']
    if wanted_tools:
        if condition == 'rpc_error':
            methods.append('resources/list')
        methods.extend(['tools/call', 'tools/list'])
    require([row['method'] for row in client] == methods, 'Exact fixture method order')
    calls = [row for row in requests if row['method'] == 'tools/call']
    require(len(calls) == wanted_tools, 'Tool call count')
    if wanted_tools:
        require(calls[0]['params'] == {'name': 'read_answer', 'arguments': {}}, 'Call input')
        reply = next(row for row in server if row['id'] == calls[0]['id'])
        require(reply['result']['isError'] is False, 'Tool result status')
        require(reply['result']['content'] == [{'type': 'text', 'text': '7'}], 'Exact answer')
        listing = next(row for row in requests if row['method'] == 'tools/list')
        listed = next(row for row in server if row['id'] == listing['id'])
        require(listed['result'] == {'tools': [{'name': 'read_answer',
            'inputSchema': {'type': 'object', 'properties': {}}}]}, 'Tool schema')
    else:
        require(len(requests) == 1, 'No business request after gate rejection')
    rpc_errors = [row for row in server if 'error' in row]
    if condition == 'rpc_error':
        require(len(rpc_errors) == 1, 'One protocol error')
        request = next(row for row in requests if row['id'] == rpc_errors[0]['id'])
        require(request['method'] == 'resources/list', 'Protocol error request')
        require(rpc_errors[0]['error'] == {
            'code': -32601, 'message': 'Method not found'}, 'Protocol error content')
    else:
        require(rpc_errors == [], 'No JSON-RPC error response')
    server_stderr = (folder / 'server-stderr.bin').read_bytes()
    require(server_stderr == (b'FIXTURE READY\n' if condition == 'stderr' else b''),
            'Exact server stderr control')
    require((folder / 'relay-stderr.log').read_bytes() == server_stderr,
            'Relay stderr equals forwarded server stderr')
    sdk_log = (folder / 'client-sdk.log').read_text()
    require(sdk_log.count('Failed to parse JSONRPC message from server')
            == int(condition in ('text', 'json')), 'Separate SDK diagnostic count')
    startup = json.loads((folder / 'startup-observation.json').read_text())
    require(startup['initialize_completed'] is True, 'Initialize completed')
    require(startup['errors'] == (['ValidationError']
            if condition in ('text', 'json') else []), 'Observed startup exception')
    kinds = ['parse_error' if isinstance(row, str) else 'message'
             for row in startup['events']]
    require(kinds == (['parse_error', 'message']
            if condition in ('text', 'json') else ['message']), 'Error before init response')
    require(startup['events'][-1]['message'] == server[0], 'Same initialization response')
    cleanup = json.loads((folder / 'relay-result.json').read_text())
    require(cleanup == {'server_returncode': 0, 'relay_errors': [],
            'threads_finished': True}, 'Finite fixture cleanup')
    findings[name] = {'raw_contamination': bad_kind, 'tool_calls': wanted_tools,
                     'rpc_error_responses': len(rpc_errors), 'server_returncode': 0}
(root / 'independent-audit.json').write_text(json.dumps(findings, indent=2) + '\n')
print(json.dumps({'independent_raw_cases': len(findings), 'passed': True}, sort_keys=True))

check_run.py 把 probe 与 audit 作为两个独立命令运行,逐字节比较完整 stdout,要求 stderr 为空且退出码为 0。只有在各自专用文件里才能出现预期的 SDK 诊断。预期输出固定如下:

expected/probe.stdout.txt
{"passed": 10, "protocol": "2025-11-25", "sdk": "1.29.0"}
expected/audit.stdout.txt
{"independent_raw_cases": 10, "passed": true}
source/check_run.py
"""Run the fixed matrix and a separate raw-byte oracle; compare full output."""
import json
from pathlib import Path
import subprocess
import sys

ROOT = Path(__file__).resolve().parent
output = Path(sys.argv[1]).resolve()
if output.exists():
    raise FileExistsError('Use a new output directory: ' + str(output))
flags = ['-B'] + (['-O'] if sys.flags.optimize else [])
receipts = {}
for name in ('probe', 'audit'):
    process = subprocess.run(
        [sys.executable, *flags, str(ROOT / (name + '.py')), str(output)],
        capture_output=True, timeout=180,
    )
    if output.is_dir():
        (output / (name + '.stdout.txt')).write_bytes(process.stdout)
        (output / (name + '.stderr.txt')).write_bytes(process.stderr)
    wanted = (ROOT.parent / 'expected' / (name + '.stdout.txt')).read_bytes()
    receipts[name] = {'returncode': process.returncode,
                      'stdout_exact': process.stdout == wanted,
                      'stderr_empty': process.stderr == b''}
    if output.is_dir():
        (output / 'command-results.json').write_text(json.dumps(receipts, indent=2) + '\n')
    if process.returncode != 0 or process.stdout != wanted or process.stderr != b'':
        raise AssertionError((name, receipts[name]))
    sys.stdout.buffer.write(process.stdout)
print('PASS: fixed matrix and independent raw oracle')

再运行一份未修改基线和四个临时变体:移除 gate;只承认 JSON 语法错误而忽略合法 JSON 的封套错误;把已捕获的 stderr 误算为解析错误;让正常完成协议交互的子进程最终退出 7。前三项分别必须在 text_gate、json_observe、stderr_observe 产生指定的错误观测,第四项必须留下实际 server_returncode=7 并被关闭检查拒绝。任意崩溃、超时、语法错误或导入错误不能冒充这些预期拒绝。

stderr 误分类变体在传输退出、泵线程汇合后才读取 stderr 文件,避免测试依赖两条管道的调度先后。这是结果分类的故意错误,不是一个生产门控方案。每个变体只修改临时副本;原始程序和固定预期保持不变。

source/run_mutants.py
"""A passing baseline, then three wrong policies and a nonzero child-exit fault."""
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile

ROOT = Path(__file__).resolve().parent
output = Path(sys.argv[1]).resolve()
output.mkdir(exist_ok=False)
flags = ['-B'] + (['-O'] if sys.flags.optimize else [])

mutations = {
    'no_gate': ('probe.py',
        "if policy != 'gate' or not observer.errors:", 'if True:',
        'text_gate', {'errors': 1, 'tool_calls': 1, 'answer': '7',
         'outcome': 'tool_returned', 'protocol_error': None}),
    'json_syntax_only': ('probe.py',
        'self.errors.append(type(item).__name__)',
        "if any(error.get('type') == 'json_invalid' for error in item.errors()):\n                self.errors.append(type(item).__name__)",
        'json_observe', {'errors': 0, 'tool_calls': 1, 'answer': '7',
         'outcome': 'tool_returned', 'protocol_error': None}),
    'stderr_is_failure': ('probe.py',
        "    sent = [json.loads(row)",
        "    if (folder / 'server-stderr.bin').read_bytes():\n        observer.errors.append('StderrOutput')\n    sent = [json.loads(row)",
        'stderr_observe', {'errors': 1, 'tool_calls': 1, 'answer': '7',
         'outcome': 'tool_returned', 'protocol_error': None}),
    'child_exit_7': ('server.py',
        'asyncio.run(main())', 'asyncio.run(main())\n    sys.exit(7)',
        'none_observe', None),
}
records = {}
for name, change in [('baseline', None), *mutations.items()]:
    with tempfile.TemporaryDirectory(prefix='stdio-noise-mutant-') as temporary:
        project = Path(temporary)
        shutil.copytree(ROOT, project / 'source', ignore=shutil.ignore_patterns('__pycache__'))
        shutil.copytree(ROOT.parent / 'expected', project / 'expected')
        if change:
            filename, old, new, case, wanted = change
            target = project / 'source' / filename
            code = target.read_text()
            if code.count(old) != 1:
                raise AssertionError('Mutation anchor: ' + name)
            target.write_text(code.replace(old, new, 1))
        run = output / name
        process = subprocess.run([sys.executable, *flags,
            str(project / 'source/probe.py'), str(run)], capture_output=True, timeout=180)
        (output / (name + '.stdout.txt')).write_bytes(process.stdout)
        # Tracebacks contain the temporary copy path; retain them for local diagnosis.
        (output / (name + '.stderr.txt')).write_bytes(process.stderr)
        if change is None:
            if process.returncode != 0 or process.stderr != b'' or process.stdout != (
                    ROOT.parent / 'expected/probe.stdout.txt').read_bytes():
                raise AssertionError('Unmodified baseline failed')
            evidence = {'baseline_accepted': True}
        else:
            if process.returncode != 1 or b'AssertionError' not in process.stderr:
                raise AssertionError((name, 'Did not fail at an assertion', process.returncode))
            if name == 'child_exit_7':
                evidence = json.loads((run / case / 'relay-result.json').read_text())
                if evidence != {'server_returncode': 7, 'relay_errors': [], 'threads_finished': True}:
                    raise AssertionError((name, evidence))
                if b'cleanup ==' not in process.stderr:
                    raise AssertionError('Wrong child-exit rejection')
            else:
                evidence = json.loads((run / case / 'observed.json').read_text())
                if evidence != wanted:
                    raise AssertionError((name, evidence, wanted))
                if case.encode() not in process.stderr:
                    raise AssertionError('Wrong mismatch case')
        records[name] = {'returncode': process.returncode, 'evidence': evidence}
(output / 'mutants.json').write_text(json.dumps(records, indent=2) + '\n')
print('PASS: baseline accepted; 3 policy mutants and child exit 7 rejected')
observed/mutants.json
{
  "baseline": {
    "returncode": 0,
    "evidence": {
      "baseline_accepted": true
    }
  },
  "no_gate": {
    "returncode": 1,
    "evidence": {
      "errors": 1,
      "tool_calls": 1,
      "answer": "7",
      "outcome": "tool_returned",
      "protocol_error": null
    }
  },
  "json_syntax_only": {
    "returncode": 1,
    "evidence": {
      "errors": 0,
      "tool_calls": 1,
      "answer": "7",
      "outcome": "tool_returned",
      "protocol_error": null
    }
  },
  "stderr_is_failure": {
    "returncode": 1,
    "evidence": {
      "errors": 1,
      "tool_calls": 1,
      "answer": "7",
      "outcome": "tool_returned",
      "protocol_error": null
    }
  },
  "child_exit_7": {
    "returncode": 1,
    "evidence": {
      "server_returncode": 7,
      "relay_errors": [],
      "threads_finished": true
    }
  }
}

这里验证的关闭故障只有有限服务正常处理后返回非零这一项;没有把它扩大为所有观察器写失败或进程挂死都能可靠恢复。变体的完整 traceback 留在读者自己的运行目录,公开示例只附稳定的退出码与决定性结果,避免捎带本地临时路径。

17. 恢复时保留失败证据,把日志移到 stderr 后重新启动

恢复步骤不删 banner,不把“能 json.loads”当作过滤规则,也不吞掉异常后继续当前业务流程。下面先运行 text_gate,确认初始化已完成但业务调用被挡住;保留该目录的每个文件及其摘要。然后用 stderr_gate 启动一个全新的合成服务进程,把相同的 FIXTURE READY 行改到 stderr,要求零解析异常、一次工具调用、答案 7,并逐字节确认旧证据没有变化。

source/recovery.py
"""Keep the failed startup evidence; move the banner to stderr in a new process."""
import hashlib
import json
from pathlib import Path
import sys
import anyio
from probe import run_case

def hashes(folder):
    return {p.name: hashlib.sha256(p.read_bytes()).hexdigest()
            for p in sorted(folder.iterdir()) if p.is_file()}

async def main():
    output = Path(sys.argv[1]).resolve()
    output.mkdir(exist_ok=False)
    rejected = output / 'before'
    rejected.mkdir()
    before = await run_case('text_gate', rejected)
    if before != {'errors': 1, 'tool_calls': 0, 'answer': None,
                  'outcome': 'startup_stream_rejected', 'protocol_error': None}:
        raise AssertionError(before)
    preserved = hashes(rejected)
    repaired = output / 'after'
    repaired.mkdir()
    after = await run_case('stderr_gate', repaired)
    if after != {'errors': 0, 'tool_calls': 1, 'answer': '7',
                 'outcome': 'tool_returned', 'protocol_error': None}:
        raise AssertionError(after)
    if hashes(rejected) != preserved:
        raise AssertionError('Old evidence changed')
    (output / 'recovery.json').write_text(json.dumps({
        'before': before, 'after': after, 'old_evidence_unchanged': True,
        'old_sha256': preserved}, indent=2) + '\n')
    print('PASS: stdout gate rejected; fresh stderr control returned 7; old evidence unchanged')

if __name__ == '__main__':
    anyio.run(main)
observed/recovery/recovery.json
{
  "before": {
    "errors": 1,
    "tool_calls": 0,
    "answer": null,
    "outcome": "startup_stream_rejected",
    "protocol_error": null
  },
  "after": {
    "errors": 0,
    "tool_calls": 1,
    "answer": "7",
    "outcome": "tool_returned",
    "protocol_error": null
  },
  "old_evidence_unchanged": true,
  "old_sha256": {
    "client-sdk.log": "543874857e68583defdf91017689ebf5a0b1776dc4bb828c24a5340986567efe",
    "client-stdin.bin": "744e5f41136d80c686e800077d5e37bd25ab5822607dd532a116f829a4c20dbd",
    "observed.json": "6045f4d4782648e9932b7e2ec5a1a3aec0b9c4c99ca98c7573416bc6fabee016",
    "relay-result.json": "3f5c338ac454f6dd1b9e2517ce9b1e3f5bc9c4b0d3aa92706262ad06b42b99d0",
    "relay-stderr.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
    "server-stderr.bin": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
    "server-stdout.bin": "f9b5ec09d4de5f4614b83cbc9b8ed9d62d6aa929e76517294105520c0fdd80e9",
    "startup-observation.json": "6e723d9cf3cb29247d67acce421c44ac05b70aa4473c94aa37d37debff856a7b"
  }
}

实际排查应先保存原始启动输入输出和客户端诊断,定位是哪一层把日志写到了 stdout,例如启动脚本或服务端初始化代码。修改那个输出位置,再用新会话与新输出目录验证。若运行权限被拒绝、文件写入失败或无法解释子进程退出,应停在失败处处理原因,不能把样例结果当作该环境已经通过。

本例工具没有外部副作用。零 tools/call 只证明这两个受污染门控用例没有发送该方法;不能证明服务启动本身没有副作用,也不表示已发请求可以撤回、已发生写入可以回滚。

18. 完整复现入口与本补充的边界

保存前面所有 source/ 和 expected/ 文件后,从本补充目录运行下面命令。输出目录必须不存在,防止覆盖旧证据。requirements.txt 只固定这里关注的三个依赖,并非所有传递依赖的完整锁文件。本次使用环境中已安装的包,没有完成全新虚拟环境安装验收。

source/requirements.txt
mcp==1.29.0
pydantic==2.13.4
anyio==4.14.2
python -B source/verify_all.py run-reader-01
python -B -O source/verify_all.py run-reader-optimized
source/verify_all.py
"""Fresh-output, fixed-stdout verification of all supplement experiments."""
import json
from pathlib import Path
import subprocess
import sys

ROOT = Path(__file__).resolve().parent
output = Path(sys.argv[1]).resolve()
output.mkdir(exist_ok=False)
flags = ['-B'] + (['-O'] if sys.flags.optimize else [])
commands = (
    ('check_run', 'matrix'),
    ('direct_baseline', 'direct'),
    ('recovery', 'recovery'),
    ('run_mutants', 'mutants'),
)
records = {}
for script, folder in commands:
    process = subprocess.run([sys.executable, *flags, str(ROOT / (script + '.py')),
        str(output / folder)], capture_output=True, timeout=600)
    (output / (script + '.stdout.txt')).write_bytes(process.stdout)
    (output / (script + '.stderr.txt')).write_bytes(process.stderr)
    expected = (ROOT.parent / 'expected' / (script + '.stdout.txt')).read_bytes()
    record = {'returncode': process.returncode,
              'stdout_exact': process.stdout == expected,
              'stderr_empty': process.stderr == b''}
    records[script] = record
    (output / 'verification.json').write_text(json.dumps(records, indent=2) + '\n')
    if record != {'returncode': 0, 'stdout_exact': True, 'stderr_empty': True}:
        raise AssertionError((script, record))
    sys.stdout.buffer.write(process.stdout)
print('PASS: all fixed supplement checks')

verify_all.py 依次运行十项矩阵与原始日志审计、两个默认会话控制、失败后恢复、基线及四个变体,并检查每个命令的完整 stdout、空 stderr 与退出码。自写验收使用显式异常,不依赖会被 -O 删除的 assert。SDK 内部行为仍属于所固定版本的范围。

expected/check_run.stdout.txt
{"passed": 10, "protocol": "2025-11-25", "sdk": "1.29.0"}
{"independent_raw_cases": 10, "passed": true}
PASS: fixed matrix and independent raw oracle
expected/direct_baseline.stdout.txt
PASS: 2 unwrapped default-session controls returned 7 with parser diagnostics
expected/recovery.stdout.txt
PASS: stdout gate rejected; fresh stderr control returned 7; old evidence unchanged
expected/run_mutants.stdout.txt
PASS: baseline accepted; 3 policy mutants and child exit 7 rejected

完整入口的固定输出如下。普通模式与 -O 模式均逐字节匹配;门控成功拒绝属于被测的正常结果,所以外层验收进程仍退出 0。

expected/verify_all.stdout.txt
{"passed": 10, "protocol": "2025-11-25", "sdk": "1.29.0"}
{"independent_raw_cases": 10, "passed": true}
PASS: fixed matrix and independent raw oracle
PASS: 2 unwrapped default-session controls returned 7 with parser diagnostics
PASS: stdout gate rejected; fresh stderr control returned 7; old evidence unchanged
PASS: baseline accepted; 3 policy mutants and child exit 7 rejected
PASS: all fixed supplement checks
observed/verification.json
{
  "check_run": {
    "returncode": 0,
    "stdout_exact": true,
    "stderr_empty": true
  },
  "direct_baseline": {
    "returncode": 0,
    "stdout_exact": true,
    "stderr_empty": true
  },
  "recovery": {
    "returncode": 0,
    "stdout_exact": true,
    "stderr_empty": true
  },
  "run_mutants": {
    "returncode": 0,
    "stdout_exact": true,
    "stderr_empty": true
  }
}

本补充核对日期为 2026-10-13。独立实验及从正文提取、从 ZIP 解压的同一份新增源代码均已实际复跑;原文代码与旧证据保持原样,没有在本补充中重新宣称验收原来的 26 项。新图按 1120×680 自然尺寸离线查看,未借此声称验证线上布局。

gate 只检查本次初始化完成前已观察到的解析错误。没有覆盖延迟出现的噪声、无终止 LF、无效 UTF-8、空行、超长消息、分片边界、所有 JSON-RPC 形状、并发任务或任意恶意输入;它不是全面 framing 校验器或安全隔离层。也没有测试真实宿主、HTTP、OAuth、Windows 回收、外部工具写入或副作用撤销。

固定版本官方来源见本节前面的对应链接与离线包 SOURCES.json。新增示例、固定输入和 SVG 为原创;没有把依赖安装包或 SDK 源码复制进 ZIP。原文各节、原有代码、日期和其他元数据不变,本补充只增加章节。

下载启动 stdout 噪声契约实验完整离线包(SHA-256:4a7cc58533c250ff90fe9b65d516d83c303de7a0b48f80b70aefe5846dba87d1)。包含全文、新增源代码、固定预期、完整合成矩阵证据、恢复记录与原创 SVG;2026-10-20 同步修正前文日志审计,并在 original-contract/ 提供完整原实验及审计回归;不含依赖包、账户信息或内部工作记录。

19. 来源、许可与复现边界

技术资料按固定版本引用:官方 Python SDK v1.29.0 源码、该版本的 PyPI 条目,以及 MCP 2025-11-25 的生命周期和取消规范。本文不根据此实验判断现在应该升级到哪个版本。

Python SDK 的固定标签 LICENSE为 MIT;本次也核对了已安装发行包携带的许可证。示例代码、合成输入和三张 SVG 为本篇原创,没有把官方 SDK 实现复制进实验包。THIRD-PARTY-NOTICE.txt 保留依赖来源和其许可证文本,SDK 由读者另行安装。

完整运行证据只涉及合成站点、实验请求 ID 和进程 PID,不含真实账户、环境变量清单或凭据。文章中的安装命令尚未在全新虚拟环境执行;这项限制与真实 stdio 已运行的结论同时保留,便于读者判断自己还需要补哪些验证。

1.29.0 是最新推荐版本吗?

不是。本文冻结已安装的1.29.0完成兼容性实验,不调整其他教程的版本选择。

读取超时会自动取消服务端工作吗?

本篇实测的1.29.0调用路径不会。需要区分停止等待和显式取消,并观察处理器实际状态。

为什么超时和协议错误都叫 McpError?

异常类型相同不代表来源相同。本例408由客户端本地产生,-32601来自服务端error响应。

这能证明真实宿主接入成功吗?

不能。本篇验收本地官方SDK客户端与独立服务端的stdio链路,宿主集成需要另测。