
工具越加越多的时候,先踩到的是依赖:本机那个 agent 主进程里,为了三个工具装了 playwright 和 pandas,结果其中一个包一升级,整个循环连启动都起不来,而当时真正要用的其实只是读一个文件。另一件事是权限,低权限工具和高权限工具住在同一个进程里,谁碰过什么就说不清楚。
所以干脆把工具拆出去了,协议选了最省事的那种。下面几张图是这套东西在本机跑出来的形状,脚本在 tools/verify-stdio-jsonrpc.mjs,服务端在 tools/verify-jsonrpc-server.mjs。
图 1 · 三条管道各走各的
agent 主进程
│
│ spawn(process.execPath, [serverPath])
│ stdio: ['pipe', 'pipe', 'inherit']
▼
工具服务子进程
├─ stdin ◄── 一行一个 JSON-RPC 请求
├─ stdout ──► 一行一个 JSON-RPC 响应(只放协议)
└─ stderr ──► 日志(父进程消费,或者 inherit 直接进终端)
图上这个形状对应协议里只约定了三件事:消息以换行分隔,一行一个 JSON;请求带 id,响应把同一个 id 带回来,这样一条连接上能同时挂多个请求;stdout 只放协议内容,日志全部走 stderr。这三条落到代码里就是下面这些:
import { createInterface } from 'node:readline';
import { readFileSync, statSync } from 'node:fs';
const TOOLS = {
wordcount: {
description: '统计文件里的字符数、中文字符数和字节数',
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
run: ({ path }) => {
const text = readFileSync(path, 'utf8');
const cjk = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
return { path, chars: text.length, cjk, bytes: statSync(path).size };
},
},
};
async function handle(req) {
const { id, method, params } = req;
if (method === 'initialize') {
return { jsonrpc: '2.0', id, result: { protocolVersion: '0.1', serverInfo: { name: 'demo-tools' } } };
}
if (method === 'tools/list') {
return {
jsonrpc: '2.0',
id,
result: {
tools: Object.entries(TOOLS).map(([name, t]) => ({
name, description: t.description, inputSchema: t.inputSchema,
})),
},
};
}
if (method === 'tools/call') {
const t = TOOLS[params?.name];
if (!t) return { jsonrpc: '2.0', id, error: { code: -32602, message: `未知工具: ${params?.name}` } };
try {
return {
jsonrpc: '2.0', id,
result: { content: [{ type: 'text', text: JSON.stringify(t.run(params.arguments ?? {})) }] },
};
} catch (e) {
return { jsonrpc: '2.0', id, result: { isError: true, content: [{ type: 'text', text: e.message }] } };
}
}
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } };
}
const rl = createInterface({ input: process.stdin });
rl.on('line', async line => {
if (!line.trim()) return;
let req;
try {
req = JSON.parse(line);
} catch (e) {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: null, error: { code: -32700, message: `Parse error: ${e.message}` },
}) + '\n');
return;
}
process.stdout.write(JSON.stringify(await handle(req)) + '\n');
});
图 2 · 一次握手的真实往返
客户端把它 spawn 起来,依次走握手,然后列工具,再调一次工具,输出是这样:
initialize → {"protocolVersion":"0.1","serverInfo":{"name":"demo-tools"}}
tools/list → wordcount, read_lines
tools/call → "{\"path\":\"/Users/yxh/blog/写作规范.md\",\"chars\":2228,\"cjk\":991,\"bytes\":4535}"
wordcount 那行值得看一眼:2228 个字符,其中 991 个是汉字,占 44%,字节数 4535 是字符数的两倍。工具里是真去 readFileSync 了,读的是子进程的路径,主进程不需要有这个文件。把它摊成时序就是:
父进程 子进程
│ {"jsonrpc":"2.0","id":1,"method":"initialize"}
├─────────────────────────────────────────────────►│
│ ◄── {"protocolVersion":"0.1","serverInfo":{"name":"demo-tools"}}
│
│ {"jsonrpc":"2.0","id":2,"method":"tools/list"}
├─────────────────────────────────────────────────►│
│ ◄── wordcount, read_lines
│
│ {"jsonrpc":"2.0","id":3,"method":"tools/call"}
├─────────────────────────────────────────────────►│
│ ◄── "{\"path\":\"/Users/yxh/blog/写作规范.md\",\"chars\":2228,\"cjk\":991,\"bytes\":4535}"
三条消息共用一条连接,靠 id 分清谁是谁,这就是握手阶段做完之后循环往复的那个动作。
图 3 · 坑一:一个 chunk 不等于一条消息
stdout 交付的是字节流,没有消息边界
{"jsonrpc":...}\n{"jsonrpc":...}\n ← 服务端一次 write,怎么切随管道的意思
客户端唯一正确的姿势
chunk ──► buf += chunk ──► buf.indexOf('\n') ──► 切一行 ──► JSON.parse ──► 按 msg.id 找 pending
在服务端加了个开关,把一条响应分两次 write,中间隔 40ms,模拟被切开的场景:
用例:服务端把一条响应拆成两个 chunk
按行缓冲的客户端 → {"protocolVersion":"0.1","serverInfo":{"name":"demo-tools"}}
每个 chunk 直接 parse 的客户端 →
解析失败 SyntaxError: Unterminated string in JSON at position 12 (line 1 column 13)
解析失败 SyntaxError: Unexpected non-whitespace character after JSON at position 3
两个报错都好认,一个是字符串没收尾,一个是 JSON 后面还有多余内容。真正的麻烦是这个 bug 在本地经常不出现:消息小,一次 write 正好是一个 chunk,测一百次都是对的,等到工具返回一个大结果才炸。客户端的写法只有一种是对的,先攒进 buffer,再按换行切:
child.stdout.setEncoding('utf8');
let buf = '';
child.stdout.on('data', chunk => {
buf += chunk; // 关键:先攒起来,不能假设一个 chunk 就是一条消息
let idx;
while ((idx = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (!line.trim()) continue;
let msg;
try {
msg = JSON.parse(line);
} catch (e) {
console.warn('stdout 上有一行不是 JSON:', line.slice(0, 60));
continue;
}
if (msg.id !== undefined && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(`${msg.error.code} ${msg.error.message}`)) : resolve(msg.result);
}
}
});
图 4 · 坑二:stdout 上多一行日志
服务端顺手 console.log 一行
├─► 按行缓冲的客户端 那行不是 JSON,记下来,循环继续 ──► 没崩
└─► 每个 chunk 直接 parse 报错 ×2,第二条恰好是一整条响应 ──► 崩
在服务端加了个 --noisy 开关,模拟刚上手最容易干的事,启动时打一行日志:
用例:服务端顺手 console.log 了一句
按行缓冲的客户端 → {"protocolVersion":"0.1","serverInfo":{"name":"demo-tools"}}
它顺手记下的杂音: ["stdout 上有一行不是 JSON: server 启动中,加载了 2 个工具"]
每个 chunk 直接 parse → 解析失败 SyntaxError: Unexpected token 's', "server 启动中"... is not valid JSON
每个 chunk 直接 parse → 解析成功 id=1 有结果
按行缓冲的客户端没崩,它把那行不是 JSON 的东西记下来了,这算兜住了。换个写法就是两条报错,第二条恰好是一整条响应。所以服务端里连 console.warn 都别用,要打日志就 process.stderr.write,或者挂到 stderr 的 logger 上。
图 5 · 坑三:两类失败分两条路
tools/call
├─ 请求本身有问题(工具名不存在)──► 协议层 error: {code:-32602} → 客户端记日志,当 bug 查
└─ 工具内部抛错(文件不存在) ──► result.isError: true + 错误原文 → 给模型看,模型可以换参数重试
这个区分是这套协议里最值得记的一处,调不存在的工具和工具内部抛错走完全不同的路:
未知工具 → 走协议层 error: -32602 未知工具: nope
文件不存在 → 走 result.isError: true / ENOENT: no such file or directory, open '/tmp/没有这个文件.txt'
区别在于谁会看到它。协议层 error 的意思是「这个请求处理不了」,客户端该把它记成日志当 bug 看。isError 的意思是工具执行失败,错误原文放在 content 里,那是要给模型看的输入,模型可以换个路径重试。早先的写法是把两者都塞进协议 error,结果工具一失败模型就完全失明,它只知道「调用出错了」,拿不到 ENOENT 这种能用来改参数的信息。
图 6 · 坑四:stderr 没人读会怎样
让子进程先往 stderr 写 2MB(管道缓冲区 64KB),写完才回一次请求
├─► Node 子进程:process.stderr.write 是异步写
│ 数据排在子进程内存里 ──► 父进程不读,也照样收到响应
└─► Python 子进程:sys.stderr 是阻塞写
管道满了就卡在 write 上 ──► 父进程不读,永远收不到响应
我原来的判断是都会卡死,实测打脸了:
【Node 子进程】读 stderr = true → 收到响应 / 消费 2097152 字节 / 子进程 RSS 涨 288KB
【Node 子进程】读 stderr = false → 收到响应 / 消费 0 字节 / 子进程 RSS 涨 304KB
【Python 子进程】读 stderr = true → 收到响应 / 消费 2097152 字节
【Python 子进程】读 stderr = false → 什么都没收到(2 秒内零响应)
Node 写的子进程没人读 stderr 也照样把响应回过来,RSS 只涨了 304KB。原因是 process.stderr.write 到管道是异步的,数据排在子进程的内存里,没人读它就拿内存当缓冲区。Python 写的子进程一个字都没回,因为 sys.stderr 是阻塞写,管道满了就卡在 write 上,后面的活全都不干。
现在很多工具服务端是 Python 写的,所以父进程漏读 stderr 就是个会挂死的 bug,而且症状是「卡住不回」,跟死循环、跟模型不响应都很像,很难查。两个修法:父进程老老实实消费 stderr,或者把子进程的 stderr 设成 inherit,日志直接进终端。
const child = spawn(process.execPath, [serverPath], {
stdio: ['pipe', 'pipe', 'inherit'], // stdin/stdout 走协议,stderr 直接继承
});
图 7 · 拆不拆的判断
要不要把工具拆到独立进程里跑
├─ 它要 import 一个不该装进主进程的重包 ──► 拆
├─ 它要碰的东西不该让主进程有权限碰 ──► 拆
└─ 三五个工具、只在本机跑、依赖也干净 ──► 别拆
满足前两条里的一条就拆,都不满足就留在主进程里。多一层进程要处理管道生命周期和僵尸进程,还得想清楚超时了怎么取消,第一次拆的时候忘了 kill 子进程,跑了几十次之后 ps 里拉出一排没退出的 node。
图 8 · 这套东西离 MCP 还有多远
这里实现的等价物 真实 MCP 规范
───────────────────── ──────────────────────
一行一个 JSON-RPC 请求/响应 能力协商
写死的 protocolVersion '0.1' 通知消息
没有能力列表 更细的握手流程
这台机器上没有配好的 MCP 客户端,所以上面这套是按「行分隔 JSON-RPC」自己写的等价物,并不是 MCP 的实现。真实 MCP 规范里的能力协商和通知消息这些细节比这里复杂,没有逐条验证过。这里的 initialize 只是回一个写死的 protocolVersion 字符串,严格说连握手都算不上。要真做兼容,去看官方规范,别拿它当参考实现。