Claude Code Tool System: 30+ Methods, Fail-Closed Defaults

3 min readAI Agents

The Tool interface design, buildTool() safety defaults, deferred tool discovery when MCP tools exceed context budget, and how the registry filters at three distinct layers.

ai-agentsclaude-codetoolspermissionsruntime

Most agent demos model a tool as one thing: a function the model can call.

Claude Code models tools as something more. When you read Tool.ts, you find three distinct layers encoded in the type system:

  1. The execution contract — how the tool runs, what it returns, how it handles progress and abort
  2. The governance protocol — what the tool's permission hints are, whether it's read-only, whether it's destructive, whether it can run concurrently
  3. The UI protocol — how the tool result renders in the transcript, what collapses in the search/read view, what name shows to the user

All three live in Tool.ts. That is not incidental — it is the architectural claim of the file.

File structure

Files covered in this post3 files
src/
├── Tool.ts
├── tools.ts
├── services/
│   └── tools/
│       ├── toolExecution.ts

src/Tool.ts

Unified tool contract: execution, governance, and UI protocol

Critical

Defines the Tool interface (30+ methods), ToolUseContext (the full execution context), ToolPermissionContext (the permission-relevant snapshot), and buildTool() (the factory that fills safe defaults). Every tool in the codebase passes through buildTool.

Module
Tool Protocol

Key Exports

  • Tool
  • Tools
  • ToolUseContext
  • ToolPermissionContext
  • buildTool
  • ToolDef
  • findToolByName

Why It Matters

  • The Tool interface is a runtime protocol covering execution, permissions, UI, concurrency, and serialization.
  • buildTool fills fail-closed defaults — isConcurrencySafe defaults to false, isReadOnly defaults to false.
  • ToolUseContext is the mega-context: it carries everything from options and abort signal to app state access.

Related Files

  • Tool registrysrc/tools.ts
  • Permission enginesrc/utils/permissions/permissions.ts
  • Tool executionsrc/services/tools/toolExecution.ts

The Tool interface — 30+ methods, not just call()

The naive model of a tool protocol is: name, description, input schema, and a call function. Tool.ts defines more than that.

The full interface includes:

| Property | Purpose | |---|---| | call() | Execute the tool; return ToolResult | | description() | Generate the tool description for the model (context-sensitive) | | inputSchema | Zod schema for input validation | | checkPermissions() | Tool-specific permission logic | | isEnabled() | Whether the tool appears in the active registry | | isReadOnly() | Input-dependent hint for UI and compact | | isDestructive() | Marks irreversible operations (delete, overwrite, send) | | isConcurrencySafe() | Whether parallel execution is safe for this input | | interruptBehavior() | What happens when user submits while tool is running: cancel or block | | isSearchOrReadCommand() | Search/read/list classification for UI collapse | | requiresUserInteraction() | Cannot be auto-approved — interactive by design | | validateInput() | Pre-permission input validation | | backfillObservableInput() | Add derived fields to copies seen by hooks/transcript (original never mutated) | | shouldDefer | Tool uses deferred loading — ToolSearch needed before calling | | alwaysLoad | Never deferred — appears in prompt on turn 1 | | maxResultSizeChars | Threshold for persisting large results to disk | | searchHint | 3-10 word hint for ToolSearch keyword matching | | aliases | Backward-compat names for renamed tools | | isMcp / isLsp | Origin markers | | strict | Enable strict schema adherence on the API side |

The most important governance properties are isReadOnly, isDestructive, and isConcurrencySafe. They are input-dependent: the same tool may be read-only for one set of arguments and destructive for another.

buildTool: fail-closed defaults

Every tool in the codebase is created through buildTool(). The reason is that the 7 defaultable methods have security-relevant defaults:

const TOOL_DEFAULTS = {
  isEnabled:          () => true,
  isConcurrencySafe:  (_input?) => false,   // assume not safe
  isReadOnly:         (_input?) => false,   // assume writes
  isDestructive:      (_input?) => false,
  checkPermissions:   (input, _ctx?) => Promise.resolve({ behavior: 'allow', updatedInput: input }),
  toAutoClassifierInput: (_input?) => '',   // skip classifier — must opt in
  userFacingName:     (_input?) => '',
}

The fail-closed direction: isConcurrencySafe defaults to false (cannot run in parallel), isReadOnly defaults to false (assume writes). toAutoClassifierInput returns empty string by default — security-relevant tools must explicitly provide classifier input to participate in auto-mode.

checkPermissions defaults to allow with the original input. This means the general permission system handles all unspecialized tools — only tools with content-specific policy (like Bash) need to override.

📝Why the defaults are typed separately from the interface

The Tool interface has strict method signatures (no optional parameters). The TOOL_DEFAULTS object uses optional parameters to match how stubbed methods were called in tests. ToolDef is the intersection that allows either — tool authors write ToolDef, buildTool fills in the gap, and callers always see a complete Tool.

The comment in the source is direct: "tests relied on that." The type-level spread BuiltTool<D> mirrors the runtime {...TOOL_DEFAULTS, ...def} at the type level — zero-error typecheck across all 60+ tools confirms the approach works.

ToolUseContext: the mega-context

ToolUseContext carries everything a tool needs during execution:

export type ToolUseContext = {
  options: {
    commands: Command[]
    tools: Tools
    mainLoopModel: string
    mcpClients: MCPServerConnection[]
    mcpResources: Record<string, ServerResource[]>
    isNonInteractiveSession: boolean
    agentDefinitions: AgentDefinitionsResult
    thinkingConfig: ThinkingConfig
    refreshTools?: () => Tools          // for mid-query MCP reconnection
    customSystemPrompt?: string
    appendSystemPrompt?: string
    // ...
  }
  abortController: AbortController      // cancel signal
  readFileState: FileStateCache          // dedup reads within a turn
  getAppState(): AppState                // live app state accessor
  setAppState(f): void                   // state mutation
  setAppStateForTasks?: (f): void        // session-scoped infra (survives turn)
  handleElicitation?: ...               // MCP auth flow in SDK/print mode
  // ... more fields
}

The important design choice: getAppState() is a function, not a snapshot. This means permission checks re-read state after any await, which matters because MCP connections or user approvals can change state mid-turn.

setAppStateForTasks is distinct from setAppState because async subagents have a no-op setAppState (they cannot mutate the parent's store). Only session-scoped infrastructure — background tasks, session hooks — uses setAppStateForTasks, which always reaches the root store.

ToolPermissionContext vs ToolUseContext

The permission engine uses ToolPermissionContext, not ToolUseContext directly. The distinction:

  • ToolUseContext is the full execution context. It is passed to every tool call.
  • ToolPermissionContext is extracted from appState at permission check time. It is a DeepImmutable snapshot of just the permission-relevant fields.

This means: the tool executes with the full context, but the permission decision is evaluated against a frozen snapshot. Mutations to app state during an async permission check cannot affect the current decision — the check starts with the snapshot and can call context.getAppState() to re-read if needed.

The Tool protocol across runtimes

JS shows the actual Tool type shape. Python and Go show how the same protocol maps to their type systems.

typescriptTool.ts (interface excerpt)

The actual Tool interface from learning-claude-code — simplified

// The full runtime protocol for a Claude Code tool
interface Tool<Input, Output, Progress> {
readonly name: string
readonly inputSchema: z.ZodType<Input>

// Execution
call(
  args: Input,
  context: ToolUseContext,
  canUseTool: CanUseToolFn,
  parentMessage: AssistantMessage,
  onProgress?: (p: Progress) => void,
): Promise<ToolResult<Output>>

// Governance — input-dependent
checkPermissions(input: Input, context: ToolUseContext): Promise<PermissionResult>
isReadOnly(input: Input): boolean
isDestructive?(input: Input): boolean
isConcurrencySafe(input: Input): boolean
isEnabled(): boolean

// UI protocol
description(input: Input, opts: DescriptionOptions): Promise<string>
isSearchOrReadCommand?(input: Input): { isSearch: boolean; isRead: boolean; isList?: boolean }
interruptBehavior?(): 'cancel' | 'block'
requiresUserInteraction?(): boolean

// Serialization and deferrals
readonly maxResultSizeChars: number
readonly shouldDefer?: boolean
readonly alwaysLoad?: boolean
backfillObservableInput?(input: Record<string, unknown>): void
}

// buildTool fills fail-closed defaults for 7 methods
export function buildTool<D extends ToolDef>(def: D): BuiltTool<D> {
return { ...TOOL_DEFAULTS, userFacingName: () => def.name, ...def } as BuiltTool<D>
}

The real lesson

Tool.ts is not a convenience file.

It defines the runtime contract that lets Claude Code treat all tools — built-in Bash, file editors, MCP-backed external tools, agent subtools — through one consistent interface.

That consistency is what makes the permission engine, the streaming executor, the UI renderer, and the compact system all work without knowing which specific tool they are dealing with.

The protocol is the product.

In Claude Code's Tool protocol, why does buildTool() default isConcurrencySafe to false rather than true?

medium

buildTool fills seven defaults for tool authors who do not explicitly implement them.

  • AFor performance: sequential execution is faster for most tools
    Incorrect.Performance is not the reason. Sequential execution has higher latency for multi-tool turns, not lower.
  • BFail-closed safety: assuming a tool cannot run in parallel is safer than assuming it can
    Correct!Correct. If a tool incorrectly defaults to concurrent, two instances could execute simultaneously and corrupt shared state (e.g. file writes). Defaulting to exclusive means the only cost of a wrong default is latency, not correctness failure.
  • CMCP tools cannot run concurrently, so the default matches their behavior
    Incorrect.MCP tool concurrency depends on the specific tool's server implementation, not a blanket rule.
  • DThe query loop only executes one tool at a time anyway
    Incorrect.False. StreamingToolExecutor can execute multiple tools concurrently when isConcurrencySafe returns true.