mcp_authorization 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d09c532c9c775775e5b5dbea5d44df18cb9a693212641fff9b86621101806e4a
4
- data.tar.gz: 18c5e6cc485efd2e73265e22d4ab037f3b21d32f72c377285794a644c8d4a225
3
+ metadata.gz: b1dabd038e65fbf04f01e9ae27d109e8734ae78b4db4baeaa6d20200291a3899
4
+ data.tar.gz: 0250e2902b73117cfe4447f6036783e4a49613ee4626a407560a82bd19bf25ef
5
5
  SHA512:
6
- metadata.gz: 27959d3f62484456e2e6978f51cef90d8879584a978c4f4937e52fa599451c01ad41cfea2cb79607726ab2dcba166f50917c46b0bc0057639d1fba5aa4b4a249
7
- data.tar.gz: cbdc8f8187594e942afc039248a8f8db638325b33f257972d85e5d8cbb62ec8d64f6e9f57b8e1ca022f9e1cbae1f7a0d590fa3cb5ea5784ad9cb87e20072e20a
6
+ metadata.gz: fbdfb8c28047b39373227ece236670dfc6df6ee34a32ca36f77d6b49f3e31ba7859d82a4c998ff505534ca3e55611f506d7b20a976cd0095a54ce03bc2377a66
7
+ data.tar.gz: 6ba9ec325debe3564cca6f8b5fdbee732aab893e1dc744d261fbac53db22772b619b764c1a4d9bb9baff59961d5352b334a9c56e171fda8c44da8e4a5d052a79
data/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ All notable changes to this gem are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.7.0] - 2026-07-21
8
+
9
+ Declarative tool grouping: a domain can present its tools as a small set of
10
+ summarized category facades instead of a flat list, with per-tool schemas
11
+ deferred out of the selection prompt. Opt-in and per-domain — domains not
12
+ configured via `facet_domain` behave exactly as before. (#30)
13
+
14
+ ### Added
15
+ - **`category :name` tool DSL.** A tool declares the group it belongs to when its domain is faceted; ignored in flat domains. An optional `summary:` kwarg serves single-tool groups; the central registry wins on conflict.
16
+
17
+ - **`config.facet_domain :admin, group_by: :category`** — present a domain as grouped facades. `tools/list` returns one facade per group the caller has at least one permitted tool in (e.g. `orders_tools`), each with a routing-only description: the group summary plus RBAC-filtered one-liners of the tools the caller may actually invoke. Groups with zero permitted tools are hidden entirely, so a facade never advertises an empty `enum` (which fails JSON Schema draft-04 validation and can fail the whole `tools/list`).
18
+
19
+ - **`config.categories { summary :orders, "..." }`** — one summary line per group, used as the facade description's lead.
20
+
21
+ - **`facet_domain(..., facade_suffix:)`** — override the token appended to a category to form its facade tool name. Defaults to `"tools"` (`orders_tools`); e.g. `facade_suffix: "hire"` exposes `orders_hire`. Must be a lowercase identifier fragment (`[a-z0-9_]`). The suffix is folded into the tools/list cache digest, so changing it invalidates cached listings.
22
+
23
+ - **Deferred-schema strategy** per domain via `schema_strategy:`. The facade `inputSchema` is always a flat object (`tool_name` enum + permissive `arguments`): an LLM tool `input_schema` must have an object root — Anthropic and OpenAI reject `oneOf`/`allOf`/`anyOf` at the top level — and hosts forward a facade's `inputSchema` straight to the model, so a correlated inline shape (each `tool_name` tied to its argument schema) is not expressible and is not offered. `:vendor_extension` (default) carries the per-tool schemas on the facade's `_meta` (key `"tool-input-schemas"`) — the MCP-sanctioned extension channel that SDKs preserve and that is never forwarded to the model as `input_schema` — for a client that wants to expand the facade. `:lazy` carries names only; argument shapes are enforced at dispatch. In both strategies the per-tool schemas are compiled per caller, so permission-gated fields never appear in a facade a caller receives.
24
+
25
+ - **Facade dispatch through the real call path.** A `tools/call` on a facade names the inner tool (`tool_name`) and its `arguments`. Dispatch checks the name against the set advertised to *this* caller, re-resolves the tool via `ToolRegistry.tool_class_for` — which re-runs `permitted?`, so gating is enforced even against a stale advertised set — and delegates to the tool's materialized `call`. Input filtering, output filtering, and `NotAuthorizedError` behave exactly as in a direct call, because it is the same code.
26
+
27
+ - **Argument coercion against the target tool's schema.** MCP clients frequently serialize nested objects as JSON strings; the facade's generic `arguments: object` contract cannot know which fields to parse. Both the `arguments` blob itself and any top-level value whose *target* schema type is an object or array are JSON-parsed before dispatch, then stripped by the target's `filter_input` as usual.
28
+
29
+ - **`uncategorized:` mode** — a tool without a `category` in a faceted domain lands in an `uncategorized` fallback group by default; `uncategorized: :error` raises instead for servers that want CI-enforced completeness. A facade name that collides with a real registered tool raises `FacadeNameCollisionError` rather than shadowing the tool.
30
+
31
+ - **`ToolRegistry.facades_for(domain:, server_context:)` / `facade_for(domain:, name:, server_context:)`** — the facade analogues of `tool_classes_for` / `tool_class_for`. `McpController` routes `tools/list` on a faceted domain to facades and resolves facade names on `tools/call` (direct tool names still resolve, so a client that learned a real tool name keeps working).
32
+
33
+ ### Changed
34
+ - **The `tools/list` cache defs digest now folds in facet configuration.** Each tool's `category`, every `facet_domain` setting, and every group summary participate in the digest, so toggling grouping, switching schema strategy, or rewording a summary invalidates cached listings the same way a gate or handler-source change does.
35
+
36
+ ## [0.6.2] - 2026-07-01
37
+
38
+ ### Fixed
39
+ - **A record field whose type is an inline string-literal union with a single field-level tag was misclassified as a per-member-tagged union.** `compile_tagged_record` routes a field into `compile_tagged_union` (which gates each `|`-separated member individually, e.g. `stage: a @feature(x) | b @feature(y)`) whenever `tagged_union_field?` sees an `@` anywhere in the type string plus more than one `|`-separated part. A plain literal union with one *field-level* tag trailing the whole thing — `logic: "AND" | "OR" @desc(...)` — matches that same heuristic even though the tag applies to the field, not an individual member. Misrouting sends each bare literal (`"AND"`, `"OR"`) through `resolve_type`, which only resolves *named alias references*; each literal fell back to `{type: "object"}`, producing `{type: "object", oneOf: [{type: "object"}, {type: "object"}]}` instead of `{type: "string", enum: ["AND", "OR"]}`. `tagged_union_field?` and `tagged_array_union_inner` now require at least one *non-final* `|`-separated member to carry a tag before treating a field as per-member-gated — every genuine per-member-tagged union in this codebase tags each gated member individually, so a tag trailing only the last member is never sufficient on its own. Field-level tags on inline literal unions now fall through to the normal RBS-library path (`visit_rbs_union`), which already resolved them correctly.
40
+
7
41
  ## [0.6.1] - 2026-07-01
8
42
 
9
43
  ### Fixed
data/README.md CHANGED
@@ -360,6 +360,71 @@ Tag a tool with multiple domains to make it available in each:
360
360
  tags "operator", "recruiting"
361
361
  ```
362
362
 
363
+ ## Tool grouping (facades)
364
+
365
+ As a domain grows into the hundreds of tools, a flat `tools/list` spends the
366
+ selection prompt on call-time schemas instead of routing signal. A domain can
367
+ opt into **grouped facades**: one tool per category, with a routing-only
368
+ description and per-tool schemas deferred out of the listing.
369
+
370
+ ```ruby
371
+ class ListOrdersTool < McpAuthorization::Tool
372
+ tags "admin"
373
+ category :orders # the group this tool belongs to
374
+ dynamic_contract OrderHandlers::List
375
+ end
376
+
377
+ McpAuthorization.configure do |config|
378
+ config.facet_domain :admin, group_by: :category
379
+
380
+ config.categories do
381
+ summary :orders, "Create, inspect, and update orders and their line items."
382
+ summary :billing, "Invoices, payments, refunds, and billing profiles."
383
+ end
384
+ end
385
+ ```
386
+
387
+ `tools/list` for the domain then returns one facade per group the caller has
388
+ at least one permitted tool in (`orders_tools`, `billing_tools`), each
389
+ describing its tools with RBAC-filtered one-liners. Calling a facade names the
390
+ inner tool and its arguments:
391
+
392
+ ```json
393
+ { "name": "orders_tools",
394
+ "arguments": { "tool_name": "update_order", "arguments": { "id": "o_1" } } }
395
+ ```
396
+
397
+ Dispatch resolves the real tool through its normal call path — authorization,
398
+ gating, and schema filtering apply exactly as if the tool had been called
399
+ directly, and JSON-string argument blobs are coerced against the *target*
400
+ tool's schema.
401
+
402
+ The facade `inputSchema` is always a flat object (a `tool_name` enum plus a
403
+ permissive `arguments` object). It has to be: an LLM tool `input_schema` must
404
+ have an object root — Anthropic and OpenAI reject `oneOf`/`allOf`/`anyOf` at the
405
+ top level — and hosts routinely forward a facade's `inputSchema` straight to the
406
+ model. A correlated inline shape (each `tool_name` tied to its own argument
407
+ schema) would need a root combinator, so it is not offered. `schema_strategy:`
408
+ therefore only chooses where the per-tool schemas go:
409
+
410
+ - `:vendor_extension` (default) — the per-tool schemas are carried on the
411
+ facade's `_meta` (key `"tool-input-schemas"`). `_meta` is the MCP-sanctioned
412
+ extension channel: SDKs preserve it and it is never forwarded to the model as
413
+ the tool `input_schema`, so the schemas stay available in-band for a client
414
+ that wants to expand the facade, without touching `inputSchema`.
415
+ - `:lazy` — names and one-liners only; argument shapes are enforced at dispatch
416
+ by the target tool's own `filter_input`.
417
+
418
+ Uncategorized tools land in an `uncategorized` facade by default; pass
419
+ `uncategorized: :error` to fail fast instead. Groups with zero permitted tools
420
+ are hidden entirely, so a facade never advertises an empty `enum`.
421
+
422
+ Facade names are `#{category}_tools` by default. Override the suffix per domain
423
+ with `facade_suffix:` — e.g. `config.facet_domain :admin, group_by: :category,
424
+ facade_suffix: "hire"` exposes `orders_hire`, `billing_hire`. The suffix must be
425
+ a lowercase identifier fragment (`[a-z0-9_]`) so the derived name stays a valid
426
+ MCP tool name. See `docs/designs/tool-grouping-facades.md` for the full design.
427
+
363
428
  ## RBS type syntax
364
429
 
365
430
  The `@rbs type` comments compile to JSON Schema:
@@ -2,6 +2,13 @@ module McpAuthorization
2
2
  class McpController < ActionController::Base
3
3
  skip_forgery_protection
4
4
 
5
+ # Response header stamped on every cached tools/list path so hosts can log
6
+ # cache behavior: "hit" (served from the store), "miss" (compiled cold and
7
+ # stored), or "bypass" (compiled but not cached — cache had no key for this
8
+ # request, e.g. the domain's decision vocabulary isn't learned yet, or the
9
+ # response wasn't a cacheable result).
10
+ TOOLS_LIST_CACHE_HEADER = "X-MCP-Tools-List-Cache" #: String
11
+
5
12
  # POST/GET/DELETE /mcp/:domain
6
13
  #: () -> void
7
14
  def handle
@@ -48,6 +55,7 @@ module McpAuthorization
48
55
 
49
56
  key = McpAuthorization::Cache.tools_list_key(domain: domain, server_context: server_context)
50
57
  if key && (cached = McpAuthorization::Cache.store.get(key))
58
+ response.set_header(TOOLS_LIST_CACHE_HEADER, "hit")
51
59
  return render json: tools_list_envelope(cached)
52
60
  end
53
61
 
@@ -63,11 +71,17 @@ module McpAuthorization
63
71
  end
64
72
  result = parsed && parsed["result"]
65
73
  unless result
74
+ response.set_header(TOOLS_LIST_CACHE_HEADER, "bypass")
66
75
  return render json: body.first, status: status # error / unexpected shape — don't cache
67
76
  end
68
77
 
69
78
  store_key = McpAuthorization::Cache.tools_list_key(domain: domain, server_context: server_context)
70
- McpAuthorization::Cache.store.set(store_key, result, ttl: McpAuthorization::Cache.ttl) if store_key
79
+ if store_key
80
+ McpAuthorization::Cache.store.set(store_key, result, ttl: McpAuthorization::Cache.ttl)
81
+ response.set_header(TOOLS_LIST_CACHE_HEADER, "miss")
82
+ else
83
+ response.set_header(TOOLS_LIST_CACHE_HEADER, "bypass")
84
+ end
71
85
  render json: tools_list_envelope(result), status: status
72
86
  end
73
87
 
@@ -96,11 +110,7 @@ module McpAuthorization
96
110
  all_tools(server_context)
97
111
  when "tools/call"
98
112
  name = mcp_request_params[:name]
99
- name ? Array(McpAuthorization::ToolRegistry.tool_class_for(
100
- domain: params[:domain],
101
- name: name,
102
- server_context: server_context
103
- )) : all_tools(server_context)
113
+ name ? Array(resolve_tool(name, server_context)) : all_tools(server_context)
104
114
  when "initialize", "ping", %r{\Anotifications/}
105
115
  []
106
116
  else
@@ -110,12 +120,34 @@ module McpAuthorization
110
120
  end
111
121
  end
112
122
 
123
+ # The single tool a tools/call needs. In a faceted domain the name is
124
+ # usually a facade ("orders_tools"); direct tool names still resolve so
125
+ # a client that learned a real tool name keeps working. Facade names
126
+ # cannot shadow real tools — FacadeBuilder raises on collision.
127
+ #: (String, untyped) -> singleton(MCP::Tool)?
128
+ def resolve_tool(name, server_context)
129
+ domain = params[:domain]
130
+ if McpAuthorization.config.faceted?(domain)
131
+ facade = McpAuthorization::ToolRegistry.facade_for(
132
+ domain: domain, name: name, server_context: server_context
133
+ )
134
+ return facade if facade
135
+ end
136
+ McpAuthorization::ToolRegistry.tool_class_for(
137
+ domain: domain, name: name, server_context: server_context
138
+ )
139
+ end
140
+
141
+ # Every tool the domain presents: grouped facades when the domain is
142
+ # faceted (see Configuration#facet_domain), the flat list otherwise.
113
143
  #: (untyped) -> Array[singleton(MCP::Tool)]
114
144
  def all_tools(server_context)
115
- McpAuthorization::ToolRegistry.tool_classes_for(
116
- domain: params[:domain],
117
- server_context: server_context
118
- )
145
+ domain = params[:domain]
146
+ if McpAuthorization.config.faceted?(domain)
147
+ McpAuthorization::ToolRegistry.facades_for(domain: domain, server_context: server_context)
148
+ else
149
+ McpAuthorization::ToolRegistry.tool_classes_for(domain: domain, server_context: server_context)
150
+ end
119
151
  end
120
152
 
121
153
  #: () -> untyped
@@ -197,10 +197,20 @@ module McpAuthorization
197
197
  tool_class.tool_name.to_s,
198
198
  (tool_class._gates || []).map { |g| [g[:name].to_s, g[:value].to_s] },
199
199
  handler&.name.to_s,
200
- source
200
+ source,
201
+ tool_class._category.to_s
201
202
  ]
202
203
  end.sort_by(&:first)
203
- Digest::SHA256.hexdigest(JSON.generate(sigs))[0, 16]
204
+ # Facet config and group summaries shape the tools/list of a faceted
205
+ # domain the same way a gate edit shapes a flat one — toggling
206
+ # grouping, switching schema strategy, or rewording a summary must
207
+ # invalidate cached listings.
208
+ config = McpAuthorization.config
209
+ facets = config.faceted_domains.sort.map do |domain, fc|
210
+ [domain, fc.sort_by { |k, _| k.to_s }.map { |k, v| [k.to_s, v.to_s] }]
211
+ end
212
+ summaries = config.category_summaries.sort_by { |k, _| k.to_s }.map { |k, v| [k.to_s, v] }
213
+ Digest::SHA256.hexdigest(JSON.generate([sigs, facets, summaries]))[0, 16]
204
214
  rescue StandardError
205
215
  # If anything about introspection fails, fall back to a process-stable
206
216
  # digest so caching still works within a boot (just not across deploys
@@ -107,6 +107,40 @@ module McpAuthorization
107
107
  #: String?
108
108
  attr_accessor :tools_list_cache_redis_url
109
109
 
110
+ # Per-domain facet (tool-grouping) configuration, keyed by domain name.
111
+ # Each value is a Hash: { group_by:, schema_strategy:, uncategorized:,
112
+ # facade_suffix: }.
113
+ # Populated by +facet_domain+; read by ToolRegistry / FacadeBuilder.
114
+ # See docs/designs/tool-grouping-facades.md.
115
+ #: Hash[String, Hash[Symbol, untyped]]
116
+ attr_reader :faceted_domains
117
+
118
+ # Group summaries keyed by category symbol. Populated by +categories+.
119
+ #: Hash[Symbol, String]
120
+ attr_reader :category_summaries
121
+
122
+ # Schema strategies FacadeBuilder knows how to emit.
123
+ # LLM tool `input_schema` must have an object root — Anthropic and OpenAI
124
+ # reject `oneOf`/`allOf`/`anyOf` at the top level — so both facade
125
+ # strategies keep a flat object root and differ only in where the per-tool
126
+ # schemas go: `:vendor_extension` carries them on the facade's `_meta`;
127
+ # `:lazy` omits them (enforced at dispatch). A correlated inline shape
128
+ # (tool_name → its argument schema) would require a root combinator and is
129
+ # therefore not offered.
130
+ SCHEMA_STRATEGIES = %i[vendor_extension lazy].freeze #: Array[Symbol]
131
+
132
+ # Behaviors for a tool in a faceted domain that declares no +category+.
133
+ UNCATEGORIZED_MODES = %i[fallback error].freeze #: Array[Symbol]
134
+
135
+ # Default suffix appended to a category to form its facade tool name
136
+ # (e.g. category +:orders+ → +orders_tools+). Overridable per domain via
137
+ # +facet_domain(..., facade_suffix:)+.
138
+ DEFAULT_FACADE_SUFFIX = "tools" #: String
139
+
140
+ # A facade suffix must be a bare identifier fragment so the derived facade
141
+ # name (+"#{category}_#{suffix}"+) stays a valid MCP tool name.
142
+ FACADE_SUFFIX_FORMAT = /\A[a-z0-9]+(?:_[a-z0-9]+)*\z/ #: Regexp
143
+
110
144
  #: () -> void
111
145
  def initialize
112
146
  @server_name = "mcp-authorization"
@@ -122,6 +156,101 @@ module McpAuthorization
122
156
  @tools_list_cache_ttl = 3600
123
157
  @tools_list_cache_redis = nil
124
158
  @tools_list_cache_redis_url = nil
159
+ @faceted_domains = {}
160
+ @category_summaries = {}
161
+ end
162
+
163
+ # Present a domain as grouped facade tools instead of a flat tool list.
164
+ #
165
+ # config.facet_domain :admin, group_by: :category
166
+ # config.facet_domain :admin, group_by: :category,
167
+ # schema_strategy: :lazy,
168
+ # uncategorized: :error
169
+ #
170
+ # +group_by+ is currently always +:category+ (the only grouping key the
171
+ # +category+ DSL provides); it is accepted explicitly so future grouping
172
+ # keys are an additive change rather than a behavior switch.
173
+ #
174
+ # +schema_strategy+ selects where the per-tool argument schemas go (the
175
+ # facade inputSchema is a flat object either way — see SCHEMA_STRATEGIES).
176
+ # +:vendor_extension+ (default) carries them on the facade's +_meta+;
177
+ # +:lazy+ omits them.
178
+ #
179
+ # +uncategorized+ controls what happens to a tool in this domain with no
180
+ # +category+: +:fallback+ (default) collects them into an +uncategorized+
181
+ # group; +:error+ raises at facade-build time.
182
+ #
183
+ # +facade_suffix+ is the token appended to a category to form its facade
184
+ # tool name — category +:orders+ → +orders_#{suffix}+. Defaults to
185
+ # +"tools"+ (+orders_tools+). Must be a lowercase identifier fragment
186
+ # (+[a-z0-9_]+) so the derived name stays a valid MCP tool name.
187
+ #: (Symbol | String, group_by: Symbol, ?schema_strategy: Symbol, ?uncategorized: Symbol, ?facade_suffix: String | Symbol) -> void
188
+ def facet_domain(domain, group_by:, schema_strategy: :vendor_extension, uncategorized: :fallback, facade_suffix: DEFAULT_FACADE_SUFFIX)
189
+ unless SCHEMA_STRATEGIES.include?(schema_strategy)
190
+ raise ArgumentError, "unknown schema_strategy #{schema_strategy.inspect}; " \
191
+ "expected one of #{SCHEMA_STRATEGIES.inspect}"
192
+ end
193
+ unless UNCATEGORIZED_MODES.include?(uncategorized)
194
+ raise ArgumentError, "unknown uncategorized mode #{uncategorized.inspect}; " \
195
+ "expected one of #{UNCATEGORIZED_MODES.inspect}"
196
+ end
197
+
198
+ suffix = facade_suffix.to_s
199
+ unless FACADE_SUFFIX_FORMAT.match?(suffix)
200
+ raise ArgumentError, "invalid facade_suffix #{facade_suffix.inspect}; " \
201
+ "expected a lowercase identifier fragment matching #{FACADE_SUFFIX_FORMAT.inspect}"
202
+ end
203
+
204
+ @faceted_domains[domain.to_s] = {
205
+ group_by: group_by.to_sym,
206
+ schema_strategy: schema_strategy,
207
+ uncategorized: uncategorized,
208
+ facade_suffix: suffix
209
+ }
210
+ end
211
+
212
+ # True when the given domain is presented as grouped facades.
213
+ #: (String) -> bool
214
+ def faceted?(domain)
215
+ @faceted_domains.key?(domain.to_s)
216
+ end
217
+
218
+ # Facet config Hash for a domain, or nil when the domain is not faceted.
219
+ #: (String) -> Hash[Symbol, untyped]?
220
+ def facet_config(domain)
221
+ @faceted_domains[domain.to_s]
222
+ end
223
+
224
+ # Declare one summary line per group. Evaluated in a small collector so
225
+ # the block reads declaratively:
226
+ #
227
+ # config.categories do
228
+ # summary :orders, "Create, inspect, and update orders."
229
+ # summary :billing, "Invoices, payments, refunds."
230
+ # end
231
+ #: () { () -> void } -> void
232
+ def categories(&block)
233
+ collector = CategoryCollector.new(@category_summaries)
234
+ collector.instance_eval(&block)
235
+ end
236
+
237
+ # The group summary for a category, or nil when none was declared.
238
+ #: (Symbol) -> String?
239
+ def category_summary(category)
240
+ @category_summaries[category.to_sym]
241
+ end
242
+
243
+ # Collects +summary :key, "text"+ declarations into a shared hash.
244
+ class CategoryCollector
245
+ #: (Hash[Symbol, String]) -> void
246
+ def initialize(store)
247
+ @store = store
248
+ end
249
+
250
+ #: (Symbol | String, String) -> void
251
+ def summary(category, text)
252
+ @store[category.to_sym] = text
253
+ end
125
254
  end
126
255
  end
127
256
  end
@@ -0,0 +1,274 @@
1
+ require "json"
2
+ require "set"
3
+
4
+ module McpAuthorization
5
+ # Builds grouped facade tools for a faceted domain.
6
+ #
7
+ # A facade is a per-request synthetic MCP::Tool subclass — one per
8
+ # non-empty category — whose description carries routing signal only
9
+ # (group summary + RBAC-filtered tool one-liners) and whose inputSchema
10
+ # defers per-tool argument schemas out of the selection prompt. A
11
+ # +tools/call+ on a facade names the inner tool (+tool_name+) and its
12
+ # +arguments+; dispatch resolves the real tool through
13
+ # ToolRegistry.tool_class_for — re-running +permitted?+ — and delegates
14
+ # to its materialized +call+, so authorization, gating, and input/output
15
+ # filtering apply exactly as if the tool had been called directly.
16
+ #
17
+ # Facades are never registered in ToolRegistry; they are produced on
18
+ # demand by +facades_for+. See docs/designs/tool-grouping-facades.md.
19
+ class FacadeBuilder
20
+ # A tool in a faceted domain declared no +category+ and the domain is
21
+ # configured with +uncategorized: :error+.
22
+ class UncategorizedToolError < StandardError; end
23
+
24
+ # A facade's derived name (e.g. "orders_tools") collides with a real
25
+ # registered tool name. Renaming the category is the fix.
26
+ class FacadeNameCollisionError < StandardError; end
27
+
28
+ # Fallback group for uncategorized tools (default mode).
29
+ FALLBACK_CATEGORY = :uncategorized #: Symbol
30
+
31
+ class << self
32
+ # All facades for a domain, one per non-empty group the caller has at
33
+ # least one permitted tool in. Empty groups produce no facade — which
34
+ # also guarantees no facade ever advertises an empty +enum+.
35
+ #: (domain: String, server_context: untyped) -> Array[singleton(MCP::Tool)]
36
+ def facades_for(domain:, server_context:)
37
+ config = McpAuthorization.config.facet_config(domain)
38
+ return [] unless config
39
+
40
+ group_tools(domain, server_context, config).map do |category, tools|
41
+ build_facade(domain, category, tools, server_context, config)
42
+ end
43
+ end
44
+
45
+ # A single facade by its tool name (e.g. "orders_tools"), or nil when
46
+ # the name matches no non-empty group for this caller. Used by
47
+ # +tools/call+ routing so one call doesn't build every facade.
48
+ #: (domain: String, name: String, server_context: untyped) -> singleton(MCP::Tool)?
49
+ def facade_for(domain:, name:, server_context:)
50
+ config = McpAuthorization.config.facet_config(domain)
51
+ return nil unless config
52
+
53
+ group_tools(domain, server_context, config).each do |category, tools|
54
+ return build_facade(domain, category, tools, server_context, config) if facade_name(category, config) == name
55
+ end
56
+ nil
57
+ end
58
+
59
+ # The MCP tool name a category's facade is exposed under. The suffix is
60
+ # per-domain (facet config +facade_suffix+, default +"tools"+): category
61
+ # +:orders+ → +orders_tools+ (or +orders_hire+, etc.).
62
+ #: (Symbol, Hash[Symbol, untyped]) -> String
63
+ def facade_name(category, config)
64
+ suffix = config[:facade_suffix] || Configuration::DEFAULT_FACADE_SUFFIX
65
+ "#{category}_#{suffix}"
66
+ end
67
+
68
+ private
69
+
70
+ # Partition the domain's tools into non-empty, RBAC-filtered groups.
71
+ # Ordering is deterministic (by category name) so listings are stable.
72
+ #: (String, untyped, Hash[Symbol, untyped]) -> Array[[Symbol, Array[singleton(McpAuthorization::Tool)]]]
73
+ def group_tools(domain, server_context, config)
74
+ candidates = McpAuthorization::ToolRegistry.tools_by_domain[domain] || []
75
+ permitted = candidates.select { |tc| tc.permitted?(server_context) }
76
+
77
+ groups = Hash.new { |h, k| h[k] = [] } #: Hash[Symbol, Array[singleton(McpAuthorization::Tool)]]
78
+ permitted.each do |tool_class|
79
+ category = tool_class._category
80
+ if category.nil?
81
+ if config[:uncategorized] == :error
82
+ raise UncategorizedToolError,
83
+ "#{tool_class} (tool #{tool_class.tool_name.inspect}) has no category, and domain " \
84
+ "#{domain.inspect} is faceted with uncategorized: :error. Declare `category :name` " \
85
+ "on the tool or switch the domain to uncategorized: :fallback."
86
+ end
87
+ category = FALLBACK_CATEGORY
88
+ end
89
+ groups[category] << tool_class
90
+ end
91
+
92
+ sorted = groups.sort_by { |category, _| category.to_s }
93
+ sorted.each { |category, _| check_name_collision!(domain, category, candidates, config) }
94
+ sorted
95
+ end
96
+
97
+ # A facade name shadowing a real tool would make that tool
98
+ # unreachable by name in this domain — fail loudly instead.
99
+ #: (String, Symbol, Array[singleton(McpAuthorization::Tool)], Hash[Symbol, untyped]) -> void
100
+ def check_name_collision!(domain, category, candidates, config)
101
+ name = facade_name(category, config)
102
+ collision = candidates.find { |tc| tc.tool_name == name }
103
+ return unless collision
104
+
105
+ raise FacadeNameCollisionError,
106
+ "facade name #{name.inspect} for category #{category.inspect} collides with registered " \
107
+ "tool #{collision} in domain #{domain.inspect}. Rename the category or the tool."
108
+ end
109
+
110
+ # Build the synthetic MCP::Tool subclass for one group, with this
111
+ # caller's description, schema, and dispatch baked in — the same
112
+ # materialization shape Tool.materialize_for uses for real tools.
113
+ #: (String, Symbol, Array[singleton(McpAuthorization::Tool)], untyped, Hash[Symbol, untyped]) -> singleton(MCP::Tool)
114
+ def build_facade(domain, category, tools, server_context, config)
115
+ name = facade_name(category, config)
116
+ desc = facade_description(category, tools, server_context)
117
+ strategy = config[:schema_strategy]
118
+ schema = facade_input_schema(tools, strategy)
119
+
120
+ # :vendor_extension ships the per-tool argument schemas out-of-band on
121
+ # the facade's `_meta` — the MCP-sanctioned extension channel — rather
122
+ # than as a non-standard key inside `inputSchema`. Client SDKs preserve
123
+ # `_meta` but strip (or, when strict, reject) unknown JSON Schema
124
+ # keywords, and `_meta` is never forwarded to the model as the tool's
125
+ # input_schema. So the listing stays valid for strict Zod clients and
126
+ # strict-mode LLM tool-calling while still carrying the schemas in-band
127
+ # for capable clients.
128
+ meta_payload = strategy == :vendor_extension ? { "tool-input-schemas" => child_schema_map(tools, server_context) } : nil
129
+
130
+ advertised = tools.map(&:tool_name).to_set
131
+ builder = self
132
+ ctx = server_context
133
+
134
+ Class.new(MCP::Tool) do
135
+ tool_name name
136
+ description desc
137
+ input_schema schema
138
+ meta(meta_payload) if meta_payload
139
+
140
+ define_singleton_method(:call) do |server_context: nil, **params|
141
+ builder.send(:dispatch, domain, advertised, params, server_context || ctx)
142
+ end
143
+ end
144
+ end
145
+
146
+ # Routing-only description: group summary + one line per tool the
147
+ # caller may invoke. No argument schemas — those are deferred into
148
+ # the inputSchema per the domain's schema strategy.
149
+ #: (Symbol, Array[singleton(McpAuthorization::Tool)], untyped) -> String
150
+ def facade_description(category, tools, server_context)
151
+ summary = McpAuthorization.config.category_summary(category) ||
152
+ tools.filter_map(&:_category_summary).first ||
153
+ "Tools in the #{category} group."
154
+
155
+ lines = tools.map do |tool_class|
156
+ one_liner = tool_class.dynamic_description(server_context: server_context)
157
+ .to_s.lines.first.to_s.strip
158
+ "- #{tool_class.tool_name} — #{one_liner}"
159
+ end
160
+
161
+ <<~DESC.strip
162
+ #{summary}
163
+
164
+ Available tools (pass one as `tool_name`):
165
+ #{lines.join("\n")}
166
+ DESC
167
+ end
168
+
169
+ # The facade inputSchema for the configured strategy. All three keep
170
+ # per-tool schemas out of the description and advertise only tools the
171
+ # caller may invoke; they differ in where argument schemas live.
172
+ #: (Array[singleton(McpAuthorization::Tool)], untyped, Symbol) -> Hash[Symbol, untyped]
173
+ # The facade inputSchema is always a flat object — a `tool_name` enum plus
174
+ # a permissive `arguments` object, with NO top-level combinator. LLM tool
175
+ # `input_schema` must have an object root: Anthropic and OpenAI reject
176
+ # `oneOf`/`allOf`/`anyOf` at the top level ("input_schema does not support
177
+ # oneOf, allOf, or anyOf at the top level"), and hosts routinely forward a
178
+ # facade's inputSchema straight to the model. The per-tool argument
179
+ # schemas therefore cannot be correlated to `tool_name` inline; they ride
180
+ # on the facade's `_meta` (:vendor_extension, see build_facade) for a
181
+ # client to expand, or are omitted entirely (:lazy) and enforced at
182
+ # dispatch by the target tool's own filter_input.
183
+ def facade_input_schema(tools, strategy)
184
+ arguments_description =
185
+ if strategy == :vendor_extension
186
+ 'Arguments for the chosen tool; per-tool schemas are in this tool\'s _meta under "tool-input-schemas".'
187
+ else # :lazy
188
+ "Arguments for the chosen tool."
189
+ end
190
+
191
+ {
192
+ type: "object",
193
+ properties: {
194
+ tool_name: { type: "string", enum: tools.map(&:tool_name) },
195
+ arguments: { type: "object", description: arguments_description }
196
+ },
197
+ required: %w[tool_name arguments]
198
+ }
199
+ end
200
+
201
+ # Per-tool compiled input schemas keyed by tool_name, filtered for this
202
+ # caller. Shipped on the facade's `_meta` by the :vendor_extension
203
+ # strategy so the argument shapes travel with the listing without a
204
+ # non-standard key inside `inputSchema`.
205
+ #: (Array[singleton(McpAuthorization::Tool)], untyped) -> Hash[String, untyped]
206
+ def child_schema_map(tools, server_context)
207
+ tools.each_with_object({}) do |tool_class, map|
208
+ map[tool_class.tool_name] = tool_class.dynamic_input_schema(server_context: server_context)
209
+ end
210
+ end
211
+
212
+ # Dispatch a facade call to the real tool.
213
+ #
214
+ # 1. tool_name must be in the set advertised to *this* caller.
215
+ # 2. tool_class_for re-runs permitted?, so gating is enforced at
216
+ # dispatch even if the advertised set were stale.
217
+ # 3. Arguments are coerced against the *target* tool's schema
218
+ # (JSON-string blobs parsed) before delegation; the target's own
219
+ # materialized call then applies filter_input / filter_output —
220
+ # the same code path as a direct call.
221
+ #: (String, Set[String], Hash[Symbol, untyped], untyped) -> untyped
222
+ def dispatch(domain, advertised, params, server_context)
223
+ tool_name = (params[:tool_name] || params["tool_name"]).to_s
224
+ unless advertised.include?(tool_name)
225
+ raise ArgumentError,
226
+ "unknown tool_name #{tool_name.inspect}; expected one of #{advertised.to_a.sort.inspect}"
227
+ end
228
+
229
+ candidates = McpAuthorization::ToolRegistry.tools_by_domain[domain] || []
230
+ original = candidates.find { |tc| tc.tool_name == tool_name }
231
+ target = McpAuthorization::ToolRegistry.tool_class_for(
232
+ domain: domain, name: tool_name, server_context: server_context
233
+ )
234
+ raise McpAuthorization::Tool::NotAuthorizedError unless original && target
235
+
236
+ arguments = coerce_arguments(original, params[:arguments] || params["arguments"], server_context)
237
+ target.call(server_context: server_context, **arguments)
238
+ end
239
+
240
+ # Coerce a facade's +arguments+ blob against the target tool's
241
+ # compiled input schema. MCP clients frequently serialize nested
242
+ # objects as JSON strings; the facade's own contract only knows
243
+ # `arguments: object`, so string blobs are parsed here — both the
244
+ # blob itself and any top-level value whose target type is an object
245
+ # or array. Unknown / permission-gated fields are then stripped by
246
+ # the target's filter_input as in a direct call.
247
+ #: (singleton(McpAuthorization::Tool), untyped, untyped) -> Hash[Symbol, untyped]
248
+ def coerce_arguments(tool_class, raw, server_context)
249
+ parsed = raw.is_a?(String) ? parse_json_blob(raw, "arguments") : raw
250
+ return {} unless parsed.is_a?(Hash)
251
+
252
+ schema = tool_class.dynamic_input_schema(server_context: server_context)
253
+ properties = schema.is_a?(Hash) ? (schema[:properties] || schema["properties"] || {}) : {}
254
+
255
+ parsed.each_with_object({}) do |(key, value), out|
256
+ sym = key.to_sym
257
+ expected = properties[sym] || properties[key.to_s] || {}
258
+ expected_type = expected[:type] || expected["type"]
259
+ if value.is_a?(String) && %w[object array].include?(expected_type.to_s)
260
+ value = parse_json_blob(value, sym)
261
+ end
262
+ out[sym] = value
263
+ end
264
+ end
265
+
266
+ #: (String, untyped) -> untyped
267
+ def parse_json_blob(string, field)
268
+ JSON.parse(string)
269
+ rescue JSON::ParserError => e
270
+ raise ArgumentError, "#{field} was sent as a string but is not valid JSON: #{e.message}"
271
+ end
272
+ end
273
+ end
274
+ end
@@ -1094,10 +1094,19 @@ module McpAuthorization
1094
1094
  # True when a record field's type is a multi-member union that carries a
1095
1095
  # predicate tag — i.e. per-member gating is intended. A `|` at bracket
1096
1096
  # depth 0 marks a real union (not one inside a nested generic/record).
1097
+ #
1098
+ # A tag trailing only the *final* member (e.g. a plain literal union
1099
+ # with one field-level `@desc(...)`, as in `"AND" | "OR" @desc(...)`)
1100
+ # is NOT per-member gating — every genuine per-member-tagged union in
1101
+ # this codebase tags each gated member individually
1102
+ # (`a @feature(x) | b @feature(y)`), so at least one *non-final*
1103
+ # member must carry a tag before we route into compile_tagged_union.
1104
+ # Otherwise the field falls through to the normal RBS-library path,
1105
+ # which resolves inline literal unions correctly via visit_rbs_union.
1097
1106
  #: (String) -> bool
1098
1107
  def tagged_union_field?(type_str)
1099
1108
  return false unless type_str.include?("@")
1100
- split_at_depth_zero(type_str, "|").size > 1
1109
+ per_member_tagged_union?(type_str, "|")
1101
1110
  end
1102
1111
 
1103
1112
  # If a field type is +Array[<multi-member tagged union>]+, return the inner
@@ -1111,7 +1120,18 @@ module McpAuthorization
1111
1120
  return nil unless m
1112
1121
 
1113
1122
  inner = m[1].to_s
1114
- split_at_depth_zero(inner, "|").size > 1 ? inner : nil
1123
+ per_member_tagged_union?(inner, "|") ? inner : nil
1124
+ end
1125
+
1126
+ # True when a `|`-separated type expression has more than one member
1127
+ # AND at least one *non-final* member carries a predicate tag. See
1128
+ # tagged_union_field? for why the final member alone doesn't count.
1129
+ #: (String, String) -> bool
1130
+ def per_member_tagged_union?(type_str, delimiter)
1131
+ parts = split_at_depth_zero(type_str, delimiter)
1132
+ return false unless parts.size > 1
1133
+
1134
+ parts[0..-2].any? { |part| part.include?("@") }
1115
1135
  end
1116
1136
 
1117
1137
  # Compile a union-style output type (+# @rbs type output = success | admin_detail @requires(:admin)+)
@@ -37,6 +37,12 @@ module McpAuthorization
37
37
  #: Array[Hash[Symbol, untyped]]?
38
38
  attr_reader :_gates
39
39
 
40
+ #: Symbol?
41
+ attr_reader :_category
42
+
43
+ #: String?
44
+ attr_reader :_category_summary
45
+
40
46
  #: untyped
41
47
  attr_reader :_contract_handler
42
48
 
@@ -71,6 +77,22 @@ module McpAuthorization
71
77
  @_tags = list.flatten
72
78
  end
73
79
 
80
+ # Declare the group this tool belongs to when its domain is faceted
81
+ # (see Configuration#facet_domain). Ignored in flat domains.
82
+ #
83
+ # class ListOrdersTool < McpAuthorization::Tool
84
+ # tags "admin"
85
+ # category :orders
86
+ # end
87
+ #
88
+ # The optional +summary:+ is a convenience for single-tool groups; the
89
+ # central +config.categories+ registry wins on conflict.
90
+ #: (Symbol | String, ?summary: String?) -> void
91
+ def category(name, summary: nil)
92
+ @_category = name.to_sym
93
+ @_category_summary = summary
94
+ end
95
+
74
96
  # Declare a generic predicate gate that must pass for this tool to be
75
97
  # visible. The gate calls +server_context.{predicate}?(value)+ at
76
98
  # request time. If the predicate returns false, the tool is hidden
@@ -90,6 +90,24 @@ module McpAuthorization
90
90
  tool_class.materialize_for(server_context)
91
91
  end
92
92
 
93
+ # Grouped facade tools for a faceted domain — one synthetic MCP::Tool
94
+ # per non-empty category the caller has at least one permitted tool
95
+ # in. Returns [] for domains not configured via facet_domain.
96
+ # See FacadeBuilder and docs/designs/tool-grouping-facades.md.
97
+ #: (domain: String, server_context: untyped) -> Array[singleton(MCP::Tool)]
98
+ def facades_for(domain:, server_context:)
99
+ McpAuthorization::FacadeBuilder.facades_for(domain: domain, server_context: server_context)
100
+ end
101
+
102
+ # A single facade by name within a faceted domain, or nil when the
103
+ # domain is not faceted or the name matches no non-empty group for
104
+ # this caller. The facade analogue of tool_class_for — a tools/call
105
+ # targeting one facade should not build every facade in the domain.
106
+ #: (domain: String, name: String, server_context: untyped) -> singleton(MCP::Tool)?
107
+ def facade_for(domain:, name:, server_context:)
108
+ McpAuthorization::FacadeBuilder.facade_for(domain: domain, name: name, server_context: server_context)
109
+ end
110
+
93
111
  # Look up a tool by its MCP tool name across all domains.
94
112
  #: (String) -> singleton(McpAuthorization::Tool)?
95
113
  def find_tool(name)
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.6.1"
2
+ VERSION = "0.7.0"
3
3
  end
@@ -6,6 +6,7 @@ require_relative "mcp_authorization/dsl"
6
6
  require_relative "mcp_authorization/rbs_schema_compiler"
7
7
  require_relative "mcp_authorization/tool_registry"
8
8
  require_relative "mcp_authorization/tool"
9
+ require_relative "mcp_authorization/facade_builder"
9
10
  require_relative "mcp_authorization/cache"
10
11
  require_relative "mcp_authorization/engine" if defined?(Rails)
11
12
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp_authorization
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - AndyGauge
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-01 00:00:00.000000000 Z
11
+ date: 2026-07-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -103,6 +103,7 @@ files:
103
103
  - lib/mcp_authorization/diagnostics.rb
104
104
  - lib/mcp_authorization/dsl.rb
105
105
  - lib/mcp_authorization/engine.rb
106
+ - lib/mcp_authorization/facade_builder.rb
106
107
  - lib/mcp_authorization/rbs_schema_compiler.rb
107
108
  - lib/mcp_authorization/tool.rb
108
109
  - lib/mcp_authorization/tool_registry.rb