Why this matters
Claude Code does not hand the model one giant, permanent tool list. It builds a
session-specific pool. That means a tool can exist in the codebase, appear in
getAllBaseTools(), and still disappear later.
Big picture first
The registry path in tools.ts has four layers:
getAllBaseTools()builds the broadest built-in catalog for the current binary and feature flags.filterToolsByDenyRules()removes tools that are blanket-denied by the current permission context.getTools()applies mode-specific rules such as simple mode, REPL hiding, and each tool’s ownisEnabled()gate.assembleToolPool()merges the surviving built-ins with MCP tools and deduplicates by name.
That top-down view matters more than memorizing every tool name. The real lesson
is that tool availability is a pipeline, not a single switch. tools.ts gets
Claude Code to a runtime pool, but it does not finalize every later prompt-time
decision. The ToolSearchTool comment in getAllBaseTools() says this
explicitly: inclusion here is only an optimistic check, and the real deferral
decision happens later in claude.ts.
getAllBaseTools() is the exhaustive built-in catalog
This is the real function that starts the process:
export function getAllBaseTools(): Tools {
return [
AgentTool,
TaskOutputTool,
BashTool,
// Ant-native builds have bfs/ugrep embedded in the bun binary (same ARGV0
// trick as ripgrep). When available, find/grep in Claude's shell are aliased
// to these fast tools, so the dedicated Glob/Grep tools are unnecessary.
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
ExitPlanModeV2Tool,
FileReadTool,
FileEditTool,
FileWriteTool,
NotebookEditTool,
WebFetchTool,
TodoWriteTool,
WebSearchTool,
TaskStopTool,
AskUserQuestionTool,
SkillTool,
EnterPlanModeTool,
...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),
...(process.env.USER_TYPE === 'ant' ? [TungstenTool] : []),
...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []),
...(WebBrowserTool ? [WebBrowserTool] : []),
...(isTodoV2Enabled()
? [TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool]
: []),
...(OverflowTestTool ? [OverflowTestTool] : []),
...(CtxInspectTool ? [CtxInspectTool] : []),
...(TerminalCaptureTool ? [TerminalCaptureTool] : []),
...(isEnvTruthy(process.env.ENABLE_LSP_TOOL) ? [LSPTool] : []),
...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),
getSendMessageTool(),
...(ListPeersTool ? [ListPeersTool] : []),
...(isAgentSwarmsEnabled()
? [getTeamCreateTool(), getTeamDeleteTool()]
: []),
...(VerifyPlanExecutionTool ? [VerifyPlanExecutionTool] : []),
...(process.env.USER_TYPE === 'ant' && REPLTool ? [REPLTool] : []),
...(WorkflowTool ? [WorkflowTool] : []),
...(SleepTool ? [SleepTool] : []),
...cronTools,
...(RemoteTriggerTool ? [RemoteTriggerTool] : []),
...(MonitorTool ? [MonitorTool] : []),
BriefTool,
...(SendUserFileTool ? [SendUserFileTool] : []),
...(PushNotificationTool ? [PushNotificationTool] : []),
...(SubscribePRTool ? [SubscribePRTool] : []),
...(getPowerShellTool() ? [getPowerShellTool()] : []),
...(SnipTool ? [SnipTool] : []),
...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []),
ListMcpResourcesTool,
ReadMcpResourceTool,
// Include ToolSearchTool when tool search might be enabled (optimistic check)
// The actual decision to defer tools happens at request time in claude.ts
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
]
}
Layman-friendly reading: this is the “everything that could plausibly exist” list for the current runtime. It already respects environment checks and feature flags, but it is still broader than the truly final story seen later by prompt assembly code.
Two details are easy to miss:
- Search tools can vanish immediately when
hasEmbeddedSearchTools()is true, soGlobToolandGrepToolare not guaranteed. - MCP-related built-ins like
ListMcpResourcesToolandReadMcpResourceToolreally are part of the base catalog, even though later code treats them specially.
filterToolsByDenyRules() is intentionally tiny
The deny-rule helper is just one filter:
export function filterToolsByDenyRules<
T extends {
name: string
mcpInfo?: { serverName: string; toolName: string }
},
>(tools: readonly T[], permissionContext: ToolPermissionContext): T[] {
return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
}
Its job is small but important. A blanket deny rule removes a tool from the pool
before the model can select it. The comment above this function in tools.ts
also explains that server-wide MCP deny rules work here too, so an entire MCP
server can disappear before call time.
getTools() is the real built-in pipeline
This is where the catalog becomes a usable built-in list:
export const getTools = (permissionContext: ToolPermissionContext): Tools => {
// Simple mode: only Bash, Read, and Edit tools
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
// --bare + REPL mode: REPL wraps Bash/Read/Edit/etc inside the VM, so
// return REPL instead of the raw primitives. Matches the non-bare path
// below which also hides REPL_ONLY_TOOLS when REPL is enabled.
if (isReplModeEnabled() && REPLTool) {
const replSimple: Tool[] = [REPLTool]
if (
feature('COORDINATOR_MODE') &&
coordinatorModeModule?.isCoordinatorMode()
) {
replSimple.push(TaskStopTool, getSendMessageTool())
}
return filterToolsByDenyRules(replSimple, permissionContext)
}
const simpleTools: Tool[] = [BashTool, FileReadTool, FileEditTool]
// When coordinator mode is also active, include AgentTool and TaskStopTool
// so the coordinator gets Task+TaskStop (via useMergedTools filtering) and
// workers get Bash/Read/Edit (via filterToolsForAgent filtering).
if (
feature('COORDINATOR_MODE') &&
coordinatorModeModule?.isCoordinatorMode()
) {
simpleTools.push(AgentTool, TaskStopTool, getSendMessageTool())
}
return filterToolsByDenyRules(simpleTools, permissionContext)
}
// Get all base tools and filter out special tools that get added conditionally
const specialTools = new Set([
ListMcpResourcesTool.name,
ReadMcpResourceTool.name,
SYNTHETIC_OUTPUT_TOOL_NAME,
])
const tools = getAllBaseTools().filter(tool => !specialTools.has(tool.name))
// Filter out tools that are denied by the deny rules
let allowedTools = filterToolsByDenyRules(tools, permissionContext)
// When REPL mode is enabled, hide primitive tools from direct use.
// They're still accessible inside REPL via the VM context.
if (isReplModeEnabled()) {
const replEnabled = allowedTools.some(tool =>
toolMatchesName(tool, REPL_TOOL_NAME),
)
if (replEnabled) {
allowedTools = allowedTools.filter(
tool => !REPL_ONLY_TOOLS.has(tool.name),
)
}
}
const isEnabled = allowedTools.map(_ => _.isEnabled())
return allowedTools.filter((_, i) => isEnabled[i])
}
This function teaches the real mental model:
- There is a separate simple-mode branch, and it is not just “take the normal list and trim it.”
specialToolsmeans not every item fromgetAllBaseTools()flows straight through the ordinary built-in prompt path.- REPL mode can hide raw primitive tools with
REPL_ONLY_TOOLS, even after deny-rule filtering. - Every surviving tool still has to pass its own
isEnabled()check.
So when someone asks “why can’t the model call tool X right now?”, the correct answer is usually “which filter removed it?” rather than “the tool does not exist.”
assembleToolPool() is where built-ins and MCP tools meet
The final merge step is short, but the comments around it explain why the exact shape matters:
export function assembleToolPool(
permissionContext: ToolPermissionContext,
mcpTools: Tools,
): Tools {
const builtInTools = getTools(permissionContext)
// Filter out MCP tools that are in the deny list
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
// Sort each partition for prompt-cache stability, keeping built-ins as a
// contiguous prefix. The server's claude_code_system_cache_policy places a
// global cache breakpoint after the last prefix-matched built-in tool; a flat
// sort would interleave MCP tools into built-ins and invalidate all downstream
// cache keys whenever an MCP tool sorts between existing built-ins. uniqBy
// preserves insertion order, so built-ins win on name conflict.
// Avoid Array.toSorted (Node 20+) — we support Node 18. builtInTools is
// readonly so copy-then-sort; allowedMcpTools is a fresh .filter() result.
const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
return uniqBy(
[...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
'name',
)
}
This is the single place in tools.ts where the ordinary built-ins and the
external MCP tools become one merged runtime pool. It does three things in
order:
- starts from
getTools(permissionContext)for built-ins - runs the same deny-rule filter over
mcpTools - sorts each side and uses
uniqBy(..., 'name')so built-ins win if names collide
That last rule is easy to overlook. Because built-ins are concatenated first and
uniqBy keeps the first occurrence, a built-in tool takes precedence over an
MCP tool with the same name.
That still does not mean every later prompt path is frozen here. The merged pool
from assembleToolPool() is an important source of truth, but comments in
tools.ts still point to later logic, especially for ToolSearchTool.
Takeaways
- The real registry pipeline is getAllBaseTools -> filterToolsByDenyRules -> getTools -> assembleToolPool.
- This chapter is about the runtime assembly pipeline in tools.ts, not the final word on every prompt-time deferral choice.
- Simple mode, REPL mode, and each tool’s own isEnabled() check all change the final built-in list.
- assembleToolPool() merges built-ins and MCP tools late, sorts for cache stability, and lets built-ins win on name conflicts.