Tool Catalog

ToolCatalog is the single source of truth for every agent tool. Each tool is declared once with its name, availability rule, and builder. Callers (AgentService, ChatToolConfiguration, MessagingAgentConcern, ChannelMentionWorkflow) opt in by listing tool groups they want, instead of maintaining parallel allowed_tools arrays that drift.

Big picture

                ┌────────────────────────────────────────────────┐
                │  app/services/tool_catalog.rb                  │
                │  ───────────────────────────────────────────── │
                │  ONE register(...) call per tool               │
                │                                                │
                │   register(:save_memory,                       │
                │            groups: :core, available: ...)      │
                │   register(:show_widget,                       │
                │            groups: :chat_only, ...)            │
                │   register(:trigger_pr_review,                 │
                │            groups: :triggers, ...)             │
                │   ...                                          │
                └────────────────────────────────────────────────┘
                                     ▲
                       build_tools(ctx, include: GROUPS)
                       tool_names(ctx,  include: GROUPS)
                                     │
        ┌───────────────┬────────────┼────────────┬──────────────┐
        │               │            │            │              │
 ┌──────┴──────┐ ┌──────┴─────┐ ┌────┴──────┐ ┌───┴──────────────┐
 │ AgentService│ │ ChatJob    │ │ Messaging │ │ ChannelMention   │
 │ full agent  │ │ web/API    │ │ Telegram/ │ │ Workflow         │
 │ FULL_AGENT_ │ │ CHAT_TOOL_ │ │ Teams     │ │ FULL_AGENT_      │
 │ GROUPS      │ │ GROUPS     │ │ MESSAGING_│ │ GROUPS           │
 │             │ │            │ │ TOOL_     │ │  + :triggers     │
 │             │ │            │ │ GROUPS    │ │                  │
 └─────────────┘ └────────────┘ └───────────┘ └──────────────────┘
                                     │
                                     ▼
                        ┌─────────────────────────┐
                        │  ToolContext            │  per-invocation
                        │  ─────────────────────  │
                        │  space, user, conv,     │  memoized predicates:
                        │  message, queue,        │    credentials
                        │  factories              │    channel_configured?
                        │                         │    skills_enabled?
                        │                         │    ...
                        └─────────────────────────┘
                                     │
                                     ▼
       Catalog keeps entries where
         (entry.groups ∩ include).any?  AND  entry.available?(ctx)
                                     │
                       ┌─────────────┴─────────────┐
                       ▼                           ▼
                builder blocks run         full_names resolve
                → SDK tool instances       → "mcp__*" name strings
                       │                           │
                       ▼                           ▼
                create_sdk_mcp_server       options.allowed_tools
                (name: "themis")

Three pieces, three responsibilities:

  1. ToolCatalog — declarative registry. One register(...) line per tool. Decides what exists.
  2. ToolContext — per-invocation value object. Decides what’s available right now (user/space/credentials in scope).
  3. Caller groupsFULL_AGENT_GROUPS / CHAT_TOOL_GROUPS / MESSAGING_TOOL_GROUPS etc. Decide which slice of the catalog this caller wants.

Each call (build_tools for SDK tool objects, tool_names for the allowed_tools whitelist) re-evaluates against the current context, so false-availability tools just disappear without the caller noticing.

When to use this

SituationUse catalog?
New in-process Themis tool exposed to one or more agentsYes
Existing tool needs a new caller (workflow / job)Yes — add to a group, include the group
One-off tool used by exactly one workflow with no reuseNo — pass via extra_themis_tools: to AgentService
External MCP channel (Linear, Sentry, Metabase, Google Drive / Calendar / Gmail, Google Analytics, Google Maps, Flight Search)Lives in ChannelRegistry; only its allowed-tool names mirror through the catalog
Per-caller side effect on top of a shared tool (enqueue job, push to instance var)Use a :triggers factory (see below)

Registering a tool

Add one entry to app/services/tool_catalog.rb inside register_core_tools! or register_chat_only_tools!:

register(:my_tool, groups: :core, available: :sentry_configured?) do |ctx|
  MyToolBuilder.build_tool(space: ctx.space)
end

Name and full_names

The symbol becomes mcp__themis__my_tool automatically. Override full_names: only when the rule below doesn’t fit:

Tool kindNaming ruleExample
In-process Themis MCPmcp__themis__<name> (default)mcp__themis__get_pr_info
External MCP servermcp__<channel>__<tool>mcp__sentry__list_issues
SDK built-inbare name"Skill"
One builder emits N toolsList names explicitlymcp__themis__get_pr_info, mcp__themis__get_pr_diff, …
Names depend on runtime statePass a Proc evaluated at tool_names time:google_drive_bundle
Tool registers but is discovered through the SDKfull_names: [] (suppresses output):skills_bundle, :automations_bundle

Groups

Pick the smallest set that makes sense:

GroupReaches
:coreFull agent + chat + messaging
:chat_onlyWeb/API chat only (widgets, file create, skills, automations, personal tasks, GDrive)
:triggersCatalog-declared, caller-supplied factory
:skill_builtinSDK-discovered “Skill” tool
:linear, :sentry_full, :sentry_messaging, :metabase_full, :metabase_messagingExternal MCP allow-list mirrors

Callers compose by group, not by entry — there is no exclude list. If you need “:core minus one tool” the answer is to split the group, not subtract at the call site.

Availability rule

Prefer a ToolContext predicate symbol over an inline Proc:

register(:query_themis_data, groups: :core, available: :can_query_themis?) { ... }

Reach for a Proc only when the condition is genuinely one-off. If two entries share a check, add a method to ToolContext instead.

available: accepts: :always / true / nil (always on), false (always off), Symbol (called on ctx), or Proc (called with ctx).

Extending a tool’s JSON schema per caller

When the same catalog tool needs caller-specific schema fields (e.g. trigger_code_generation requires a project argument only in messaging contexts), pass them through the builder, not the catalog:

# ChannelMentionWorkflow — adds `project` as a required arg
CodeGenerationToolBuilder.build_tool(
  extra_properties: { project: { type: "string", description: "..." } },
  extra_required:   %w[project]
) { |args| handle_code_generation_tool(args) }

Wire this up via the :triggers factory (next section), so the catalog entry stays declarative.

Wiring a caller

Three steps: declare a group constant, build a ToolContext, ask the catalog twice.

MY_GROUPS = %i[core triggers chat_only].freeze

ctx = ToolContext.new(
  space: space, user: user, conversation: conv, message: msg,
  message_queue: queue,
  factories: { trigger_pr_review: ->(_ctx) { build_my_handler } }
)

tools = ToolCatalog.build_tools(ctx, include: MY_GROUPS)
mcp = ClaudeAgentSDK.create_sdk_mcp_server(name: "themis", tools: tools)

allowed = ToolCatalog.tool_names(ctx, include: MY_GROUPS)

Reuse the same ToolContext for build_tools and tool_names. credentials, channel_configured?, can_query_themis?, google_drive_available?, skills_enabled?, and automations_enabled? all memoize per-context — sharing the context avoids running each predicate twice.

Canonical examples

  • app/services/agent_service.rb:37FULL_AGENT_GROUPS (full-agent path)
  • app/jobs/concerns/chat_tool_configuration.rb:15CHAT_TOOL_GROUPS (web/API chat)
  • app/services/concerns/messaging_agent_concern.rb:13MESSAGING_TOOL_GROUPS (Telegram/Teams)
  • app/services/workflows/channel_mention_workflow.rb:302 — building the themis MCP server with :triggers

Factories (:triggers group)

trigger_code_generation and trigger_pr_review are catalog-declared but their handlers differ per caller (chat enqueues a job, mention captures a request to an instance var). The catalog entry gates on the factory’s presence — callers that don’t need the trigger simply omit the factory and the entry auto-skips:

# In tool_catalog.rb — declared once
register(:trigger_pr_review,
        groups: :triggers,
        available: ->(ctx) { ctx.factory(:trigger_pr_review).present? },
        full_names: "mcp__themis__trigger_pr_review") do |ctx|
  ctx.factory(:trigger_pr_review).call(ctx)
end

# In your caller — supply the factory
factories: { trigger_pr_review: ->(_ctx) { PRReviewTriggerToolBuilder.build_tool { |args| ... } } }

Use a factory when the side effect depends on caller state. If you only need different config, pass it through the builder block (ctx.conversation, ctx.message) instead.

External MCP and the security-hook mirror

External MCP tool names (:linear, :sentry_full, :metabase_full) are fetched from ChannelRegistry.tools_for(...) at tool_names time. Restricted variants (:sentry_messaging, :metabase_messaging) are hard-coded constants in ToolCatalog because the chat/messaging surface area is intentionally smaller than the full-agent path.

⚠️ The allowed_tools list is not enforced by the SDK for external MCP servers — the MCP server registers all tools and the SDK forwards calls regardless of the whitelist. Destructive Metabase tools are blocked by AgentSecurityHooks::METABASE_RESTRICTED_TOOLS via a PreToolUse hook. If you add a new dangerous external tool, the hook is the real guard, not the catalog.

Testing

  • Catalog DSL changes → add cases to test/services/tool_catalog_test.rb. Use ToolCatalog.reset! at the top of a test for an isolated registry; the teardown already restores defaults via load_defaults!.
  • ToolContext predicate changes → add cases to test/services/tool_context_test.rb. Verify memoization with expects(:method).once.
  • Caller integration → exercise the caller’s allowed_tools (or equivalent) and assert the expected names. See test/jobs/chat_job_test.rb for the pattern.

The catalog raises ArgumentError if you accidentally register the same name twice — duplicate-tool bugs surface at boot, not silently in production.

Deprecating a tool

  1. Remove the register(...) block from tool_catalog.rb.
  2. Drop any full_names references that hard-code the tool name.
  3. grep -r mcp__themis__<name> across app/prompts/, app/services/, and tests — clean up stale instructions or assertions.
  4. If the underlying builder has no other callers, delete it.

Gotchas

  • Empty Queue is blank? — gate on !ctx.message_queue.nil? (not .present?) when checking queue presence.
  • full_names: [] suppresses the allowed_tools output but the builder still runs — used for SDK-discovered bundles like skills/automations.
  • Builder blocks run per invocation, not at registration. Tools are rebuilt on every build_tools call; don’t memoize tool instances inside a builder.
  • Boolean predicates need defined? memoization, not ||=false ||= ... re-runs every call. ToolContext already does this; follow the same pattern if you add a predicate.