Studio Notebook

Claude Code Atlas

Tool Registry And Assembly

Learn how `tools.ts` builds the runtime tool catalog and merged pool before later prompt-time deferral decisions.

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:

  1. getAllBaseTools() builds the broadest built-in catalog for the current binary and feature flags.
  2. filterToolsByDenyRules() removes tools that are blanket-denied by the current permission context.
  3. getTools() applies mode-specific rules such as simple mode, REPL hiding, and each tool’s own isEnabled() gate.
  4. 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:

  1. Search tools can vanish immediately when hasEmbeddedSearchTools() is true, so GlobTool and GrepTool are not guaranteed.
  2. MCP-related built-ins like ListMcpResourcesTool and ReadMcpResourceTool really 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:

  1. There is a separate simple-mode branch, and it is not just “take the normal list and trim it.”
  2. specialTools means not every item from getAllBaseTools() flows straight through the ordinary built-in prompt path.
  3. REPL mode can hide raw primitive tools with REPL_ONLY_TOOLS, even after deny-rule filtering.
  4. 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:

  1. starts from getTools(permissionContext) for built-ins
  2. runs the same deny-rule filter over mcpTools
  3. 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.