测试 MCP 服务器

17 分钟读完

C3
深入解析 · MCP

删掉握手,也就删掉了建立在它之上的每一个 fixture——于是今天值得写的 MCP 测试,断言的是每一个独立请求都自我描述;而人人都抄过的那两段进程内写法,要换掉 import 才还成立。

一套照着 2025-11-25 规范写的测试仍然会通过,而这正是危险所在:它是在一个你的服务器早已不再讲的协议上亮绿灯。到了 2026-07-28,没有 initialize、没有 Mcp-Session-Id、也没有可恢复的 SSE 重放,于是 session fixture 与可恢复性测试根本什么都没测到。那些直觉完好无损——进程内胜过子进程、schema 测试该与行为测试分文件、agent 循环不是测试——但 Python 的 import 换了地方,TypeScript 的入口整个换掉了,而今天价值最高的那几条断言(requestState 完整性、双时代矩阵、一个外来的 Origin),一年前几乎没人在写。

STEP 1

Python 的进程内测试,以及两个都正确的 Client。

MCP 测试上最大的单项提升,仍是本页最老的那条建议:别把服务器作为子进程拉起。启动一个 Python 解释器、再拨一个 socket,在做任何有用的事之前每个用例就要先花掉 200–500ms;进程内的等价写法是个位数毫秒。这个差距决定了一个装了 50 个用例的文件是开发时就会顺手跑,还是只在 CI 里露面;而只在 CI 里跑的文件会开始飘——进程启动与慢盘赛跑、残留的端口占用、一个没有空闲核心的容器。所有二阶的测试病症(只跑改动过的那个、只信"通常能过"的那些、把其余隔离掉)都从一套慢测试机械地推导出来。讲服务器构建的那一篇结尾列过一份"第一台服务器上会犯的错"清单;"还没试过进程内客户端就先写了子进程测试"属于那份清单。

变的是那道门。现在有两个正确的 Python Client,而且来自不同项目。官方 SDK mcp v2.2.0 在顶层导出一个——from mcp import Client——它直接接受一个服务器对象。老教程里的两个名字随之消失:mcp.shared.memory.create_connected_server_and_client_session 已被移除,mcp.server.fastmcp.FastMCP 更名为 mcp.server.MCPServer。另一边,PrefectHQ 的 FastMCP 4.0.3 仍然原样提供 fastmcp.client.Client,它也仍然可用;只需注意仓库已从 jlowin/fastmcp 迁到 PrefectHQ/fastmcp,收藏夹里那个 URL 已经不是在维护的那个。如果你要抄的片段没说明自己从哪个包 import,那它是有歧义,而不是写错了。

# tests/test_add.py — official SDK (mcp 2.2.0), in-process, no subprocess, no port
import pytest
from inline_snapshot import snapshot
from mcp import Client
from mcp.types import CallToolResult, TextContent
from server import mcp

@pytest.fixture
def anyio_backend(): return "asyncio"

@pytest.fixture
async def client():
    async with Client(mcp, raise_exceptions=True) as c:
        yield c

@pytest.mark.anyio
async def test_call_add_tool(client: Client):
    result = await client.call_tool("add", {"a": 1, "b": 2})
    result.meta = None                      # drop the serverInfo stamp
    assert result == snapshot(CallToolResult(
        content=[TextContent(type="text", text="3")],
        structured_content={"result": 3}))

那份 fixture 里没有出现 mode=,也不该出现。Client(server) 默认 mode="auto",而由于 MCPServer 在包括进程内在内的每一种传输上都应答 server/discover,当它对话的是你自己的服务器时,auto 永远会落到 2026-07-28。三种取值是 "auto"、"legacy",或一个钉死的版本字符串;钉版本是为了 STEP 4 的双时代矩阵,不是日常用法。还要注意快照比较之前的 result.meta = None:服务器会在每个 result 的 _meta 上盖 io.modelcontextprotocol/serverInfo 的戳,保留它的快照,就是每次版本号一动就会碎掉的快照。

raise_exceptions=True 值得搞懂而不是照抄,因为它的含义比名字看上去窄得多。它只影响工具体之外的失败——也就是生产环境会刻意消毒成 "Internal server error"、以免堆栈跟踪流到客户端的那些。测试要的是真实信息,所以开着。工具体内部的失败无论开不开这个标志,都以 is_error=True 回来。而它在生产里根本没有含义:这是一个测试用的便利开关,不是你选择留着不管的不安全配置。

STEP 2

TypeScript:用 handler.fetch,不是 createLinkedPair。

老建议里 TypeScript 的那一半,如今是真正错掉的那部分。InMemoryTransport.createLinkedPair() 并没有被删除——它仍然从 @modelcontextprotocol/client 导出——但官方文档对它的适用范围写得毫不含糊:"createLinkedPair connects 2025-era instances only; handler.fetch is the in-process entry for 2026-07-28 coverage."(createLinkedPair 只连接 2025 时代的实例;要覆盖 2026-07-28,进程内入口是 handler.fetch。)因此一对 linked pair 给你的,是一套从未演练过你所部署那个修订版的绿色测试。该用的包是 @modelcontextprotocol/server 与 @modelcontextprotocol/client 的 2.0.0;v1 以 @modelcontextprotocol/sdk@1.30.0 的形式继续存在。运行器是 vitest——如果你的 MCP 测试还挂在 jest 上,那是 2.0 这条线默认你已经换掉的第二样东西。

// tests/discount.test.ts — the transport never leaves the process
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';

const handler = createMcpHandler(createServer);          // createServer: () => McpServer
const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), {
    fetch: (url, init) => handler.fetch(new Request(url, init))
});
const client = new Client({ name: 'test-harness', version: '1.0.0' },
                          { versionNegotiation: { mode: 'auto' } });
await client.connect(transport);
const result = await client.callTool({ name: 'apply-discount', arguments: { price: 80, percent: 25 } });

// teardown order matters
await client.close();
await handler.close();

片段里那个 URL 是个标签,不是目的地:传输从不真的去拨 http://test.local/mcp——handler.fetch 在进程内服务每一个请求,走的正是你部署的那个 createMcpHandler。这恰恰是 linked pair 给不了的性质,因为 linked pair 完全绕开了 handler。另有三个后续细节决定这套脚手架还诚不诚实。拆除顺序是先 client 后 handler,因为 handler.close() 会中止任何仍在飞的交换,这样一次挂住的工具调用就不会漏进下一个用例。handler 的失败会以一个普通 result 加 isError: true 的形式 resolve,而不是抛出——没有任何东西可以 catch,一条 rejects.toThrow() 断言根本不会触发。而 stdio 完全没有进程内捷径:如果你出货的是 stdio 服务器,那个面要么走真实管道测,要么就没测。

STEP 3

NoBackChannelError 是你的服务器抛的,不是你的测试抛的。

这是进程内客户端能教给你的最有用的一件事,而它很容易被误读成脚手架的 bug。NoBackChannelError 是在服务器侧抛出的——当服务器代码去够一条无状态连接并不具备的反向通道时。你的代码落在界线哪一侧,取决于它是怎么问的:

  • Resolver / 依赖标记——在参数上声明的 Elicit、Sample、ListRoots。在 legacy 连接上可用(SDK 把问题推过去);在 2026-07-28 连接上同样可用,只是 SDK 改为返回一个 InputRequiredResult。
  • 命令式调用——await ctx.elicit(...)、ctx.session.create_message()、ctx.session.list_roots()。在 legacy 连接上可用;在 2026-07-28 上失败——没有反向通道可调。

官方的措辞值得原样带上,因为它同时就是迁移的理由:resolver "works on every connection. For a client on a legacy connection the SDK sends it the question directly; on a 2026-07-28 connection the SDK returns the question from the call… Your resolver never knows the difference."(它在每一种连接上都有效。对于处在 legacy 连接上的客户端,SDK 直接把问题发给它;在 2026-07-28 连接上,SDK 则把问题从这次调用中返回出来……你的 resolver 永远察觉不到差别。)于是指引收敛成一句话:把服务器迁到 resolver,你的测试就完全不需要 mode=。只有当服务器确实还在调 ctx.elicit()、create_message() 或 list_roots(),或者你要测的是 message_handler 时,才去动 mode="legacy"——并且在那个 fixture 里把 raise_exceptions=True 去掉,因为 legacy 连接本来就不做消毒,这个标志什么也换不来。

客户端这一侧的情况,比"时代分裂"听上去要好:一套回调同时服务两个时代。在 2026-07-28,独立的 server→client RPC 没有了,但一模一样的 ElicitRequest、CreateMessageRequest 与 ListRootsRequest 负载改为搭在 input_requests 里,派发到的还是你早就写好的那些回调。Client 会带着答案与回显的 request_state 重试,一直继续到拿回一个 CallToolResult;中间这些轮次对测试正文是不可见的。用 Client(..., input_required_max_rounds=10) 给它封顶,免得一台不停发问的服务器把整套测试挂住。完全不给回调,call_tool 会抛 MCPError("Elicitation not supported")——这本身就是一条挺好的断言。当你想检视这些轮次而不是跳过它们时,往下降一层:client.session.call_tool(..., allow_input_required=True),然后自己掌管那个 while isinstance(result, InputRequiredResult) 循环。采样与征询那一篇讲这些负载的含义;在这里,它们只是 fixture 的输入。

STEP 4

现在的 fixture 要搭什么:什么都不搭——改为断言那份自我描述。

已经没有"搭建"这一步了。取代协商出来的 session 的,是每个请求都必须自我描述,而这正是一套讲究合规的测试如今该断言的东西。params._meta 里必填:io.modelcontextprotocol/protocolVersion 与 io.modelcontextprotocol/clientCapabilities;io.modelcontextprotocol/clientInfo 则 SHOULD 发送。服务器 SHOULD 在每个 result 的 _meta 上盖 io.modelcontextprotocol/serverInfo——也就是 STEP 1 做快照前必须剥掉的那个字段。2026-07-28 修订版那一篇解释了逐请求重述为何存在;这里要紧的是,它把一次看不见的握手,换成了一串可检查的行为,每一条都自带错误码与 HTTP 状态:

  • 缺少某个必填的 _meta 字段 → -32602,HTTP 400。
  • 需要一项未被声明的能力 → -32021 MissingRequiredClientCapabilityError,由 data.requiredCapabilities 列出是哪些,HTTP 400。
  • 头部与正文的版本不一致,或缺了某个标准头 → -32020 HeaderMismatch,HTTP 400。每个 POST 都必带:MCP-Protocol-Version、Mcp-Method,以及 tools/call、resources/read、prompts/get 所需的 Mcp-Name。这些值可能以 Base64 哨兵编码 =?base64?…?= 的形式到达,服务器 MUST 先解码再比较——发一个这样的过去,看看你的服务器会不会。
  • 不支持的版本 → -32022,由 data.supported 列出支持哪些版本。
  • HTTP 上的未知方法 → 404 外加 -32601。
  • 一次 notification 的 POST → 202 Accepted,无正文。
  • 遗留流量 → GET 与 DELETE 一律 405;Mcp-Session-Id 头被忽略,既不铸造也不回显;Last-Event-ID 被忽略。
  • 错误的 Origin → 403 Forbidden。

这次更新的另一半是删除,而且删掉的比加上的多。Session fixture、Mcp-Session-Id 那套管道、initialize/initialized 的次序断言、用 DELETE 终结会话的拆除逻辑,以及每一个 SSE 可恢复性测试——Last-Event-ID 处理、event id 单调性、重连重放——都该整个从测试里拿掉。它们不只是冗余;它们断言的是一台合规服务器 MUST NOT 表现出来的行为。在 2026-07-28,流断掉只有一种正确处理:客户端以一个新 id 把这份工作作为新请求重发。

一个无状态的现代 handler 是逐请求构造的。一个在两次调用之间改服务器状态的 fixture——预热缓存、在服务器对象上翻一个 feature flag、藏一个计数器——会悄无声息地什么也不做,因为第二次调用拿到的是一个全新实例。测试照样通过,只是理由是错的;而且在它本该保护的那个行为消失之后,它还会继续通过。

真正新的,是一组几乎没人写过的断言——因为在这次修订之前,根本没有东西可断言。它们便宜、机械,而且该放进 schema 契约那个文件、而不是行为那个文件——正是模式与契约那篇深入解析在一般意义上主张的同一种拆分,而分开放的理由没变:契约一破,会一次性打中每一个客户端,包括你并不拥有的那些。

  • server/discover 确实实现了。服务器 MUST 实现它;而移植过来的服务器里,没实现的数量出人意料。
  • tools/list 不随连接而变(它 MAY 随所出示的授权而变)。直接测:列一次、调几个工具、再列一次,断言两份清单完全相同。
  • ttlMs 与 cacheScope 都在——在 tools/list、prompts/list、resources/list、resources/read 与 resources/templates/list 上。
  • 工具以确定的顺序返回(SHOULD)——一份靠字典迭代顺序排出来的清单,就是一个毫无理由地飘的快照测试。
  • 状态句柄不是身份凭证。服务器 MUST NOT 把"持有句柄"当作已认证,并且 SHOULD 以 <user_id>:<handle> 的形式给状态分键。以主体 A 铸出一个,以主体 B 出示,断言被拒。
  • 双时代矩阵。现代↔现代通;现代客户端 → 遗留服务器失败;遗留客户端 → 现代服务器失败;双时代服务器两个方向都通。只测你真的对外承诺支持的那几格。
STEP 5

resultType、requestState 完整性,以及鉴权的接缝。

每个 result 都 MUST 带 resultType,取值为 "complete" 或 "input_required"。无法识别的值 MUST 当作非法;而缺失的那个 MUST 当作 "complete"——正是这条规则让更老的服务器还能工作。请在线级断言它,而不要透过一个有类型的客户端——类型化客户端早就把这个字段归一化了,于是一台漏发它的服务器看起来和正确设置的那台一模一样,直到它撞上一个更严格的客户端为止。

实现最常写错的是重试规则,而规范把它写得很直白:"Note that the JSON-RPC id MUST be different between the initial request and the retry."(注意:初始请求与重试之间的 JSON-RPC id 必须不同。)下面这个形状值得钉进一份 fixture,因为嵌套很容易搞错——inputResponses 与 requestState 直接坐在 params 里面,与 arguments 平级,而不是在它内部。

// round 1 response
{"jsonrpc":"2.0","id":2,"result":{
  "resultType":"input_required",
  "inputRequests":{"github_login":{"method":"elicitation/create","params":{...}}},
  "requestState":"eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0..."}}

// round 2 request — NEW id
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
  "name":"get_weather","arguments":{"location":"New York"},
  "inputResponses":{"github_login":{"action":"accept","content":{"name":"octocat"}}},
  "requestState":"eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0..."}}

// tamper: flip one byte of requestState, retry, assert the frozen error
{"code": -32602, "message": "Invalid or expired requestState"}

requestState 的完整性是一条 MUST,也是 MCP 测试里最缺的一条断言。两程之间那份状态由客户端保管,也就是说回来的东西是客户端提供的输入:可改、可过期,或者整段从另一次调用里搬过来。这个测试三行就够。跑一轮,把 requestState 里翻掉一个字节,重试,断言那条固定的错误 {"code": -32602, "message": "Invalid or expired requestState"}——所有原因共用同一条消息,于是线上永远不会泄露是哪一项检查失败。然后再重复两次:一次重放从另一次调用里抓来的有效状态,一次出示由另一个主体铸出的有效状态。三次必须给出同一个答案。一台在错误文本里把它们区分开的服务器,等于亲手造了一台预言机。

这里有一处不对称值得挑明,因为它会悄悄决定这条测试是天生就过,还是第一天就挂。Python 的 MCPServer 默认会密封 requestState,用的是进程启动时生成的密钥;对任何跨实例、或必须熬过一次重启的部署,请配置 RequestStateSecurity(keys=[...]),密钥不少于 32 字节,并注意 TTL 默认 600 秒且绑定已认证的主体。TypeScript 默认不密封。你要用 @modelcontextprotocol/server 的 createRequestStateCodec 显式开启,它的 key MUST 至少 32 字节,否则构造会抛 RangeError。Python 的低层 Server 同样不密封。而且没有哪个 SDK 能知道某个答案属于你的哪一个问题——把你自己的问题标识放进状态里,重试时核对它。还有一条时代护栏:在 legacy 连接上返回 InputRequiredResult 会得到 -32603 "Handler returned an invalid result",所以一台双时代服务器必须先看协议版本,再决定怎么问。

鉴权不必搭起一整个身份提供方也能测,而"没有 IdP"正是最常被拿来解释"没测"的借口。TokenVerifier 是一个只有一个异步方法的 protocol——verify_token 接收原始令牌,返回一个 AccessToken 或 None,此外没有别的要实现——所以把它 stub 掉,从测试里驱动每一条分支。TypeScript 暴露的是同样的接缝:verifyBearerToken、requireBearerAuth、buildOAuthProtectedResourceMetadata。陷阱在于 get_access_token() 在进程内与 stdio 上都返回 None,所以任何按 scope 判权的逻辑都必须走真实 HTTP 来测——这是进程内脚手架唯一真的够不着的地方。OAuth 2.1 配置那一篇讲这些令牌里必须有什么。

STEP 6

一致性测试、Inspector,以及该停止推荐的东西。

现在有了官方的一致性测试套件 @modelcontextprotocol/conformance,而入门处就有一个版本陷阱:npm 上的 latest 是 0.1.16,但 SDK 自己跑的是 0.2.0-alpha.11。优先用钉死版本的 npx 形式,而不是官方那个 composite Action——后者的 tag 停在 v0.1.16。

$ npx --yes @modelcontextprotocol/conformance@0.2.0-alpha.11 server \
    --url http://localhost:3001/mcp --requirements 2026-07-28 \
    --expected-failures ./conformance/baseline.yml

$ npx @modelcontextprotocol/inspector --cli node build/index.js \
    --method tools/list --strict --format json \
    | jq -e '[.schemaFindings[]?.findings[]? | select(.severity=="error")] | length == 0'

这套件既跑逐场景的断言,又把每一条 JSON-RPC 消息拿去对规范的 JSON Schema 做校验,这比任何手写脚手架都多做了一步。冻结下来的 2026-07-28 集合是37 个必测的服务器场景、32 个客户端场景、20 个不计分场景——其中包含 11 个 MRTR 场景,以及 server-stateless、dns-rebinding-protection 与 caching。两个运维细节决定它说不说实话。场景各自按其所属修订版的线上版本运行,所以一个对两个修订版都适用的场景必须在两个版本下各跑一次;跑一次不等于覆盖了另一次。还有,expected-failures 基线是双向棘轮:列在基线里的失败退出 0,新出现的失败退出 1,而一个仍列在基线里却开始通过的场景同样退出 1——正是这一条让基线不至于烂成一张永久免罪符。在你依赖它之前,有一个缺口值得点明:auth/ 那组场景只为客户端存在。如果你跑的是一台受保护的服务器,一致性测试根本不会测你的令牌校验。

"Inspector 是调试器、不是测试"这个说法,从 v2 起就已经过时。Inspector v2.6.0 提供了为 CI 打造的 --cli 客户端,带九个稳定的退出码:0 成功、1 用法错误或意外、2 没找到 MCP App、3 服务器要求鉴权、4 连不上、5 工具错误、6 --strict schema 可移植性错误、7 --verify skills 违规、8 --verify 不完整。任何非零退出,它都会向 stderr 写一行 JSON 错误信封——取最后一行。--strict 才是真正算契约测试的那部分,而它的理由是最值得引用的一句:对 617 台公开服务器的普查发现,没有一台过不了 SDK 自己的解析器。纯 JSON Schema 校验什么也查不出来;真正打断客户端的,是每个消费方各自接受的那个更窄的子集。坑:Inspector 的协议时代默认是 legacy,而且只是一个配置文件字段——protocolEra,没有对应的命令行开关——所以一次 --cli 运行走的是遗留路径,除非你传一份设了 "protocolEra": "modern" 的配置。

相比之下,本页从前推荐的那个工具实际上已经死了。微软的 mcp-interviewer 最后一次发布是 2025-10-11 的 v0.0.12,之后的提交记录只剩 Dependabot 且止于 2025-12-01,而微软自己的 README 把它描述为研究性/实验性。别拿它搭 CI 门禁。同样的判断适用于第三方测试与扫描层的大部分:steviec/mcp-server-tester、mclenhard/mcp-evals、f/mcptools、PyPI 上的 mcp-testing-framework,以及 mcp-shield、mcpSafetyScanner、mcp-guardian、mcp-context-protector 这一串扫描器,全都无人维护;曾在 2025 年各种链接清单里广为流传的 Janix-ai/mcp-validator,如今是一个 404。还在维护的那一面,是一致性测试套件、Inspector,以及你自己写的测试。

把一致性测试接进 CI,有一个两个官方 SDK 都收敛到的形状,而它的每一行都是一道疤。先预检端口,若已有东西在监听就拒绝启动,因为就绪检查分不清你的服务器和一台残留的。在等待循环里用 kill -0 探活,这样崩掉的服务器一秒内就失败,而不是把超时烧完。给 curl 一个 --max-time,免得一个黑洞式的监听端口把循环永远卡住。再 trap 住清理,免得一次失败的断言把进程漏进下一个 job。

# ci/conformance.sh — precheck, liveness, bounded curl, trapped cleanup
set -euo pipefail

lsof -i :3001 -sTCP:LISTEN -t >/dev/null && { echo "port 3001 already in use"; exit 1; }

node build/index.js --port 3001 --stateless &
SERVER_PID=$!
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT

for _ in $(seq 1 50); do
  kill -0 "$SERVER_PID" 2>/dev/null || { echo "server exited during startup"; exit 1; }
  curl -fsS --max-time 2 http://localhost:3001/health >/dev/null && break
  sleep 0.2
done

npx --yes @modelcontextprotocol/conformance@0.2.0-alpha.11 server \
  --url http://localhost:3001/mcp --requirements 2026-07-28

要在一个 job 里跑完两个时代,就把服务器起两次——一次有状态、对 --requirements 2025-11-25,一次无状态、对 --requirements 2026-07-28——而不是指望一次运行能推出另一次。这里有句现实话该说:目前没有任何一台旗舰级 MCP 服务器在跑官方一致性测试套件。落地发生在 SDK 与网关那一层,这意味着你去跑它,是跑在你要与之互操作的那些服务器前面,而不只是跟它们持平。

如果本页你什么都不采纳,至少采纳这三条覆盖面远超其成本的测试。第一,发一个外来的 Origin,断言 403。四行代码,覆盖的是 MCP 上被重复最多的那个漏洞:DNS 重绑定与缺失的 origin 校验,以七个 CVE 的形式散落在六个 SDK 以及 Inspector 自身上,其中 CVE-2025-49596 的 CVSS 是 9.4——这套模式的目录在安全反模式那一篇。第二,并发打出 N 个 tools/call,一次用互不相同的 JSON-RPC id,一次故意重复。这抓的是 CVE-2026-25536——共享传输把一个客户端的响应漏给了另一个,"在无状态部署里最常见"——以及 python-sdk 上一个尚未关闭的 issue:两个共用同一个 id 的并发请求会串线,其报告者观察到在生产中,一位用户的工具响应被投递给了另一段对话的请求。第三,给 tools/list 做一份带字节上限的快照,并在若干次调用之后重新核对一遍。重新核对才是全部要点:Deadbugz 行动只在第三次调用之后才改动工具描述,所以一次性断言会干干净净地通过——机制见工具毒化那一篇。同一条测试顺手还能抓住 schema 方言破裂,以及把上下文撑爆的工具目录。

有两条负面结论值得说出来而不是藏起来,因为假装不是这样,正是一套测试染上表演性的途径。今天 MCP 没有像样的压测方案;如果你需要吞吐数字,那是要自己搭脚手架的,你该为此排预算而不是去选型。而提示词注入事件不是服务器测试能抓到的:没有任何服务器侧的断言会失败,因为服务器做的正是别人要它做的事。你测的是架构上的控制——allowlist、确认门、收窄的令牌——而不是注入本身。这也正是关于 agent 循环那条老建议的诚实版本。探索性的 agent 跑法是你弄清"该测什么"的方式,仍然值得做;它们不是告诉你契约守住了的那个东西,因为一个够格的 agent 会读错误、用改对的参数名重试,然后在一次会打断你并不拥有的每一个缓存客户端的 schema 回归上,产出一个看起来没问题的结果。