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 +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +65 -0
- data/app/controllers/mcp_authorization/mcp_controller.rb +42 -10
- data/lib/mcp_authorization/cache.rb +12 -2
- data/lib/mcp_authorization/configuration.rb +138 -0
- data/lib/mcp_authorization/facade_builder.rb +289 -0
- data/lib/mcp_authorization/tool.rb +22 -0
- data/lib/mcp_authorization/tool_registry.rb +18 -0
- data/lib/mcp_authorization/version.rb +1 -1
- data/lib/mcp_authorization.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '058c192ec678d3ceb11d6ae5fed31c1025156a83d40cf12f7458054f50935682'
|
|
4
|
+
data.tar.gz: 00aee0863c22ead686543f40e8658a3a019711db84c8554c28bcac287bc51253
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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)
|
data/lib/mcp_authorization.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|