1. 为什么你的工具代码总在重复适配不同 AI 客户端
如果你写过 Function Calling,大概率经历过这种循环:给 Claude 写一套工具描述,给 GPT 写一套,给本地模型再写一套。参数格式不一样,调用协议不一样,连"工具发现"这件事都得手动硬编码。我试过在一个项目里同时对接三个模型平台,光是维护工具定义就占掉了将近一半的开发时间。
MCP(Model Context Protocol)想解决的就是这个问题。它把工具调用抽象成一套基于 JSON-RPC 2.0 的标准协议,Server 端只负责声明"我有哪些工具、参数是什么、怎么执行",Client 端负责把这些工具翻译成各家模型能理解的格式。同一套 MCP Server,Claude Desktop 能调,Cline 能调,你自己写的 Agent 也能调。
这篇文章面向的是需要让同一套工具服务被多个 AI 客户端复用的开发者。我会从协议结构讲到可运行的 Server 和 Client 代码,再演示怎么通过 TaoToken 的统一 Key 通道完成"一次开发、多处调用"的验证。全程 TypeScript,代码可直接跑。
核心检索词先明确:MCP 是一套让 AI 应用以统一方式调用外部工具的开放协议,适合需要跨客户端复用工具能力的 Agent 开发者。读完你能自己写一个 MCP Server,并让它在不同客户端里被发现和调用。
2. TaoToken 统一通道:MCP Client 接入前的准备
MCP 本身只管工具协议,不管模型调用。但一个完整的 Agent 循环里,Client 拿到工具列表后,最终还是要发给某个大模型去决策"该调哪个工具"。这时候模型 API 的接入方式就成了变量——不同平台的 Base URL、Key、模型 ID 都不一样,切换一次就要改一轮配置。
TaoToken 在这里的角色是统一模型调用通道。它提供兼容主流格式的 API 入口,你用一个 Key 就能访问多个模型,Base URL 固定,模型 ID 按需切换。对 MCP Client 来说,这意味着工具协议不变,模型侧只改三个字段。
先把接入信息固定下来,后面代码里直接引用:
| 配置项 | 值 |
|---|---|
| Base URL | https://taotoken.net/api |
| API Key | 在控制台创建,形如sk-... |
| Model ID | 按需选择,例如claude-3-5-sonnet系列 |
API Key 的创建入口在控制台的 API Keys 页面,登录后新建即可。文档页有各语言的调用示例,接入前建议扫一眼确认参数格式。
注意:Base URL 用
https://taotoken.net/api,不要带末尾斜杠,也不要拼其他路径。Key 只放在环境变量里,别写进代码提交到仓库。
为什么要在 MCP 教程里先讲模型通道?因为 MCP Client 的核心工作有两件:一是通过 JSON-RPC 跟 Server 通信拿工具,二是把工具转成模型能理解的 Function Calling 格式发出去。第二件事依赖模型 API,如果这里配置混乱,后面调试工具调用时会分不清是协议问题还是模型接入问题。先把模型通道固定,排障时变量就少一个。
环境变量这样设:
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"后面 Client 初始化模型时直接读这两个变量。这样同一套 MCP 代码换模型只改 Model ID,不用动工具层。
3. 可复制的 MCP Server 配置与 Tools 注册片段
这一节给出能直接落地的配置和代码。先看 MCP Server 在客户端里的配置格式,这是"一次开发多处调用"的关键——不同客户端读的是同一份 Server 声明。
以常见的 MCP 客户端配置为例,Server 通过 stdio 启动,配置片段如下(JSON 格式,路径按你本机实际调整):
{ "mcpServers": { "dev-tools": { "command": "node", "args": ["/absolute/path/to/mcp-agent-demo/dist/index.js"], "env": { "MCP_WORKSPACE": "/absolute/path/to/workspace", "TAOTOKEN_API_KEY": "sk-你的key", "TAOTOKEN_BASE_URL": "https://taotoken.net/api" } } } }这份配置的核心是三件套:启动命令、工作目录、环境变量。任何支持 MCP 的客户端都认这个结构,区别只是配置文件放的位置不同。Claude Desktop 放在claude_desktop_config.json,Cline 在设置里填,Codex 走auth.json加 MCP 段。同一份 Server,配置复制过去就能用。
接下来是 Tools 注册。MCP 的工具声明用 JSON Schema 描述参数,Server 端注册时把 name、description、inputSchema 三样给全。下面是一个精简但完整的注册片段:
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; const server = new Server( { name: "dev-tools", version: "1.0.0" }, { capabilities: { tools: {} } } ); // 工具清单:name + description + inputSchema const TOOLS = [ { name: "read_file", description: "读取工作目录下的文本文件内容", inputSchema: { type: "object", properties: { path: { type: "string", description: "相对工作目录的文件路径" }, }, required: ["path"], }, }, { name: "http_request", description: "发送 HTTP 请求并返回响应体", inputSchema: { type: "object", properties: { url: { type: "string", description: "请求 URL" }, method: { type: "string", enum: ["GET", "POST"], default: "GET" }, }, required: ["url"], }, }, ]; server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: TOOLS }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (name === "read_file") { const fs = await import("fs/promises"); const content = await fs.readFile(String(args?.path), "utf-8"); return { content: [{ type: "text", text: content }] }; } if (name === "http_request") { const res = await fetch(String(args?.url), { method: String(args?.method ?? "GET"), }); const text = await res.text(); return { content: [{ type: "text", text }] }; } return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, }; }); const transport = new StdioServerTransport(); await server.connect(transport);这段代码里,ListToolsRequestSchema处理tools/list请求,CallToolRequestSchema处理tools/call请求,正好对应 JSON-RPC 的两个核心方法。工具声明和实现分离,加新工具只需往TOOLS数组里加一项、在 handler 里加一个分支。
编译运行:
npm install @modelcontextprotocol/sdk npx tsc node dist/index.jsServer 启动后会阻塞在 stdin 上等待 JSON-RPC 消息。你可以手动发一条测试:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node dist/index.js正常会返回包含两个工具的 JSON。这一步通了,说明 Server 侧的协议层没问题,接下来接 Client。
4. 验证请求:从 tools/list 到模型决策的完整链路
Server 能响应tools/list只是第一步。真正的验证是让 Client 连上 Server、拿到工具、发给模型、模型决定调用、结果回传。这条链路走通,才算"一次开发多处调用"成立。
先写 Client 连接部分。MCP SDK 的 Client 通过 stdio transport 启动 Server 子进程:
import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; const client = new Client( { name: "mcp-client", version: "1.0.0" }, { capabilities: {} } ); const transport = new StdioClientTransport({ command: "node", args: ["/absolute/path/to/dist/index.js"], }); await client.connect(transport); // 发现工具 const toolsResult = await client.request( { method: "tools/list", params: {} }, {} as any ); console.log("Discovered tools:", JSON.stringify(toolsResult, null, 2));跑通后你会看到 Server 声明的工具列表。这一步验证的是 JSON-RPC 通信和工具发现。
接下来把工具转成模型能用的格式,发给 TaoToken 通道。这里用 OpenAI 兼容的 chat completions 格式举例,工具转成tools字段:
const toolDefs = (toolsResult as any).tools.map((t: any) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.inputSchema, }, })); const response = 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: "帮我读取 README.md 的内容" }, ], tools: toolDefs, tool_choice: "auto", }), }); const data = await response.json(); console.log(JSON.stringify(data.choices[0].message, null, 2));如果模型决定调用工具,返回的 message 里会带tool_calls字段,里面是工具名和参数。你把这个参数原样传给 MCP Server 的tools/call:
const toolCall = data.choices[0].message.tool_calls[0]; const callResult = await client.request( { method: "tools/call", params: { name: toolCall.function.name, arguments: JSON.parse(toolCall.function.arguments), }, }, {} as any ); console.log("Tool result:", callResult);到这里,完整链路是:Client 发现工具 → 转成模型格式 → 模型决策 → 回传工具调用 → Client 执行 → 结果返回。整个过程里,MCP 协议负责工具侧,TaoToken 通道负责模型侧,两边解耦。
实测下来,同一套 Server 代码,换成 Claude Desktop 的配置、换成 Cline 的 MCP 设置、换成自己写的 Agent,工具层一行不用改。这就是协议标准化的价值——你开发一次工具服务,多个客户端复用。
验证成功的标志有三个:tools/list返回完整工具清单、模型返回带tool_calls的响应、tools/call返回预期结果。三个都过,链路就通了。
5. 常见报错排查:401、local proxy failed 与 reading choices
接入过程里踩的坑基本集中在几个固定报错上。这一节按真实错误信息对照排查。
401 Unauthorized。这个最常见,出现在模型调用那一步。原因通常是 Key 没设对或没传。检查三处:环境变量TAOTOKEN_API_KEY是否导出、请求头Authorization是否是Bearer sk-...格式、Key 是否在控制台被禁用。如果 Key 正确但仍 401,确认 Base URL 是https://taotoken.net/api,路径拼成/v1/chat/completions,不要多拼或少拼。
local proxy failed / connection refused。这个报错通常出现在 Client 启动 Server 子进程时。MCP 的 stdio transport 要求command和args指向真实可执行文件。排查:args里的路径必须是绝对路径,相对路径在不同客户端的工作目录下会失效;node命令是否在 PATH 里,某些客户端环境变量精简,需要写 node 的绝对路径;Server 编译产物dist/index.js是否存在,npx tsc有没有报错。
reading 'choices' of undefined。这个报错说明模型响应结构和你预期的不一样。常见原因是请求体格式不对,比如model字段写错、messages为空、或者返回的是错误对象而不是正常响应。排查时先把response.json()的完整结果打印出来,看是error字段还是choices字段。如果是error,里面通常有具体原因;如果是choices为空,检查messages是否至少有一条 user 消息。
OAuth / authentication failed。某些客户端(比如 Claude Code 类工具)在接入时会走 OAuth 流程。如果你用的是 API Key 模式,需要在配置里明确指定认证方式,别让它默认走 OAuth。检查客户端的 MCP 配置段,确认env里传的是 API Key 而不是 OAuth token。
工具被发现但调用返回 Unknown tool。这是 Server 端 handler 的 name 匹配问题。tools/list里声明的 name 必须和tools/call里 handler 判断的字符串完全一致,大小写、下划线都不能差。建议把工具名抽成常量,声明和判断都引用同一个常量。
Codex auth.json 配置。如果你用 Codex 类客户端,MCP 配置写在auth.json里,结构是mcpServers对象。三件套要写全:command(启动命令)、args(脚本路径)、env(含TAOTOKEN_API_KEY和TAOTOKEN_BASE_URL)。缺任何一个都会导致 Server 起不来或模型调不通。
排障的通用思路是分层验证:先单独跑 Server 确认tools/list能返回,再单独调模型确认 API 通,最后合起来跑完整链路。哪一层断了一眼就能定位。
6. 把工具服务跑起来:从本地验证到多客户端复用
代码和排障都过了,最后说怎么真正把工具服务用起来。
本地验证阶段,建议先写一个最小 Server,只放一个echo工具,参数就一个字符串。跑通tools/list和tools/call后,再逐步加真实工具。这样出问题时变量最少。
多客户端复用的关键是配置分离。Server 代码和工具实现放在一个仓库里,编译产物路径固定。每个客户端只维护自己的配置文件,里面引用同一个dist/index.js。新增客户端时,复制配置片段、改一下路径就行,工具层零改动。
模型侧通过 TaoToken 统一通道接入,Base URL 和 Key 固定,换模型只改 Model ID。这样工具协议和模型调用两条线各自独立,任何一边调整都不影响另一边。
如果你要长期跑 Agent 类任务,建议把模型调用走 Coding Plan 这类长期通道,避免频繁换 Key。工具服务本身部署在本地或内网,通过 stdio 或 SSE 暴露,安全边界清晰。
最后给一个实用技巧:在 Server 启动时把工具清单打到 stderr,客户端日志里能看到实际注册了哪些工具。调试时比翻代码快得多。
console.error("Registered tools:", TOOLS.map((t) => t.name).join(", "));工具服务跑起来后,你会发现同一套代码在不同客户端里的行为是一致的——因为协议层统一了。剩下的差异只在模型决策风格上,那是模型侧的事,跟工具无关。