写一个 MCP server 并断言协议往返
本文的每个数字都是跑出来的。 实验环境 Node v22.22.3 +
@modelcontextprotocol/sdk@1.30.0, 协议版本2025-06-18。正文里的代码可以整段复制运行,不依赖任何包 —— 只有第四节「换真实客户端」那部分需要装 SDK。
写一个 MCP server 不难,难的是知道它到底合不合规。跑通一次 tools/call 只能证明
happy path 通了;协议里那些看着像样板的字段(jsonrpc、id、isError)一旦写错,
症状五花八门 —— 从「立刻抛错」到「等六十秒然后给你一句和原因完全无关的话」都有。
所以这一篇的结构是倒过来的:先写 server,再写断言,然后故意把 server 写坏六次, 看断言抓不抓得到、真实客户端又是什么反应。最后那一步才是这篇真正的产出。
一、六十行的 server
stdio 传输 + JSON-RPC 2.0,一个工具。刻意不用 SDK —— 这一篇要讲的就是协议本身, 用 SDK 会把要讲的东西整个盖住。
#!/usr/bin/env node
// 最小 MCP server:stdio + JSON-RPC 2.0,一个工具。
import { createInterface } from 'node:readline';
const PROTOCOL_VERSION = '2025-06-18';
const TOOLS = [{
name: 'count_chars',
description: '统计一段文本的字符数与行数。',
inputSchema: {
type: 'object',
properties: { text: { type: 'string', description: '要统计的文本' } },
required: ['text'],
},
}];
const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
const result = (id, r) => send({ jsonrpc: '2.0', id, result: r });
const error = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
const rl = createInterface({ input: process.stdin });
rl.on('line', (line) => {
if (!line.trim()) return;
let req;
try { req = JSON.parse(line); } catch { return error(null, -32700, 'Parse error'); }
const { id, method, params } = req;
switch (method) {
case 'initialize':
return result(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: 'count-chars', version: '0.1.0' },
});
case 'notifications/initialized':
return; // 通知没有 id,不回响应
case 'tools/list':
return result(id, { tools: TOOLS });
case 'tools/call': {
if (params?.name !== 'count_chars') {
return error(id, -32602, `Unknown tool: ${params?.name}`);
}
const text = params?.arguments?.text;
if (typeof text !== 'string') {
// 工具**执行**失败走 isError,不走 JSON-RPC error。理由在第四节有实测。
return result(id, {
content: [{ type: 'text', text: '参数 text 缺失或不是字符串' }],
isError: true,
});
}
return result(id, {
content: [{ type: 'text', text: `字符 ${text.length} · 行 ${text.split('\n').length}` }],
});
}
default:
return error(id, -32601, `Method not found: ${method}`);
}
});
三件事值得先点出来,它们后面都被实验证明过或推翻过:
- 一行一个 JSON(换行分隔),不是 HTTP 那套
Content-Length头。stdio 传输就这么简单。 - 通知没有
id,因此不回响应。notifications/initialized是客户端握手后发来的。 - 两种「失败」走两条路:协议层错误用 JSON-RPC 的
error,工具执行失败用result.isError = true并且照样带content。这是整个协议里最容易搞混的地方。
二、二十条协议往返断言
initialize → tools/list → tools/call,逐字段校验。同样不依赖任何包 ——
断言要能在别人机器上原样跑起来,才算可复现。
// 起一个 server,按顺序发若干请求,收齐响应后退出
function session(serverPath, requests) {
return new Promise((resolve, reject) => {
const p = spawn('node', [serverPath], { stdio: ['pipe', 'pipe', 'pipe'] });
const out = [];
let buf = '';
const wantIds = requests.filter((r) => r.id !== undefined).length;
p.stdout.on('data', (d) => {
buf += d;
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.trim()) out.push(JSON.parse(line));
if (out.length >= wantIds) { p.kill(); resolve(out); }
}
});
p.on('close', () => resolve(out));
p.on('error', reject);
setTimeout(() => { p.kill(); resolve(out); }, 5000); // ⚠️ 见下
for (const r of requests) p.stdin.write(JSON.stringify(r) + '\n');
});
}
⚠️ 那个 5 秒兜底 setTimeout 不是防御性编程的装饰,它是这套断言唯一的活路 ——
后面会看到,有两类破坏会让 server 永远不回话,没有它整个测试进程就停在那里不动了。
断言分五组共二十条。挑两组关键的:
console.log('\n【4】两种「失败」必须走两条不同的路');
{
const rs = await session(S, [
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
// 工具执行失败(参数不对)→ 期望 result + isError
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'count_chars', arguments: {} } },
// 协议层错误(工具不存在)→ 期望 JSON-RPC error
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'nope', arguments: {} } },
// 方法不存在 → 期望 -32601
{ jsonrpc: '2.0', id: 4, method: 'no/such/method' },
]);
const exec = rs.find((x) => x.id === 2);
const proto = rs.find((x) => x.id === 3);
const nom = rs.find((x) => x.id === 4);
claim('工具执行失败 → result.isError = true(不是 JSON-RPC error)',
exec?.result?.isError === true && !exec?.error);
claim('工具执行失败仍带 content(模型要看得到原因)',
Array.isArray(exec?.result?.content) && exec.result.content.length > 0);
claim('未知工具 → JSON-RPC error -32602', proto?.error?.code === -32602);
claim('未知方法 → JSON-RPC error -32601', nom?.error?.code === -32601);
}
console.log('\n【5】通知(无 id)不该产生响应');
{
// 🚨 graceMs 不能省,理由见本节末尾那个「恒真断言」——
// 判据是「不该有多余响应」,收满预期条数就收摊的话,多余的那条永远看不到。
const rs = await session(S, [
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
{ jsonrpc: '2.0', method: 'notifications/initialized' },
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
], { graceMs: 300 });
claim('只收到 2 条响应(通知不回)', rs.length === 2);
}
跑出来 20 条全过。
到这里如果就收工,这篇文章就是错的 —— 因为全绿的断言必须先证明它会红。
三、把 server 故意写坏六次
一次只破坏一处,然后用同一套断言打。
| 破坏方式 | 改了什么 |
|---|---|
no-jsonrpc |
响应里不带 jsonrpc 字段 |
wrong-id |
id 不回显(永远回 0) |
no-schema |
tools/list 不给 inputSchema |
exec-as-proto |
工具执行失败误用 JSON-RPC error |
reply-to-notification |
给通知也回一条响应 |
content-as-string |
content 给成字符串而不是数组 |
实测:
| 破坏方式 | 二十条断言里失败几条 |
|---|---|
wrong-id |
11 |
content-as-string |
3 |
no-schema |
2 |
exec-as-proto |
2 |
no-jsonrpc |
1 |
reply-to-notification |
1 |
六个全部被抓到 —— 断言不是恒真式,这一步的目的达到了。
🚨 2026-09-07 复核补记:上面这句话当时只对了五个半。
重跑时
reply-to-notification报「一条都没红」,与表上的 1 不符 —— 而验证代码逐字节没动过。连跑三次:2 次报 0、1 次报 1。根因在收响应的那个
session():它收满预期条数就kill并返回。 第 5 组的判据是「只收到 2 条」,而预期条数正好是 2 —— 第 3 条根本没机会到达,那条断言因此恒为真。 当初偶尔报红,只是因为 3 条响应挤在同一个data块里被一次读完了。修法就是上面代码里那个
graceMs:收满之后再等 300ms, 专门给「多余的响应」一个出现的机会。修完连跑 5 次稳定报红,合规版仍 20 条全过。📌 判据写成「不该有 X」的时候,收集器必须给 X 出现的机会。 「收满就停」和「断言没有多余的」是直接冲突的两件事,而冲突的表现是一个看着正常的数字。 ⚠️ 间歇性通过比稳定失败危险得多:这处破坏名义上「有断言覆盖」,实际覆盖率约 1/3, 而表格上的「1」让它看起来和其它五个一样可靠。
⚠️ 本次复核只重跑了零依赖那一档(
npm run verify:生成破坏版 + 负例总控)。 下一节那张「官方 SDK 客户端怎么表现」的表没有重跑(要先npm i)—— 不过@modelcontextprotocol/sdk至今仍是写作时的1.30.0(仍是 latest), 依赖没动过。
但这张表给出了一个和直觉相反的分布:
id回显是最 load-bearing 的字段。 破坏它触发 11 条失败,因为客户端按id把响应配回请求;一旦对不上,后面所有步骤都拿不到自己的结果。它在代码里长得最像 样板(send({ jsonrpc, id, result })),却最不能错。jsonrpc: '2.0'只让断言掉了 1 条。 它是规范里写得最显眼、也最容易漏的字段, 但在这套断言的视角里几乎不承重。
先别据此下结论说「jsonrpc 字段不重要」—— 下一节会把这个印象推翻。
四、换真实客户端再打一遍
上一节用的是我自己写的客户端。它按 id 配对、按字段断言 —— 但真实客户端不一定这么做。
换成官方 SDK 的 Client(Claude Code、Codex 底下用的就是它),同样六个版本再打一遍:
| 破坏方式 | 官方 SDK 客户端的表现 |
|---|---|
| 合规基线 | 全通;badArgs 返回 isError=true |
no-jsonrpc |
connect 就卡住,60 秒后 MCP error -32001: Request timed out |
wrong-id |
connect 过了,listTools 卡住,60 秒后同样的超时 |
no-schema |
connect 过,listTools 立刻抛 zod 校验错(expected: "object") |
exec-as-proto |
全通;只有 badArgs 从「返回 isError」变成「抛 MCP error -32602」 |
reply-to-notification |
完全没有影响,行为与合规基线一字不差 |
content-as-string |
connect、listTools 过,callTool 立刻抛 zod 校验错(expected: "array") |
超时那两条是掐表量的,两次都是 60.0 秒:
不带 jsonrpc 字段 connect 60.0s 抛出:MCP error -32001: Request timed out
id 不回显 listTools 60.0s 抛出:MCP error -32001: Request timed out
这张表把上一节的结论改掉了一半,也给出了这篇最有用的三条。
1. 故障分两类,可查性差一个数量级
类型错(no-schema、content-as-string)—— SDK 用 zod 校验响应,立刻抛出带字段
路径的错误,expected: "array" 直接告诉你哪里不对。这类最好查。
结构错(no-jsonrpc、wrong-id)—— SDK 等 60 秒,然后给你一句
Request timed out。那句话完全不指向真实原因:你缺的是一个字段,它告诉你的是
「超时」。这就是「我的 MCP server 连不上」难查的原因 —— 错误消息把你引向网络、进程、
路径,而问题在响应体的一个键上。
所以上一节那个「jsonrpc 字段不承重」的印象,是我的断言造成的假象:在我的客户端里
它只值 1 条失败,在真实客户端里它让整个握手停摆一分钟。
2. reply-to-notification 在真实客户端里完全无害
规范说通知不该有响应,我的断言为此专门写了一条(rs.length === 2),而官方 SDK
根本不在意 —— 多回的那条它直接丢掉,全流程与合规版一字不差。
这不是说可以随便违反规范,而是说:规范里的条目不是等权重的, 而「哪些真的承重」只能测出来,不能读出来。
3. exec-as-proto 是六个里最阴的一个
它在正常路径上完全正常 —— connect、listTools、callTool 全通,返回值一模一样。
差别只在失败路径:正确写法返回 isError=true 带 content,错误写法抛
MCP error -32602。
为什么这个差别要紧:isError 的意义是让模型看到失败原因并自我修正
(「参数 text 缺失」会作为工具结果进入对话,模型下一轮可以补上参数)。抛成协议错误的话,
这段内容不进对话,模型只知道「工具坏了」,通常的反应是放弃或者原样重试。
而这个 bug 只在出错时才出错。写完跑一遍 happy path,你不会发现它。
五、这一篇的判据
回到开头那个问题:怎么知道自己的 server 合不合规。
自制断言不能代替真实客户端验证 —— 两者查的东西不一样,敏感度分布几乎相反。
| 自制断言 | 真实 SDK 客户端 | |
|---|---|---|
wrong-id |
11 条失败(最敏感) | 60 秒超时,消息不指向原因 |
no-jsonrpc |
1 条失败(几乎不敏感) | 握手直接停摆 60 秒 |
reply-to-notification |
1 条失败 | 毫无影响 |
no-schema / content-as-string |
2 ~ 3 条失败 | 立刻抛出带字段路径的错误 |
两者都要跑:自制断言告诉你协议字段对不对(快、可复现、能进 CI),真实客户端告诉你 这些错在实际使用中长什么样(哪些会卡死、哪些会静默)。
如果只能留一个,留真实客户端那一套 —— 因为它会暴露「60 秒超时」这类 你的断言看不见、而使用者一定会撞上的形态。
本文没有回答的一个问题:工具的
description怎么写才会被模型正确调用。 那需要真实的模型调用来统计命中率(同一个描述写三个版本、各测 20 次那类实验), 本机跑不了,放在同节点的下一篇《把 MCP server 挂进真实客户端》里做。 这里不给「描述要清晰」这种没有可判定性的建议。