119 行 Node.js 拆解 MCP 服务最小实现
作者用单文件、零外部依赖实现 stdio MCP 服务,为草稿事实核验和链接检查提供工具接口。文章拆解 JSON-RPC 消息处理、初始化与工具方法、通知静默规则,以及标准输出和日志的分离要求。
AI 深度解读
把发布前的两项检查接进 MCP,需要多大的工程?作者给出的答案是:一个文件、119 行代码,只导入 node:fs 和 node:readline。它检查草稿里的数字是否能在事实文件中找到,以及链接能否正常访问。
这篇最有价值的地方,是把 stdio MCP 服务端需要处理的消息、错误和测试拆开讲清楚。作者还记录了一次协议实现错误。对想把现有 Node 脚本接入 AI 编程工具的开发者,这比照抄一个服务端模板更有参考意义。
两个工具,服务端究竟要处理什么
作者的需求很窄:让 MCP 客户端调用发布检查。check_numbers 接收草稿和 facts.md,支持传入文本或文件路径;check_links 接收草稿,检查链接。
在这个仅提供工具、采用标准输入输出传输(stdio)的实现中,服务端通过 stdin 逐行读取 JSON-RPC 2.0 消息,再向 stdout 逐行输出 JSON 回复。日志要写入 stderr,因为 stdout 是协议通道,混入一条调试日志,也会让客户端收到非 MCP 内容。
消息分发集中在四个方法:initialize、tools/list、tools/call 和 ping。初始化返回协议版本、capabilities: { tools: {} },以及名称为 publish-gate、版本取自 VERSION 的 serverInfo;列举工具返回 TOOLS;调用工具执行检查;心跳返回 {}。
还有一个容易漏掉的边界:没有 id 的消息属于通知,例如 notifications/initialized,服务端不能回复。
作者用 readline 驱动读循环,通过 JSON.parse 解析消息,空行或解析失败的行直接忽略。这描述的是他们的简化实现,不能据此推导出完整的协议异常处理要求。
初始化也有捷径:直接回传客户端的 params?.protocolVersion,没有提供时使用 2025-06-18。作者承认,依赖更新功能的服务端应该正式协商版本。回传一个版本号,并不能证明实现支持那个版本的全部要求。
请求有问题,和工具执行失败,要分开返回
作者第一版把所有错误都装进 isError: true 的工具结果,包括工具名不存在、调用时没传草稿。这是他们明确修正过的协议问题。
两类错误的区别在于:请求是否已经构成一次有效的工具调用。工具名未知、参数不合法,属于协议错误,应该返回 JSON-RPC error;请求有效,但执行过程中失败,才返回带 isError: true 的正常工具结果。
下面这段代码先拦截错误调用,再区分执行失败:
const invalidParams = (message) => Object.assign(new Error(message), { code: -32602 });
// 在 callTool 中:执行前拒绝错误调用
if (TOOLS.some((t) => t.name === name) === false) throw invalidParams(`unknown tool: ${name}`);
if (args.draft_text == null && args.draft_path == null) throw invalidParams("give draft_text or draft_path");
// 在 handle 的 case "tools/call" 中:
try { return await callTool(params?.name, params?.arguments); }
catch (e) {
if (e.code === -32602) throw e; // 协议错误
return { content: [{ type: "text", text: `error: ${e.message}` }], isError: true };
}
关键是 if (e.code === -32602) throw e:它让参数错误继续交给外层读循环,转换为 JSON-RPC error,而不是被包装成工具执行失败。
修正后,未知工具和缺少草稿返回 -32602,未知方法返回 -32601;draft_path 指向无法读取的文件时,返回以 error: 开头、带 isError 的工具结果。外层错误兜底码是 -32603,实际返回仍取决于异常在哪一层被捕获。
请求没成立,与请求执行失败,是两个不同的接口契约。
至于检查发现不合格内容,工具输出的是纯文本:通过时为 PASS,不通过时为 FAIL (n),随后逐行列出问题。作者认为这便于模型阅读,但没有提供效果对照,不能把它当成已经验证的最佳格式。
测试协议,不必先启动一个 MCP 客户端
stdio 的一个好处是可以直接从 shell 输入消息。作者用 printf '%s\n' 依次发送 initialize、不带 id 的 notifications/initialized、tools/list,再通过管道交给 node server.js。
这样就能观察客户端实际收到的内容,包括通知是否保持静默、请求是否获得对应回复。
异步处理还会让回复乱序。作者在测试中看到,请求 4 的未知方法错误,先于请求 3 调用 check_numbers 的结果返回。这符合协议:客户端按 id 匹配请求,而不是按到达顺序。只有工具业务确实要求顺序执行时,才需要额外排队。
测试被分成两部分。服务端导出 checkNumbers、checkLinks 供单元测试使用,其中 checkLinks 接收可注入的 fetchFn,所以 node --test 不需要真实网络。另有 stdio 测试覆盖未知工具、缺少草稿、文件不可读和未知方法。
作者报告共有 5 个测试,在 Node v26.7.0 上通过。客户端方面,只验证了 Claude Code,claude mcp list 显示 Connected;其他 stdio 客户端尚未实测。
仓库尚未发布到 npm,原文给出的接入方式如下:
git clone https://github.com/skibuff22/agent-ops-kit-lite
claude mcp add publish-gate -- node "$PWD/agent-ops-kit-lite/mcp/publish-gate/server.js"
要看清第二行:Claude Code 启动的命令是 node,参数是 server.js 的路径。这里接入的是一个本地进程。
我的判断
我认可作者按需求控制实现范围的选择。已有确定性检查函数,只想向 MCP 暴露两个工具,直接处理协议能让边界清楚,也省去 SDK 的依赖树。
但 119 行是这个项目的结果,不是一般团队应当追求的指标。作者没有给出手写协议与官方 SDK 在开发时间、维护成本、兼容性上的对照数据。采用 SDK 还是自行实现,仍要看团队愿意承担多少协议维护工作。
更需要打问号的是兼容范围:测试通过发生在 Node v26.7.0,连接验证发生在 Claude Code。它们不足以证明其他 Node 版本、其他客户端或更多协议能力都能正常工作。
数字检查也有明确边界。数字能在 facts.md 中找到,不等于事实文件正确,更不等于数字在句子里的使用正确;链接能访问,也不等于目标页面支持草稿中的论断。
原文同时带有产品推广。MIT 开源代码旁的付费 Publish Gate 标价为一次性 $19,增加风格检查、逐条论断的 LLM 检查、SHA-256 锁,以及拒绝发布检查后被修改草稿的发布器。作者称其中的 LLM 检查采用失败即拒绝放行的方式,但没有给出这些能力的对照评测。文末那句建议屏蔽或举报他人的文字与技术内容无关,应当剔除。
能拿来做什么
- 把已有 Node 检查脚本拆成可直接测试的函数,再包一层 MCP 调用入口,先让草稿检查这类范围清楚的任务进入 AI 编程流程。
- 为工具接口分别验证参数错误、执行失败和检查不通过,避免模型或客户端把三种状态混在一起处理。
- 测试链接检查时注入
fetchFn,稳定模拟请求结果;另外通过 stdio 测试验证消息格式、通知静默和错误码。 - 接入自己的客户端前,先确认 Node 版本和实际连接结果;若开始使用更多 MCP 能力,就重新评估版本协商和官方 SDK。