我想把本地几个小工具接到 Agent 上,先装了官方 SDK,跑起来报一句"工具调用失败"。盯着这句话我发现自己根本没法动手——握手没过、协议不对、工具自己炸了,这三种完全不是一回事,但它们的报错长得一模一样。

SDK 把细节都封起来了,出问题只能猜。所以我把它扔了,自己写了个最小的 MCP server,把字节流看清楚。写完发现这东西比我想的简单得多:不装任何 SDK,80 行 Node,能握手、能列工具、能被真调用。

这篇就是那个过程的记录。代码和下面所有输出都是本机实跑的,Windows + Node 22,一个字没改。

MCP 到底是个什么东西

拆开看只有两样:JSON-RPC 2.0 的消息格式,加一个传输层(stdio 或 HTTP)。

所谓"给 Agent 接个工具",本质就是:宿主起一个子进程,往它 stdin 写一行 JSON,从 stdout 读回一行 JSON。就这样。没有魔法。

我愿意自己写一遍,不是为了造轮子,是为了排障能力。现在再看到"工具调用失败",我知道该先看握手有没有过、再看协议字段对不对、最后才查工具本身。这三个的排查方向完全不同,以前是一笔糊涂账。

翻规范的时候被版本搞了一下

动手前我去翻官方规范,发现已经推到 2026-07-28 版,而且这一版动了个不小的手术:

  • 新版(modern):不要 initialize 握手了,协议版本改成塞在每个请求的 _meta 字段里,服务端必须实现 server/discover 让客户端来探版本
  • 旧版(legacy,2025-11-25 及更早):就是大家熟悉那套——先 initialize,再发 notifications/initialized,然后干活

规范里专门写了一整节 dual-era 兼容矩阵,明说两种实现会长期并存。

我最后按 legacy 流程写,理由很实际:现存的客户端、SDK、各类 Agent 宿主大量走的还是这套,我这个 server 是要给它们用的。但要新起项目的话,动手前先去 modelcontextprotocol.io/specification/latest 看一眼——别拿这篇当协议圣经,协议还在动。

Server:80 行

消息格式是 NDJSON,一行一条 JSON,换行分隔,不带 Content-Length 头(那是 HTTP 传输的事)。

#!/usr/bin/env node
const fs = require('fs');

const PROTOCOL_VERSION = '2025-06-18';
const MAX_READ = 64 * 1024;   // 读文件上限,别把整本日志灌进上下文

const log = (...a) => console.error('[server]', ...a);   // ⚠️ 只能 stderr

const TOOLS = [
  {
    name: 'list_dir',
    description: '列出指定目录下的条目,标注是文件还是目录',
    inputSchema: {
      type: 'object',
      properties: { path: { type: 'string', description: '目录绝对路径' } },
      required: ['path'],
    },
  },
  {
    name: 'read_file',
    description: '读取文本文件内容,超过 64KB 会截断',
    inputSchema: {
      type: 'object',
      properties: { path: { type: 'string', description: '文件绝对路径' } },
      required: ['path'],
    },
  },
];

function ok(id, result) {
  process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
}
function fail(id, code, message) {
  process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n');
}

function callTool(name, args) {
  if (name === 'list_dir') {
    const items = fs.readdirSync(args.path, { withFileTypes: true })
      .slice(0, 50)
      .map(d => `${d.isDirectory() ? 'DIR ' : 'FILE'}  ${d.name}`);
    return { content: [{ type: 'text', text: items.join('\n') || '(空目录)' }], isError: false };
  }
  if (name === 'read_file') {
    const buf = fs.readFileSync(args.path);
    const truncated = buf.length > MAX_READ;
    const text = buf.slice(0, MAX_READ).toString('utf8');
    return { content: [{ type: 'text', text: truncated ? text + '\n...(已截断)' : text }], isError: false };
  }
  return null;
}

async function handle(line) {
  let msg;
  try { msg = JSON.parse(line); }
  catch (e) { log('收到非 JSON 行,已忽略'); return; }

  const { id, method, params } = msg;
  log('<-', method || '(notification)');

  if (id === undefined) return;   // 通知没有 id,不回复

  try {
    if (method === 'initialize') {
      ok(id, {
        protocolVersion: PROTOCOL_VERSION,
        capabilities: { tools: { listChanged: false } },
        serverInfo: { name: 'mini-mcp-server', version: '0.1.0' },
      });
    } else if (method === 'tools/list') {
      ok(id, { tools: TOOLS });
    } else if (method === 'tools/call') {
      const r = callTool(params.name, params.arguments || {});
      if (!r) return fail(id, -32602, 'Unknown tool: ' + params.name);
      ok(id, r);
    } else {
      fail(id, -32601, 'Method not found: ' + method);
    }
  } catch (e) {
    // 工具内部异常属于"工具执行错误",不是协议错误
    ok(id, { content: [{ type: 'text', text: '工具执行失败: ' + e.message }], isError: true });
  }
}

let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
  buf += chunk;
  let i;
  while ((i = buf.indexOf('\n')) >= 0) {
    const line = buf.slice(0, i);
    buf = buf.slice(i + 1);
    if (line.trim()) handle(line);
  }
});
process.stdin.on('end', () => { log('stdin 关闭,退出'); process.exit(0); });

光有 server 我不放心,又写了个客户端

server 自己说"我好了"不算数。我写了个客户端真的发一遍请求,顺便把两条错误路径也跑掉:

const { spawn } = require('child_process');
const p = spawn(process.execPath, ['mini-mcp-server.js'], { stdio: ['pipe', 'pipe', 'pipe'] });

let seq = 0; const pending = new Map(); let buf = '';
p.stdout.on('data', d => {
  buf += d.toString();
  let i;
  while ((i = buf.indexOf('\n')) >= 0) {
    const line = buf.slice(0, i); buf = buf.slice(i + 1);
    if (!line.trim()) continue;
    const msg = JSON.parse(line);
    if (msg.id !== undefined && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
  }
});
p.stderr.on('data', d => process.stderr.write('  ' + d.toString()));

function request(method, params) {
  const id = ++seq;
  return new Promise(resolve => {
    pending.set(id, resolve);
    p.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
  });
}
function notify(method, params) {
  p.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
}

(async () => {
  const init = await request('initialize', {
    protocolVersion: '2025-06-18', capabilities: {},
    clientInfo: { name: 'mini-test-client', version: '0.1.0' },
  });
  console.log('initialize ->', JSON.stringify(init.result));

  notify('notifications/initialized');           // 通知,不该有回复

  const list = await request('tools/list', {});
  console.log('tools ->', list.result.tools.map(t => t.name));

  const dir = await request('tools/call', { name: 'list_dir', arguments: { path: '.' } });
  console.log('list_dir ->', dir.result.content[0].text);

  const bad = await request('tools/call', { name: 'no_such_tool', arguments: {} });
  console.log('协议错误 ->', JSON.stringify(bad.error));

  const boom = await request('tools/call', { name: 'list_dir', arguments: { path: 'C:/no/such/dir' } });
  console.log('工具执行错误 ->', JSON.stringify(boom.result));

  p.stdin.end();
})();

跑出来是这样

握手返回的 result:

{
  "protocolVersion": "2025-06-18",
  "capabilities": { "tools": { "listChanged": false } },
  "serverInfo": { "name": "mini-mcp-server", "version": "0.1.0" }
}

列目录:

FILE  mcp-client-test.js
FILE  mini-mcp-server.js

调一个不存在的工具——这是协议错误,走 JSON-RPC 的 error:

{ "code": -32602, "message": "Unknown tool: no_such_tool" }

给一个不存在的路径——这是工具执行错误,走 result 里的 isError:

{
  "content": [{ "type": "text", "text": "工具执行失败: ENOENT: no such file or directory, scandir 'C:\\no\\such\\dir'" }],
  "isError": true
}

这两个必须分开,工具执行错误也要返回 result,不是 error。因为这一步协议是通的,是工具自己没干成。我一开始也差点写成 -32603,但那样客户端拿不到能给模型看的信息——模型只看到"内部错误",不知道是路径不存在。把原因塞进 content 里,它才有机会自我纠正。

网上传的那几个坑,我故意写错了一遍

"日志别写 stdout""通知别回消息"这类话网上到处都是,但我不想抄。我写了个脚本,把三处故意写错,看真实报什么错:

=== 坑 A:日志混进 stdout ===
客户端收到的行:
  [1] JSON ❌ Unexpected token 's', "[server] 收到"... is not  [server] 收到 initialize
  [2] JSON ✅  {"jsonrpc":"2.0","id":1,"result":{"ok":true}}

=== 坑 B:给通知回消息 ===
客户端收到的响应:
   {"jsonrpc":"2.0","id":1,"result":{"ok":true}}
   {"jsonrpc":"2.0","result":{"ok":true}}

=== 坑 C:不缓冲 stdin,长消息被切开 ===
请求字节数: 300067
服务端 stderr:
   [server] JSON.parse 失败: Unterminated string in JSON at position  | 这段长度 65536
   [server] JSON.parse 失败: Unexpected token 'x', "xxxxxxxxxx"... is | 这段长度 65536
   [server] JSON.parse 失败: Unexpected token 'x', "xxxxxxxxxx"... is | 这段长度 65536
   [server] JSON.parse 失败: Unexpected token 'x', "xxxxxxxxxx"... is | 这段长度 65536
  ...共 5 条
服务端 stdout: (空,请求被丢弃)

坑 A:stdout 第一条是日志文本,客户端当场 JSON.parse 炸掉。表现出来就是"连不上",但你查半天网络。

坑 B:多出一条没有 id 的幽灵响应。客户端的 pending map 里找不到对应项,行为完全看实现心情。

坑 C 是我觉得最有意思的一个。我先用 40KB 试,没复现——管道缓冲一次就收全了。加到 300KB 才复现,而且每段正好 65536 字节。这说明这个坑跟消息体积强相关:你的工具参数小的时候它永远不出现,一旦传个文件内容进去就开始丢请求,而且丢得莫名其妙。不缓冲 stdin 的代码能跑通一百次小请求,然后在第一百零一次炸掉。

汇总一下:

容易翻车的地方真实症状我怎么处理的
日志写进 stdout客户端 JSON 解析失败,报"无法连接"所有日志走 console.error
给通知回消息多出一条 id=null 的响应,行为未定义id === undefined 直接 return
不缓冲 stdin大消息被切成 65536 一段,请求直接丢累积到 \n 再解析
协议错误与工具错误混用模型看不到失败原因未知工具 → -32602;工具内部异常 → isError: true
Windows 上 spawn 写死 node 路径换台机器就起不来用 process.execPath
工具返回不限流一个 10MB 日志灌爆上下文截断,并明确告诉模型"已截断"

最后补一条协议纪律:服务端在收到 notifications/initialized 之前,除了 ping 和 logging 不要主动发请求。我这个极简实现不会撞上,但你要往上加能力(比如服务端反向调用 sampling)就会。

怎么接进真的宿主

本地 stdio server 的配置基本长这样:

{
  "mcpServers": {
    "mini-local": {
      "command": "node",
      "args": ["C:/path/to/mini-mcp-server.js"]
    }
  }
}

配完之后,先用上面那个客户端脚本跑一遍,确认握手和 tools/list 都通了,再挂到 Agent 上。跳过这步,你分不清是 server 的问题还是宿主配置的问题——我一开始就在这上面绕了半天。

说两句我的看法

MCP 被炒作的部分是"生态",我觉得真正值钱的是它把工具调用变成了纯文本协议:stdio + 一行 JSON。这意味着任何能起子进程的东西都能接,跟语言无关,也不绑定任何模型厂商。这个性质比它有多少现成 server 重要得多。

但协议本身还在快速演进。2026-07-28 版敢把握手整个改成逐请求 _meta 协商,这种量级的改动说明它没定型。所以我的结论是:别把业务逻辑硬编码进协议细节。协议解析和业务实现分开写,将来协议升级你只改解析层,不用动工具。

要不要用官方 SDK?我的分界线是:调试和学习阶段自己写一遍,上生产用 SDK。手写实现最大的风险不是写不出来,是规范一改你得跟着改——我现在就是知道自己在承担这个风险,所以才把版本那节写得那么啰嗦。


参考:

Last modification:September 29, 2026
如果觉得我的文章对你有用,请随意赞赏