1. 当 Agent 的“手”开始打架:工具调用碎片化的真实困境
如果你正在做 AI Agent 的 Harness Engineering,大概率遇到过这种场面:代码助手 Agent 已经接好了本地文件读写、Git 操作、终端命令执行,跑得挺顺。结果产品说“再加个 Jira 查询吧”,你打开项目一看,工具注册表里已经躺着十几个函数,每个函数的参数 Schema 写法都不一样,有的用zod,有的手写 JSON Schema,有的干脆把参数拼成字符串让模型自己解析。更头疼的是,换一个 Harness 框架——比如从自研的调度循环切到某个开源 Agent 框架——这些工具定义几乎要全部重写一遍。
这就是 AI Agent 工具调用碎片化的典型症状。Agent 的“大脑”(LLM)越来越强,但它的“手”(工具调用层)却因为缺乏统一标准而各自为政。每个 Harness 有自己的工具注册方式,每个工具提供方有自己的接口约定,结果就是:工具复用率极低,接入成本极高,维护起来像在打地鼠。
MCP 协议(Model Context Protocol)试图解决的就是这个问题。它把工具调用从“每个 Harness 自己定一套”变成“大家共用一套描述和调用标准”。你可以把它理解成 Agent 工具世界的 USB-C:不管你是笔记本、手机还是平板,接口形状统一了,插上就能用。本文聚焦 TypeScript 项目场景,给出一套可复制的 MCP 工具注册配置骨架和类型定义,并带你走通本地验证工具调用链路的完整步骤。适合正在做 Agent 工程化、被工具接入折磨过的开发者。
2. 前置准备:TaoToken 接入与 TypeScript 项目环境
在开始写 MCP 工具注册骨架之前,需要先把模型调用通道准备好。我试过用 TaoToken 作为统一接入层,它兼容 OpenAI 风格的接口,同时支持 Claude Code、Coding Plan 等场景,对于需要频繁切换模型做工具调用验证的 Harness Engineering 来说比较省事。
2.1 获取 API Key 与配置环境变量
首先到 TaoToken 控制台创建一个 API Key。地址是https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=mcp_harness_ts。创建完成后,把 Key 写入项目根目录的.env文件:
# .env TAOTOKEN_API_KEY=sk-你的实际key TAOTOKEN_BASE_URL=https://taotoken.net/api注意:API 地址不要加 UTM 参数,直接用https://taotoken.net/api即可。如果你用的是 Claude Code 或 Anthropic 风格的调用,可以参考接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=mcp_harness_ts。
2.2 初始化 TypeScript 项目
创建一个新的 TypeScript 项目,安装 MCP 官方 SDK 和必要的类型依赖:
mkdir mcp-harness-skeleton && cd mcp-harness-skeleton npm init -y npm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node tsx然后在tsconfig.json中开启严格模式和 ESM 支持:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "outDir": "dist", "rootDir": "src" }, "include": ["src/**/*"] }在package.json中加上"type": "module"和运行脚本:
{ "type": "module", "scripts": { "dev": "tsx src/server.ts", "build": "tsc" } }到这里,项目骨架就准备好了。接下来进入核心部分:用 TypeScript 定义 MCP 工具注册的标准结构。
3. 可复制配置:MCP 工具注册骨架与 TypeScript 类型定义
MCP 协议的核心思路是:工具提供方(MCP Server)用标准格式描述自己有哪些工具、每个工具接受什么参数、返回什么结果;工具使用方(MCP Client / Harness)按照同样的标准去发现和调用。下面这套骨架可以直接复制到你的项目里。
3.1 定义工具描述的类型结构
先创建一个src/types.ts,把工具注册需要的类型集中管理:
// src/types.ts import { z } from "zod"; /** MCP 工具的标准描述结构 */ export interface McpToolDefinition<T extends z.ZodTypeAny = z.ZodTypeAny> { /** 工具名称,全局唯一,建议用 snake_case */ name: string; /** 工具用途描述,会直接进入模型上下文,写清楚“什么时候用” */ description: string; /** 参数 Schema,用 zod 定义,运行时校验 + 生成 JSON Schema */ inputSchema: T; /** 工具执行函数,入参类型由 inputSchema 推导 */ handler: (args: z.infer<T>) => Promise<McpToolResult>; } /** 工具调用返回结构 */ export interface McpToolResult { content: Array<{ type: "text" | "resource"; text?: string; resource?: { uri: string; mimeType: string; text: string }; }>; isError?: boolean; } /** 工具注册表,Harness 通过它统一发现和调用 */ export class McpToolRegistry { private tools = new Map<string, McpToolDefinition>(); register<T extends z.ZodTypeAny>(tool: McpToolDefinition<T>): void { if (this.tools.has(tool.name)) { throw new Error(`工具名称冲突: ${tool.name}`); } this.tools.set(tool.name, tool as McpToolDefinition); } list(): McpToolDefinition[] { return Array.from(this.tools.values()); } get(name: string): McpToolDefinition | undefined { return this.tools.get(name); } /** 生成 MCP 标准的 tools/list 响应 */ toMcpToolList() { return this.list().map((tool) => ({ name: tool.name, description: tool.description, inputSchema: zodToJsonSchema(tool.inputSchema), })); } }这里的关键点是:用 zod 作为单一事实来源。参数校验、类型推导、JSON Schema 生成都从同一个定义出发,避免手写 Schema 和实际校验逻辑不一致。
3.2 实现 zod 到 JSON Schema 的转换
MCP 协议要求工具的inputSchema是 JSON Schema 格式。写一个轻量转换函数,覆盖常用类型即可:
// src/schema.ts import { z } from "zod"; export function zodToJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> { if (schema instanceof z.ZodObject) { const shape = schema.shape; const properties: Record<string, unknown> = {}; const required: string[] = []; for (const [key, value] of Object.entries(shape)) { properties[key] = zodToJsonSchema(value as z.ZodTypeAny); if (!(value instanceof z.ZodOptional)) { required.push(key); } } return { type: "object", properties, required }; } if (schema instanceof z.ZodString) { return { type: "string", description: schema.description }; } if (schema instanceof z.ZodNumber) { return { type: "number", description: schema.description }; } if (schema instanceof z.ZodBoolean) { return { type: "boolean", description: schema.description }; } if (schema instanceof z.ZodOptional) { return zodToJsonSchema(schema.unwrap() as z.ZodTypeAny); } if (schema instanceof z.ZodEnum) { return { type: "string", enum: schema.options }; } return { type: "object" }; }这段代码不追求覆盖所有 zod 特性,但足够支撑大部分工具定义场景。如果你的工具参数更复杂,可以按需扩展。
3.3 注册两个示例工具
创建src/tools.ts,注册两个典型工具:一个读文件,一个查 Git 状态。这两个工具在 Harness Engineering 里出现频率很高。
// src/tools.ts import { z } from "zod"; import { readFile } from "node:fs/promises"; import { exec } from "node:child_process"; import { promisify } from "node:util"; import { McpToolRegistry } from "./types.js"; const execAsync = promisify(exec); export function createToolRegistry(): McpToolRegistry { const registry = new McpToolRegistry(); registry.register({ name: "read_local_file", description: "读取本地文件内容。当需要查看项目中的源码、配置或文档时使用。", inputSchema: z.object({ path: z.string().describe("文件的绝对路径或相对于项目根目录的路径"), encoding: z.enum(["utf-8", "ascii"]).optional().describe("文件编码,默认 utf-8"), }), handler: async ({ path, encoding }) => { try { const content = await readFile(path, { encoding: encoding ?? "utf-8" }); return { content: [{ type: "text", text: content }], }; } catch (err) { return { content: [{ type: "text", text: `读取失败: ${(err as Error).message}` }], isError: true, }; } }, }); registry.register({ name: "git_status", description: "查看当前 Git 仓库的状态,包括修改、暂存和未跟踪文件。", inputSchema: z.object({ cwd: z.string().describe("Git 仓库根目录路径"), short: z.boolean().optional().describe("是否使用简短输出格式"), }), handler: async ({ cwd, short }) => { try { const { stdout } = await execAsync(`git status${short ? " --short" : ""}`, { cwd }); return { content: [{ type: "text", text: stdout || "工作区干净" }], }; } catch (err) { return { content: [{ type: "text", text: `Git 命令执行失败: ${(err as Error).message}` }], isError: true, }; } }, }); return registry; }注意description的写法:不要只写“读取文件”,而要写清楚“什么时候用”。模型在选择工具时,描述的质量直接影响调用准确率。
3.4 接入 MCP Server 标准协议
最后创建src/server.ts,把注册表挂到 MCP Server 上:
// src/server.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { createToolRegistry } from "./tools.js"; const registry = createToolRegistry(); const server = new Server( { name: "harness-tool-server", version: "1.0.0" }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: registry.toMcpToolList(), })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const tool = registry.get(request.params.name); if (!tool) { return { content: [{ type: "text", text: `未知工具: ${request.params.name}` }], isError: true, }; } const parsed = tool.inputSchema.safeParse(request.params.arguments); if (!parsed.success) { return { content: [{ type: "text", text: `参数校验失败: ${parsed.error.message}` }], isError: true, }; } return tool.handler(parsed.data); }); const transport = new StdioServerTransport(); await server.connect(transport);这套骨架的核心价值在于:工具定义、参数校验、协议响应三者统一。新增工具只需要在createToolRegistry里加一段注册代码,不需要改 Server 逻辑,也不需要手写 JSON Schema。
4. 验证请求:本地跑通工具调用链路
配置写完了,接下来验证它是否真的能跑通。分两步:先用 MCP Inspector 做协议层验证,再用 TaoToken 的模型对话做端到端验证。
4.1 用 MCP Inspector 检查工具列表
MCP 官方提供了一个调试工具@modelcontextprotocol/inspector,可以直接查看 Server 暴露的工具:
npx @modelcontextprotocol/inspector npx tsx src/server.ts启动后浏览器会自动打开一个界面,左侧能看到read_local_file和git_status两个工具,点击每个工具可以查看它的 JSON Schema 和描述。如果这里能看到正确的工具列表,说明协议层配置没问题。
4.2 用模型对话验证工具调用
协议层通了之后,需要验证模型能否正确选择工具并生成合法参数。到 TaoToken 的模型对话页面创建一个测试会话:https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=mcp_harness_ts。
把上面toMcpToolList()生成的工具列表作为tools参数传给模型,然后发一条测试消息:
{ "model": "claude-sonnet-4-20250514", "messages": [ { "role": "user", "content": "帮我看看 /Users/demo/project 这个仓库当前的 Git 状态,用简短格式" } ], "tools": [ { "name": "git_status", "description": "查看当前 Git 仓库的状态,包括修改、暂存和未跟踪文件。", "inputSchema": { "type": "object", "properties": { "cwd": { "type": "string", "description": "Git 仓库根目录路径" }, "short": { "type": "boolean", "description": "是否使用简短输出格式" } }, "required": ["cwd"] } } ] }如果模型返回的tool_calls里包含git_status,并且arguments是{"cwd": "/Users/demo/project", "short": true},说明工具描述和 Schema 的质量足够让模型正确理解。实测下来,描述写得越具体,模型选错工具的概率越低。
4.3 完整链路:从模型输出到工具执行
把模型返回的tool_calls参数传给registry.get(name).handler(),执行结果再作为tool角色的消息回传给模型。这一步在 Harness 里通常是一个循环:
async function runToolLoop(userMessage: string) { const messages = [{ role: "user", content: userMessage }]; const tools = registry.toMcpToolList(); while (true) { const response = await callModel({ messages, tools }); const choice = response.choices[0]; if (choice.finish_reason === "tool_calls") { for (const call of choice.message.tool_calls) { const tool = registry.get(call.function.name); const args = JSON.parse(call.function.arguments); const result = await tool!.handler(args); messages.push({ role: "tool", tool_call_id: call.id, content: result.content.map((c) => c.text).join("\n"), }); } continue; } return choice.message.content; } }这段循环就是 Harness Engineering 里最核心的“手脑协同”逻辑。MCP 的价值在于:tools的格式是标准的,tool_calls的解析是标准的,工具执行结果的回传格式也是标准的。换一个模型、换一个 Harness,这套循环几乎不用改。
5. 本篇常见错排查
工具调用链路跑不通时,问题通常集中在几个地方。下面按出现频率从高到低排列。
5.1 工具列表为空或 Schema 格式错误
现象:MCP Inspector 里看不到工具,或者模型返回“没有可用工具”。
排查方向:检查ListToolsRequestSchema的 handler 是否真的返回了tools数组;检查zodToJsonSchema对z.ZodOptional的处理是否正确——如果 optional 字段被错误地放进了required,部分模型会拒绝调用。另外确认inputSchema的顶层type是"object",MCP 协议要求工具参数必须是对象类型。
5.2 参数校验失败但模型生成的参数看起来没问题
现象:safeParse返回失败,但打印出来的arguments肉眼看着是对的。
常见原因是类型不匹配。比如模型把short传成了字符串"true"而不是布尔值true,或者把数字传成了字符串。zod 的z.boolean()不会自动转换字符串。解决办法是在 Schema 里用z.coerce.boolean()做强制转换,或者在 handler 里做一次规范化。另一个原因是模型把可选字段传成了null,而 zod 的.optional()只接受undefined,这时需要用.nullable().optional()。
5.3 工具名称冲突导致注册失败
现象:启动时报工具名称冲突。
MCP 协议要求工具名称在单个 Server 内唯一。如果多个模块各自注册了同名工具,McpToolRegistry.register会直接抛错。建议在工具命名时加前缀,比如file_read、git_status、jira_query,避免不同模块之间撞名。如果确实需要覆盖,可以在注册表里加一个override选项,但更推荐从命名规范上解决。
5.4 模型选错工具或反复调用同一个工具
现象:模型明明该调read_local_file,却调了git_status;或者调用失败后不换工具,反复重试同一个。
这通常不是代码问题,而是工具描述的问题。description里要写清楚“什么时候用这个工具”和“什么时候不要用”。比如read_local_file的描述可以加上“仅用于读取文件内容,不用于查看目录结构”。另外,工具执行失败时返回的isError: true和错误信息要足够具体,模型会根据错误信息决定下一步。如果错误信息只写“失败”,模型很难做出正确判断。
5.5 Stdio 传输下日志输出干扰协议通信
现象:MCP Inspector 能连上,但工具调用没有响应,或者连接随机断开。
Stdio 传输模式下,Server 的stdout被协议占用,任何console.log都会污染 JSON-RPC 消息流。解决办法是把所有调试日志写到stderr,用console.error而不是console.log。如果用了第三方库,确认它没有往stdout打印内容。这个问题在本地开发时特别隐蔽,因为单独跑 Server 看起来一切正常,一接入 Harness 就出问题。
6. 把工具调用标准落到你的 Harness 里
回到最开始的问题:Agent 的“手”不够用,本质不是工具太少,而是工具的接入和复用成本太高。MCP 协议提供的统一描述和调用标准,让工具从“每个 Harness 自己适配”变成“一次定义、多处使用”。上面这套 TypeScript 骨架可以直接作为你项目里的工具注册层,新增工具只需要加一段registry.register,协议响应、参数校验、类型推导都自动完成。
如果你正在做长期编码类 Agent 或需要频繁切换模型的场景,可以了解一下 TaoToken 的 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=mcp_harness_ts。它把模型调用和工具调用链路的验证放在同一个环境里,省去来回切换配置的麻烦。API Key 管理入口在https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=mcp_harness_ts,接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=mcp_harness_ts。
最后留一个实用建议:工具注册表里的description值得反复打磨。我踩过的坑是,一开始把描述写得太简略,模型经常在read_local_file和git_status之间犹豫。后来把每个工具的“适用场景”和“不适用场景”都写进描述,调用准确率明显提升。工具调用的稳定性,一半靠协议标准,一半靠描述质量。