1. 从零搭一个 MCP 工具服务,为什么值得花三小时
MCP 全称 Model Context Protocol,你可以把它理解成大模型世界的 USB-C 接口。以前每接一个外部能力,比如查天气、读数据库、跑一段计算,都要为不同模型写一套适配代码;有了 MCP,工具服务端只要按统一规范暴露能力,Claude、GPT 甚至本地模型都能用同一套接口调用。对做 AI 工具开发的人来说,这意味着你写一次工具,就能被多个客户端复用。
这篇面向的是想快速上手 MCP 的 Node.js 开发者:你不需要先啃完协议文档,也不用搭复杂基础设施,只要会写 Express 路由、能跑 npm 命令,就能在三小时内完成一个可被大模型调用的工具服务,并接上统一 Key 通道做端到端联调。核心检索词就是 MCP、Node.js、AI 工具开发、极简实践。
我试过的路径是这样的:先跑通一个最小 MCP server,让它暴露一个能返回股票价格的工具;再用 curl 模拟模型侧的 JSON-RPC 调用;最后把模型请求接到统一 API 通道上,让真实对话触发工具调用。整个过程最耗时的不是写代码,而是搞清 MCP 的消息格式和工具描述规范。下面把每一步拆开,你跟着敲就行。
先明确 MCP 和传统 Function Calling 的差别,这决定了你后面怎么设计工具。Function Calling 是厂商私有协议,工具声明通常硬编码在客户端;MCP 是开放标准,工具通过 discovery 端点动态发现,消息基于 JSON-RPC 2.0,还支持 SSE 流式进度。简单说,Function Calling 像给某个品牌手机配专用充电线,MCP 像通用 Type-C,谁都能插。
一个最小可用的 MCP 服务端需要两个端点:一个是发现端点,告诉客户端“我有哪些工具”;另一个是调用端点,接收 JSON-RPC 请求并执行对应工具。工具描述里要包含名称、功能说明、参数 schema,模型靠这些信息判断该不该调用、传什么参数。参数 schema 用 JSON Schema 写,和 OpenAI 的 tools 格式基本能互转。
环境准备很轻:Node.js 18 以上,一个空目录,npm 初始化后装 express 和 eventsource。如果你后面要接真实模型,再装 openai 或对应 SDK。统一 Key 通道的作用在这里体现:你不需要为每个模型单独申请和切换 Key,Base URL 指向同一个入口,模型 ID 按需切换,工具服务端保持不动。
时间分配建议:前 40 分钟写 server 和 discovery/invoke 两个端点;40 分钟写客户端调用和 curl 验证;40 分钟接统一 Key 做真实对话联调;剩下时间排错和加流式。别一上来就追求完整协议,先把“模型能发现工具并成功调用一次”跑通,后面都是增量。
2. TaoToken 前置:统一 Key 与 Base URL 怎么配
在写代码之前,先把模型侧的通道准备好。TaoToken 在这里扮演的是统一 API 入口:你拿到一个 Key,把 Base URL 指向它的 API 地址,就能用 OpenAI 兼容的方式请求不同模型。对 MCP 联调来说,好处是你不用在客户端里维护多套鉴权逻辑,工具服务端只管暴露能力,模型请求统一走一个通道。
第一步是拿 Key。打开官网 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_content=console&utm_campaign=rewrite ,Key 只在创建时完整显示一次,复制后存到环境变量里,别写死在代码里。
第二步是确认 Base URL。API 地址是 https://taotoken.net/api ,注意这个地址不带 UTM 参数,直接作为 OpenAI SDK 的 baseURL 使用。如果你用的是 OpenAI Node SDK,配置大概是这样:
import OpenAI from 'openai'; const client = new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: 'https://taotoken.net/api' });第三步是选模型 ID。不同模型在工具调用能力上表现不一样,联调阶段建议选一个对 tools 支持稳定的模型。模型 ID 可以在模型对话页面里确认,地址是 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。你先把模型 ID 记下来,后面写进请求参数。
环境变量建议这样组织,放在项目根目录的.env里:
TAOTOKEN_API_KEY=sk-你的Key TAOTOKEN_BASE_URL=https://taotoken.net/api MCP_SERVER_URL=http://localhost:3000 MODEL_ID=你的模型ID如果你用的是 Claude Code 这类编码工具,或者 Cline、Codex 这类客户端,配置逻辑是一样的三件套:Base URL、API Key、Model ID。以 Claude Code 为例,它读取的是环境变量或 settings 文件,你需要把ANTHROPIC_BASE_URL指向统一入口,Key 填进去,模型 ID 按客户端要求填。具体路径和字段名以客户端文档为准,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
这里有个容易踩的坑:Base URL 末尾不要多加/v1或斜杠,SDK 会自己拼接路径。如果你手动用 curl,请求地址是https://taotoken.net/api/chat/completions,别写成/api/v1/chat/completions。另一个坑是 Key 权限,创建时如果选了受限范围,可能只能调部分模型,联调时先用全量权限的 Key,跑通后再收紧。
长期做编码或 Agent 的话,可以考虑 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,它更适合高频调用场景。但本文的联调阶段,用普通 API Key 就够了,先把链路跑通再考虑套餐。
3. 可复制配置:MCP server 与客户端 settings 片段
现在进入代码部分。先初始化项目:
mkdir mcp-node-demo && cd mcp-node-demo npm init -y npm install express eventsource openai dotenv然后在根目录建server.js。这个文件实现两个核心端点:/mcp/discover返回工具列表,/mcp/invoke执行工具调用。消息格式严格按 JSON-RPC 2.0 来,jsonrpc字段必须是"2.0",请求带id,响应原样带回。
import express from 'express'; import dotenv from 'dotenv'; dotenv.config(); const app = express(); app.use(express.json()); // 工具注册中心:每个工具包含描述、参数 schema、执行逻辑 const tools = { stock_price: { description: '获取指定股票的实时价格', parameters: { type: 'object', properties: { symbol: { type: 'string', description: '股票代码,如 AAPL' } }, required: ['symbol'] }, handler: async ({ symbol }) => { // 这里用模拟数据,真实场景替换为行情 API const price = (Math.random() * 200 + 50).toFixed(2); return { symbol, price, currency: 'USD' }; } }, add_numbers: { description: '计算两个数字之和', parameters: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] }, handler: async ({ a, b }) => ({ result: a + b }) } }; // 发现端点:客户端据此了解可用工具 app.post('/mcp/discover', (req, res) => { res.json({ jsonrpc: '2.0', result: { protocol_version: '1.0', tools: Object.entries(tools).map(([name, def]) => ({ name, description: def.description, parameters: def.parameters })) }, id: req.body.id ?? null }); }); // 调用端点:method 格式为 tool.{name} app.post('/mcp/invoke', async (req, res) => { const { jsonrpc, method, params, id } = req.body; if (jsonrpc !== '2.0') { return res.status(400).json({ jsonrpc: '2.0', error: { code: -32600, message: 'Invalid Request' }, id }); } const [, toolName] = method.split('.'); const tool = tools[toolName]; if (!tool) { return res.json({ jsonrpc: '2.0', error: { code: -32601, message: 'Method not found' }, id }); } try { const result = await tool.handler(params); res.json({ jsonrpc: '2.0', result, id }); } catch (err) { res.json({ jsonrpc: '2.0', error: { code: -32603, message: err.message }, id }); } }); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`MCP server running at http://localhost:${PORT}`); });启动服务:
node server.js看到MCP server running at http://localhost:3000就说明服务起来了。接下来写客户端client.js,它做三件事:从 discovery 端点拉工具列表、转成模型能识别的 tools 格式、发起对话并在模型要求调用工具时执行 MCP 调用。
import OpenAI from 'openai'; import dotenv from 'dotenv'; dotenv.config(); const client = new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL }); const MCP_SERVER = process.env.MCP_SERVER_URL; // 把 MCP 工具描述转成 OpenAI tools 格式 function toOpenAITool(tool) { return { type: 'function', function: { name: `tool.${tool.name}`, description: tool.description, parameters: tool.parameters } }; } async function getMCPTools() { const res = await fetch(`${MCP_SERVER}/mcp/discover`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method: 'discover', id: 1 }) }); const { result } = await res.json(); return result.tools.map(toOpenAITool); } async function callMCPTool(method, params) { const res = await fetch(`${MCP_SERVER}/mcp/invoke`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method, params, id: Math.floor(Math.random() * 10000) }) }); const data = await res.json(); if (data.error) throw new Error(data.error.message); return data.result; } async function main() { const tools = await getMCPTools(); const userMessage = '帮我查一下 AAPL 的股价,再算一下 12 加 30 等于多少'; const first = await client.chat.completions.create({ model: process.env.MODEL_ID, messages: [{ role: 'user', content: userMessage }], tools }); const msg = first.choices[0].message; const toolCalls = msg.tool_calls || []; if (toolCalls.length === 0) { console.log('模型未调用工具:', msg.content); return; } const toolResults = []; for (const call of toolCalls) { const args = JSON.parse(call.function.arguments); const result = await callMCPTool(call.function.name, args); toolResults.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) }); } const final = await client.chat.completions.create({ model: process.env.MODEL_ID, messages: [ { role: 'user', content: userMessage }, msg, ...toolResults ] }); console.log('最终回答:', final.choices[0].message.content); } main().catch(console.error);运行:
node client.js如果一切正常,你会看到模型先要求调用tool.stock_price和tool.add_numbers,客户端执行后把结果回传,模型给出自然语言总结。这就是 MCP 端到端联调的最小闭环。
如果你用的是 Cline 或 Claude Code 这类客户端,配置片段要写全三件套。以 Cline 的 MCP 配置为例,settings 里通常包含:
{ "mcpServers": { "local-tools": { "url": "http://localhost:3000/mcp", "transport": "http" } } }而模型侧的统一 Key 配置在客户端的环境变量或设置里,Base URL 填https://taotoken.net/api,Key 填你的 Key,Model ID 填你选的模型。三件套缺一不可,少一个就会报鉴权或模型不存在。
4. 验证请求:用 curl 和日志确认工具调用成功
代码跑通不代表链路稳定,你需要能独立验证每一层。最直接的方式是用 curl 打 MCP server 的两个端点,看返回是否符合 JSON-RPC 规范。
先验证 discovery:
curl -s -X POST http://localhost:3000/mcp/discover \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"discover","id":1}' | jq预期返回里result.tools是一个数组,每个元素有name、description、parameters。如果tools为空,检查tools对象是否正确定义,以及Object.entries是否被正确调用。
再验证 invoke:
curl -s -X POST http://localhost:3000/mcp/invoke \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"tool.add_numbers","params":{"a":12,"b":30},"id":2}' | jq预期返回:
{ "jsonrpc": "2.0", "result": { "result": 42 }, "id": 2 }如果返回Method not found,说明method里的工具名和tools对象的 key 不一致。注意method格式是tool.{name},split('.')后取第二段,所以tool.add_numbers对应tools.add_numbers。
验证模型侧通道,直接用 curl 打统一 API:
curl -s https://taotoken.net/api/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "'"$MODEL_ID"'", "messages": [{"role":"user","content":"只回复两个字:收到"}] }' | jq '.choices[0].message.content'如果这一步返回正常文本,说明 Key 和 Base URL 没问题。如果报 401,检查 Key 是否复制完整、是否有多余空格;如果报模型不存在,检查 Model ID 是否和平台一致。
日志方面,在 server 的 invoke 端点里加一行打印,能帮你看清模型实际传了什么参数:
app.post('/mcp/invoke', async (req, res) => { console.log('[MCP invoke]', JSON.stringify(req.body)); // ... 原有逻辑 });联调时观察终端输出,你会看到模型发来的method和params。常见情况是模型把参数名写错,比如把symbol写成stock,这时工具 handler 解构出来是 undefined,返回结果就不对。解决办法是在工具描述里把参数说明写清楚,模型会按 schema 来。
流式进度是 MCP 的一个加分项。对于耗时超过几秒的工具,你可以加一个 SSE 端点,让客户端实时看到进度。最小实现:
app.get('/mcp/stream/:taskId', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); const { taskId } = req.params; let progress = 0; const timer = setInterval(() => { progress += 20; res.write(`data: ${JSON.stringify({ jsonrpc: '2.0', method: 'progress_update', params: { taskId, progress } })}\n\n`); if (progress >= 100) { clearInterval(timer); res.write(`data: ${JSON.stringify({ jsonrpc: '2.0', method: 'task_complete', params: { taskId, result: 'done' } })}\n\n`); res.end(); } }, 500); req.on('close', () => clearInterval(timer)); });用 curl 验证 SSE:
curl -N http://localhost:3000/mcp/stream/task-1你会看到每隔 500ms 推一条data:消息,直到 100% 后连接关闭。这个能力在长任务场景里很有用,模型侧可以边执行边给用户反馈。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
联调阶段最容易卡在几个固定报错上,下面按真实错误信息对照排查。
401 Unauthorized。这个通常出在模型侧请求。先确认Authorization头格式是Bearer sk-xxx,中间有一个空格。再确认 Key 没有过期或被禁用。如果你用的是环境变量,打印一下process.env.TAOTOKEN_API_KEY的前几位和后几位,确认没有换行符或引号。还有一种情况是 Base URL 写错,比如写成了带/v1的地址,导致请求打到了不存在的路径,有些网关会返回 401 而不是 404。
local proxy failed。这个报错一般出现在客户端配置了本地代理但代理没启动,或者代理地址填错。排查顺序:先确认本地没有多余代理进程占用端口;再检查客户端 settings 里的 proxy 字段是否为空或指向了不存在的地址;最后确认 Base URL 是直连地址,不需要额外代理。如果你在容器里跑,检查容器网络是否能访问外网。
reading 'choices' of undefined。这是客户端代码里最常见的错误,说明response.choices是 undefined。原因通常是请求失败但没检查错误,直接取了choices。修复方式是在取choices前先判断:
const data = await res.json(); if (!data.choices) { console.error('响应异常:', JSON.stringify(data)); throw new Error('模型未返回 choices'); }另一个原因是流式和非流式混用,流式响应没有choices数组,需要逐块解析delta。确认你的请求参数里stream是 false 还是 true,两者解析方式不同。
OAuth 相关报错。如果你在 Claude Code 或类似客户端里看到 OAuth 失败,通常是因为客户端默认走 OAuth 流程,而你用的是 API Key 模式。解决办法是在客户端配置里显式指定 API Key 认证,把 Base URL 和 Key 填到对应字段,关闭 OAuth 选项。具体字段名看客户端文档,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
工具调用返回空结果。模型要求调用工具,但 handler 返回 undefined。检查 handler 是否 async 且 return 了值;检查参数解构是否对得上 schema;检查method.split('.')后工具名是否正确。加日志打印toolName和params最快定位。
JSON-RPC id 不匹配。有些客户端会校验响应 id 和请求 id 一致。确保 invoke 端点把请求的id原样返回,不要自己生成新 id。discovery 端点同理。
端口占用。EADDRINUSE说明 3000 端口被占,换端口或杀掉占用进程:
lsof -i :3000 kill -9 <PID>排障的核心思路是分层验证:先用 curl 确认 MCP server 本身正常,再用 curl 确认模型通道正常,最后跑客户端看两层拼接。哪一层报错就查哪一层,不要混在一起猜。
6. 把工具接到真实场景:下一步怎么走
最小闭环跑通后,你可以把模拟的stock_price换成真实数据源,比如接一个行情 API,在 handler 里发 HTTP 请求并返回结构化结果。工具描述里的description要写清楚用途和返回格式,模型靠它判断何时调用。参数 schema 尽量用enum限制取值范围,减少模型传错参数的概率。
如果你要做多个工具的组合调用,MCP 的 discovery 端点天然支持。模型会一次性看到所有工具,按需选择。你可以在客户端里加一个循环,处理多轮工具调用,直到模型不再要求调用为止。注意设置最大轮数,避免死循环。
对于长期运行的 Agent 场景,建议把工具服务独立部署,客户端通过环境变量配置 MCP server 地址。统一 Key 通道的好处在这里更明显:工具服务不需要关心模型鉴权,模型请求统一走一个入口,换模型只改 Model ID。需要更高调用频率的话,Coding Plan 地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,适合编码和 Agent 类持续调用。
最后提醒几个工程细节:工具 handler 里做好超时和错误捕获,别让一个工具挂掉整个服务;参数校验用 JSON Schema 加一层手动检查,模型偶尔会传多余字段;日志里别打印完整 Key,只打印前后几位用于排查。把这些做完,你的 MCP 工具服务就能从 demo 走向可用。