docs/references/ai/tool-registry.md
interface ToolEntry {
name: string // wire-name, what the LLM emits in tool_calls
namespace: string // ownership key (web, kb, mcp:<serverId>, meta) — never shown to the model
namespaceLabel?: string // what `tool_search` groups by and shows; defaults to `namespace`
description: string // one-line summary for `tool_search`
defer: 'never' | 'always' | 'auto'
tool: Tool // AI SDK Tool (schema + execute + needsApproval + toModelOutput)
applies?(scope): boolean
}
registry (src/main/ai/tools/adapters/aiSdk/registry.ts) is a
process-wide singleton. Tool files register at module-import time; the
registry is read at request time by buildAgentParams. The Claude Code
runtime has a separate tool system — tools/adapters/claudeCode/agentTools.ts
builds its descriptors from MCP servers and built-in descriptors directly;
it does not consume this aiSdk ToolRegistry.
Tests construct their own new ToolRegistry() to avoid singleton pollution.
Double underscore is the segment separator (so internal single _ stays
unambiguous):
| Source | Name pattern | Example |
|---|---|---|
| Built-in | fixed wire name (<namespace>_<verb>) | web_search, kb_search |
| MCP (AI SDK) | mcp__<server-slug>__<tool-slug>_<identity-digest> | mcp__gmail__sendMessage_a1b2c3d4e5f60718293a |
| Meta | tool_<verb> | tool_search, tool_invoke, tool_inspect (tool_exec is defined but not injected — see below) |
The built-in wire names live in @shared/ai/builtinTools (single-underscore,
e.g. web_search); they are not derived from a __ segment convention like MCP.
The AI SDK MCP digest is derived from the stable server id plus the original
protocol tool name. The readable slugs romanize Han characters (tiny-pinyin)
so CJK names still produce a meaningful segment; kana and Hangul do not
romanize and fall back to server / tool plus the digest. Claude Code keeps
its separate runtime naming contract.
src/main/ai/tools/adapters/aiSdk/builtin/ registers four entries:
web_search (WebSearchTool.ts → createWebSearchToolEntry) — namespace
web. Talks to the configured web-search provider via the
renderer-shared search service.web_fetch (WebFetchTool.ts → createWebFetchToolEntry) — namespace
web. Fetches a URL's content.kb_search (KnowledgeSearchTool.ts) — semantic search over the active
knowledge base.kb_list (KnowledgeListTool.ts) — enumerate available knowledge bases /
documents.Registration happens in builtin/registerBuiltinTools.ts (registerBuiltinTools). Each
tool's applies gates on the relevant assistant.settings.* flag (e.g.
enableWebSearch).
src/main/ai/tools/adapters/aiSdk/mcp/:
resolveAssistantMcpToolIds — assistant's enabled MCP servers + per-tool
disable list → set of tool ids.mcpTools.syncMcpToolsToRegistry({ selectedToolIds }) — scans active servers'
cache-only catalogs via McpCatalogService.listTools, matches full tool ids,
and registers only exact selections as ToolEntry objects whose
tool.execute proxies through the MCP transport. The scan stops early once
every selected id has been claimed. Ownership uses the stable
namespace: mcp:<serverId>; display names never determine it, and
namespaceLabel: mcp:<serverName> is what tool_search groups by and shows
the model. Because reads are last-known-good cache snapshots, a transient
catalog failure does not evict a still-active server's prior entries.The sync is idempotent; a stale entry is overwritten on the next sync.
McpCatalogService splits the MCP tool catalog into a read facade and a
write/refresh path:
listTools(serverId) is cache-only — it returns the shared
mcp.tools.<serverId> cache and never connects to the upstream MCP server.
Every hot path that builds an agent/chat's tool surface uses it: the Claude Code
SDK bridge (createSdkMcpServerInstance), buildMcpToolMetadata, the agent
tool-policy (agentTools.listMcpDescriptors), and the two AI-SDK adapters
above. A dead or slow server therefore cannot block agent/chat startup
(issue #16242).refreshTools(serverId) (and the private listToolsForServer) is the live
path that connects, lists, and writes the cache. It is driven entirely by
background warmers: prewarmActiveServerTools (at onReady), the
onToolListChanged refresh, the renderer's on-demand refreshTools (via
useAgentTools), the server-enable toggle, and restartServer.listTools also fires a single non-blocking refreshTools the first time it sees
a never-warmed server (cache undefined, distinct from a warmed-but-empty []),
so headless/cron starts self-warm without re-probing dead servers.
Trade-off: tool availability is eventually consistent. A server whose cache is still cold when a session starts contributes no tools to that session and appears on the next one — the Claude Agent SDK snapshots the tool list per session, so this cannot be made live mid-session.
src/main/ai/tools/adapters/aiSdk/meta/ defines four tools that turn the
registry into a search-then-call interface for the model. Only the first
three are injected:
| Tool | Injected? | Use |
|---|---|---|
tool_search | yes | Browse the deferred pool by namespace + query, returns brief descriptions |
tool_inspect | yes | Emit a JSDoc stub for one tool — enough to call it correctly |
tool_invoke | yes | Invoke any registry tool by name with a JSON arg blob |
tool_exec | no | Sandboxed JS exec with the full registry as a global API (meta/exec/runtime.ts, meta/exec/worker.ts) — defined but intentionally not injected |
The injected three are added to the tool set by applyDeferExposition when
(and only when) the request actually defers tools. See below.
src/main/ai/tools/adapters/aiSdk/exposition/:
shouldDefer(entries, contextWindow) — returns the set of names to
defer. Two gates above the simple threshold:
applyDeferExposition(tools, registry, contextWindow) — strips the
deferred names out of tools, injects tool_search / tool_inspect /
tool_invoke, and returns the entries the system-prompt's
<DEFERRED_TOOLS> section needs to enumerate (so the model knows what
namespaces exist).
Approval-gated tools are never deferred. A force-prompt MCP tool is registered
with defer: 'never' — mcp/mcpTools.ts reads isMcpToolForcePromptBySource once
to drive both defer and needsApproval — so it stays inline and the SDK's native
approval gate fires on it. Deferring it would drop it from the SDK tool-set, so the
gate would never fire and it would be reachable only through tool_invoke with no
approval card. As a runtime backstop the tool_invoke / tool_exec meta-tools also
call isApprovalGated at execution time and refuse a gated tool (covering the
registry.getByName(any-name) vector), steering the model to call it inline. See
Tool Approval.
tool_exec is not injected by applyDeferExposition — there is no
metaTools.exec flag. The injection site (applyDeferExposition.ts:50-53)
deliberately leaves it out: its worker_threads + new Function sandbox
runs model-authored code with full Node privileges, a privilege-escalation
surface vs the renderer's prior restrictions. It is meant to be re-enabled
behind an explicit Preference key once there is a concrete need.
applies and tool-call repairapplies(scope: ToolApplyScope) — per-entry predicate consulted at
registry.selectActive. Throws are caught and treated as "inactive"
with a warning log.createAiRepair(...) (tools/adapters/aiSdk/repair.ts) — passed to AI SDK as
experimental_repairToolCall. When the model emits malformed args
(InvalidToolInputError), the repair function gets one chance to fix it via a
follow-up LLM call. Other failures (e.g. an unknown tool name) are
returned unrepaired.src/main/ai/tools/adapters/aiSdk/ (Claude Code adapter:
src/main/ai/tools/adapters/claudeCode/)tools/adapters/aiSdk/__tests__/,
tools/adapters/aiSdk/builtin/__tests__/,
tools/adapters/aiSdk/exposition/__tests__/,
tools/adapters/aiSdk/mcp/__tests__/,
tools/adapters/aiSdk/meta/__tests__/tools/adapters/aiSdk/exposition/shouldDefer.ts (header doc + tests)