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:
ToolCatalog— declarative registry. Oneregister(...)line per tool. Decides what exists.ToolContext— per-invocation value object. Decides what’s available right now (user/space/credentials in scope).- Caller groups —
FULL_AGENT_GROUPS/CHAT_TOOL_GROUPS/MESSAGING_TOOL_GROUPSetc. 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
| Situation | Use catalog? |
|---|---|
| New in-process Themis tool exposed to one or more agents | Yes |
| 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 reuse | No — 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 kind | Naming rule | Example |
|---|---|---|
| In-process Themis MCP | mcp__themis__<name> (default) | mcp__themis__get_pr_info |
| External MCP server | mcp__<channel>__<tool> | mcp__sentry__list_issues |
| SDK built-in | bare name | "Skill" |
| One builder emits N tools | List names explicitly | mcp__themis__get_pr_info, mcp__themis__get_pr_diff, … |
| Names depend on runtime state | Pass a Proc evaluated at tool_names time | :google_drive_bundle |
| Tool registers but is discovered through the SDK | full_names: [] (suppresses output) | :skills_bundle, :automations_bundle |
Groups
Pick the smallest set that makes sense:
| Group | Reaches |
|---|---|
:core | Full agent + chat + messaging |
:chat_only | Web/API chat only (widgets, file create, skills, automations, personal tasks, GDrive) |
:triggers | Catalog-declared, caller-supplied factory |
:skill_builtin | SDK-discovered “Skill” tool |
:linear, :sentry_full, :sentry_messaging, :metabase_full, :metabase_messaging | External 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:37—FULL_AGENT_GROUPS(full-agent path)app/jobs/concerns/chat_tool_configuration.rb:15—CHAT_TOOL_GROUPS(web/API chat)app/services/concerns/messaging_agent_concern.rb:13—MESSAGING_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_toolslist 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 byAgentSecurityHooks::METABASE_RESTRICTED_TOOLSvia aPreToolUsehook. 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. UseToolCatalog.reset!at the top of a test for an isolated registry; theteardownalready restores defaults viaload_defaults!. ToolContextpredicate changes → add cases totest/services/tool_context_test.rb. Verify memoization withexpects(:method).once.- Caller integration → exercise the caller’s
allowed_tools(or equivalent) and assert the expected names. Seetest/jobs/chat_job_test.rbfor 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
- Remove the
register(...)block fromtool_catalog.rb. - Drop any
full_namesreferences that hard-code the tool name. grep -r mcp__themis__<name>acrossapp/prompts/,app/services/, and tests — clean up stale instructions or assertions.- If the underlying builder has no other callers, delete it.
Gotchas
- Empty
Queueisblank?— gate on!ctx.message_queue.nil?(not.present?) when checking queue presence. full_names: []suppresses theallowed_toolsoutput 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_toolscall; don’t memoize tool instances inside a builder. - Boolean predicates need
defined?memoization, not||=—false ||= ...re-runs every call.ToolContextalready does this; follow the same pattern if you add a predicate.