我想把本地几个小工具接到 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。手写实现最大的风险不是写不出来,是规范一改你得跟着改——我现在就是知道自己在承担这个风险,所以才把版本那节写得那么啰嗦。
参考:
- 官方规范(最新):https://modelcontextprotocol.io/specification/latest
- 生命周期(legacy 握手流程):https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle
- Tools 定义与错误约定:https://modelcontextprotocol.io/specification/2025-06-18/server/tools
- 本文配套脚本:
mini-mcp-server.js(server)、mcp-client-test.js(全链路)、break-test.js(故意写错验证三个坑)
