Tool 的本质与设计原则
在 MCP 中,Tool(工具)是最核心、使用最频繁的能力类型。Tool 代表 AI 可以主动调用的操作——这些操作会对外部世界产生副作用(读写文件、执行代码、调用 API 等)。
Tool(工具)
MCP 中由模型主动调用的操作单元。每个 Tool 有唯一名称、自然语言描述和结构化参数规范。当 AI 判断需要使用某个工具时,它会生成调用请求,Host 通过 MCP 协议发送给 Server 执行,并将结果返回给 AI。
用户: "读取 /home/user/report.md 的内容" LLM 决策 ┌────────────────────────────────────────────────┐ │ 判断:需要调用 read_file 工具 │ │ 参数:{ "path": "/home/user/report.md" } │ └────────────────┬───────────────────────────────┘ │ tools/call MCP Client ▼ MCP Server ┌──────────────────────────────────────────────┐ │ 发送: { method: "tools/call", │ │ params: { name: "read_file", │ │ arguments: { path } } } │ └───────────────────┬──────────────────────────┘ │ 执行 ▼ Server 处理: fs.readFile(path) → 文件内容 │ ▼ CallToolResult LLM 收到文件内容,生成最终回答给用户
Tool 定义规范
每个 Tool 在协议层面由三个核心字段定义:
name
工具的唯一标识符,使用
snake_case 或 camelCase,应简洁且具有描述性。例如:read_file、search_github、run_sql_query。description
自然语言描述,极其重要。AI 完全依赖这个字段决定何时调用此工具。描述应清晰说明:工具做什么、何时应该使用、何时不应该使用、有什么限制。
inputSchema
JSON Schema 格式的参数规范,定义工具接受的参数名、类型、描述和约束。SDK 通常接受 Zod Schema 并自动转换为 JSON Schema。
工具返回值结构
工具调用的返回值为 CallToolResult 类型,包含一个 content 数组:
// CallToolResult 的结构 interface CallToolResult { content: Array< | { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } // base64 | { type: "resource"; resource: TextResourceContents | BlobResourceContents } >; isError?: boolean; // true 表示这是一个错误结果 }
完整工具实现示例
下面是一个文件系统操作工具集的完整实现,展示了 Tool 开发的所有关键模式:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import * as fs from "fs/promises"; import * as path from "path"; const server = new McpServer({ name: "filesystem-tools", version: "1.0.0" }); // ─── 工具一:读取文件 ───────────────────────────────────────── server.tool( "read_file", `读取指定路径的文件内容。 适用于:查看文本文件、配置文件、代码文件。 不适用于:二进制文件(图片、PDF等)。 返回文件的完整文本内容。`, { path: z.string() .describe("文件的绝对路径或相对于工作目录的路径"), encoding: z.enum(["utf-8", "ascii", "base64"]) .default("utf-8") .describe("文件编码,默认 utf-8"), }, async ({ path: filePath, encoding }) => { try { // 参数验证:确保路径在允许的目录下 const resolved = path.resolve(filePath); const allowedBase = path.resolve(process.env.ALLOWED_DIR || "."); if (!resolved.startsWith(allowedBase)) { return { content: [{ type: "text", text: `错误:不允许访问目录 ${resolved}` }], isError: true, }; } const content = await fs.readFile(resolved, encoding); return { content: [{ type: "text", text: content as string }], }; } catch (err) { const error = err as NodeJS.ErrnoException; return { content: [{ type: "text", text: `读取文件失败:${error.code} - ${error.message}`, }], isError: true, }; } } ); // ─── 工具二:写入文件 ───────────────────────────────────────── server.tool( "write_file", `将文本内容写入指定文件。 如果文件已存在,默认会覆盖;可设置 append=true 追加内容。 会自动创建中间目录。`, { path: z.string().describe("目标文件路径"), content: z.string().describe("要写入的文本内容"), append: z.boolean().default(false).describe("true=追加,false=覆盖"), }, async ({ path: filePath, content, append }) => { try { const resolved = path.resolve(filePath); // 确保父目录存在 await fs.mkdir(path.dirname(resolved), { recursive: true }); const flag = append ? "a" : "w"; await fs.writeFile(resolved, content, { flag, encoding: "utf-8" }); return { content: [{ type: "text", text: `✓ 文件已${append ? "追加" : "写入"}:${resolved}`, }], }; } catch (err) { return { content: [{ type: "text", text: `写入失败:${(err as Error).message}` }], isError: true, }; } } ); // ─── 工具三:列举目录 ───────────────────────────────────────── server.tool( "list_directory", "列举指定目录下的文件和子目录,返回名称、类型和大小信息", { path: z.string().describe("要列举的目录路径"), recursive: z.boolean().default(false).describe("是否递归列举子目录"), }, async ({ path: dirPath, recursive }) => { try { const entries = await fs.readdir(path.resolve(dirPath), { withFileTypes: true, }); const lines = entries.map(e => { const type = e.isDirectory() ? "[DIR] " : "[FILE]"; return `${type} ${e.name}`; }); return { content: [{ type: "text", text: lines.join("\n") || "(空目录)" }], }; } catch (err) { return { content: [{ type: "text", text: `失败:${(err as Error).message}` }], isError: true, }; } } );
参数验证
使用 Zod 进行参数验证是 MCP TypeScript 开发的最佳实践。SDK 会自动将 Zod Schema 转换为 JSON Schema,并在调用时验证参数,验证失败会自动返回错误响应。
Zod 验证模式详解
import { z } from "zod"; // 字符串验证 const pathSchema = z.string() .min(1, "路径不能为空") .max(4096, "路径过长") .regex(/^[^<>:"|?*]+$/, "路径包含非法字符") .describe("文件路径"); // 数值范围验证 const limitSchema = z.number() .int("必须是整数") .min(1).max(1000) .default(100) .describe("返回结果数量上限(1-1000)"); // 枚举验证 const formatSchema = z.enum(["json", "csv", "markdown"]) .describe("输出格式"); // 复杂对象验证 const querySchema = z.object({ table: z.string().describe("表名"), conditions: z.array( z.object({ field: z.string(), op: z.enum(["eq", "gt", "lt", "like"]), value: z.union([z.string(), z.number()]), }) ).optional().describe("过滤条件列表"), });
错误返回格式
Tool 的错误处理有两种方式,理解它们的区别非常重要:
业务错误(推荐)
通过 isError: true 返回,让 AI 能够理解错误并做出响应。
return {
content: [{
type: "text",
text: "文件不存在"
}],
isError: true,
};
协议错误(慎用)
通过抛出异常,让 MCP 框架转换为 JSON-RPC 错误响应。
throw new McpError( ErrorCode.InvalidParams, "参数无效" );
最佳实践
对于业务层面的错误(文件不存在、权限不足、网络超时),使用
isError: true 的内容返回,这样 AI 能理解错误并可能尝试其他方案。对于协议层面的错误(参数类型错误、缺少必填字段),才使用异常抛出。
工具设计最佳实践
原则一:单一职责
每个工具只做一件事,名称和行为要高度一致。拆分大而全的工具为多个专注的小工具,AI 能更精准地选择使用哪个。
不好的设计
"file_manager": {
// 参数太多,职责混乱
operation: "read"|"write"|
"delete"|"list"
path: string
content?: string
recursive?: boolean
}
好的设计
"read_file": { path }
"write_file": { path, content }
"delete_file": { path }
"list_dir": { path, recursive }
原则二:幂等性设计
幂等性是指:多次执行相同操作的结果与执行一次相同。对于只读操作,幂等性天然成立;对于写操作,应尽量设计为幂等的。
// ✓ 幂等:upsert 操作,不管执行多少次,结果一致 server.tool("set_config", "设置配置项(已存在则更新)", { key: z.string(), value: z.string(), }, async ({ key, value }) => { await upsertConfig(key, value); // 幂等操作 return { content: [{ type: "text", text: `已设置 ${key} = ${value}` }] }; }); // ✗ 非幂等:每次调用都追加,多次执行产生重复数据 server.tool("add_log", "添加日志(每次追加一条)", { message: z.string(), }, async ({ message }) => { await appendLog(message); // 非幂等,需要在描述中说明 return { content: [{ type: "text", text: `已记录:${message}` }] }; });
原则三:描述即文档
Tool 的 description 字段既是给 AI 看的说明,也是给开发者看的文档。好的描述应包含:
- 功能说明:工具的具体作用
- 适用场景:何时应该使用这个工具
- 边界条件:什么情况下会返回错误
- 副作用说明:工具是否会修改数据
原则四:安全防御
永远不要相信输入参数,即使 Zod 已经验证了类型,也需要在业务层面进行安全检查:
- 路径遍历攻击防护:检查路径不超出允许目录
- SQL 注入防护:使用参数化查询
- 命令注入防护:不直接拼接 shell 命令
- 大文件防护:限制文件大小上限