mcp_authorization v0.7.1

Tool grouping (facades)

As a domain grows into the hundreds of tools, a flat tools/list spends the

On 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
end

Grouping is opt-in and per-domain: a domain with no facet_domain behaves exactly as before, and category is inert there. facet_domain takes:

OptionDefaultDescription
group_by:requiredGrouping key. Only :category today; anything else raises ArgumentError.
schema_strategy::vendor_extensionWhere 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๐Ÿ”—

PartValue
Name"#{category}_#{facade_suffix}" โ€” default suffix tools, so orders_tools
DescriptionGroup summary, then one line per tool the caller may invoke: - tool_name โ€” first line of that tool's description for this caller
inputSchemaFlat object: tool_name (string enum of permitted tool names) + arguments (object). Both required.
_metaUnder :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:

  1. Advertised-set check. tool_name must be in the set advertised to this caller, else ArgumentError listing the valid names.
  2. Re-resolution. The tool is re-fetched via ToolRegistry.tool_class_for, which re-runs permitted? โ€” so a stale advertised set cached by a client cannot get a caller into a tool they've lost access to. Failure raises NotAuthorizedError.
  3. Argument coercion. MCP clients frequently serialize nested objects as JSON strings, and the facade's generic arguments: object contract cannot know which fields are structured. The arguments blob itself, and any top-level value whose type in the target's compiled schema is object or array, are JSON-parsed. Invalid JSON raises ArgumentError naming the field.
  4. Delegation. The target's materialized call runs, applying filter_input and filter_output exactly 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"). _meta is the MCP-sanctioned extension channel: SDKs preserve it and it is never forwarded to the model as the tool input_schema, so the schemas stay available in-band for a client that wants to expand the facade, without touching inputSchema.
  • :lazy โ€” names and one-liners only; argument shapes are enforced at dispatch by the target tool's own filter_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๐Ÿ”—

ErrorRaised when
FacadeBuilder::UncategorizedToolErrorA tool has no category in a domain faceted with uncategorized: :error
FacadeBuilder::FacadeNameCollisionErrorA 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
NotAuthorizedErrorRe-resolution found the caller isn't permitted after all

Collected from README.md in the repository. Edit it there, not here.