工具目录
ToolCatalog 是每个智能体工具的单一真实来源。每个工具只声明一次,包含名称、可用性规则和构建器。调用方(AgentService、ChatToolConfiguration、MessagingAgentConcern、ChannelMentionWorkflow)通过列出所需的工具组来选择性加入,而不是维护容易漂移的并行 allowed_tools 数组。
整体架构
┌────────────────────────────────────────────────┐
│ 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")
三块、三种职责:
ToolCatalog—— 声明式注册表。每个工具一行register(...)。决定什么存在。ToolContext—— 每次调用的值对象。决定当前什么可用(作用域内的用户/空间/凭证)。- 调用方的组 ——
FULL_AGENT_GROUPS/CHAT_TOOL_GROUPS/MESSAGING_TOOL_GROUPS等。决定该调用方想要目录的哪个切片。
每次调用(build_tools 用于 SDK 工具对象、tool_names 用于 allowed_tools 白名单)都会针对当前上下文重新求值,因此 false 可用性的工具只是消失,调用方无需察觉。
何时使用
| 场景 | 使用目录? |
|---|---|
| 暴露给一个或多个智能体的新进程内 Themis 工具 | 是 |
| 现有工具需要新的调用方(工作流 / 任务) | 是 —— 添加到组中,引用该组 |
| 仅一个工作流使用、不复用的一次性工具 | 否 —— 通过 AgentService 的 extra_themis_tools: 传递 |
| 外部 MCP 通道(Linear、Sentry、Metabase、Google Drive / Calendar / Gmail、Google Analytics、Google Maps、Flight Search) | 存在于 ChannelRegistry 中;只有允许工具名称镜像到目录中 |
| 共享工具上的调用方特定副作用(任务入队、推送到实例变量) | 使用 :triggers 工厂(见下文) |
注册工具
在 app/services/tool_catalog.rb 的 register_core_tools! 或 register_chat_only_tools! 中添加一个条目:
register(:my_tool, groups: :core, available: :sentry_configured?) do |ctx|
MyToolBuilder.build_tool(space: ctx.space)
end
名称和 full_names
符号会自动变成 mcp__themis__my_tool。仅当下方规则不适用时才覆盖 full_names::
| 工具类型 | 命名规则 | 示例 |
|---|---|---|
| 进程内 Themis MCP | mcp__themis__<name>(默认) | mcp__themis__get_pr_info |
| 外部 MCP 服务器 | mcp__<channel>__<tool> | mcp__sentry__list_issues |
| SDK 内置 | 裸名称 | "Skill" |
| 一个构建器生成 N 个工具 | 显式列出名称 | mcp__themis__get_pr_info, mcp__themis__get_pr_diff, … |
| 名称依赖运行时状态 | 传入在 tool_names 时求值的 Proc | :google_drive_bundle |
| 工具注册但通过 SDK 发现 | full_names: [](抑制输出) | :skills_bundle, :automations_bundle |
组
选择有意义的最小集合:
| 组 | 覆盖范围 |
|---|---|
:core | 完整智能体 + 对话 + 消息 |
:chat_only | 仅 Web/API 对话(小组件、文件创建、技能、自动化、个人任务、GDrive) |
:triggers | 目录声明、调用方提供工厂 |
:skill_builtin | SDK 发现的 “Skill” 工具 |
:linear, :sentry_full, :sentry_messaging, :metabase_full, :metabase_messaging | 外部 MCP 允许列表镜像 |
调用方按组组合,而不是按条目 —— 不存在排除列表。如果你需要”:core 减一个工具”,答案是拆分组,而不是在调用方减去。
可用性规则
优先使用 ToolContext predicate 符号而不是内联 Proc:
register(:query_themis_data, groups: :core, available: :can_query_themis?) { ... }
仅当条件确实是一次性的时使用 Proc。如果两个条目共享同一检查,则在 ToolContext 中添加方法。
available: 接受::always / true / nil(始终启用)、false(始终禁用)、Symbol(在 ctx 上调用)或 Proc(以 ctx 调用)。
按调用方扩展工具的 JSON schema
当同一目录工具需要调用方特定的 schema 字段时(例如 trigger_code_generation 仅在消息上下文中需要 project 参数),通过构建器传递,而不是目录:
# ChannelMentionWorkflow —— 添加 `project` 作为必需参数
CodeGenerationToolBuilder.build_tool(
extra_properties: { project: { type: "string", description: "..." } },
extra_required: %w[project]
) { |args| handle_code_generation_tool(args) }
通过 :triggers 工厂(下一节)连接此项,使目录条目保持声明式。
调用方接线
三步:声明组常量、构建 ToolContext、向目录询问两次。
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)
为 build_tools 和 tool_names 复用同一个 ToolContext。credentials、channel_configured?、can_query_themis?、google_drive_available?、skills_enabled? 和 automations_enabled? 都按上下文级别记忆 —— 共享上下文可避免每个 predicate 运行两次。
标准示例
app/services/agent_service.rb:37——FULL_AGENT_GROUPS(完整智能体路径)app/jobs/concerns/chat_tool_configuration.rb:15——CHAT_TOOL_GROUPS(Web/API 对话)app/services/concerns/messaging_agent_concern.rb:13——MESSAGING_TOOL_GROUPS(Telegram/Teams)app/services/workflows/channel_mention_workflow.rb:302—— 使用:triggers构建 themis MCP 服务器
工厂(:triggers 组)
trigger_code_generation 和 trigger_pr_review 是目录声明的,但其处理器因调用方而异(对话将任务入队,提及将请求捕获到实例变量)。目录条目以工厂的存在为门禁 —— 不需要触发器的调用方只需省略工厂,条目即自动跳过:
# 在 tool_catalog.rb 中 —— 一次性声明
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
# 在你的调用方 —— 提供工厂
factories: { trigger_pr_review: ->(_ctx) { PRReviewTriggerToolBuilder.build_tool { |args| ... } } }
当副作用依赖于调用方状态时使用工厂。如果你只需要不同的配置,则通过构建器块(ctx.conversation、ctx.message)传递。
外部 MCP 和安全钩子镜像
外部 MCP 工具名称(:linear、:sentry_full、:metabase_full)在 tool_names 时从 ChannelRegistry.tools_for(...) 获取。受限变体(:sentry_messaging、:metabase_messaging)是 ToolCatalog 中的硬编码常量,因为对话/消息表面积有意小于完整智能体路径。
⚠️
allowed_tools列表对于外部 MCP 服务器不会被 SDK 强制执行 —— MCP 服务器注册所有工具,SDK 无论白名单如何都会转发调用。破坏性的 Metabase 工具通过PreToolUse钩子由AgentSecurityHooks::METABASE_RESTRICTED_TOOLS阻止。如果你添加新的危险外部工具,真正的守卫是钩子,而不是目录。
测试
- 目录 DSL 变更 → 在
test/services/tool_catalog_test.rb添加用例。在测试顶部使用ToolCatalog.reset!获得隔离的注册表;teardown已通过load_defaults!恢复默认值。 ToolContextpredicate 变更 → 在test/services/tool_context_test.rb添加用例。用expects(:method).once验证记忆化。- 调用方集成 → 调用调用方的
allowed_tools(或等价物)并断言期望的名称。模式见test/jobs/chat_job_test.rb。
如果你不小心 register 同一个名称两次,目录将引发 ArgumentError —— 重复工具的 bug 在启动时浮现,而不是在生产中悄悄发生。
弃用工具
- 从
tool_catalog.rb删除register(...)块。 - 删除任何硬编码工具名称的
full_names引用。 - 在
app/prompts/、app/services/和测试中执行grep -r mcp__themis__<name>—— 清理过时的指令或断言。 - 如果底层构建器没有其他调用方,则删除它。
注意事项
- 空
Queue是blank?—— 检查队列存在时使用!ctx.message_queue.nil?(不是.present?)。 full_names: []抑制allowed_tools输出,但构建器仍然运行 —— 用于像技能/自动化这样的 SDK 发现的捆绑包。- 构建器块在每次调用时运行,而不是在注册时。 工具在每次
build_tools调用时重建;不要在构建器内记忆工具实例。 - 布尔 predicate 需要
defined?记忆化,而不是||=——false ||= ...每次调用都会重新运行。ToolContext已经这样做;如果你添加 predicate,请遵循相同的模式。