第 06 章:工具的注册与发现

源码验证日期:2026-05-15,基于 commit
0d81bb6
上一章追踪了 system prompt 的构建。在 API 调用之前,还有一项关键准备工作:工具列表的组装。模型能使用哪些工具、每个工具接受什么参数、什么时候可以并行执行——这些都在注册阶段决定。
路线图
1 | graph LR |
知识补全:Zod Schema 基础
Zod 是一个 TypeScript 优先的 schema 验证库。Claude Code 用它定义工具的输入参数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15import { z } from 'zod'
const UserSchema = z.object({
name: z.string(), // 必填字符串
age: z.number().optional(), // 可选数字
role: z.enum(['admin', 'user']), // 枚举
})
type User = z.infer<typeof UserSchema>
// 等价于: { name: string; age?: number; role: 'admin' | 'user' }
const result = UserSchema.safeParse({ name: 'Alice', role: 'admin' })
if (result.success) {
console.log(result.data) // { name: 'Alice', role: 'admin' }
}
Claude Code 使用 Zod 的三个关键能力:
- 类型推导:
z.infer<typeof schema>自动生成 TypeScript 类型 - 运行时验证:
safeParse()验证输入是否符合 schema - JSON Schema 生成:Zod schema 可以转换为 JSON Schema,这是 Anthropic API 要求的工具参数格式
源码入口
本章追踪的调用链:1
2
3
4
5
6main.tsx 初始化阶段
→ src/tools.ts (getTools — 组装工具列表)
→ src/tools.ts (getAllBaseTools — 收集所有内置工具)
→ src/tools.ts (assembleToolPool — 合并内置 + MCP 工具)
→ src/Tool.ts (buildTool — 工具工厂函数)
→ src/tools/BashTool/ (BashTool — 具体工具实现示例)
逐行阅读
6.1 Tool 类型:统一的工具接口
所有工具都遵循 Tool 类型定义。它是一个泛型类型:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27// → src/Tool.ts 的 Tool 类型(简化版,展示核心字段)
export type Tool<
Input extends AnyObject = AnyObject,
Output = unknown,
P extends ToolProgressData = ToolProgressData,
> = {
// === 基础属性 ===
readonly name: string // 工具名,如 "Bash"
aliases?: string[] // 别名(重命名后向后兼容)
// === 核心方法 ===
call(args, context, canUseTool, parentMessage, onProgress?): Promise<ToolResult<Output>>
description(input, options): Promise<string>
readonly inputSchema: Input // Zod schema
// === 行为标记 ===
isReadOnly(input): boolean // 是否只读(影响并发策略)
isConcurrencySafe(input): boolean // 是否并发安全
isDestructive?(input): boolean // 是否破坏性操作
isEnabled(): boolean // 当前是否启用
// === 中断行为 ===
interruptBehavior?(): 'cancel' | 'block' // 用户发新消息时:取消 or 等待
// === 其他 ===
maxResultSizeChars?: number // 结果持久化阈值
}
关键设计决策:
| 方法 | 默认值 | 含义 |
|---|---|---|
isReadOnly | false | 默认假设是写操作——安全第一 |
isConcurrencySafe | false | 默认不能并行——避免竞态条件 |
isDestructive | false | 默认非破坏性 |
isEnabled | true | 默认启用 |
设计一瞥:为什么
isReadOnly默认是false?因为”默认不安全”比”默认安全”更安全——如果开发者忘了标记,工具会被当作写操作处理(串行执行),而不是意外并行执行导致竞态条件。
6.2 buildTool:工具工厂函数
buildTool 是创建工具的标准方式:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// → src/Tool.ts 的 buildTool() 函数(简化版)
const TOOL_DEFAULTS = {
isEnabled: () => true,
isConcurrencySafe: (_input?: unknown) => false,
isReadOnly: (_input?: unknown) => false,
isDestructive: (_input?: unknown) => false,
}
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
return {
...TOOL_DEFAULTS, // 先铺上默认值
userFacingName: () => def.name,
...def, // 然后用具体实现覆盖
} as BuiltTool<D>
}
这个模式让每个工具只需要定义自己特殊的部分。比如 ReadTool 只需要 isReadOnly: () => true,其他都用默认值。
6.3 BashTool:一个完整的工具实现
BashTool 是最复杂的工具之一:1
2
3
4
5
6
7
8// → src/tools/BashTool/BashTool.tsx 的 fullInputSchema(简化版)
const fullInputSchema = lazySchema(() => z.strictObject({
command: z.string().describe('The command to execute'),
timeout: z.number().optional().describe('Optional timeout in milliseconds'),
description: z.string().optional().describe('Clear, concise description...'),
run_in_background: z.boolean().optional(),
dangerouslyDisableSandbox: z.boolean().optional(),
}))
有趣的设计:
1. lazySchema():延迟求值。schema 在第一次使用时才创建,避免循环依赖。
2. _simulatedSedEdit 被从模型 schema 中移除:1
2
3
4
5
6// → src/tools/BashTool/BashTool.tsx
// Always omit _simulatedSedEdit from the model-facing schema. It is an internal-only
// field set by SedEditPermissionRequest after the user approves a sed edit preview.
const inputSchema = lazySchema(() => fullInputSchema().omit({
_simulatedSedEdit: true
}))
如果暴露给模型,模型就能绕过权限检查直接写入任意文件。
3. buildTool() 调用:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15export const BashTool = buildTool({
name: 'Bash',
searchHint: 'execute shell commands',
maxResultSizeChars: 30_000, // 超过 30K 字符时结果存到文件
isReadOnly(input) {
const result = checkReadOnlyConstraints(input)
return result.behavior === 'allow'
},
isConcurrencySafe(input) {
return this.isReadOnly?.(input) ?? false // 只读命令才能并发
},
async call(args, context, canUseTool, parentMessage) {
// ...执行 bash 命令的完整逻辑
},
})
6.4 getAllBaseTools:收集所有内置工具
1 | // → src/tools.ts 的 getAllBaseTools() 函数(简化版) |
工具分类一览:
| 类别 | 工具 | 特点 |
|---|---|---|
| 文件操作 | Read, Edit, Write, NotebookEdit | Read 是只读,其他是写操作 |
| 搜索 | Glob, Grep | 只读,可并行 |
| 执行 | Bash | 根据 isReadOnly() 动态判断 |
| 网络 | WebFetch, WebSearch | 只读 |
| 子代理 | Agent | 发起子任务 |
| 交互 | AskUserQuestion, TodoWrite | 需要用户交互 |
| MCP | MCPTool(动态) | 外部工具 |
6.5 getTools:过滤和组装
1 | // → src/tools.ts 的 getTools() 函数(简化版) |
6.6 assembleToolPool:合并内置 + MCP 工具
1 | // → src/tools.ts 的 assembleToolPool() 函数(简化版) |
排序的原因:工具列表的顺序影响 prompt cache。按名称排序确保缓存稳定。
设计一瞥:为什么内置工具优先于 MCP 工具?因为 MCP 工具来自外部服务器,可能不稳定。如果 MCP 服务器定义了一个叫 “Bash” 的工具,它不应该覆盖内置的 BashTool。
常见错误与检查方法
| 常见错误 | 检查方法 |
|---|---|
| 工具没注册 | 在 getTools() return 前打印 tools.map(t => t.name) |
| 工具被 deny 规则过滤 | 在 filterToolsByDenyRules 前后对比工具列表 |
| MCP 工具没加载 | 检查 assembleToolPool 是否收到 mcpTools |
| Zod 验证失败 | 检查 inputSchema.safeParse() 的 error 信息 |
| 缓存命中率低 | 检查工具列表排序是否稳定 |
试试看
修改 1:打印所有已注册工具
在 src/tools.ts 的 getTools() 函数 return 之前加:1
2console.log('[DEBUG] Active tools:', result.map(t => t.name).join(', '))
console.log('[DEBUG] Tool count:', result.length)
然后启动 Claude Code,观察输出了哪些工具。
修改 2:检查工具属性
1 | for (const tool of result) { |
修改 3:查看 BashTool 的 schema
1 | console.log('[DEBUG] BashTool inputSchema:', JSON.stringify(inputSchema(), null, 2)) |
检查点
你现在已经理解了:
- Tool 接口:统一的方法签名(
call、description、inputSchema)和行为标记(isReadOnly、isConcurrencySafe、isDestructive) - buildTool 工厂:填充默认值(默认不安全),具体工具只需定义特殊部分
- Zod schema:
lazySchema()延迟求值,.describe()生成模型可见的描述,.omit()隐藏内部字段 - 工具注册流程:
getAllBaseTools()→getTools()→assembleToolPool() - 条件包含:
feature()编译时消除、process.env.USER_TYPE运行时检查、isEnabled()动态检查 - 工具过滤:deny 规则、模式过滤、排序保证 prompt cache 稳定性
- 内置 vs MCP:内置工具优先,MCP 工具按名称去重,各自排序后合并
下一站预告:第 7 章将追踪 API 调用——query() 的 AsyncGenerator 模式、流式响应、Prompt Cache 机制。