1. 为什么我要手写一个 MCP Server
MCP Server 是大模型应用里把「模型能调用的工具」标准化暴露出来的服务端程序,它让 Agent 不用为每个平台单独适配接口,只要按 Model Context Protocol 说话,就能被支持 MCP 的客户端发现并调用工具。适合谁?适合已经会写一点后端、想真正搞懂 Agent 工具调用链路的大模型开发者。我见过太多人背得出「MCP 是模型上下文协议」,但一被问「initialize 之后客户端怎么拿到 POST 地址」「tools/call 的响应为什么必须走 SSE 回推」就卡住。
原因很简单:官方白皮书讲的是规范,不是运行轨迹。你只看文档,会觉得 SSE、JSON-RPC、工具注册是三块孤立知识;只有自己起一个 Server,用 curl 或 Inspector 打一遍,才会发现它们是同一条时间线上的三个动作——先拉长连接拿端点,再用 JSON-RPC 打招呼,最后才轮到工具上场。
这篇就按这个顺序来:先讲清楚 MCP 的三层结构,再给一份能跑的最小骨架,然后逐条拆 JSON-RPC 报文,最后用本地请求验证成功结果,并把最容易踩的坑列出来。全程不依赖任何特殊网络环境,本地 localhost 就能完成。
2. TaoToken 前置:统一 Key 与 API 通道
在动手写 Server 之前,先把「模型侧」的通道准备好。因为 MCP Server 本身只负责暴露工具,真正决定 Agent 怎么理解工具描述、怎么生成调用参数的,是背后的大模型。如果你每个模型都单独配一套 Key、一套 Base URL,调试阶段会非常痛苦。
我习惯用 TaoToken 做统一入口:一个 Key 覆盖多家模型,API 地址固定为https://taotoken.net/api,兼容 OpenAI 风格的调用方式。这样我的 MCP Server 只管工具逻辑,模型切换只改一个 model 字段,不用动业务代码。
具体操作分三步。第一步,打开官网https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=注册并登录。第二步,进入控制台创建 API Key,地址是https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite,创建后立刻复制保存,页面刷新后不再完整显示。第三步,如果你要长期跑编码类 Agent,建议直接看 Coding Plan 页面https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite,它针对长会话和工具调用场景做了额度优化。
注意:API Key 只放在服务端环境变量里,不要写进前端代码或提交到 Git。MCP Server 如果对外暴露,务必加 Origin 校验,后面排障章节会讲。
配好之后,你可以先用模型对话页面https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite验证 Key 是否可用,确认通道没问题再进入编码环节,避免把「Key 错」和「Server 写错」混在一起排查。
3. 可复制配置:MCP Server 最小骨架
MCP 的通信结构可以拆成三层:传输层用 SSE 做服务端到客户端的单向推送,用 HTTP POST 做客户端到服务端的请求;消息层用 JSON-RPC 2.0 定义请求和响应格式;协议层规定 initialize、tools/list、tools/call 这些方法的调用顺序。任何语言实现 MCP Server,本质都是把这三层拼起来。
下面用 Node.js 写一个最小可运行版本,不引第三方 MCP SDK,纯手写,方便你看清每一行在干什么。先建目录并初始化:
mkdir mcp-demo && cd mcp-demo npm init -y npm install express然后创建server.js,核心是三个路由:GET /sse建立长连接并下发 endpoint 事件,POST /message接收 JSON-RPC 请求,以及一个工具注册表。
const express = require('express'); const app = express(); app.use(express.json()); // 会话表:sessionId -> res 对象 const sessions = new Map(); // 工具注册表:name -> { description, inputSchema, handler } const tools = { get_time: { description: '返回服务器当前时间', inputSchema: { type: 'object', properties: {} }, handler: () => new Date().toISOString(), }, echo: { description: '原样返回传入的 message', inputSchema: { type: 'object', properties: { message: { type: 'string', description: '要回显的内容' } }, required: ['message'], }, handler: (args) => `echo: ${args.message}`, }, }; // 1. SSE 长连接:下发 endpoint 事件 app.get('/sse', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); const sessionId = 'sess-' + Date.now(); sessions.set(sessionId, res); // 关键:告诉客户端后续 POST 到哪个地址 res.write(`event: endpoint\ndata: /message?sessionId=${sessionId}\n\n`); req.on('close', () => sessions.delete(sessionId)); }); // 2. 接收 JSON-RPC 请求 app.post('/message', (req, res) => { const sessionId = req.query.sessionId; const sse = sessions.get(sessionId); const { jsonrpc, id, method, params } = req.body; let result = null; let error = null; if (method === 'initialize') { result = { protocolVersion: '2024-11-05', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'demo-mcp-server', version: '1.0.0' }, }; } else if (method === 'tools/list') { result = { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema, })), }; } else if (method === 'tools/call') { const tool = tools[params.name]; if (!tool) { error = { code: -32601, message: `Unknown tool: ${params.name}` }; } else { const output = tool.handler(params.arguments || {}); result = { content: [{ type: 'text', text: String(output) }] }; } } else if (method === 'notifications/initialized') { res.status(202).end(); return; } else { error = { code: -32601, message: `Method not found: ${method}` }; } // 3. 响应通过 SSE 回推,而不是直接返回 const payload = error ? { jsonrpc: '2.0', id, error } : { jsonrpc: '2.0', id, result }; if (sse) sse.write(`event: message\ndata: ${JSON.stringify(payload)}\n\n`); res.status(202).end(); }); app.listen(3000, () => console.log('MCP Server on http://localhost:3000'));启动:
node server.js到这里,一个具备 SSE 通道、JSON-RPC 解析、工具注册能力的 MCP Server 骨架就完成了。它没有依赖任何 MCP 专用库,所有行为都对应规范里的固定动作。
4. 验证请求与成功结果
先验证 SSE 通道。开一个终端执行:
curl -N http://localhost:3000/sse你会看到服务端立刻推来一行:
event: endpoint data: /message?sessionId=sess-1750000000000这行就是 MCP 的「两通道」握手:SSE 负责回推,endpoint 事件告诉客户端 POST 往哪打。记下这个 sessionId,另开终端发 initialize:
curl -X POST "http://localhost:3000/message?sessionId=sess-1750000000000" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'POST 接口返回 202,真正的响应会从刚才那个 SSE 终端里冒出来:
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"demo-mcp-server","version":"1.0.0"}}}接着拉工具列表:
curl -X POST "http://localhost:3000/message?sessionId=sess-1750000000000" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'SSE 终端会收到包含get_time和echo的 tools 数组。最后调用工具:
curl -X POST "http://localhost:3000/message?sessionId=sess-1750000000000" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hello mcp"}}}'SSE 回推:
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"echo: hello mcp"}]}}看到这个结果,说明整条链路通了:SSE 建连、endpoint 下发、initialize 协商、tools/list 发现、tools/call 执行,五步全部命中。你也可以用 MCP Inspector 图形化验证,执行npx @modelcontextprotocol/inspector,选择 SSE Transport,填入http://localhost:3000/sse,点 Connect 后依次点 Initialize、List Tools、Call Tool,效果和 curl 完全一致。
5. 本篇常见错排查
第一个高频错误是 POST 响应直接返回 JSON。很多人习惯让/message接口res.json(result),结果客户端收不到。MCP 规定响应必须走 SSE 回推,POST 只返回 202 表示已接收。如果你发现 Inspector 一直转圈,先检查这里。
第二个是 endpoint 事件格式写错。规范要求data是纯 URI 字符串,不要包成 JSON 对象。写成data: {"uri":"/message"}客户端会解析失败,表现为连上了但发不出请求。
第三个是 sessionId 对不上。SSE 建连时生成的 sessionId 必须和 POST 查询参数一致,否则服务端找不到对应的 SSE 通道,响应无处可推。多客户端场景下建议用 Map 严格隔离。
第四个是 initialize 没返回 protocolVersion。部分客户端会校验版本号,缺失或格式不对会直接断开。固定写2024-11-05即可。
第五个是工具 inputSchema 写成非标准 JSON Schema。type、properties、required三个字段要齐全,否则模型侧生成参数时容易出错。如果你用 TaoToken 接入模型做联调,可以在接入文档https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite里对照请求格式,确认工具描述被正确传递。
第六个是 Origin 校验缺失导致的安全问题。生产环境务必校验 Origin 头,只允许可信来源,避免被恶意页面利用。本地调试可以暂时放开,上线前补上。
6. 继续深入的方向
把上面这套跑通之后,你对 MCP 的理解就不再停留在概念层了。下一步可以做的:把工具 handler 换成真实业务逻辑,比如查数据库、调内部 API;给 tools/list 加上动态刷新,配合listChanged: true让客户端感知工具变更;或者把 SSE 换成 Streamable HTTP 传输,适配更新的客户端实现。
如果你打算把这套 Server 接到真实 Agent 里跑长会话,建议用 TaoToken 的 Coding Plan 通道,Key 和 API 地址在 API Keys 页面https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite管理,模型对话调试用https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_campaign=rewrite。工具注册和 JSON-RPC 这两块吃透之后,剩下的就是业务问题了。