Chapter 04

Tools:让 AI 执行操作

设计和实现高质量的 MCP Tool,让 AI 具备真实的执行能力

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 ClientMCP Server
  ┌──────────────────────────────────────────────┐
  │  发送: { method: "tools/call",               │
  │           params: { name: "read_file",       │
  │                     arguments: { path } } }  │
  └───────────────────┬──────────────────────────┘
                      │ 执行
                      ▼
  Server 处理: fs.readFile(path) → 文件内容
                      │
                      ▼ CallToolResult
  LLM 收到文件内容,生成最终回答给用户

Tool 定义规范

每个 Tool 在协议层面由三个核心字段定义:

name
工具的唯一标识符,使用 snake_casecamelCase,应简洁且具有描述性。例如:read_filesearch_githubrun_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 已经验证了类型,也需要在业务层面进行安全检查: