Tool grouping (facades)
As a domain grows into the hundreds of tools, a flat tools/list spends theOn this page
As a domain grows into the hundreds of tools, a flat tools/list spends the selection prompt on call-time schemas instead of routing signal. A domain can opt into grouped facades: one tool per category, with a routing-only description and per-tool schemas deferred out of the listing.
class ListOrdersTool < McpAuthorization::Tool
tags "admin"
category :orders # the group this tool belongs to
dynamic_contract OrderHandlers::List
end
McpAuthorization.configure do |config|
config.facet_domain :admin, group_by: :category
config.categories do
summary :orders, "Create, inspect, and update orders and their line items."
summary :billing, "Invoices, payments, refunds, and billing profiles."
end
endGrouping is opt-in and per-domain: a domain with no facet_domain behaves exactly as before, and category is inert there. facet_domain takes:
| Option | Default | Description |
|---|---|---|
group_by: | required | Grouping key. Only :category today; anything else raises ArgumentError. |
schema_strategy: | :vendor_extension | Where per-tool argument schemas go โ :vendor_extension or :lazy (below). |
uncategorized: | :fallback | :fallback collects tools with no category into an uncategorized group; :error raises instead. |
facade_suffix: | "tools" | Token appended to a category to form the facade name (orders_tools). |
For a group that holds a single tool, category :orders, summary: "..." saves a trip to the central registry. When both are declared, config.categories wins.
tools/list for the domain then returns one facade per group the caller has at least one permitted tool in (orders_tools, billing_tools), each describing its tools with RBAC-filtered one-liners. Calling a facade names the inner tool and its arguments:
{ "name": "orders_tools",
"arguments": { "tool_name": "update_order", "arguments": { "id": "o_1" } } }Facade shape๐
| Part | Value |
|---|---|
| Name | "#{category}_#{facade_suffix}" โ default suffix tools, so orders_tools |
| Description | Group summary, then one line per tool the caller may invoke: - tool_name โ first line of that tool's description for this caller |
inputSchema | Flat object: tool_name (string enum of permitted tool names) + arguments (object). Both required. |
_meta | Under :vendor_extension, key "tool-input-schemas" โ a map of tool name to that caller's compiled input schema |
Facade dispatch๐
Dispatch resolves the real tool through its normal call path โ there is no second code path, and therefore no second place for authorization to be wrong:
- Advertised-set check.
tool_namemust be in the set advertised to this caller, elseArgumentErrorlisting the valid names. - Re-resolution. The tool is re-fetched via
ToolRegistry.tool_class_for, which re-runspermitted?โ so a stale advertised set cached by a client cannot get a caller into a tool they've lost access to. Failure raisesNotAuthorizedError. - Argument coercion. MCP clients frequently serialize nested objects as JSON strings, and the facade's generic
arguments: objectcontract cannot know which fields are structured. Theargumentsblob itself, and any top-level value whose type in the target's compiled schema isobjectorarray, are JSON-parsed. Invalid JSON raisesArgumentErrornaming the field. - Delegation. The target's materialized
callruns, applyingfilter_inputandfilter_outputexactly as in a direct call.
Direct tool names still resolve on tools/call, so a client that learned a real tool name before the domain was faceted keeps working.
A tools/call build skips the _meta per-tool schema map (for_dispatch: true) โ nothing on the call path reads it, and building it would recompile every tool in the group on every call.
The facade inputSchema is always a flat object (a tool_name enum plus a permissive arguments object). It has to be: an LLM tool input_schema must have an object root โ Anthropic and OpenAI reject oneOf/allOf/anyOf at the top level โ and hosts routinely forward a facade's inputSchema straight to the model. A correlated inline shape (each tool_name tied to its own argument schema) would need a root combinator, so it is not offered. schema_strategy: therefore only chooses where the per-tool schemas go:
:vendor_extension(default) โ the per-tool schemas are carried on the facade's_meta(key"tool-input-schemas")._metais the MCP-sanctioned extension channel: SDKs preserve it and it is never forwarded to the model as the toolinput_schema, so the schemas stay available in-band for a client that wants to expand the facade, without touchinginputSchema.:lazyโ names and one-liners only; argument shapes are enforced at dispatch by the target tool's ownfilter_input.
Uncategorized tools land in an uncategorized facade by default; pass uncategorized: :error to fail fast instead. Groups with zero permitted tools are hidden entirely, so a facade never advertises an empty enum. A facade name that collides with a real registered tool in the domain raises FacadeBuilder::FacadeNameCollisionError rather than shadowing that tool.
Facade names are #{category}_tools by default. Override the suffix per domain with facade_suffix: โ e.g. config.facet_domain :admin, group_by: :category, facade_suffix: "hire" exposes orders_hire, billing_hire. The suffix must be a lowercase identifier fragment ([a-z0-9_]) so the derived name stays a valid MCP tool name. See the design doc for the full rationale.
Facet configuration participates in the tools/list cache digest โ each tool's category, every facet_domain setting, and every group summary โ so toggling grouping or rewording a summary invalidates cached listings the same way a gate change does.
Facade errors๐
| Error | Raised when |
|---|---|
FacadeBuilder::UncategorizedToolError | A tool has no category in a domain faceted with uncategorized: :error |
FacadeBuilder::FacadeNameCollisionError | A derived facade name collides with a registered tool in the domain |
ArgumentError (config) | Invalid group_by:, schema_strategy:, uncategorized:, or facade_suffix: |
ArgumentError (dispatch) | tool_name not advertised, or a string argument that isn't valid JSON |
NotAuthorizedError | Re-resolution found the caller isn't permitted after all |
Collected from README.md in the repository. Edit it there, not here.