☰
MCP协议开发实战:用Node.js SDK从零搭建AI Agent工具链并接入TaoToken
2026/9/27 14:18:29 网站建设 项目流程

1. 为什么我要在 Node.js 里手搓一条 MCP 工具链

MCP 协议(Model Context Protocol)是 Anthropic 提出的开放标准,用来把 AI 模型和外部工具、数据源之间的通信方式统一起来。你可以把它理解成「AI 世界的 USB-C 接口」:以前每接一个工具就要写一套私有适配层,现在只要工具端实现 MCP Server,客户端按 MCP 协议握手,就能即插即用。它适合谁?适合正在做 AI Agent、想让模型调用真实业务能力(查数据库、调内部 API、跑脚本)的 Node.js 开发者,尤其是那些被「工具集散、协议不统一、扩展性差」折磨过的人。

这篇不讲概念空转,直接带你从零搭一条可运行的链路:用@modelcontextprotocol/sdk写一个 MCP Server,注册工具、暴露资源、定义提示词模板,再写一个 Agent 客户端完成协议握手与调用链编排,最后把模型请求统一走 TaoToken 的 Key 接入。全程 Node.js,命令和配置都能直接复制。我试过把这条链路跑通后,再往上面加新工具基本就是「写一个文件、注册一次」的事,扩展成本比传统 Agent 开发低很多。

需要提前说明的是,MCP 本身只负责「客户端与工具服务器怎么对话」,它不绑定任何模型厂商。所以模型侧你可以自由选择,本文用 TaoToken 作为统一入口,一个 Key 就能切换不同模型,省去多平台配置的麻烦。

2. 前置准备:Node 环境与 TaoToken 统一 Key

2.1 环境要求与依赖安装

Node.js 版本建议 18 以上(SDK 用到较新的 ESM 与 fetch 能力),我用的是 20 LTS。先建目录并初始化:

mkdir mcp-agent-chain && cd mcp-agent-chain npm init -y npm install @modelcontextprotocol/sdk zod dotenv

三个依赖各司其职:@modelcontextprotocol/sdk是官方 Node SDK,zod用来做工具参数的运行时校验,dotenv负责把 Key 从环境变量读进来,避免硬编码。

2.2 拿到 TaoToken 的 API Key

模型调用这一层,我统一用 TaoToken 的 Key。它的好处是一个 Key 覆盖多种模型,Agent 里切换模型不用改接入代码。操作路径很直接:

打开控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,登录后在 API Keys 页面创建一个新 Key,复制保存。这个 Key 就是后面.env里的TAOTOKEN_API_KEY。

注意:Key 只显示一次,创建后立刻存到密码管理器或本地.env,不要提交到 Git。

2.3 项目骨架

最终目录结构如下,先有个全局印象,后面逐个文件填:

mcp-agent-chain/ ├── .env ├── package.json ├── servers/ │ └── weather-server.js ├── client/ │ └── agent-client.js └── config/ └── server-config.json

.env内容:

TAOTOKEN_API_KEY=sk-你的Key TAOTOKEN_BASE_URL=https://taotoken.net/api

这里TAOTOKEN_BASE_URL指向 https://taotoken.net/api ,注意 API 地址不带任何查询参数,保持干净。

3. 可复制配置:写一个带工具、资源、提示词的 MCP Server

3.1 用 SDK 初始化 Server 并注册工具

MCP Server 的核心是「声明能力 + 响应请求」。下面这个weather-server.js注册了一个get_weather工具,参数用 zod 校验,返回结构化 JSON。为了让你能直接跑,天气数据先用本地模拟,真实项目里把fetchWeather换成第三方 API 即可。

// servers/weather-server.js import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import fs from 'node:fs/promises'; import path from 'node:path'; const server = new Server( { name: 'weather-server', version: '1.0.0' }, { capabilities: { tools: {}, resources: {} } } ); // 工具参数 schema const WeatherArgs = z.object({ city: z.string().describe('城市名称,例如:北京、上海'), units: z.enum(['metric', 'imperial']).default('metric'), }); const MOCK = { 北京: { temp: 22, condition: '晴朗', humidity: 45 }, 上海: { temp: 25, condition: '多云', humidity: 65 }, 广州: { temp: 28, condition: '阵雨', humidity: 80 }, }; async function fetchWeather({ city, units }) { const key = Object.keys(MOCK).find((k) => k === city); if (!key) throw new Error(`未找到城市 "${city}" 的天气数据`); const raw = MOCK[key]; let temp = raw.temp; let unit = '°C'; if (units === 'imperial') { temp = Math.round((temp * 9) / 5 + 32); unit = '°F'; } return { city: key, temperature: { value: temp, unit }, condition: raw.condition, humidity: `${raw.humidity}%`, updated: new Date().toISOString(), }; } // 声明工具列表 server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'get_weather', description: '查询指定城市的当前天气情况', inputSchema: { type: 'object', properties: { city: { type: 'string', description: '城市名称' }, units: { type: 'string', enum: ['metric', 'imperial'], default: 'metric' }, }, required: ['city'], }, }, ], })); // 处理工具调用 server.setRequestHandler(CallToolRequestSchema, async (req) => { if (req.params.name !== 'get_weather') { throw new Error(`未知工具: ${req.params.name}`); } const args = WeatherArgs.parse(req.params.arguments ?? {}); const result = await fetchWeather(args); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }); // 暴露一个只读资源:服务器配置 server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [ { uri: 'config://server', name: 'server-config', description: '服务器配置信息', mimeType: 'application/json', }, ], })); server.setRequestHandler(ReadResourceRequestSchema, async (req) => { if (req.params.uri !== 'config://server') { throw new Error(`资源不存在: ${req.params.uri}`); } const cfgPath = path.join(process.cwd(), 'config', 'server-config.json'); const text = await fs.readFile(cfgPath, 'utf-8'); return { contents: [{ uri: req.params.uri, mimeType: 'application/json', text }] }; }); // 用 Stdio 传输启动 const transport = new StdioServerTransport(); await server.connect(transport); console.error('weather-server 已通过 stdio 启动');

几个关键点值得展开。capabilities里声明了tools和resources,客户端握手时才知道这个 Server 能干什么。工具的参数校验交给 zod,WeatherArgs.parse会在参数不合法时直接抛错,避免脏数据流进业务逻辑。传输层用StdioServerTransport,也就是标准输入输出,这是本地开发最省事的方式,客户端把 Server 当子进程拉起即可。

3.2 配置文件与资源读取

config/server-config.json放一些可被客户端读取的元信息:

{ "server": { "name": "weather-server", "version": "1.0.0" }, "features": { "enableCaching": true, "maxConcurrentRequests": 10 }, "logging": { "level": "info" } }

资源(Resource)和工具的区别在于:工具是「可执行的动作」,资源是「可读取的数据」。客户端可以先读配置了解服务能力,再决定调哪个工具,这种「先看说明书再动手」的模式在多服务器编排时特别有用。

4. 验证请求:写 Agent 客户端完成握手与调用链

4.1 客户端连接 Server 并列出工具

客户端负责拉起 Server 子进程、完成 MCP 握手、发现能力。下面agent-client.js先做连接与工具发现:

// client/agent-client.js import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import 'dotenv/config'; const transport = new StdioClientTransport({ command: 'node', args: ['servers/weather-server.js'], }); const client = new Client( { name: 'agent-client', version: '1.0.0' }, { capabilities: {} } ); await client.connect(transport); console.log('MCP 握手完成'); const { tools } = await client.listTools(); console.log('可用工具:', tools.map((t) => t.name)); const { resources } = await client.listResources(); console.log('可用资源:', resources.map((r) => r.uri));

运行node client/agent-client.js,如果看到「MCP 握手完成」和工具列表,说明协议层已经通了。这一步是整个链路的地基,握手失败后面全白搭。

4.2 调用工具并读取资源

在连接基础上追加调用逻辑:

// 读取服务器配置资源 const cfg = await client.readResource({ uri: 'config://server' }); console.log('服务器配置:', cfg.contents[0].text); // 调用天气工具 const weather = await client.callTool({ name: 'get_weather', arguments: { city: '北京', units: 'metric' }, }); console.log('天气结果:', weather.content[0].text);

成功时你会看到类似输出:

天气结果: { "city": "北京", "temperature": { "value": 22, "unit": "°C" }, "condition": "晴朗", "humidity": "45%", "updated": "2025-01-01T00:00:00.000Z" }

4.3 把工具结果交给模型:接入 TaoToken

工具返回的是结构化数据,真正给用户看的自然语言报告还得模型来生成。这里用 TaoToken 的统一接口,把工具结果拼进 prompt:

async function askModel(weatherJson) { const res = await fetch(`${process.env.TAOTOKEN_BASE_URL}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.TAOTOKEN_API_KEY}`, }, body: JSON.stringify({ model: 'claude-3-5-sonnet', messages: [ { role: 'user', content: `根据以下天气数据,用一句话给出出行建议:\n${weatherJson}`, }, ], }), }); const data = await res.json(); return data.choices?.[0]?.message?.content ?? '模型无返回'; } const advice = await askModel(weather.content[0].text); console.log('模型建议:', advice);

到这里,完整链路就跑通了:客户端握手 → 发现工具 → 调用工具 → 结果喂给模型 → 输出建议。模型侧想换别的,只改model字段,Key 和地址都不用动,这就是统一入口的价值。如果你更想先在网页里验证模型连通性,可以直接用模型对话 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 快速试一条请求。

5. 本篇常见错排查

5.1 握手失败或进程直接退出

最常见的原因是 Server 里用了console.log输出调试信息。Stdio 传输下,标准输出是协议通道,任何非协议内容都会污染消息流,导致客户端解析失败。记住一条铁律:Server 里所有日志走console.error,把 stdout 留给协议。

5.2 工具调用报参数校验错误

如果报 zod 校验失败,先检查客户端传的arguments字段名是否和inputSchema一致。MCP 不会帮你做字段名映射,city写成City就会直接失败。另外units有默认值,但如果你显式传了null,zod 的.default()不会兜底,需要自己处理。

5.3 模型请求 401 或 404

401 通常是 Key 没读到,检查.env是否被dotenv/config正确加载,以及变量名拼写。404 多半是 base URL 写错,正确地址是 https://taotoken.net/api ,不要多加/v1之外的路径,也不要在末尾带斜杠。请求路径本身是/v1/chat/completions,两者拼起来才是完整端点。

5.4 资源读取路径找不到

readResource里用了process.cwd(),它取决于你在哪个目录启动客户端。如果你在项目根目录跑node client/agent-client.js,cwd就是根目录,config/server-config.json能找到;如果换了目录就会失败。稳妥做法是用import.meta.url推导绝对路径,避免依赖启动位置。

5.5 多服务器编排时的工具重名

当你同时接入天气、日历、邮件多个 Server,如果两个 Server 都注册了search工具,客户端listTools会拿到重名项,调用时无法区分。解决办法是在客户端维护「服务器名 + 工具名」的命名空间映射,或者在 Server 侧给工具加前缀,比如weather_get_weather。

6. 继续往下走:把链路变成长期可用的 Agent

单次跑通只是起点。真正要长期用,建议把这条链路沉淀成可复用的工程结构:每个能力独立成一个 MCP Server,客户端只负责发现与编排,模型接入统一走 TaoToken。这样新增能力时,你只需要写一个新的 Server 文件并注册,客户端几乎不用改。

如果你打算把 Agent 用在日常编码或长时间运行的任务上,可以考虑 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite ,配合 MCP 工具链做代码检索、文件操作这类高频动作会更顺。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有各语言 SDK 的完整参数说明,遇到字段不确定时翻一下比猜快。Key 管理仍然在 API Keys https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 页面,建议给不同项目建不同的 Key,方便按项目排查用量。

最后留一个我踩过的坑:MCP Server 启动是异步的,客户端connect之后不要立刻假设工具已就绪,稳妥做法是listTools成功返回后再进入业务逻辑。这个顺序在本地看不出问题,一旦 Server 启动慢(比如要连远程数据库)就会偶发失败,加上这一步判断能省掉很多玄学 bug。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询