Claude Code 的工具系统是怎么跑起来的
Claude Code 能读文件、跑命令、调用 MCP、开子任务,靠的是一套工具接口、注册表、权限管线和并发调度。这里用小白能懂的方式拆开看。


Claude Code 看起来像一个会写代码的聊天工具,但它真正能干活,靠的不是聊天本身。
模型只会生成文本。它要读文件、改代码、跑 shell 、查网页、调用 MCP 、派子 Agent,必须通过一套工具系统。可以把它理解成一个运行时:模型提出“我要调用哪个工具、传什么参数”,运行时负责检查、授权、执行,再把结果塞回对话。
这套架构大致分四层:工具接口、工具注册表、调度执行管线、并发调度器。

先看整体:模型不直接碰电脑
Claude Code 的执行链路可以这样理解:
- 模型生成
tool_use结构,说明要调用哪个工具。 - 系统根据工具名找到对应工具。
- 用 Zod schema 检查参数是否合法。
- 跑 hook 和权限规则。
- 通过后执行工具。
- 把工具结果转成
tool_result,回传给模型。
这里先把区别拆清楚:模型负责“决定做什么”,工具运行时负责“能不能做、怎么做、做完怎么回传”。
每个工具都长一个样
Claude Code 里的工具很多,但它们都实现同一套接口。文件读取、 Bash 、网页搜索、 MCP 工具、子 Agent,本质上都要遵守同一个形状。
核心接口大概是这样:
type Tool<Input, Output, P> = {
name: string
inputSchema: ZodType // Zod schema for input validation
call(input, context, canUseTool,
parentMessage, onProgress): Promise<ToolResult>
// Behavior declarations
isConcurrencySafe(input): boolean // Can run in parallel?
isReadOnly(input): boolean // Read-only operation?
isDestructive(input): boolean // Destructive action?
// Permission and validation
checkPermissions(input, context): Promise<PermissionResult>
validateInput(input, context): Promise<ValidationResult>
// API integration
description(input, options): Promise<string>
prompt(options): Promise<string> // System prompt text for this tool
mapToolResultToToolResultBlockParam(result, toolUseId): ToolResultBlockParam
// UI rendering (React)
renderToolUseMessage(input, options): ReactNode
renderToolResultMessage(content, ...): ReactNode
}
这段代码里,最关键的是几组字段。
name 用来让模型指定工具。inputSchema 用来检查输入。call() 是真正执行工具的地方。isConcurrencySafe()、isReadOnly()、isDestructive() 会影响能不能并发、是否需要更严格的权限。checkPermissions() 决定这次调用是否放行。
工具不是每个都从零写一遍。系统用 buildTool() 给工具补默认行为。这个设计很保守:没有声明并发安全,就按不能并发处理;没有明确权限逻辑,就走默认权限流程。简单说,默认不冒险。

工具结果也有固定形状:
type ToolResult<T> = {
data: T // The actual output
newMessages?: Message[] // Optional follow-up messages
contextModifier?: (ctx) => ToolUseContext // Mutate context for next tool
mcpMeta?: { ... } // MCP protocol metadata
}
这里的 contextModifier 很重要。它允许某些工具修改后续工具的执行上下文,比如切换工作目录。为了避免并发时互相踩状态,只有非并发安全工具才允许改共享上下文。
工具注册表:哪些能力能被模型看到
工具定义好之后,还要被放进工具池。 Claude Code 里有一个 getAllBaseTools(),会返回基础工具列表。有些工具一直可用,有些工具要看功能开关、环境变量和平台。
源材料里提到的常驻工具有 16 个,功能开关工具大约 27 个。

一些工具示例:
Ant-only: config, tungsten, suggest_background_pr, repl (also needs REPL flag)
Feature flags: web_browser, web_search, sleep, monitor, overflow_test, ctx_inspect, terminal_capture, list_peers, workflow, snip
Agent triggers: cron_create, cron_delete, cron_list, remote_trigger
Kairos (proactive agent): sleep, send_user_file, push_notification, subscribe_pr
Multi-agent swarms: team_create, team_delete, send_message
Todo v2: task_create, task_get, task_update, task_list
Environment: lsp (ENABLE_LSP_TOOL), enter_worktree / exit_worktree (worktree mode), powershell (Windows)
Tool discovery: tool_search (when tool pool is large)
Test-only: testing_permission (NODE_ENV=test)
这说明 Claude Code 的工具池不是固定死的。它会根据运行环境动态变化。 Windows 下可能有 PowerShell,打开某个 feature flag 后才有某些工具,连接 MCP 后又会多出外部工具。
MCP 工具为什么能接进来
MCP 的价值就在这里。外部 MCP server 可以暴露自己的工具,Claude Code 把这些工具包成同样的 Tool 接口。
运行时看来,MCP 工具和内置工具没有本质区别:都要注册、校验、过权限、执行、返回结果。 MCP 工具只是多带一层来源信息,比如:
mcpInfo: { serverName, toolName }
这层信息会用于权限规则、错误处理和认证。如果某个 MCP 工具因为认证失败调用不了,系统可以把 server 状态标记成 needs-auth,再提示用户处理。
工具池怎么合并
最终工具池由三步组成:
getAllBaseTools():拿到带 feature gate 的内置工具。getTools(permissionContext):按 deny rules 和isEnabled()过滤。assembleToolPool(permissionContext, mcpTools):把内置工具和 MCP 工具合并。
合并时有一个细节:内置工具优先。如果 MCP 工具和内置工具重名,内置工具胜出。每一组内部按字母排序,最后去重。这样做还能保护 prompt cache,因为工具数组顺序也是 API 请求的一部分,顺序乱掉会影响缓存命中。
// Sort each partition alphabetically, concat, deduplicate
const byName = (a, b) => a.name.localeCompare(b.name)
return uniqBy(
[...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
'name',
)
在发给 Claude API 之前,toolToAPISchema() 还会把工具的 Zod schema 转成 Anthropic API 使用的 JSON Schema 。模型看到的是结构化工具描述,不是 TypeScript 对象本身。
一次工具调用要过七道关
当 Claude 的回复里出现 tool_use block,Claude Code 会把它拿出来,走一条调度管线。每个工具调用都要按顺序经过这些阶段。
第一步是提取。主循环会从 assistant message 里筛出工具调用:
const msgToolUseBlocks = message.message.content.filter(
content => content.type === 'tool_use',
) as ToolUseBlock[]
每个 block 有三件东西:工具名、输入对象、唯一 ID 。 ID 很关键,因为工具结果必须引用同一个 ID,否则对话结构就断了。
第二步是输入校验。 Zod 会检查模型传进来的参数是否符合 schema 。
const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
let errorContent = formatZodValidationError(tool.name, parsedInput.error)
// Return error to model, skip execution
}
safeParse() 不会直接抛异常,而是返回成功或失败。失败时,系统会把格式化后的错误告诉模型,工具本身不会执行。这一点很重要:模型参数乱写,真实代码不会被触发。
有些工具还会跑第二层 validateInput()。 schema 只能检查类型,语义检查要靠工具自己做。比如文件路径必须是绝对路径,不能只靠 z.string() 判断。
第三步是 pre-tool hook 。 hook 是外部脚本或命令,可以在工具执行前介入。它可以允许、拒绝、修改输入、阻止执行,或者给用户补充上下文。
但 hook 有一个边界:hook 的 allow 不能绕过 settings.json 里的 deny / ask 规则。也就是说,hook 可以把门关得更严,不能把已经上锁的门打开。
第四步是权限检查。权限系统会按顺序看这些层:
- Deny rules:先检查,命中就立即停止。
- Ask rules:命中就请求用户确认。
- Tool-specific permissions:工具自己的权限逻辑,比如 Bash 会按子命令检查。
- Safety checks:保护
.git/、.claude/、 shell 配置等敏感路径。 - Permission mode:看用户当前模式怎么设。
- Allow rules:最后才看 allow 。
权限模式大概可以这样理解:
default:遇到 ask 就提示用户。
acceptEdits:安全文件操作自动允许,其他仍然提示。
bypassPermissions:大部分都自动允许,但 deny rules 和安全检查仍然有效。
plan:先批准计划,再按之前的模式执行。
auto:用 AI 分类器决定放行还是提示。
dontAsk:所有 ask 都变成 deny,不提示,直接拒绝。
权限规则也有来源优先级:组织策略、本地设置、项目设置、用户设置、 flag 、 CLI 参数、命令、 session 。企业策略可以压过个人设置,CLI 参数又可以覆盖一部分上下文。
第五步才是执行。权限通过后,工具的 call() 被调用:
const result = await tool.call(
callInput,
{ ...toolUseContext, toolUseId: toolUseID },
canUseTool,
assistantMessage,
progress => onToolProgress({ toolUseID: progress.toolUseID, data: progress.data })
)
这里传了五类东西:校验后的输入、执行上下文、权限回调、父级 assistant message 、进度回调。进度回调让界面能实时显示工具执行到哪里。
一个很细的设计:传给 call() 的 input,仍然是模型原始输入,而不是 hook 和权限检查过程中补过的版本。这样可以保持 transcript 一致,记录下来的工具调用和模型实际生成的内容一致。
第六步是 post-tool hook 。工具执行后,外部系统还能修改 MCP 输出、补充上下文,或者阻止对话继续。失败时还有单独的 PostToolUseFailure hook,方便日志系统或 CI/CD 给出补救建议。
第七步是结果映射。每个工具都会把自己的结果转成 Anthropic API 能接收的 tool_result block,并带上刚才的 tool_use_id。
如果结果太大,比如读了一个 10000 行文件,系统会把完整结果写到磁盘:
sessionDir/tool-results/{toolUseId}.txt
再把预览和文件引用发回 API,避免上下文被大输出撑爆。
并发调度:哪些能一起跑,哪些必须排队
模型一次可能发出多个工具调用。比如让它读 5 个文件,它可能同时生成 5 个 read call 。运行时不会简单全部并发,而是按工具声明的并发安全性分批。
简化逻辑如下:
// Simplified from toolOrchestration.ts
for (const toolUse of toolUseMessages) {
const isSafe = tool.isConcurrencySafe(parsedInput)
if (isSafe && lastBatch.isConcurrencySafe) {
lastBatch.blocks.push(toolUse) // Merge into concurrent batch
} else {
batches.push({ isConcurrencySafe: isSafe, blocks: [toolUse] })
}
}
安全批次会并发执行,上限可以通过环境变量控制:
CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY
不安全批次串行执行,一次只跑一个。上下文修改也只会在批次之间应用,不会在同一个并发批次里乱改共享状态。
实际效果是:
读 5 个文件:可以并发。
先读一个文件,再编辑它:读和写要分成两个批次。
多个 read 后面接一个 write:read 批量跑,write 单独排队。
还有一条 streaming 执行路径。开启 streaming 时,模型还没完整生成完回复,工具调用一旦在流里完成,就可以立刻排队执行。这样能减少等待时间。
streaming executor 还加了 Bash 错误级联:如果一个 Bash 命令失败,而它旁边还有并行工具在跑,系统会中止这些兄弟任务。
if (isErrorResult && tool.block.name === BASH_TOOL_NAME) {
this.hasErrored = true
this.siblingAbortController.abort('sibling_error')
}
这个设计很实用。 Bash 失败通常意味着环境状态已经不对,继续跑旁边任务可能只会制造更多噪音。
走一遍真实例子:读一个文件
假设模型想读 /src/index.ts,它会生成一个工具调用:
{
"type": "tool_use",
"id": "toolu_01XYZ",
"name": "read",
"input": { "file_path": "/src/index.ts" }
}
系统接下来会做这些事:
- 从 assistant message 里提取
tool_use。 - 用
findToolByName(tools, "read")找到 FileReadTool 。 - 用 Zod 检查
{ file_path: "/src/index.ts" }。 - 执行 pre-tool hooks 。
- 跑 FileReadTool 的权限检查。
- 调用
FileReadTool.call()读取文件,并按行号格式处理。 - 把文件内容映射成
tool_result,引用toolu_01XYZ。 - 把结果作为下一轮上下文发回模型。
因为 FileReadTool 通常是只读、并发安全的,如果同一条消息里有 5 个文件读取请求,它们可以一起跑。
这套设计有什么启发
第一,Claude Code 的工具系统是“失败默认关闭”。未知工具、非法输入、没声明并发安全、没权限声明,都会走更保守的路径。模型可能幻觉工具名,也可能传错参数,运行时的职责就是把这些错误挡住。
第二,模型其实也是调度器。运行时可以判断哪些调用能并发,但调用顺序还是模型决定的。系统提示会鼓励模型把独立工具调用并行发出,把依赖关系用单个 Bash 或顺序调用表达出来。运行时是在执行模型的计划,不是在替模型做全局规划。
第三,hook 是企业化扩展点。公司可以通过 pre-hook 做策略控制,通过 post-hook 做审计日志,通过 failure hook 接 CI/CD 或告警。 hook 能收紧权限,不能绕过 deny 规则,这个边界很关键。
第四,43+ 个工具共用一个接口。 Bash 、 web_fetch 、 MCP 、子 Agent 、 cron 、推送通知,看起来差别很大,但调度器眼里都是同一种对象。复杂度留在工具实现和权限规则里,路由层保持统一。
理解这套架构后,再看各种 Coding Agent,就不会只盯着“模型聪不聪明”。真正决定它能不能稳定干活的,是工具接口是否统一、权限是否保守、并发是否可控、错误结果能不能正确回到上下文里。
Claude Code 强的地方,不只是会写代码。它把一个 stateless 的语言模型,接到了一个带权限、工具、调度和状态回传的运行时上。