AI Agent 教程

第10章:自定义工具 —— 给 Agent 装上新技能

一句话:创建你自己的工具,让 Agent 能做任何你想做的事。

本章目标

前置知识


10.1 为什么需要自定义工具?

内置工具够用吗?

在第5章里,我们学了 Agent 自带的那些工具 —— Read、Write、Bash、Grep 等等。这些工具很强大,能帮 Agent 读写文件、执行命令、搜索内容。

但是,想想这些场景:

这些事情,内置工具统统做不到。内置工具就像一个瑞士军刀 —— 日常够用,但你要砍树的话,得去买一把电锯。

自定义工具 = 给 Agent 装新手臂

自定义工具就是你自己写的函数,然后"注册"给 Agent,让它在需要的时候可以调用。

打个比方:

你想让 Agent 有什么新能力,就给它做一个对应的工具就行了。理论上,只要你能用代码实现的功能,都可以包装成一个工具给 Agent 用。

工作原理简单说

整个流程是这样的:

1. 你定义一个工具:名字、描述、参数、执行函数
2. 你把工具注册到一个"工具服务器"
3. 你把工具服务器传给 query()
4. Agent 运行时,看到工具列表,根据描述判断什么时候该用
5. Agent 决定用某个工具时,传入参数,你的函数被调用
6. 函数执行完,返回结果给 Agent
7. Agent 拿到结果,继续思考下一步

是不是很简单?就是"定义 -> 注册 -> 使用"三步走。


10.2 用 createSdkMcpServer() 创建工具服务器

核心概念

在 Claude Agent SDK 里,自定义工具不是直接丢给 query() 的。你需要先创建一个工具服务器(MCP Server),然后把工具服务器传给 query()

为什么要多这一步?因为 SDK 采用了 MCP(Model Context Protocol)协议来管理工具。MCP 是 Anthropic 发明的一个开放标准,下一章会专门讲。现在你只需要知道:工具服务器就是一个装工具的盒子

两个关键函数

函数 干什么的 比喻
createSdkMcpServer() 创建一个工具服务器 造一个工具箱
tool() 定义一个工具 造一把具体的工具

最小示例

先看一个最简单的例子,感受一下整体结构:

import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

// 第一步:用 tool() 定义一个工具
// 第二步:用 createSdkMcpServer() 把工具放进服务器

const myServer = createSdkMcpServer({
  name: "my-tools",       // 服务器名字(随便起)
  version: "1.0.0",       // 版本号
  tools: [                // 工具列表,可以放多个
    tool(
      "say_hello",                                    // 工具名字
      "向指定的人打招呼",                                // 工具描述(Agent 靠这个决定什么时候用)
      { name: z.string().describe("要打招呼的人的名字") }, // 参数 schema
      async ({ name }) => {                            // 执行函数
        return {
          content: [{ type: "text", text: `你好,${name}!很高兴认识你!` }]
        };
      }
    )
  ]
});

// 第三步:把工具服务器传给 query()
for await (const message of query({
  prompt: "请跟小明打个招呼",
  options: {
    mcpServers: {
      "my-tools": myServer   // 键名要和服务器 name 一致
    },
    allowedTools: [
      "mcp__my-tools__say_hello"  // 允许 Agent 使用这个工具
    ]
  }
})) {
  if (message.type === "assistant") {
    console.log(message.content);
  }
}

逐行解析

tool() 函数的四个参数:

tool(
  name,         // 第1个参数:工具名字(string)
  description,  // 第2个参数:工具描述(string)
  schema,       // 第3个参数:参数定义(Zod schema 对象)
  handler       // 第4个参数:执行函数(async function)
)
参数 类型 说明 比喻
name string 工具的唯一标识,建议用 动词_名词 格式 工具的名牌
description string 告诉 Agent 这个工具干什么用的 工具的说明书
schema Zod 对象 定义工具需要哪些参数 工具的操作面板
handler async (args) => result 工具被调用时执行的代码 工具的实际功能

createSdkMcpServer() 的参数:

createSdkMcpServer({
  name: "server-name",   // 服务器名字
  version: "1.0.0",      // 版本号
  tools: [               // 工具列表
    tool(...),
    tool(...),
    tool(...)
  ]
})

工具命名规则:

allowedTools 里引用自定义工具时,格式是:

mcp__<服务器名>__<工具名>

比如服务器叫 my-tools,工具叫 say_hello,那就是:

mcp__my-tools__say_hello

你也可以用通配符允许服务器里的所有工具:

mcp__my-tools__*

tool() 的返回值格式

每个工具的执行函数必须返回一个固定格式的对象:

{
  content: [
    { type: "text", text: "这里是返回给 Agent 的文本内容" }
  ]
}

content 是一个数组,里面可以放多条内容。最常用的是 type: "text",即文本内容。


10.3 完整示例:天气查询工具

来做一个真正有用的工具 —— 查天气。我们用免费的 Open-Meteo API(不需要 API Key)。

完整代码

// weather-agent.ts
import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

// ============================================
// 第一步:定义一个城市坐标的映射表
// (真实项目中你可以调用地理编码 API 来获取坐标)
// ============================================
const cityCoordinates: Record<string, { lat: number; lon: number }> = {
  "北京": { lat: 39.9042, lon: 116.4074 },
  "上海": { lat: 31.2304, lon: 121.4737 },
  "广州": { lat: 23.1291, lon: 113.2644 },
  "深圳": { lat: 22.5431, lon: 114.0579 },
  "杭州": { lat: 30.2741, lon: 120.1551 },
  "成都": { lat: 30.5728, lon: 104.0668 },
  "东京": { lat: 35.6762, lon: 139.6503 },
  "纽约": { lat: 40.7128, lon: -74.0060 },
  "伦敦": { lat: 51.5074, lon: -0.1278 },
};

// ============================================
// 第二步:创建天气工具服务器
// ============================================
const weatherServer = createSdkMcpServer({
  name: "weather-tools",
  version: "1.0.0",
  tools: [
    tool(
      "get_weather",
      "查询指定城市的当前天气信息,包括温度、湿度、风速等",
      {
        city: z.string().describe("城市名称,例如:北京、上海、东京、纽约")
      },
      async ({ city }) => {
        // 查找城市坐标
        const coords = cityCoordinates[city];
        if (!coords) {
          return {
            content: [{
              type: "text",
              text: `抱歉,暂时不支持查询"${city}"的天气。支持的城市有:${Object.keys(cityCoordinates).join("、")}`
            }]
          };
        }

        try {
          // 调用 Open-Meteo API(免费,无需 API Key)
          const url = `https://api.open-meteo.com/v1/forecast?latitude=${coords.lat}&longitude=${coords.lon}&current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code&timezone=auto`;

          const response = await fetch(url);
          const data = await response.json();
          const current = data.current;

          // 把天气代码翻译成中文描述
          const weatherDesc = getWeatherDescription(current.weather_code);

          const result = [
            `城市:${city}`,
            `天气:${weatherDesc}`,
            `温度:${current.temperature_2m}°C`,
            `湿度:${current.relative_humidity_2m}%`,
            `风速:${current.wind_speed_10m} km/h`,
          ].join("\n");

          return {
            content: [{ type: "text", text: result }]
          };
        } catch (error) {
          return {
            content: [{
              type: "text",
              text: `查询天气失败:${error instanceof Error ? error.message : "未知错误"}`
            }]
          };
        }
      }
    )
  ]
});

// 辅助函数:天气代码转中文
function getWeatherDescription(code: number): string {
  const weatherMap: Record<number, string> = {
    0: "晴天",
    1: "大部晴朗", 2: "局部多云", 3: "多云",
    45: "有雾", 48: "雾凇",
    51: "小毛毛雨", 53: "中毛毛雨", 55: "大毛毛雨",
    61: "小雨", 63: "中雨", 65: "大雨",
    71: "小雪", 73: "中雪", 75: "大雪",
    80: "小阵雨", 81: "中阵雨", 82: "大阵雨",
    95: "雷暴", 96: "雷暴伴冰雹", 99: "强雷暴伴冰雹",
  };
  return weatherMap[code] || `未知天气(代码:${code})`;
}

// ============================================
// 第三步:创建 Agent 并使用天气工具
// ============================================
async function main() {
  console.log("天气查询 Agent 启动!\n");

  for await (const message of query({
    prompt: "请帮我查一下北京和上海的天气,然后告诉我哪个城市更适合今天出门散步。",
    options: {
      mcpServers: {
        "weather-tools": weatherServer
      },
      allowedTools: [
        "mcp__weather-tools__get_weather"
      ],
      maxTurns: 10
    }
  })) {
    if (message.type === "assistant") {
      // 打印 Agent 的回复
      for (const block of message.message.content) {
        if (block.type === "text") {
          console.log(block.text);
        } else if (block.type === "tool_use") {
          console.log(`\n[调用工具] ${block.name}(${JSON.stringify(block.input)})`);
        }
      }
    } else if (message.type === "result") {
      console.log("\n--- Agent 执行完毕 ---");
    }
  }
}

main().catch(console.error);

运行效果

运行这段代码后,你会看到类似这样的输出:

天气查询 Agent 启动!

[调用工具] get_weather({"city":"北京"})

[调用工具] get_weather({"city":"上海"})

根据查询结果:

- 北京:晴天,温度 22°C,湿度 35%,风速 12 km/h
- 上海:多云,温度 25°C,湿度 68%,风速 8 km/h

建议去北京散步!虽然上海温度稍高,但北京是晴天而且湿度低,
体感会更舒适。不过北京风稍大一些,建议带件薄外套。

--- Agent 执行完毕 ---

代码要点

  1. Agent 自己决定调用顺序:你只说"查北京和上海的天气",Agent 自己决定先查哪个、查几次。
  2. Agent 能综合分析:拿到两个城市的数据后,Agent 会自己对比分析,给出建议。
  3. 错误处理很重要:如果用户问了一个不支持的城市,工具会返回友好提示,而不是让程序崩溃。

10.4 完整示例:数据库查询工具

让 Agent 能查数据库 —— 这是企业场景里最常见的需求。我们用 SQLite 做示例。

安装依赖

npm install better-sqlite3
npm install -D @types/better-sqlite3

完整代码

// db-agent.ts
import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import Database from "better-sqlite3";

// ============================================
// 第一步:创建并初始化数据库(带示例数据)
// ============================================
const db = new Database(":memory:"); // 用内存数据库做演示

// 创建表
db.exec(`
  CREATE TABLE products (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    category TEXT NOT NULL,
    price REAL NOT NULL,
    stock INTEGER NOT NULL
  );

  CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    product_id INTEGER,
    customer TEXT NOT NULL,
    quantity INTEGER NOT NULL,
    order_date TEXT NOT NULL,
    FOREIGN KEY (product_id) REFERENCES products(id)
  );
`);

// 插入示例数据
const insertProduct = db.prepare(
  "INSERT INTO products (name, category, price, stock) VALUES (?, ?, ?, ?)"
);
const insertOrder = db.prepare(
  "INSERT INTO orders (product_id, customer, quantity, order_date) VALUES (?, ?, ?, ?)"
);

const products = [
  ["iPhone 15", "手机", 5999, 100],
  ["MacBook Pro", "电脑", 14999, 50],
  ["AirPods Pro", "耳机", 1299, 200],
  ["iPad Air", "平板", 4799, 80],
  ["Apple Watch", "手表", 2999, 150],
];

products.forEach(p => insertProduct.run(...p));

const orders = [
  [1, "张三", 2, "2024-01-15"],
  [2, "李四", 1, "2024-01-16"],
  [3, "王五", 3, "2024-01-17"],
  [1, "赵六", 1, "2024-01-18"],
  [4, "张三", 1, "2024-01-19"],
  [5, "李四", 2, "2024-01-20"],
  [3, "钱七", 1, "2024-01-21"],
];

orders.forEach(o => insertOrder.run(...o));

// ============================================
// 第二步:创建数据库工具服务器
// ============================================
const dbServer = createSdkMcpServer({
  name: "database",
  version: "1.0.0",
  tools: [
    // 工具1:执行 SQL 查询(只读)
    tool(
      "query_database",
      "执行 SQL 查询语句来查询数据库。注意:只支持 SELECT 查询,不允许修改数据。数据库包含 products 表(id, name, category, price, stock)和 orders 表(id, product_id, customer, quantity, order_date)。",
      {
        sql: z.string().describe("SQL 查询语句,只支持 SELECT 语句")
      },
      async ({ sql }) => {
        // 安全检查:只允许 SELECT 语句
        const trimmed = sql.trim().toUpperCase();
        if (!trimmed.startsWith("SELECT")) {
          return {
            content: [{
              type: "text",
              text: "错误:只允许执行 SELECT 查询语句,不能修改数据。"
            }]
          };
        }

        try {
          const rows = db.prepare(sql).all();

          if (rows.length === 0) {
            return {
              content: [{ type: "text", text: "查询结果为空,没有找到匹配的数据。" }]
            };
          }

          // 把结果格式化成表格
          const headers = Object.keys(rows[0] as object);
          const table = [
            headers.join(" | "),
            headers.map(() => "---").join(" | "),
            ...rows.map(row =>
              headers.map(h => String((row as Record<string, unknown>)[h])).join(" | ")
            )
          ].join("\n");

          return {
            content: [{
              type: "text",
              text: `查询到 ${rows.length} 条结果:\n\n${table}`
            }]
          };
        } catch (error) {
          return {
            content: [{
              type: "text",
              text: `SQL 执行错误:${error instanceof Error ? error.message : "未知错误"}`
            }]
          };
        }
      }
    ),

    // 工具2:查看数据库结构
    tool(
      "list_tables",
      "查看数据库中有哪些表,以及每个表的字段信息",
      {},  // 不需要参数
      async () => {
        const tables = db.prepare(
          "SELECT name FROM sqlite_master WHERE type='table'"
        ).all() as { name: string }[];

        const result: string[] = [];
        for (const table of tables) {
          const columns = db.prepare(
            `PRAGMA table_info(${table.name})`
          ).all() as { name: string; type: string }[];

          result.push(`表名:${table.name}`);
          result.push(`字段:${columns.map(c => `${c.name}(${c.type})`).join(", ")}`);
          result.push("");
        }

        return {
          content: [{ type: "text", text: result.join("\n") }]
        };
      }
    )
  ]
});

// ============================================
// 第三步:使用数据库工具
// ============================================
async function main() {
  console.log("数据库查询 Agent 启动!\n");

  for await (const message of query({
    prompt: "请帮我分析一下销售数据:哪个产品卖得最多?哪个客户消费最多?整理成一个简单的报告。",
    options: {
      mcpServers: {
        database: dbServer
      },
      allowedTools: [
        "mcp__database__query_database",
        "mcp__database__list_tables"
      ],
      maxTurns: 15
    }
  })) {
    if (message.type === "assistant") {
      for (const block of message.message.content) {
        if (block.type === "text") {
          console.log(block.text);
        } else if (block.type === "tool_use") {
          console.log(`\n[SQL 查询] ${JSON.stringify(block.input)}\n`);
        }
      }
    }
  }
}

main().catch(console.error);

关键设计点

  1. 只读限制:数据库工具只允许 SELECT 查询,这是安全红线。你不会想让 Agent 随便 DELETE 或者 DROP TABLE。

  2. 工具描述里写清楚表结构:注意 query_databasedescription 里提到了两个表名和字段名。这非常重要!Agent 是靠描述来写 SQL 的,你不告诉它有什么表、什么字段,它就写不出正确的 SQL。

  3. 提供 list_tables 工具:让 Agent 可以自己探索数据库结构。这样即使描述里漏了某些表信息,Agent 也能自己去查。

  4. 结果格式化:把查询结果格式化成 Markdown 表格,Agent 看起来更清晰。


10.5 完整示例:待办事项管理工具

这个例子展示如何在一个服务器里放多个工具,实现完整的增删改查(CRUD)功能。

完整代码

// todo-agent.ts
import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

// ============================================
// 数据存储(内存中的简单实现)
// ============================================
interface TodoItem {
  id: number;
  title: string;
  completed: boolean;
  createdAt: string;
  priority: "high" | "medium" | "low";
}

let nextId = 1;
const todos: TodoItem[] = [];

// ============================================
// 创建待办事项工具服务器(4 个工具)
// ============================================
const todoServer = createSdkMcpServer({
  name: "todo-manager",
  version: "1.0.0",
  tools: [
    // 工具1:添加待办事项
    tool(
      "add_todo",
      "添加一个新的待办事项到列表中",
      {
        title: z.string().describe("待办事项的标题,描述要做什么"),
        priority: z.enum(["high", "medium", "low"])
          .describe("优先级:high=紧急, medium=一般, low=不急")
          .default("medium")
      },
      async ({ title, priority }) => {
        const item: TodoItem = {
          id: nextId++,
          title,
          completed: false,
          createdAt: new Date().toISOString().slice(0, 10),
          priority
        };
        todos.push(item);

        return {
          content: [{
            type: "text",
            text: `已添加待办事项 #${item.id}:${item.title}(优先级:${item.priority})`
          }]
        };
      }
    ),

    // 工具2:列出所有待办事项
    tool(
      "list_todos",
      "列出所有待办事项,可以按状态过滤",
      {
        filter: z.enum(["all", "pending", "completed"])
          .describe("过滤条件:all=全部, pending=未完成, completed=已完成")
          .default("all")
      },
      async ({ filter }) => {
        let filtered = todos;
        if (filter === "pending") {
          filtered = todos.filter(t => !t.completed);
        } else if (filter === "completed") {
          filtered = todos.filter(t => t.completed);
        }

        if (filtered.length === 0) {
          return {
            content: [{ type: "text", text: "当前没有待办事项。" }]
          };
        }

        // 按优先级排序:high > medium > low
        const priorityOrder = { high: 0, medium: 1, low: 2 };
        filtered.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);

        const list = filtered.map(t => {
          const status = t.completed ? "[x]" : "[ ]";
          const priorityEmoji =
            t.priority === "high" ? "[!!!]" :
            t.priority === "medium" ? "[!!]" : "[!]";
          return `${status} #${t.id} ${priorityEmoji} ${t.title} (${t.createdAt})`;
        }).join("\n");

        return {
          content: [{
            type: "text",
            text: `待办事项列表(共 ${filtered.length} 条):\n\n${list}`
          }]
        };
      }
    ),

    // 工具3:完成待办事项
    tool(
      "complete_todo",
      "将指定的待办事项标记为已完成",
      {
        id: z.number().describe("待办事项的 ID 编号")
      },
      async ({ id }) => {
        const item = todos.find(t => t.id === id);
        if (!item) {
          return {
            content: [{ type: "text", text: `错误:找不到 ID 为 ${id} 的待办事项。` }]
          };
        }
        if (item.completed) {
          return {
            content: [{ type: "text", text: `待办事项 #${id} 已经是完成状态了。` }]
          };
        }

        item.completed = true;
        return {
          content: [{
            type: "text",
            text: `已完成:#${id} ${item.title}`
          }]
        };
      }
    ),

    // 工具4:删除待办事项
    tool(
      "delete_todo",
      "删除指定的待办事项",
      {
        id: z.number().describe("要删除的待办事项的 ID 编号")
      },
      async ({ id }) => {
        const index = todos.findIndex(t => t.id === id);
        if (index === -1) {
          return {
            content: [{ type: "text", text: `错误:找不到 ID 为 ${id} 的待办事项。` }]
          };
        }

        const removed = todos.splice(index, 1)[0];
        return {
          content: [{
            type: "text",
            text: `已删除:#${removed.id} ${removed.title}`
          }]
        };
      }
    )
  ]
});

// ============================================
// 使用待办事项 Agent
// ============================================
async function main() {
  console.log("待办事项管理 Agent 启动!\n");

  for await (const message of query({
    prompt: `请帮我管理今天的待办事项:
1. 添加:写周报(高优先级)
2. 添加:买菜(低优先级)
3. 添加:回复客户邮件(高优先级)
4. 添加:整理书桌(中优先级)
5. 把"回复客户邮件"标记为已完成
6. 最后列出所有未完成的待办事项`,
    options: {
      mcpServers: {
        "todo-manager": todoServer
      },
      // 用通配符允许所有 todo-manager 的工具
      allowedTools: [
        "mcp__todo-manager__*"
      ],
      maxTurns: 20
    }
  })) {
    if (message.type === "assistant") {
      for (const block of message.message.content) {
        if (block.type === "text") {
          console.log(block.text);
        } else if (block.type === "tool_use") {
          console.log(`\n[操作] ${block.name}(${JSON.stringify(block.input)})`);
        }
      }
    }
  }
}

main().catch(console.error);

运行效果

Agent 会依次调用 add_todo 四次,complete_todo 一次,list_todos 一次,最后给你一个整理好的报告。

设计亮点

  1. 通配符 mcp__todo-manager__*:一次性允许这个服务器里的所有工具,不用一个一个列。
  2. 默认参数priority 有默认值 "medium",用户不说优先级时会自动用中等。
  3. Zod 枚举:用 z.enum() 限制参数取值范围,防止 Agent 传入乱七八糟的值。
  4. 幂等检查:完成一个已完成的事项不会出错,而是返回友好提示。

10.6 工具设计最佳实践

好的工具设计能让 Agent 更聪明、更可靠。下面是我总结的几条经验。

实践1:工具描述必须清晰

Agent 完全靠 description 来决定什么时候用这个工具。描述写得好不好,直接决定 Agent 用得对不对。

// 差的描述 —— Agent 看了一头雾水
tool("query", "查询数据", ...)

// 好的描述 —— Agent 清楚知道这个工具干什么、什么时候该用
tool(
  "query_database",
  "执行 SQL SELECT 查询来获取数据。数据库包含 users 表(id, name, email, created_at)和 orders 表(id, user_id, amount, status, created_at)。只支持 SELECT 查询,不能修改数据。",
  ...
)

描述里应该包含:

实践2:参数一定要加 .describe()

// 差 —— Agent 不知道 q 是什么意思
{ q: z.string() }

// 好 —— Agent 清楚该传什么
{ query: z.string().describe("搜索关键词,支持中英文,可以用空格分隔多个关键词") }

参数名也很重要,用完整的英文单词,不要用缩写。

实践3:返回值要信息量充足

Agent 拿到工具返回值后,需要根据返回内容来决定下一步。信息量太少,Agent 就会迷茫。

// 差 —— Agent 只知道成功了,不知道具体情况
return { content: [{ type: "text", text: "成功" }] };

// 好 —— Agent 拿到了足够的信息来做下一步判断
return {
  content: [{
    type: "text",
    text: `成功添加了待办事项 #${id}:${title}。当前共有 ${todos.length} 条待办事项,其中 ${pendingCount} 条未完成。`
  }]
};

实践4:错误处理要友好

工具出错时,不要让程序直接崩溃,而是返回一个清晰的错误信息给 Agent。Agent 看到错误信息后,可能会换个方式重试,或者告诉用户出了什么问题。

async ({ sql }) => {
  try {
    const result = db.prepare(sql).all();
    return {
      content: [{ type: "text", text: JSON.stringify(result) }]
    };
  } catch (error) {
    // 返回错误信息给 Agent,而不是 throw
    return {
      content: [{
        type: "text",
        text: `SQL 执行失败:${error instanceof Error ? error.message : "未知错误"}。请检查 SQL 语法是否正确。`
      }]
    };
  }
}

实践5:一个工具只做一件事

不要做一个"万能工具",把所有功能都塞进去。Agent 更擅长从多个简单工具中选择合适的组合,而不是理解一个复杂工具的所有参数。

// 差 —— 一个工具干太多事
tool("manage_todo", "管理待办事项", {
  action: z.enum(["add", "delete", "complete", "list"]),
  title: z.string().optional(),
  id: z.number().optional(),
  filter: z.string().optional()
}, ...)

// 好 —— 拆成多个简单工具
tool("add_todo", "添加新的待办事项", { title: z.string(), priority: z.string() }, ...)
tool("delete_todo", "删除待办事项", { id: z.number() }, ...)
tool("complete_todo", "完成待办事项", { id: z.number() }, ...)
tool("list_todos", "列出待办事项", { filter: z.enum(["all", "pending", "completed"]) }, ...)

实践6:命名统一用 动词_名词

工具名就像函数名,要一看就知道它干什么。

// 推荐的命名模式
"get_weather"       // 获取天气
"search_products"   // 搜索产品
"create_order"      // 创建订单
"delete_user"       // 删除用户
"list_todos"        // 列出待办
"send_email"        // 发送邮件
"calculate_tax"     // 计算税费

// 不推荐
"weather"           // 动词还是名词?
"doStuff"           // 太模糊
"handler1"          // 看不出干什么

10.7 工具的输入输出格式

输入:Zod Schema 定义

工具的参数用 Zod schema 定义。Zod 是一个 TypeScript 类型验证库,SDK 内部会自动把 Zod schema 转换成 JSON Schema,告诉 Claude 工具接受什么参数。

常用的 Zod 类型:

import { z } from "zod";

// 基本类型
z.string()                          // 字符串
z.number()                          // 数字
z.boolean()                         // 布尔值

// 带约束
z.string().min(1).max(100)          // 1-100 个字符的字符串
z.number().int().min(0)             // 非负整数
z.number().positive()               // 正数

// 枚举
z.enum(["low", "medium", "high"])   // 只能是这三个值之一

// 可选参数(有默认值)
z.string().optional()               // 可以不传
z.string().default("hello")         // 不传时用默认值

// 数组
z.array(z.string())                 // 字符串数组

// 对象
z.object({
  name: z.string(),
  age: z.number()
})

// 记得加 describe!
z.string().describe("用户的邮箱地址,格式如 user@example.com")

一个完整的参数定义示例:

tool(
  "search_products",
  "在商品库中搜索产品",
  {
    keyword: z.string().describe("搜索关键词"),
    category: z.enum(["电子产品", "服装", "食品", "全部"])
      .describe("商品分类")
      .default("全部"),
    minPrice: z.number().min(0).describe("最低价格(元)").optional(),
    maxPrice: z.number().min(0).describe("最高价格(元)").optional(),
    limit: z.number().int().min(1).max(50)
      .describe("返回结果数量上限")
      .default(10)
  },
  async (args) => { /* ... */ }
)

输出:content 数组

工具的返回值必须是这个格式:

{
  content: [
    { type: "text", text: "文本内容" }
  ]
}

文本输出(最常用):

return {
  content: [{
    type: "text",
    text: "查询到 5 条结果..."
  }]
};

多段文本输出:

return {
  content: [
    { type: "text", text: "=== 搜索结果 ===" },
    { type: "text", text: "1. iPhone 15 - ¥5999" },
    { type: "text", text: "2. MacBook Pro - ¥14999" },
    { type: "text", text: `共找到 2 条结果` }
  ]
};

图片输出(高级场景):

return {
  content: [{
    type: "image",
    data: base64EncodedImage,  // base64 编码的图片数据
    mimeType: "image/png"
  }]
};

返回错误信息:

// 不要 throw error,而是用 isError 标记
return {
  content: [{ type: "text", text: "查询失败:连接超时" }],
  isError: true
};

完整的错误处理模式

推荐在每个工具里都用 try/catch 包一下:

tool(
  "do_something",
  "做某事",
  { param: z.string() },
  async ({ param }) => {
    try {
      // 你的业务逻辑
      const result = await someOperation(param);

      return {
        content: [{ type: "text", text: `操作成功:${result}` }]
      };
    } catch (error) {
      // 出错了,返回错误信息而不是 throw
      const errorMessage = error instanceof Error
        ? error.message
        : "未知错误";

      return {
        content: [{
          type: "text",
          text: `操作失败:${errorMessage}。请检查输入参数是否正确。`
        }],
        isError: true
      };
    }
  }
)

10.8 进阶:异步工具和超时控制

处理耗时操作

有些工具可能需要较长时间执行(比如调用慢速 API、处理大量数据)。建议加上超时控制:

tool(
  "slow_api_call",
  "调用外部 API(可能需要几秒钟)",
  { query: z.string() },
  async ({ query }) => {
    // 用 AbortController 实现超时
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 10000); // 10 秒超时

    try {
      const response = await fetch(`https://api.example.com/search?q=${query}`, {
        signal: controller.signal
      });
      clearTimeout(timeout);

      const data = await response.json();
      return {
        content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
      };
    } catch (error) {
      clearTimeout(timeout);
      if (error instanceof Error && error.name === "AbortError") {
        return {
          content: [{ type: "text", text: "请求超时(10秒),请稍后重试。" }],
          isError: true
        };
      }
      return {
        content: [{ type: "text", text: `请求失败:${error}` }],
        isError: true
      };
    }
  }
)

工具之间共享状态

多个工具之间可以通过闭包共享状态。前面待办事项的例子已经展示了这个模式 —— todos 数组被多个工具共同读写:

// 共享状态定义在工具外面
const sharedState = {
  todos: [] as TodoItem[],
  nextId: 1
};

const server = createSdkMcpServer({
  name: "stateful-tools",
  version: "1.0.0",
  tools: [
    tool("add_item", "添加项目", { ... }, async (args) => {
      // 读写 sharedState
      sharedState.todos.push({ ... });
      sharedState.nextId++;
      // ...
    }),
    tool("list_items", "列出项目", { ... }, async () => {
      // 读取 sharedState
      return {
        content: [{ type: "text", text: sharedState.todos.map(...).join("\n") }]
      };
    })
  ]
});

动手练习

练习1:计算器工具

做一个简单的计算器工具,支持四则运算。Agent 应该能处理类似"帮我算一下 (15 + 27) * 3 - 40 / 8"的请求。

要求:

参考代码框架:

import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

const calculatorServer = createSdkMcpServer({
  name: "calculator",
  version: "1.0.0",
  tools: [
    tool(
      "add",
      "将两个数字相加,返回它们的和",
      {
        a: z.number().describe("第一个加数"),
        b: z.number().describe("第二个加数")
      },
      async ({ a, b }) => {
        const result = a + b;
        return {
          content: [{ type: "text", text: `${a} + ${b} = ${result}` }]
        };
      }
    ),

    tool(
      "subtract",
      "用第一个数字减去第二个数字,返回它们的差",
      {
        a: z.number().describe("被减数"),
        b: z.number().describe("减数")
      },
      async ({ a, b }) => {
        const result = a - b;
        return {
          content: [{ type: "text", text: `${a} - ${b} = ${result}` }]
        };
      }
    ),

    tool(
      "multiply",
      "将两个数字相乘,返回它们的积",
      {
        a: z.number().describe("第一个因数"),
        b: z.number().describe("第二个因数")
      },
      async ({ a, b }) => {
        const result = a * b;
        return {
          content: [{ type: "text", text: `${a} * ${b} = ${result}` }]
        };
      }
    ),

    tool(
      "divide",
      "用第一个数字除以第二个数字,返回商。注意:除数不能为零。",
      {
        a: z.number().describe("被除数"),
        b: z.number().describe("除数,不能为零")
      },
      async ({ a, b }) => {
        if (b === 0) {
          return {
            content: [{ type: "text", text: "错误:不能除以零!" }],
            isError: true
          };
        }
        const result = a / b;
        return {
          content: [{ type: "text", text: `${a} / ${b} = ${result}` }]
        };
      }
    )
  ]
});

// 试试看:让 Agent 算一个复杂表达式
async function main() {
  for await (const message of query({
    prompt: "请帮我计算:(100 + 50) * 2 - 75 / 3,请一步步算。",
    options: {
      mcpServers: { calculator: calculatorServer },
      allowedTools: ["mcp__calculator__*"],
      maxTurns: 15
    }
  })) {
    if (message.type === "assistant") {
      for (const block of message.message.content) {
        if (block.type === "text") console.log(block.text);
        if (block.type === "tool_use") {
          console.log(`[计算] ${block.name}(${JSON.stringify(block.input)})`);
        }
      }
    }
  }
}

main().catch(console.error);

练习2:文件统计工具

做一个文件统计工具,能统计指定目录下的文件信息。

要求:

提示: 用 Node.js 的 fspath 模块。

import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";

const fileStatsServer = createSdkMcpServer({
  name: "file-stats",
  version: "1.0.0",
  tools: [
    tool(
      "count_files",
      "统计指定目录中的文件数量(不包含子目录中的文件)",
      {
        directory: z.string().describe("要统计的目录路径,例如:/home/user/projects")
      },
      async ({ directory }) => {
        try {
          const entries = fs.readdirSync(directory, { withFileTypes: true });
          const fileCount = entries.filter(e => e.isFile()).length;
          const dirCount = entries.filter(e => e.isDirectory()).length;

          return {
            content: [{
              type: "text",
              text: `目录 ${directory} 中有 ${fileCount} 个文件和 ${dirCount} 个子目录。`
            }]
          };
        } catch (error) {
          return {
            content: [{
              type: "text",
              text: `无法访问目录 ${directory}:${error instanceof Error ? error.message : "未知错误"}`
            }],
            isError: true
          };
        }
      }
    ),

    tool(
      "get_file_sizes",
      "获取指定目录中所有文件的名称和大小",
      {
        directory: z.string().describe("目录路径")
      },
      async ({ directory }) => {
        try {
          const entries = fs.readdirSync(directory, { withFileTypes: true });
          const files = entries.filter(e => e.isFile());

          if (files.length === 0) {
            return {
              content: [{ type: "text", text: `目录 ${directory} 中没有文件。` }]
            };
          }

          const fileInfos = files.map(f => {
            const stats = fs.statSync(path.join(directory, f.name));
            const sizeKB = (stats.size / 1024).toFixed(1);
            return `  ${f.name} — ${sizeKB} KB`;
          });

          return {
            content: [{
              type: "text",
              text: `目录 ${directory} 中的文件:\n${fileInfos.join("\n")}`
            }]
          };
        } catch (error) {
          return {
            content: [{
              type: "text",
              text: `获取文件信息失败:${error instanceof Error ? error.message : "未知错误"}`
            }],
            isError: true
          };
        }
      }
    ),

    tool(
      "find_largest_file",
      "找到指定目录中体积最大的文件",
      {
        directory: z.string().describe("目录路径")
      },
      async ({ directory }) => {
        try {
          const entries = fs.readdirSync(directory, { withFileTypes: true });
          const files = entries.filter(e => e.isFile());

          if (files.length === 0) {
            return {
              content: [{ type: "text", text: "目录中没有文件。" }]
            };
          }

          let largest = { name: "", size: 0 };
          for (const f of files) {
            const stats = fs.statSync(path.join(directory, f.name));
            if (stats.size > largest.size) {
              largest = { name: f.name, size: stats.size };
            }
          }

          const sizeMB = (largest.size / 1024 / 1024).toFixed(2);
          return {
            content: [{
              type: "text",
              text: `最大的文件是:${largest.name},大小为 ${sizeMB} MB`
            }]
          };
        } catch (error) {
          return {
            content: [{
              type: "text",
              text: `查找失败:${error instanceof Error ? error.message : "未知错误"}`
            }],
            isError: true
          };
        }
      }
    )
  ]
});

练习3:翻译工具

做一个翻译工具,调用免费的翻译 API。

要求:

提示: 你可以用 LibreTranslate 的免费 API 或者模拟翻译来练习。下面是一个简单的模拟版本:

tool(
  "translate_text",
  "将文本从一种语言翻译成另一种语言。支持中文和英文之间的互译。",
  {
    text: z.string().describe("要翻译的文本内容"),
    targetLanguage: z.enum(["zh", "en"])
      .describe("目标语言:zh=中文, en=英文")
  },
  async ({ text, targetLanguage }) => {
    try {
      // 这里调用真实的翻译 API
      // 以下是使用 LibreTranslate 免费 API 的示例
      const sourceLanguage = targetLanguage === "zh" ? "en" : "zh";

      const response = await fetch("https://libretranslate.com/translate", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          q: text,
          source: sourceLanguage,
          target: targetLanguage,
          format: "text"
        })
      });

      const data = await response.json();

      return {
        content: [{
          type: "text",
          text: `原文(${sourceLanguage}):${text}\n翻译(${targetLanguage}):${data.translatedText}`
        }]
      };
    } catch (error) {
      return {
        content: [{
          type: "text",
          text: `翻译失败:${error instanceof Error ? error.message : "未知错误"}`
        }],
        isError: true
      };
    }
  }
)

本章小结

恭喜你!读完这一章,你已经掌握了自定义工具的核心技能。来回顾一下要点:

  1. 为什么需要自定义工具:内置工具不够用时,自定义工具让 Agent 获得无限扩展能力。

  2. 核心 API

    • tool(name, description, schema, handler) —— 定义一个工具
    • createSdkMcpServer({ name, version, tools }) —— 创建工具服务器
    • 通过 mcpServers 选项传给 query() —— 注册到 Agent
  3. 工具命名规则mcp__<服务器名>__<工具名>,支持 * 通配符。

  4. 输入格式:用 Zod schema 定义参数,记得加 .describe()

  5. 输出格式{ content: [{ type: "text", text: "..." }] }

  6. 最佳实践

    • 描述写清楚,Agent 才知道什么时候该用
    • 一个工具只做一件事
    • 错误要返回而不是抛出
    • 命名用 动词_名词 格式
  7. 安全意识:数据库工具只允许 SELECT,文件操作要限制路径,敏感操作要校验权限。


下一章预告

这一章我们学会了在代码里创建自定义工具。但如果别人已经做好了一个工具,你怎么直接拿来用?如果你做的工具想分享给别人呢?

下一章我们来学 MCP(Model Context Protocol) —— Agent 世界的"USB 接口"。MCP 是一个开放标准,让不同人做的工具可以互相兼容、即插即用。你可以连接到 GitHub MCP Server 操作仓库,连接到数据库 MCP Server 查询数据,甚至一次连接多个 MCP Server,让 Agent 拥有来自各方的超能力。

← 上一章9. 结构化输出