写一个 MCP server 并断言协议往返

MCP 开发第 1 / 2 篇

本文的每个数字都是跑出来的。 实验环境 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 挂进真实客户端》里做。 这里不给「描述要清晰」这种没有可判定性的建议。