mcp_authorization 0.6.2 → 0.7.1

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: 7998105e5a6828b9673a8b97918503842f5eea467ee3ced5c8fd8298c1000f15
4
- data.tar.gz: 4a130479e78bdd4ec696ee78623ff233331afebbd968c715eb9b87296163c94b
3
+ metadata.gz: '058c192ec678d3ceb11d6ae5fed31c1025156a83d40cf12f7458054f50935682'
4
+ data.tar.gz: 00aee0863c22ead686543f40e8658a3a019711db84c8554c28bcac287bc51253
5
5
  SHA512:
6
- metadata.gz: 02ae2178372da419ccbaffe5e7df67bc030e158cfd2b055cfa6682e5cf89d1daa428983c1687479e40de9fc466da01e9f5c2313fa561d5b815a48e884a95b4c0
7
- data.tar.gz: 14ac5790e1e7f12d36c05331a8193744b2355fd30610416265f7d8f8e0aa730654ff2bdf4bf514d3c3b4d3f677e66916014719fdcda9dfc62b90a7dc4891402a
6
+ metadata.gz: aeeb0fa5ab4074654132516b58e0b649aa3eaf3012b3703e98cb9d519c440f39f5bc080bde6947f802a8169073dc03dc1ee2cdf6ffc91b77f520182c7741a143
7
+ data.tar.gz: 89d8566f176bb0c6cb53cc84ca933adb01e858df69caa320d9d480759b7479efd814f6392c25fe61ecfef891c6246b7fbc0c6a349ba9c6892a01f1370c9f6255
data/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@ 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.1]
8
+
9
+ Follow-ups from the 0.7.0 review (onboardiq/mcp_authorization#31).
10
+
11
+ ### Fixed
12
+ - **A facade `tools/call` no longer recompiles every tool in the group.** `FacadeBuilder.facade_for` (the `tools/call` routing path) built the facade with the `:vendor_extension` `_meta` payload, which compiles the input schema of *every* tool in the group — but dispatch only needs the advertised name set, and nothing on the call path reads `_meta`. Over a 30-tool group that was ~30 extra per-tool compiles on every call, partially regressing the 0.6.0 "compile only the invoked tool" optimization on exactly the large domains facades target. `build_facade` now takes `for_dispatch:` (true from `facade_for`) and skips the `_meta` map; `tools/list` (`facades_for`) is unchanged and still carries it.
13
+ - **`facet_domain(group_by:)` is now validated.** Any value but `:category` (the only supported grouping key) raises `ArgumentError`, consistent with `schema_strategy:`/`uncategorized:`. Previously a typo (`group_by: :tag`) was silently accepted and behaved as `:category`.
14
+ - **Corrected a stale RBS annotation on `FacadeBuilder.facade_input_schema`** (declared 3 params for a 2-param method; the intervening prose comment also caused Sentinel to omit it from the generated sigs). Annotation fixed and moved adjacent to the def so it regenerates.
15
+
16
+ ### Added
17
+ - **Cache-vocabulary test for the facade path** (design doc §8): asserts a cold compile of a faceted domain learns the consulted decisions, so two callers with different permission sets produce different `tools_list_key`s (guarding against a shared-listing RBAC leak).
18
+
19
+ ## [0.7.0] - 2026-07-21
20
+
21
+ Declarative tool grouping: a domain can present its tools as a small set of
22
+ summarized category facades instead of a flat list, with per-tool schemas
23
+ deferred out of the selection prompt. Opt-in and per-domain — domains not
24
+ configured via `facet_domain` behave exactly as before. (#30)
25
+
26
+ ### Added
27
+ - **`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.
28
+
29
+ - **`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`).
30
+
31
+ - **`config.categories { summary :orders, "..." }`** — one summary line per group, used as the facade description's lead.
32
+
33
+ - **`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.
34
+
35
+ - **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.
36
+
37
+ - **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.
38
+
39
+ - **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.
40
+
41
+ - **`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.
42
+
43
+ - **`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).
44
+
45
+ ### Changed
46
+ - **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.
47
+
7
48
  ## [0.6.2] - 2026-07-01
8
49
 
9
50
  ### 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,45 @@ 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
+ # Grouping keys +facet_domain+ knows how to group by. Only +:category+
136
+ # exists today (the +category+ DSL is the only grouping key); accepted
137
+ # explicitly so a future key is an additive, validated change.
138
+ GROUP_BY_KEYS = %i[category].freeze #: Array[Symbol]
139
+
140
+ # Default suffix appended to a category to form its facade tool name
141
+ # (e.g. category +:orders+ → +orders_tools+). Overridable per domain via
142
+ # +facet_domain(..., facade_suffix:)+.
143
+ DEFAULT_FACADE_SUFFIX = "tools" #: String
144
+
145
+ # A facade suffix must be a bare identifier fragment so the derived facade
146
+ # name (+"#{category}_#{suffix}"+) stays a valid MCP tool name.
147
+ FACADE_SUFFIX_FORMAT = /\A[a-z0-9]+(?:_[a-z0-9]+)*\z/ #: Regexp
148
+
110
149
  #: () -> void
111
150
  def initialize
112
151
  @server_name = "mcp-authorization"
@@ -122,6 +161,105 @@ module McpAuthorization
122
161
  @tools_list_cache_ttl = 3600
123
162
  @tools_list_cache_redis = nil
124
163
  @tools_list_cache_redis_url = nil
164
+ @faceted_domains = {}
165
+ @category_summaries = {}
166
+ end
167
+
168
+ # Present a domain as grouped facade tools instead of a flat tool list.
169
+ #
170
+ # config.facet_domain :admin, group_by: :category
171
+ # config.facet_domain :admin, group_by: :category,
172
+ # schema_strategy: :lazy,
173
+ # uncategorized: :error
174
+ #
175
+ # +group_by+ is currently always +:category+ (the only grouping key the
176
+ # +category+ DSL provides); it is accepted explicitly so future grouping
177
+ # keys are an additive change rather than a behavior switch.
178
+ #
179
+ # +schema_strategy+ selects where the per-tool argument schemas go (the
180
+ # facade inputSchema is a flat object either way — see SCHEMA_STRATEGIES).
181
+ # +:vendor_extension+ (default) carries them on the facade's +_meta+;
182
+ # +:lazy+ omits them.
183
+ #
184
+ # +uncategorized+ controls what happens to a tool in this domain with no
185
+ # +category+: +:fallback+ (default) collects them into an +uncategorized+
186
+ # group; +:error+ raises at facade-build time.
187
+ #
188
+ # +facade_suffix+ is the token appended to a category to form its facade
189
+ # tool name — category +:orders+ → +orders_#{suffix}+. Defaults to
190
+ # +"tools"+ (+orders_tools+). Must be a lowercase identifier fragment
191
+ # (+[a-z0-9_]+) so the derived name stays a valid MCP tool name.
192
+ #: (Symbol | String, group_by: Symbol, ?schema_strategy: Symbol, ?uncategorized: Symbol, ?facade_suffix: String | Symbol) -> void
193
+ def facet_domain(domain, group_by:, schema_strategy: :vendor_extension, uncategorized: :fallback, facade_suffix: DEFAULT_FACADE_SUFFIX)
194
+ unless GROUP_BY_KEYS.include?(group_by.to_sym)
195
+ raise ArgumentError, "unknown group_by #{group_by.inspect}; " \
196
+ "expected one of #{GROUP_BY_KEYS.inspect}"
197
+ end
198
+ unless SCHEMA_STRATEGIES.include?(schema_strategy)
199
+ raise ArgumentError, "unknown schema_strategy #{schema_strategy.inspect}; " \
200
+ "expected one of #{SCHEMA_STRATEGIES.inspect}"
201
+ end
202
+ unless UNCATEGORIZED_MODES.include?(uncategorized)
203
+ raise ArgumentError, "unknown uncategorized mode #{uncategorized.inspect}; " \
204
+ "expected one of #{UNCATEGORIZED_MODES.inspect}"
205
+ end
206
+
207
+ suffix = facade_suffix.to_s
208
+ unless FACADE_SUFFIX_FORMAT.match?(suffix)
209
+ raise ArgumentError, "invalid facade_suffix #{facade_suffix.inspect}; " \
210
+ "expected a lowercase identifier fragment matching #{FACADE_SUFFIX_FORMAT.inspect}"
211
+ end
212
+
213
+ @faceted_domains[domain.to_s] = {
214
+ group_by: group_by.to_sym,
215
+ schema_strategy: schema_strategy,
216
+ uncategorized: uncategorized,
217
+ facade_suffix: suffix
218
+ }
219
+ end
220
+
221
+ # True when the given domain is presented as grouped facades.
222
+ #: (String) -> bool
223
+ def faceted?(domain)
224
+ @faceted_domains.key?(domain.to_s)
225
+ end
226
+
227
+ # Facet config Hash for a domain, or nil when the domain is not faceted.
228
+ #: (String) -> Hash[Symbol, untyped]?
229
+ def facet_config(domain)
230
+ @faceted_domains[domain.to_s]
231
+ end
232
+
233
+ # Declare one summary line per group. Evaluated in a small collector so
234
+ # the block reads declaratively:
235
+ #
236
+ # config.categories do
237
+ # summary :orders, "Create, inspect, and update orders."
238
+ # summary :billing, "Invoices, payments, refunds."
239
+ # end
240
+ #: () { () -> void } -> void
241
+ def categories(&block)
242
+ collector = CategoryCollector.new(@category_summaries)
243
+ collector.instance_eval(&block)
244
+ end
245
+
246
+ # The group summary for a category, or nil when none was declared.
247
+ #: (Symbol) -> String?
248
+ def category_summary(category)
249
+ @category_summaries[category.to_sym]
250
+ end
251
+
252
+ # Collects +summary :key, "text"+ declarations into a shared hash.
253
+ class CategoryCollector
254
+ #: (Hash[Symbol, String]) -> void
255
+ def initialize(store)
256
+ @store = store
257
+ end
258
+
259
+ #: (Symbol | String, String) -> void
260
+ def summary(category, text)
261
+ @store[category.to_sym] = text
262
+ end
125
263
  end
126
264
  end
127
265
  end
@@ -0,0 +1,289 @@
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
+ #
49
+ # Built with +for_dispatch: true+ — a +tools/call+ only needs the
50
+ # advertised name set to route through, never the +_meta+ per-tool
51
+ # schemas (those are a +tools/list+ concern). Skipping them avoids
52
+ # recompiling every tool's input schema in the group on each call,
53
+ # preserving the "compile only the invoked tool" cost that facades'
54
+ # large target domains most depend on.
55
+ #: (domain: String, name: String, server_context: untyped) -> singleton(MCP::Tool)?
56
+ def facade_for(domain:, name:, server_context:)
57
+ config = McpAuthorization.config.facet_config(domain)
58
+ return nil unless config
59
+
60
+ group_tools(domain, server_context, config).each do |category, tools|
61
+ if facade_name(category, config) == name
62
+ return build_facade(domain, category, tools, server_context, config, for_dispatch: true)
63
+ end
64
+ end
65
+ nil
66
+ end
67
+
68
+ # The MCP tool name a category's facade is exposed under. The suffix is
69
+ # per-domain (facet config +facade_suffix+, default +"tools"+): category
70
+ # +:orders+ → +orders_tools+ (or +orders_hire+, etc.).
71
+ #: (Symbol, Hash[Symbol, untyped]) -> String
72
+ def facade_name(category, config)
73
+ suffix = config[:facade_suffix] || Configuration::DEFAULT_FACADE_SUFFIX
74
+ "#{category}_#{suffix}"
75
+ end
76
+
77
+ private
78
+
79
+ # Partition the domain's tools into non-empty, RBAC-filtered groups.
80
+ # Ordering is deterministic (by category name) so listings are stable.
81
+ #: (String, untyped, Hash[Symbol, untyped]) -> Array[[Symbol, Array[singleton(McpAuthorization::Tool)]]]
82
+ def group_tools(domain, server_context, config)
83
+ candidates = McpAuthorization::ToolRegistry.tools_by_domain[domain] || []
84
+ permitted = candidates.select { |tc| tc.permitted?(server_context) }
85
+
86
+ groups = Hash.new { |h, k| h[k] = [] } #: Hash[Symbol, Array[singleton(McpAuthorization::Tool)]]
87
+ permitted.each do |tool_class|
88
+ category = tool_class._category
89
+ if category.nil?
90
+ if config[:uncategorized] == :error
91
+ raise UncategorizedToolError,
92
+ "#{tool_class} (tool #{tool_class.tool_name.inspect}) has no category, and domain " \
93
+ "#{domain.inspect} is faceted with uncategorized: :error. Declare `category :name` " \
94
+ "on the tool or switch the domain to uncategorized: :fallback."
95
+ end
96
+ category = FALLBACK_CATEGORY
97
+ end
98
+ groups[category] << tool_class
99
+ end
100
+
101
+ sorted = groups.sort_by { |category, _| category.to_s }
102
+ sorted.each { |category, _| check_name_collision!(domain, category, candidates, config) }
103
+ sorted
104
+ end
105
+
106
+ # A facade name shadowing a real tool would make that tool
107
+ # unreachable by name in this domain — fail loudly instead.
108
+ #: (String, Symbol, Array[singleton(McpAuthorization::Tool)], Hash[Symbol, untyped]) -> void
109
+ def check_name_collision!(domain, category, candidates, config)
110
+ name = facade_name(category, config)
111
+ collision = candidates.find { |tc| tc.tool_name == name }
112
+ return unless collision
113
+
114
+ raise FacadeNameCollisionError,
115
+ "facade name #{name.inspect} for category #{category.inspect} collides with registered " \
116
+ "tool #{collision} in domain #{domain.inspect}. Rename the category or the tool."
117
+ end
118
+
119
+ # Build the synthetic MCP::Tool subclass for one group, with this
120
+ # caller's description, schema, and dispatch baked in — the same
121
+ # materialization shape Tool.materialize_for uses for real tools.
122
+ #
123
+ # +for_dispatch:+ true skips the +_meta+ per-tool schema map, which only
124
+ # a +tools/list+ consumer reads — a +tools/call+ routes on the advertised
125
+ # name set alone. See +facade_for+.
126
+ #: (String, Symbol, Array[singleton(McpAuthorization::Tool)], untyped, Hash[Symbol, untyped], ?for_dispatch: bool) -> singleton(MCP::Tool)
127
+ def build_facade(domain, category, tools, server_context, config, for_dispatch: false)
128
+ name = facade_name(category, config)
129
+ desc = facade_description(category, tools, server_context)
130
+ strategy = config[:schema_strategy]
131
+ schema = facade_input_schema(tools, strategy)
132
+
133
+ # :vendor_extension ships the per-tool argument schemas out-of-band on
134
+ # the facade's `_meta` — the MCP-sanctioned extension channel — rather
135
+ # than as a non-standard key inside `inputSchema`. Client SDKs preserve
136
+ # `_meta` but strip (or, when strict, reject) unknown JSON Schema
137
+ # keywords, and `_meta` is never forwarded to the model as the tool's
138
+ # input_schema. So the listing stays valid for strict Zod clients and
139
+ # strict-mode LLM tool-calling while still carrying the schemas in-band
140
+ # for capable clients. Skipped on the dispatch path (nothing reads it
141
+ # there) so a facade call doesn't recompile every tool in the group.
142
+ meta_payload = !for_dispatch && strategy == :vendor_extension ? { "tool-input-schemas" => child_schema_map(tools, server_context) } : nil
143
+
144
+ advertised = tools.map(&:tool_name).to_set
145
+ builder = self
146
+ ctx = server_context
147
+
148
+ Class.new(MCP::Tool) do
149
+ tool_name name
150
+ description desc
151
+ input_schema schema
152
+ meta(meta_payload) if meta_payload
153
+
154
+ define_singleton_method(:call) do |server_context: nil, **params|
155
+ builder.send(:dispatch, domain, advertised, params, server_context || ctx)
156
+ end
157
+ end
158
+ end
159
+
160
+ # Routing-only description: group summary + one line per tool the
161
+ # caller may invoke. No argument schemas — those are deferred into
162
+ # the inputSchema per the domain's schema strategy.
163
+ #: (Symbol, Array[singleton(McpAuthorization::Tool)], untyped) -> String
164
+ def facade_description(category, tools, server_context)
165
+ summary = McpAuthorization.config.category_summary(category) ||
166
+ tools.filter_map(&:_category_summary).first ||
167
+ "Tools in the #{category} group."
168
+
169
+ lines = tools.map do |tool_class|
170
+ one_liner = tool_class.dynamic_description(server_context: server_context)
171
+ .to_s.lines.first.to_s.strip
172
+ "- #{tool_class.tool_name} — #{one_liner}"
173
+ end
174
+
175
+ <<~DESC.strip
176
+ #{summary}
177
+
178
+ Available tools (pass one as `tool_name`):
179
+ #{lines.join("\n")}
180
+ DESC
181
+ end
182
+
183
+ # The facade inputSchema for the configured strategy. Both strategies keep
184
+ # per-tool schemas out of the description and advertise only tools the
185
+ # caller may invoke; they differ in where argument schemas live.
186
+ #
187
+ # The facade inputSchema is always a flat object — a `tool_name` enum plus
188
+ # a permissive `arguments` object, with NO top-level combinator. LLM tool
189
+ # `input_schema` must have an object root: Anthropic and OpenAI reject
190
+ # `oneOf`/`allOf`/`anyOf` at the top level ("input_schema does not support
191
+ # oneOf, allOf, or anyOf at the top level"), and hosts routinely forward a
192
+ # facade's inputSchema straight to the model. The per-tool argument
193
+ # schemas therefore cannot be correlated to `tool_name` inline; they ride
194
+ # on the facade's `_meta` (:vendor_extension, see build_facade) for a
195
+ # client to expand, or are omitted entirely (:lazy) and enforced at
196
+ # dispatch by the target tool's own filter_input.
197
+ #: (Array[singleton(McpAuthorization::Tool)], Symbol) -> Hash[Symbol, untyped]
198
+ def facade_input_schema(tools, strategy)
199
+ arguments_description =
200
+ if strategy == :vendor_extension
201
+ 'Arguments for the chosen tool; per-tool schemas are in this tool\'s _meta under "tool-input-schemas".'
202
+ else # :lazy
203
+ "Arguments for the chosen tool."
204
+ end
205
+
206
+ {
207
+ type: "object",
208
+ properties: {
209
+ tool_name: { type: "string", enum: tools.map(&:tool_name) },
210
+ arguments: { type: "object", description: arguments_description }
211
+ },
212
+ required: %w[tool_name arguments]
213
+ }
214
+ end
215
+
216
+ # Per-tool compiled input schemas keyed by tool_name, filtered for this
217
+ # caller. Shipped on the facade's `_meta` by the :vendor_extension
218
+ # strategy so the argument shapes travel with the listing without a
219
+ # non-standard key inside `inputSchema`.
220
+ #: (Array[singleton(McpAuthorization::Tool)], untyped) -> Hash[String, untyped]
221
+ def child_schema_map(tools, server_context)
222
+ tools.each_with_object({}) do |tool_class, map|
223
+ map[tool_class.tool_name] = tool_class.dynamic_input_schema(server_context: server_context)
224
+ end
225
+ end
226
+
227
+ # Dispatch a facade call to the real tool.
228
+ #
229
+ # 1. tool_name must be in the set advertised to *this* caller.
230
+ # 2. tool_class_for re-runs permitted?, so gating is enforced at
231
+ # dispatch even if the advertised set were stale.
232
+ # 3. Arguments are coerced against the *target* tool's schema
233
+ # (JSON-string blobs parsed) before delegation; the target's own
234
+ # materialized call then applies filter_input / filter_output —
235
+ # the same code path as a direct call.
236
+ #: (String, Set[String], Hash[Symbol, untyped], untyped) -> untyped
237
+ def dispatch(domain, advertised, params, server_context)
238
+ tool_name = (params[:tool_name] || params["tool_name"]).to_s
239
+ unless advertised.include?(tool_name)
240
+ raise ArgumentError,
241
+ "unknown tool_name #{tool_name.inspect}; expected one of #{advertised.to_a.sort.inspect}"
242
+ end
243
+
244
+ candidates = McpAuthorization::ToolRegistry.tools_by_domain[domain] || []
245
+ original = candidates.find { |tc| tc.tool_name == tool_name }
246
+ target = McpAuthorization::ToolRegistry.tool_class_for(
247
+ domain: domain, name: tool_name, server_context: server_context
248
+ )
249
+ raise McpAuthorization::Tool::NotAuthorizedError unless original && target
250
+
251
+ arguments = coerce_arguments(original, params[:arguments] || params["arguments"], server_context)
252
+ target.call(server_context: server_context, **arguments)
253
+ end
254
+
255
+ # Coerce a facade's +arguments+ blob against the target tool's
256
+ # compiled input schema. MCP clients frequently serialize nested
257
+ # objects as JSON strings; the facade's own contract only knows
258
+ # `arguments: object`, so string blobs are parsed here — both the
259
+ # blob itself and any top-level value whose target type is an object
260
+ # or array. Unknown / permission-gated fields are then stripped by
261
+ # the target's filter_input as in a direct call.
262
+ #: (singleton(McpAuthorization::Tool), untyped, untyped) -> Hash[Symbol, untyped]
263
+ def coerce_arguments(tool_class, raw, server_context)
264
+ parsed = raw.is_a?(String) ? parse_json_blob(raw, "arguments") : raw
265
+ return {} unless parsed.is_a?(Hash)
266
+
267
+ schema = tool_class.dynamic_input_schema(server_context: server_context)
268
+ properties = schema.is_a?(Hash) ? (schema[:properties] || schema["properties"] || {}) : {}
269
+
270
+ parsed.each_with_object({}) do |(key, value), out|
271
+ sym = key.to_sym
272
+ expected = properties[sym] || properties[key.to_s] || {}
273
+ expected_type = expected[:type] || expected["type"]
274
+ if value.is_a?(String) && %w[object array].include?(expected_type.to_s)
275
+ value = parse_json_blob(value, sym)
276
+ end
277
+ out[sym] = value
278
+ end
279
+ end
280
+
281
+ #: (String, untyped) -> untyped
282
+ def parse_json_blob(string, field)
283
+ JSON.parse(string)
284
+ rescue JSON::ParserError => e
285
+ raise ArgumentError, "#{field} was sent as a string but is not valid JSON: #{e.message}"
286
+ end
287
+ end
288
+ end
289
+ end
@@ -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.2"
2
+ VERSION = "0.7.1"
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.2
4
+ version: 0.7.1
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-23 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