4.2 Tool Registration and Assembly
src/tools.ts defines a three-layer assembly pipeline from tool definition to availability. This is not a simple "register + use" pattern, but rather a precise pipeline with compile-time trimming, runtime filtering, and cache-aware sorting:
flowchart TD
L1[Layer 1: getAllBaseTools<br/>Static imports ~31<br/>+ conditional loads ~20 (plus 3 lazy requires)] --> L2[Layer 2: getTools<br/>Filter based on permission context]
L2 --> L3[Layer 3: assembleToolPool<br/>Built-in tools + MCP bridged tools<br/>Deduplication]
L3 --> Final[Final tool pool]Layer 1: getAllBaseTools() — Compile-Time Tool Trimming
getAllBaseTools() (src/tools.ts:193-251) is the single source of truth for all tools. It returns all potentially available tools under the current build environment.
Core tools (approximately 31 static imports) are directly imported and are always present (the full tool set enumerates to roughly 55-60):
import { BashTool } from './tools/BashTool/BashTool.js'
import { FileReadTool } from './tools/FileReadTool/FileReadTool.js'
import { FileEditTool } from './tools/FileEditTool/FileEditTool.js'
// ... other core toolsFeature-gated tools (approximately 20) are loaded via conditional require() (there are also 3 Team/SendMessage tools that use lazy require() to break a circular dependency, not as feature gates):
const SleepTool = feature('PROACTIVE') || feature('KAIROS')
? require('./tools/SleepTool/SleepTool.js').SleepTool
: null
const SnipTool = feature('HISTORY_SNIP')
? require('./tools/SnipTool/SnipTool.js').SnipTool
: nullHere, feature() is not a runtime function—it is a Bun bundler compile-time macro. When building the version for external users, feature('PROACTIVE') is evaluated to false during the compilation phase, the entire ternary expression is simplified to const SleepTool = null, and the require() call is physically removed by Dead Code Elimination. This means internal tools are not just "hidden"—they don't exist at all in the external build binary, fundamentally eliminating the possibility of bypassing the Feature Gate through runtime means.
There's also an interesting optimization: when hasEmbeddedSearchTools() returns true (Anthropic's internal build compiles bfs/ugrep into the Bun binary), GlobTool and GrepTool are excluded—because shell aliases already point to faster embedded implementations, making the dedicated tools unnecessary.
Layer 2: getTools() — Runtime Context Filtering
getTools() (src/tools.ts:271-327) filters tools at runtime based on the current environment and permission context. It contains four progressive filtering layers:
1. SIMPLE mode (CLAUDE_CODE_SIMPLE environment variable / --bare flag): Reduces the toolset to the minimum core—only BashTool, FileReadTool, and FileEditTool are retained. This is the lightest tool configuration, suitable for resource-constrained or embedded scenarios. When REPL mode is also enabled, these three tools are replaced by REPLTool (because the REPL's VM internally encapsulates them).
2. REPL mode filtering: When isReplModeEnabled() is true and REPLTool is available, tools in the REPL_ONLY_TOOLS set (Bash, FileRead, FileEdit, etc.) are hidden from the direct tool list. These tools still exist within the REPL VM's execution context, but the model cannot call them directly—they must be used indirectly through the REPL tool.
3. Deny rule filtering: filterToolsByDenyRules() checks whether each tool matches a global deny rule. A deny rule without ruleContent (blanket deny) completely removes the corresponding tool, making it invisible to the model in the system prompt. For MCP tools, prefix matching rules like mcp__server remove all tools from that server at once—this is done before the model sees the tool list, not checked at call time.
4. isEnabled() runtime check: Each tool's isEnabled() method is called, and tools returning false are filtered out. This allows tools to decide whether to enable themselves based on runtime conditions (such as whether dependencies are available).
Layer 3: assembleToolPool() — Merging and Cache-Aware Sorting
assembleToolPool() (src/tools.ts:345-367) is the final assembly point, merging built-in tools and MCP tools into a unified tool pool:
export function assembleToolPool(
permissionContext: ToolPermissionContext,
mcpTools: Tools,
): Tools {
const builtInTools = getTools(permissionContext)
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
// Partitioned sorting: built-in tools as a contiguous prefix, MCP tools as a suffix
const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
return uniqBy(
[...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
'name',
)
}This code has two key design decisions:
Partitioned sorting rather than global sorting: Built-in tools are alphabetically sorted to form a contiguous prefix block, and MCP tools are alphabetically sorted and appended as a suffix block. Why not simply do a single global sort across all tools? Because the API server's caching strategy (claude_code_system_cache_policy) sets a cache breakpoint after the last built-in tool. If global sorting were used, an MCP tool named mcp__github__create_issue would be inserted between GlobTool and GrepTool, invalidating all downstream cache keys. Partitioned sorting ensures that adding/removing MCP tools only affects the suffix portion, and the (larger) prefix block of built-in tools always hits the cache.
uniqBy('name') built-in priority: When a built-in tool and an MCP tool share the same name, uniqBy retains the first occurrence (i.e., the built-in tool), because built-in tools come first in the concatenated array. This ensures that built-in tools are not accidentally overridden by MCP tools.