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 +4 -4
- data/CHANGELOG.md +34 -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 +129 -0
- data/lib/mcp_authorization/facade_builder.rb +274 -0
- data/lib/mcp_authorization/rbs_schema_compiler.rb +22 -2
- 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: b1dabd038e65fbf04f01e9ae27d109e8734ae78b4db4baeaa6d20200291a3899
|
|
4
|
+
data.tar.gz: 0250e2902b73117cfe4447f6036783e4a49613ee4626a407560a82bd19bf25ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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)
|
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.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-
|
|
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
|