夜雨聆风学习资料网

ARTICLE · 1350783

Claude Code 源码-Tool 契约与注册表

Claude Code 源码-Tool 契约与注册表

Claude Code 源码解析系列

第 3 讲:Tool 契约与注册表

基于 Claude Code 源码快照 · 2026-11-13

💡 本讲一句话:Claude Code 里的"工具"不是"模型能调的一个函数",而是一个三面体——对模型是 schema+描述,对安全系统是权限元数据,对 UI 是一组渲染方法。这一讲逐行拆解 Tool.ts(792 行)的契约定义和 tools.ts(389 行)的注册表装配,读完你能明白任何新工具如何靠一个 buildTool({...}) 插进这台机器。

一、一张契约表,三个面孔

第 2 讲停在命令分发。从这里开始进入 agent 循环的核心:模型做的每一个动作,最终都是一次工具调用。Claude Code 在写第一个具体工具之前,先回答了一个更根本的问题——"工具"到底是什么?答案在 src/Tool.ts 第 362-695 行:一个类型,约 40 个成员。先看最核心的几个(每个工具都必须实现或可覆盖):

📄 src/Tool.ts (第 379-416 行,核心成员摘录)

call(args, context, canUseTool, parentMessage, onProgress?)  // 唯一的"执行"方法:args 已经过 inputSchema 校验,context 是整个世界(下节细讲)    : Promise<ToolResult<Output>>  // Output 泛型 = 该工具自己的输出类型;canUseTool 回调让工具在执行中也能二次询问权限description(input, options): Promise<string>  // 动态描述:可以按会话状态/权限上下文改变给模型看的措辞,不是写死的字符串readonly inputSchema: Input  // Zod schema——对模型是 JSON Schema(API 参数),对运行时是 args 的校验器,一份定义两用readonly inputJSONSchema?: ToolInputJSONSchema  // MCP 工具可直接提供原生 JSON Schema,跳过 Zod→JSON 的转换环节outputSchema?: z.ZodType<unknown>  // 输出类型声明;源码注释写着 "TODO: Make it required"——目前还允许缺省inputsEquivalent?(a, b): boolean  // 判断两次调用是否等价——供去重/缓存类优化做决策用isConcurrencySafe(input): boolean  // 这次调用能否与其他工具并行?默认 false = 假设不安全,宁可串行isEnabled(): boolean  // 运行时开关:这个会话里要不要把我注册进工具列表(区别于编译期的 feature gate)isReadOnly(input): boolean  // 只读还是写操作——权限系统对这两类的默认态度完全不同isDestructive?(input): boolean  // 不可逆操作(删除/覆盖/发送);注释明说"Defaults to false,只在真做破坏性动作时设 true"interruptBehavior?(): 'cancel' | 'block'  // 工具运行中用户发来新消息:cancel=停下丢弃结果,block=继续跑、让新消息排队

为什么这样设计:注意这份契约里混着三类完全不同的关注点——inputSchema/description() 是给模型看的,isReadOnly/isDestructive 是给安全系统看的,renderToolUseMessage(后面会看到)是给终端 UI看的。把三者塞进同一个类型,意味着"新增一个工具"和"保证它安全、能显示"是同一份代码里的两件事——不可能出现"模型能调但权限系统不认识"的缝隙。这是用 TypeScript 类型当架构契约的典型手法:成员分必填(call、inputSchema、checkPermissions)和可选(带 ?),必填的是"不做就不能进系统",可选的是"有默认行为、按需覆盖"。

把约 40 个成员按职责归组,这张契约表的全貌就清楚了:

职责组
代表成员
作用
模型面契约
inputSchema / prompt() / description()
API 参数 schema + system prompt 里的工具说明文本
安全元数据
isReadOnly / isDestructive / toAutoClassifierInput
权限分级 + auto-mode 安全分类器的紧凑输入(如 Bash 的 ls -la)
权限闸门
validateInput / checkPermissions / preparePermissionMatcher
执行前校验 + 工具特化权限逻辑 + hook 规则匹配器
UI 渲染
renderToolUseMessage / renderToolResultMessage / renderGroupedToolUse
调用中/结果/被拒/出错各状态的终端显示,支持多实例分组渲染
isEnabled / isConcurrencySafe / interruptBehavior
生命周期控制
是否注册、能否并行、被打断时取消还是阻塞

还有一个容易被忽略的成员:maxResultSizeChars(第 457-466 行)。注释写得很直白——结果超过这个字符数就落盘存文件,模型只拿到预览+路径;而 Read 工具把它设成 Infinity,因为"persisting creates a circular Read→file→Read loop"——读文件的结果再落盘成文件、模型再去读那个文件,会形成死循环。一个数字背后是对上下文预算的精细控制。

二、ToolUseContext:每次调用都递进去的"世界"

call() 的第二个参数 ToolUseContext(第 158-300 行)是整份契约里最"胖"的类型——约 40 个字段。它的设计哲学是:工具可能需要的任何东西,都从这一个对象拿,而不是散落的参数:

📄 src/Tool.ts (第 158-300 行,字段摘录)

export type ToolUseContext = {  // 每次工具调用都收到的"世界对象":配置、状态、UI 钩子、身份,全在这里  options: {  // 会话级静态配置块    commands: Command[]  // slash command 列表——工具可以查询当前有哪些 /命令可用    mainLoopModel: string  // 主循环模型名——需要"用哪个模型做二次判断"的工具从这里拿    tools: Tools  // 本会话完整工具表——子代理/组合型工具可以查其他工具的 schema    mcpClients: MCPServerConnection[]  // 已连接的 MCP server 列表    isNonInteractiveSession: boolean  // 是否 headless(-p / SDK):决定能不能弹 UI、问用户    refreshTools?: () => Tools  // 拉取最新工具表的回调——MCP server 可能在查询中途才连上  }  abortController: AbortController  // 全局中断句柄:用户按 Esc,所有长操作都该检查这个 signal  readFileState: FileStateCache  // 文件读取状态 LRU 缓存——防重复读、给 CLAUDE.md 注入做去重  getAppState(): AppState  // 读全局应用状态(权限上下文、MCP 状态、后台任务……)  setAppState(f): void  // 写回全局状态;注释警告:对异步子代理这是 no-op,见 createSubagentContext  handleElicitation?: (serverName, params, signal) => Promise<ElicitResult>  // MCP -32042 URL elicitation 回调:print/SDK 模式委托给 structuredIO  setToolJSX?: SetToolJSXFn  // 注入自定义 React UI——local JSX command 用它渲染自己的界面  addNotification?: (notif) => void  // 往通知中心推一条消息(OS 级或应用内)  appendSystemMessage?: (msg: Exclude<SystemMessage, SystemLocalCommandMessage>) => void  // 追加"仅 UI"系统消息:在 normalizeMessagesForAPI 边界被剥掉,Exclude<> 让类型系统强制这一点  // ……(中间约 25 个字段:文件历史、commit 归属状态、skill 触发器、denial 计数、内容替换预算……)  messages: Message[]  // 当前会话消息流——AgentTool 这类工具靠它把任务上下文传给子代理  agentId?: AgentId  // 仅子代理设置:hooks 用它区分"主线程调用"还是"子代理调用"

为什么这样设计:胖,是因为工具的种类太杂——BashTool 需要 abortController(长命令要能杀),FileReadTool 需要 readFileState(去重+缓存),AgentTool 需要 messages+agentId(传上下文+标身份),local JSX command 需要 setToolJSX(画界面)。与其给每个工具设计不同的参数签名,不如一个"上帝对象"全量下发、各取所需——代价是类型膨胀,收益是新增工具零适配成本:任何工具拿到 context 就能做上面所有事。两个细节值得放大看:setAppState 对异步子代理是 no-op(第 186-190 行注释),因为子代理跑在独立上下文里,写主线程状态会串台;而 appendSystemMessage 用 Exclude<> 在类型层面保证"UI-only 消息永远不会漏进 API 请求"——安全边界不靠自觉,靠编译器。

三、buildTool:安全默认值 + 显式覆盖

如果每个工具都要实现全部约 40 个成员,写新工具会是噩梦。Claude Code 的解法是 buildTool()(第 783-792 行):给最常打桩的 8 个方法提供默认值,工具定义里只写自己关心的部分。源码注释把原则说得很清楚——"fail-closed where it matters":

📄 src/Tool.ts (第 707-713 行 + 第 757-792 行)

type DefaultableToolKeys =  // buildTool 提供默认值的 8 个方法:ToolDef 里可以省略它们  | 'isEnabled'  | 'isConcurrencySafe'  | 'isReadOnly'  | 'isDestructive'  | 'checkPermissions'  | 'toAutoClassifierInput'  | 'userFacingName'const TOOL_DEFAULTS = {  // 默认值集合——注释原话:"Defaults (fail-closed where it matters)"  isEnabled: () => true,  // 默认可用(opt-out):想禁用要显式声明,而不是默认藏着  isConcurrencySafe: (_input?) => false,  // 假设不可并行 → 调度器串行执行,安全侧兜底  isReadOnly: (_input?) => false,  // 假设是写操作 → 权限系统按更高风险对待,宁可多问一次  isDestructive: (_input?) => false,  // 默认非破坏性(只有真做删除/覆盖才设 true)  checkPermissions: (input, _ctx?) => Promise.resolve({ behavior: 'allow', updatedInput: input }),  // 无工具特化逻辑 → 放行给通用权限系统裁决,不自己拍板  toAutoClassifierInput: (_input?) => '',  // 空串 = 跳过 auto-mode 安全分类器;注释警告:安全相关工具必须覆盖它  userFacingName: (_input?) => '',  // 占位——下面 buildTool 会立刻用 name 覆盖掉}export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {  // 注释要求:所有工具导出都走这里,默认值只活在一个地方  return {  // 运行时就是一个 spread:先铺默认值……    ...TOOL_DEFAULTS,    userFacingName: () => def.name,  // ……显示名默认 = 工具自己的 name(覆盖上面的空串占位)    ...def,  // ……最后铺工具自身定义——"安全默认 + 显式覆盖",调用方永远拿到完整 Tool  } as BuiltTool<D>  // 类型层面用 BuiltTool<D> 镜像这个 spread:0-error typecheck across all 60+ tools(源码注释)

为什么这样设计:看默认值的取向就懂了——isConcurrencySafe 默认 false(假设不能并行,调度器就串行跑),isReadOnly 默认 false(假设会写,权限系统就按高风险走)。两个"假设最坏情况"的默认值,意味着新工具作者忘了声明时,系统自动走向保守一侧——这正是 fail-closed:出错的代价是慢一点/多问一次,而不是数据被改。而 checkPermissions 默认返回 allow 看似宽松,实则是"我不懂这个工具的语义,交给通用权限系统(permissions.ts)按规则裁决"——职责分离。类型层的 BuiltTool<D>(第 735-741 行)用条件类型精确镜像运行时的 spread:工具自己写了的方法保留原签名,没写的填上默认值——60+ 个工具全部通过 typecheck,说明这套"部分定义→完整对象"的推导没有一处失真。

四、实例解剖:GrepTool 如何填这张表

契约是抽象的,看一个真实工具就具体了。选 Grep(src/tools/GrepTool/GrepTool.ts,577 行)——它是 ripgrep 的包装器,麻雀虽小五脏俱全:

📄 src/tools/GrepTool/GrepTool.ts (第 160-200 行,声明部分)

export const GrepTool = buildTool({  // 一个对象字面量就是全部"注册表":契约的每个槽位在这里各就各位  name: GREP_TOOL_NAME,  // 主名 'Grep'——模型按这个名字发起调用  searchHint: 'search file contents with regex (ripgrep)',  // ToolSearch 关键词匹配用的一句话能力描述(规范要求 3-10 词)  maxResultSizeChars: 20_000,  // 结果超 2 万字符 → 落盘,模型只拿预览+路径(源码注释:persistence threshold)  strict: true,  // 严格模式:API 端更严格遵守工具说明和参数 schema  async description() { return getDescription() },  // 动态描述函数——可以按环境变化措辞  userFacingName() { return 'Search' },  // UI 显示名:模型调的是 Grep,用户看到的是 Search(两个名字各服务一个读者)  get inputSchema(): InputSchema { return inputSchema() },  // Zod schema:pattern/path/glob/type/output_mode/-A/-B/-C/head_limit……  isConcurrencySafe() { return true },  // 纯读搜索 → 显式声明可并行(覆盖默认的 false),调度器可以和其他调用并发跑  isReadOnly() { return true },  // 只读 → 权限系统可按"低风险"处理  toAutoClassifierInput(input) {  // auto-mode 安全分类器的紧凑输入:把参数压成一行可读文本    return input.path ? `${input.pattern} in ${input.path}` : input.pattern  // "pattern in path"——分类器一眼看懂这次搜索的范围  },  isSearchOrReadCommand() { return { isSearch: true, isRead: false } },  // UI 折叠标记:搜索类调用在紧凑视图里合并显示  getPath({ path }) { return path || getCwd() },  // 提取文件路径——权限规则(如 Grep(src/**))就是拿它做匹配的  async preparePermissionMatcher({ pattern }) {  // hook if 条件匹配器:每个输入只调用一次,昂贵解析放这里    return rulePattern => matchWildcardPattern(rulePattern, pattern)  // 返回闭包逐条规则模式匹配——"准备一次、匹配多次"的两段式设计  },

为什么这样设计:三个细节最能体现契约的价值。其一,name(Grep)和 userFacingName(Search)分开——模型需要稳定的 API 名,用户需要直觉的显示名,一个字段服务不了两个读者。其二,isConcurrencySafe() 显式返回 true:搜索是纯读操作,声明之后调度器就能把它和其他工具并发执行——而默认值是 false,所以"想并行必须证明安全"。其三,preparePermissionMatcher 的两段式:解析输入只做一次(返回闭包),之后每条 hook 规则模式都复用这个闭包——把 O(规则数 × 解析成本) 降成 O(解析 + 规则数)。再看执行器 call(),以及一个藏在校验里的安全细节:

📄 src/tools/GrepTool/GrepTool.ts (第 207-210 行 + 第 310-348 行摘录)

// —— validateInput 摘录:一个藏在输入校验里的安全细节 ——if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) {  // UNC 网络路径(\\server\share)——源码注释:防止 NTLM 凭据泄漏  return { result: true }  // 直接跳过 fs.stat:不去触碰网络共享,把风险挡在文件系统调用之外// —— call() 摘录:工具如何把模型参数翻译成真实命令 ——async call({ pattern, path, glob, type, output_mode = 'files_with_matches', '-B': context_before, ... }, { abortController, getAppState }) {  // 解构 args;'-B'/'-A'/'-C' 是 ripgrep 风格 flag(带连字符的 key 必须加引号)  const absolutePath = path ? expandPath(path) : getCwd()  // 没给路径 → 从当前工作目录搜起  const args = ['--hidden']  // 也搜隐藏文件:.env、.gitignore 这类模型经常需要的东西默认是隐藏的  for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) {  // 排除版本控制元数据目录,减少 .git/objects 之类的噪音    args.push('--glob', `!${dir}`)  // ripgrep 的 --glob !pattern = 排除匹配项  }  args.push('--max-columns', '500')  // 单行超 500 字符就截断——防 base64/压缩代码把上下文灌爆(源码注释原话)  if (multiline) { args.push('-U', '--multiline-dotall') }  // 多行模式只在显式要求时开:默认关闭避免误伤性能  if (case_insensitive) { args.push('-i') }  // 大小写不敏感 flag 透传  if (output_mode === 'files_with_matches') { args.push('-l') }  // -l = 只列文件名(默认模式)  else if (output_mode === 'count') { args.push('-c') }  // -c = 按文件统计匹配数  if (show_line_numbers && output_mode === 'content') { args.push('-n') }  // 行号只在 content 模式下有意义,其他模式加了是噪音  if (pattern.startsWith('-')) { args.push('-e', pattern) }  // 以连字符开头的 pattern → 用 -e 显式声明"这是模式不是 flag"  else { args.push(pattern) }  // 常规情况直接透传

为什么这样设计:--max-columns 500 是上下文预算的守门员——模型搜到一个 minified JS 文件,不截断的话一行几万字符直接吃掉整个窗口。而 UNC 路径那段(第 207-210 行)值得单独说:在 Windows 上对 \\server\share 做 fs.stat 会触发 SMB/NTLM 认证握手,可能把凭据信息暴露给恶意共享——所以校验阶段直接短路返回"通过",让后续环节按普通路径处理而不发起网络调用。这是"安全边界前移到输入校验层"的实例:不等到执行时才发现问题。-e flag 同理——模型完全可能生成 grep -foo bar 这种以连字符开头的 pattern,不加防护会被 ripgrep 当成命令行选项解析。

五、注册表 tools.ts:工具清单是怎么拼出来的

单个工具定义好了,谁来决定"这个会话里存在哪些工具"?答案是 src/tools.ts(389 行)的 getAllBaseTools()——源码注释自称 "the source of truth for ALL tools"。它的写法本身就是一篇供应链安全教材:

📄 src/tools.ts (第 193-251 行,清单摘录)

export function getAllBaseTools(): Tools {  // "当前环境里所有可能可用的工具"——注释自称 source of truth  return [    AgentTool,  // 子代理派生——多智能体编排的核心入口    TaskOutputTool,  // 读后台任务输出(配合 TaskCreate/Stop 构成任务系统)    BashTool,  // shell 执行——能力最强也最危险的工具    ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),  // Ant 内部构建把 bfs/ugrep 嵌进 bun 二进制 → 专用搜索工具冗余,直接不注册    ExitPlanModeV2Tool,  // 退出计划模式(v2)    FileReadTool, FileEditTool, FileWriteTool,  // 文件读/编辑/写三件套    NotebookEditTool,  // Jupyter ipynb 单元格编辑    WebFetchTool, TodoWriteTool, WebSearchTool, TaskStopTool,  // 抓取 / todo 列表 / 联网搜索 / 停任务    AskUserQuestionTool, SkillTool, EnterPlanModeTool,  // 向用户澄清 / 加载技能 / 进入计划模式    ...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),  // Ant 内部专用:配置工具,外部构建里根本不存在    ...(isTodoV2Enabled() ? [TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool] : []),  // todo v2 = 任务管理四件套(运行时开关)    ...(isEnvTruthy(process.env.ENABLE_LSP_TOOL) ? [LSPTool] : []),  // LSP 语言服务——环境变量门控,默认关    ...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),  // git worktree 隔离模式进出    getSendMessageTool(),  // agent 间消息——注意是函数调用:lazy require 打破循环依赖(见下)    ...(isAgentSwarmsEnabled() ? [getTeamCreateTool(), getTeamDeleteTool()] : []),  // 多智能体团队(swarm)创建/删除    ...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []),  // 仅测试环境存在的权限工具——生产构建里不存在    ListMcpResourcesTool, ReadMcpResourceTool,  // MCP 资源读取——常开(MCP server 连上才有实际内容)    ...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),  // 工具搜索:乐观检查,真正"延迟加载哪些工具"在请求时由 claude.ts 决定  ]}

为什么这样设计:这份清单里藏着三层门控,对应三种"工具不存在":编译期——feature('AGENT_TRIGGERS') 这类 bun:bundle 特性开关配合条件 require()(第 29-52 行),让 cron/Sleep/Monitor 等工具在外部构建里被物理删除(dead code elimination),不是运行时 if,而是代码根本不在产物里;环境期——USER_TYPE === 'ant'、ENABLE_LSP_TOOL 等环境变量,同一份构建按部署环境裁剪;运行期——每个工具的 isEnabled(),会话级动态开关。三层叠加,"模型能看到什么工具"就变成一条可审计的决策链。另一个工程细节:TeamCreate/TeamDelete/SendMessage 三个工具用 lazy require(第 61-72 行),注释明说原因——tools.ts → TeamCreateTool → …… → tools.ts 的循环依赖,顶层 import 会死锁模块图,改成函数内 require 就断了。还有第 190-192 行那条注释值得所有做 prompt cache 的人抄走:"This MUST stay in sync with [Statsig dynamic config]…in order to cache the system prompt across users"——工具清单的顺序和成员必须与服务端缓存策略严格同步,否则跨用户共享的 system prompt 缓存全部失效。

门控层
代表工具
生效时机与手段
编译期 feature gate
CronCreate/Delete/List、Sleep、Monitor、WebBrowser
构建时 DCE:feature('…') + 条件 require,代码不进产物
环境期 env gate
ConfigTool、TungstenTool(ant-only);LSPTool、PowerShellTool
启动时读 USER_TYPE / ENABLE_LSP_TOOL 等环境变量裁剪
运行期 isEnabled()
TaskCreate/Get/List(todo v2)
会话内动态开关,getTools() 最后一步过滤
NODE_ENV === 'test'TestingPermissionTool
仅测试环境注册,生产构建不可见

注册表旁边还有一个容易被忽略的目录:src/tools/shared/——跨工具复用的公共件。两个文件很有代表性:gitOperationTracking.ts(277 行)用正则识别 git commit / git push / gh pr create,文件头注释点明设计意图——"regexes operate on raw command text so they work identically for Bash and PowerShell":BashTool 和 PowerShellTool 各写一套检测是重复劳动,抽出来按原始命令文本匹配就两边通用;spawnMultiAgent.ts(1093 行)则是"从 TeammateTool 里抽出来的 teammate 创建逻辑,供 AgentTool 复用"——多智能体 spawn 的公共底座。shared/ 的存在说明这套工具系统已经过了"每个工具自给自足"的阶段,开始收敛公共能力。

六、getTools 过滤管线与 prompt cache 稳定性

基础清单 ≠ 最终清单。模型实际看到的工具表,还要过 getTools(permissionContext)(第 271-327 行)这道权限过滤:

📄 src/tools.ts (第 271-327 行,主干摘录)

export const getTools = (permissionContext: ToolPermissionContext): Tools => {  // 本会话最终工具表 = 基础清单 × 权限上下文  if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {  // SIMPLE 模式(--bare):最小工具集,面向嵌入场景的"最小权限"需求    const simpleTools: Tool[] = [BashTool, FileReadTool, FileEditTool]  // 只留 Bash/Read/Edit 三件套——其余一律不存在    if (feature('COORDINATOR_MODE') && coordinatorModeModule?.isCoordinatorMode()) {  // 若同时开了协调器模式……      simpleTools.push(AgentTool, TaskStopTool, getSendMessageTool())  // ……补上编排三件套:coordinator 要派任务、停任务、发消息(源码注释)    }    return filterToolsByDenyRules(simpleTools, permissionContext)  // 最小模式也要过 deny 规则——权限过滤没有豁免通道  }  const specialTools = new Set([ListMcpResourcesTool.name, ReadMcpResourceTool.name, SYNTHETIC_OUTPUT_TOOL_NAME])  // "特殊工具":别处按条件单独添加,这里先剔除防重复注册  const tools = getAllBaseTools().filter(tool => !specialTools.has(tool.name))  // 基础清单减去特殊工具  let allowedTools = filterToolsByDenyRules(tools, permissionContext)  // blanket deny(如 mcp__server 整服禁用)——在模型看到之前就删掉,不是调用时才拒绝  if (isReplModeEnabled()) {  // REPL 模式:原始工具被包进 VM 沙箱里……    allowedTools = allowedTools.filter(tool => !REPL_ONLY_TOOLS.has(tool.name))  // ……所以从直用列表隐藏,防止绕过沙箱直接调(源码注释)  }  const isEnabled = allowedTools.map(_ => _.isEnabled())  // 运行期开关:每个工具自己决定这个会话要不要出现  return allowedTools.filter((_, i) => isEnabled[i])  // 最终表 = 基础清单 − 特殊工具 − deny − REPL 隐藏,再 × isEnabled

为什么这样设计:注意 deny 规则的生效位置——第 258-260 行注释说得很清楚:用与运行时权限检查(step 1a)同一个 matcher,让 mcp__server 这类前缀规则在模型看到工具表之前就把整个 server 的工具删光。区别在于:调用时才拒绝,模型会反复尝试、浪费轮次;注册时就删除,模型压根不知道有这个东西——对"用户明确禁用的能力",后者才是正确的安全姿态(不提示攻击面)。REPL 隐藏同理:工具没被禁用,只是换了个入口(VM 内),直用通道必须关掉。

内置工具和 MCP 工具的合流在 assembleToolPool()(第 345-367 行),这里藏着一个跨端协作的细节:

📄 src/tools.ts (第 345-367 行)

export function assembleToolPool(permissionContext: ToolPermissionContext, mcpTools: Tools): Tools {  // 内置 + MCP 统一工具池——REPL.tsx 和 runAgent.ts(coordinator worker)共用这一个入口  const builtInTools = getTools(permissionContext)  // 第一步:内置工具(已经过模式/权限/isEnabled 全套过滤)  const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)  // 第二步:MCP 工具同样过 deny 规则——外部 server 的工具没有豁免权  const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)  // 排序比较器:按名字字母序  return uniqBy(  // 第三步:按 name 去重——内置工具优先(uniqBy 保留插入顺序,冲突时先出现的赢)    [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),  // 关键:两个分区各自排序后拼接,内置保持连续前缀    'name',  )}

为什么这样设计:"各自排序再拼接"看起来多此一举,第 354-361 行注释给出了原因——服务端的 claude_code_system_cache_policy 在"最后一个前缀匹配的内置工具之后"放全局缓存断点。如果做扁平排序(内置和 MCP 混在一起按名字排),某个 MCP 工具的名字恰好排在两个内置工具之间,就会把内置前缀打断,下游所有缓存键全部失效;分区排序保证内置工具永远是连续前缀,MCP 工具的增删只影响断点之后的部分。这是客户端代码为服务端缓存策略"让路"的实例——prompt cache 是跨用户共享的省钱机制,工具表顺序就是它的命门。注释还特意避开 Array.toSorted(Node 20+),因为要兼容 Node 18——readonly 数组先 copy 再 sort,细节都照顾到了。

七、一次工具调用的完整旅程

把这一讲的零件串起来:模型发出一个 tool_use 块之后,系统按固定管线处理它——契约表上的每个成员都在某个环节被调用:

一次工具调用的完整旅程(契约成员 → 管线环节)

① 查找:findToolByName(tools, name)

按主名或 alias 匹配(toolMatchesName);找不到 → 错误直接回给模型。

▼

② 校验:validateInput(input, context)

工具特化输入检查;失败返回 message+errorCode,让模型自己纠正重试。

▼

③ 权限:checkPermissions + 通用权限系统

allow/deny/ask 三态裁决;后台 agent 经 shouldAvoidPermissionPrompts 自动拒绝。

▼

④ 执行:call(args, context, canUseTool, …)

返回 Promise<ToolResult>;长操作经 onProgress 上报进度、监听 abortController。

▼

⑤ 序列化:mapToolResultToToolResultBlockParam

转成 API tool_result 块;超 maxResultSizeChars 落盘,模型拿预览+路径。

▼

⑥ 渲染与副作用:renderToolResultMessage + newMessages

UI 展示结果;newMessages 注入附加消息,contextModifier 仅对非并发安全工具生效。

小结:Claude Code 的工具系统 = 一张契约表(Tool.ts)× 一个注册表(tools.ts)。契约表用 TypeScript 类型把"模型面/安全面/UI 面"焊死在同一个对象上,buildTool 的 fail-closed 默认值保证新工具作者犯错时系统自动走向保守;注册表用编译期 DCE + 环境期 env gate + 运行期 isEnabled() 三层门控决定"谁能存在",再用 deny 规则、REPL 隐藏和分区排序把最终清单打磨成既安全又对 prompt cache 友好的形态。下一讲进入上下文收集——CLAUDE.md 是怎么被注入进模型视野的。

📚 系列导航

← 第 2 讲:CLI 入口与命令分发

→ 第 4 讲:上下文收集(CLAUDE.md 注入)

关注公众号「AI技术推荐官」获取更多源码解析内容

📌 每天 30 条,挑出真能用的 AI 东西

UnitAI 每天从 GitHub / Hacker News / Hugging Face 抓一遍 AI 热榜,还收录了 50+ 个上过榜的项目,能按分类、星级、上榜天数筛。免费看,不用注册。

长按识别二维码打开 · 或点左下角「阅读原文」

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料