上一篇 MCP 入门里,我们用官方 SDK 十几行就跑起来一个待办清单 Server。方便是方便,但 SDK 就像自动挡——你会开,却未必知道离合器在哪。面试官或同事一句"MCP 底下到底是什么",很多人就卡壳了。
这篇文章反其道而行:不用任何 SDK,用 Node.js 原生能力手写一个能被真实客户端连接的 MCP Server。目标只有一个——把 SDK 藏起来的协议层亲手摸一遍。读完你会明确知道:MCP 的传输是什么、消息长什么样、客户端和服务端各自怎么对话。
把 SDK 拿掉之后,MCP 只剩三句话:
没有魔法。所谓"能力协商",就是客户端先发一个 initialize 请求报上自己支持什么,服务端回应自己支持什么;所谓"工具调用",就是客户端发 tools/call 请求带上工具名和参数,服务端执行后回 result。全部是普通 JSON 文本。
不装任何依赖,新建 mini-mcp.mjs:
import { createInterface } from 'node:readline';
const TOOLS = [
{
name: 'echo',
description: '原样返回输入文本,用于连通性测试',
inputSchema: {
type: 'object',
properties: { text: { type: 'string', describe: '要返回的文本' } },
required: ['text'],
},
},
{
name: 'now',
description: '返回当前时间的 ISO 8601 字符串',
inputSchema: { type: 'object', properties: {} },
},
];
const reply = (id, result) =>
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
const replyError = (id, code, message) =>
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n');
const handlers = {
initialize: (id) =>
reply(id, {
protocolVersion: '2024-11-05',
capabilities: { tools: { listChanged: false } },
serverInfo: { name: 'mini-mcp', version: '0.1.0' },
}),
'tools/list': (id) => reply(id, { tools: TOOLS }),
'tools/call': (id, params) => {
const tool = TOOLS.find((t) => t.name === params.name);
if (!tool) {
replyError(id, -32602, 'unknown tool: ' + params.name);
return;
}
const text = params.name === 'echo'
? String(params.arguments.text)
: new Date().toISOString();
reply(id, { content: [{ type: 'text', text }] });
},
};
const rl = createInterface({ input: process.stdin });
rl.on('line', (line) => {
if (!line.trim()) return;
let msg;
try {
msg = JSON.parse(line);
} catch {
return;
}
if (msg.id === undefined) return; // 通知(如 initialized):无需回应
const handler = handlers[msg.method];
if (handler) handler(msg.id, msg.params || {});
else replyError(msg.id, -32601, 'method not found: ' + msg.method);
});不到 60 行,一个合法的 MCP Server 就写完了。逐段看它处理了什么。
readline 逐行读 stdin,每行 parse 成一个 JSON-RPC 消息——这就是 stdio 传输的全部实现。SDK 里的 StdioServerTransport 干的就是这件事,外加一些缓冲和背压处理。
注意响应必须单行写出:JSON.stringify 不会产生裸换行,所以末尾补一个 \n 就是完整的帧。反过来,任何多行 JSON 都会把协议流弄脏。
msg.id === undefined 的就是通知。客户端完成 initialize 握手后会发一条 notifications/initialized 通知,正确行为是什么都不回——对通知回响应反而是协议错误。SDK 里这些分支都被封装掉了,手写一次就忘不掉。
客户端:你好,我说 2024-11-05 版协议,我会这些能力。服务端:收到,我支持 tools(不支持 resources/prompts 就不写进 capabilities)。之后客户端才会开始调 tools/list。这段对话决定了后面哪些方法会被调用——协商过什么,才能用什么。
工具不存在返回 -32602(无效参数),未知方法返回 -32601(方法不存在)——这些码来自 JSON-RPC 2.0 规范。SDK 会把异常自动包装成错误响应;手写时你要自己保证:任何分支都必须回消息,否则客户端会一直等到超时,表现就是"卡死"。
不用客户端,直接用管道喂数据:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hello mcp"}}}' \
| node mini-mcp.mjs预期输出两行(initialize 的应答 + tools/call 的应答,中间的通知没有回应):
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"mini-mcp","version":"0.1.0"}}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"hello mcp"}]}}id=1 对第一行、id=2 对第二行——JSON-RPC 靠 id 关联请求与响应,异步乱序也不怕。
把它写进 Claude Desktop 的配置(macOS 路径 ~/Library/Application Support/Claude/claude_desktop_config.json),和上一篇的待办 Server 并排:
{
"mcpServers": {
"mini": {
"command": "node",
"args": ["/绝对路径/mini-mcp.mjs"]
}
}
}重启客户端,对话里说"用 echo 工具返回 MCP",模型就会发起完整的握手和调用链。你的 60 行代码扛住了真实客户端。
以上是当前客户端生态的事实标准路径(initialize 握手)。值得知道的是,最新版规范(2026-07-28 起)把协议推向无状态化:每个请求在 _meta 里自带协议版本与客户端能力,服务端能力改由 server/discover 上报,transport 层也全面转向 Streamable HTTP。方向是让服务端更容易做水平扩展。
但演进不改变本文的核心事实:消息格式仍是 JSON-RPC 2.0,语义仍是请求/通知/响应三件套。手写过这一层的人,读任何新版本的 spec 都只是在看"方法名变了没"。
手写��为了理解,不是为了生产。真实项目请回到 SDK,理由很实际:
一句话:手写一次懂原理,生产永远用 SDK。
MCP 被 SDK 包装得像黑盒,拆开看只是"换行分隔的 JSON-RPC":请求带 id 要回应答、通知不回、错误有码。60 行代码实现 initialize 协商、tools/list、tools/call 之后,协议在你眼里就再无秘密——下次调试 MCP 连接问题,你直接看消息流就知道哪一步断了。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。