mcp_authorization 0.1.1 → 0.3.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 +52 -0
- data/README.md +32 -4
- data/lib/mcp_authorization/configuration.rb +14 -0
- data/lib/mcp_authorization/rbs_schema_compiler.rb +282 -19
- data/lib/mcp_authorization/tool.rb +40 -3
- data/lib/mcp_authorization/version.rb +1 -1
- 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: 79a43a7ab018242f92e8f0448f73ae5acbae410ebd7397868ec52553490a15ec
|
|
4
|
+
data.tar.gz: 2f41f4e5cfc4a923b26636551f25facf94c2c902be106ea19235a0c46b68dae6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 07fc7850bfa4960c00aee35f3543e907d5b1037ffe4b45a46b247e002d3d9a8271d4a7070746c031a2cd03d498a93a496bf1a74c998f17c1023fe361f416e27b
|
|
7
|
+
data.tar.gz: f83d36ff6ff7b43fffad639f8e81790a20c450959fe1cadb5d009ff39dee41464199e9d636ad1e1048d32e053eba84b4239d915e9837fcde11a495eb48361642
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.3.0] - 2026-05-14
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Generic predicate tags.** Any `@tag(:value)` annotation not in the known constraint list becomes a predicate filter: the compiler calls `server_context.tag_name?(value)` at schema compile time. If the predicate returns false, the field/variant is excluded from the JSON Schema. This makes the gem infinitely extensible — define `feature?`, `tier?`, `beta?`, or any predicate on your server context without gem changes.
|
|
11
|
+
- Backward-compat fallback for `@requires`: if the server context lacks a `requires?` method, the compiler falls back to `server_context.current_user.can?(:flag)` directly. No deploy-ordering constraint.
|
|
12
|
+
- Error isolation: exceptions from individual predicates are rescued and logged. A single broken predicate no longer crashes the entire `tools/list` response.
|
|
13
|
+
- Development-mode warning with DidYouMean suggestion when a predicate method is not found on the server context (e.g., `@feture(:x)` warns "Did you mean @feature?").
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- `@requires` is now handled through the generic predicate path. The `tags[:requires]` key is removed; `@requires(:flag)` is stored only in `tags[:predicates]` like any other predicate.
|
|
17
|
+
- `predicate_excluded?` replaces the three hardcoded `current_user.can?` filter lines in `compile_tagged_record`, `compile_tagged_union`, and `filter_call_signature`.
|
|
18
|
+
- Configuration docs updated to describe the predicate protocol on server context objects.
|
|
19
|
+
|
|
20
|
+
### Migration notes
|
|
21
|
+
- Consumers using `OpenStruct` as server context continue to work — `@requires` falls back to `current_user.can?`. To use `@feature` or custom predicates, define the corresponding `?` methods on your server context.
|
|
22
|
+
- If you read `tags[:requires]` from parsed tag hashes (unlikely outside the gem), switch to `tags[:predicates].find { |p| p[:name] == "requires" }`.
|
|
23
|
+
|
|
24
|
+
## [0.2.1] - 2026-05-13
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- `tools/call` responses now include `structuredContent` when the tool declares an `outputSchema` via `dynamic_contract`. Previously the response carried only text content, which spec-compliant clients rejected as a validation error. (#6)
|
|
28
|
+
- `RbsSchemaCompiler` now finds the handler's source file when `#call` is wrapped via `Module#prepend` (param coercion, instrumentation, `ActiveSupport::Concern`, tracing libraries). It walks `UnboundMethod#super_method` past prepended modules until the owner is the handler class itself, so `# @rbs type` and `#:` annotations are read from the right file instead of raising a contract violation. (#8)
|
|
29
|
+
|
|
30
|
+
## [0.2.0] - 2026-04-20
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- `RbsSchemaCompiler.filter_input(handler, params, server_context:)` — projects inbound params onto the user's compiled input schema before the handler runs. Keys gated by `@requires` the user lacks, and any keys not declared in the schema at all, are dropped.
|
|
34
|
+
- `RbsSchemaCompiler.filter_output(handler, result, server_context:)` — projects the handler's return value onto the user's compiled output schema. Hidden `oneOf` variants and their fields are stripped before serialization.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- **`@requires` is now a security boundary, not just a hint to the LLM.** Tool calls through `Tool.call` and the anonymous class produced by `Tool.materialize_for` pipe params through `filter_input` on the way in and results through `filter_output` on the way out. A crafted JSON-RPC request that sends a gated param, and a handler that accidentally emits a gated output field, can no longer leak.
|
|
38
|
+
- README updated to describe enforcement as a guarantee. Handler authors no longer have to remember to re-check `can?` in every branch that touches a gated field — the schema is the boundary.
|
|
39
|
+
|
|
40
|
+
### Migration notes
|
|
41
|
+
- If your handler's `#call` quietly accepted params that weren't declared in the `#:` annotation, those will now arrive as `nil`/default values. Declare them (with `@requires` if appropriate) or drop them.
|
|
42
|
+
- If your handler's output included fields that weren't in `@rbs type output`, those are now stripped. Add them to the output type definition if they should ship.
|
|
43
|
+
|
|
44
|
+
## [0.1.1] - 2026-04-02
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
- Added MIT license, homepage, author metadata.
|
|
48
|
+
|
|
49
|
+
## [0.1.0] - 2026-04-01
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
- Initial gem extraction from the monorepo. Rails engine, `RbsSchemaCompiler`, `Tool` / `ToolRegistry`, `DSL` mixin, `McpController`.
|
data/README.md
CHANGED
|
@@ -11,11 +11,20 @@ The gem gives you three independent controls over what each user sees:
|
|
|
11
11
|
| Layer | Mechanism | Effect |
|
|
12
12
|
|---|---|---|
|
|
13
13
|
| **Tool visibility** | `authorization :manage_workflows` on the tool class | Tool hidden entirely from users who lack the flag |
|
|
14
|
-
| **Input fields** | `@requires(:backward_routing)` on a param in `#:` annotation | Field excluded from the input schema |
|
|
15
|
-
| **Output variants** | `@requires(:backward_routing)` on a variant in `@rbs type output` | Variant excluded from the `oneOf` |
|
|
14
|
+
| **Input fields** | `@requires(:backward_routing)` on a param in `#:` annotation | Field excluded from the input schema *and* stripped from inbound params at call time |
|
|
15
|
+
| **Output variants** | `@requires(:backward_routing)` on a variant in `@rbs type output` | Variant excluded from the `oneOf` *and* fields projected out of the handler's return value before it crosses the wire |
|
|
16
16
|
|
|
17
17
|
All three go through the same predicate: `current_user.can?(:symbol)`. The symbol can represent a permission, a feature flag, a plan tier, an A/B bucket -- whatever your app puts behind it.
|
|
18
18
|
|
|
19
|
+
### Enforcement, not just shaping
|
|
20
|
+
|
|
21
|
+
`@requires` is a security boundary, not a hint. At tool-call time the gem:
|
|
22
|
+
|
|
23
|
+
- **Filters inbound params** against the user's compiled input schema. Gated fields, and any keys not declared in the schema at all, are dropped before the handler's `#call` is invoked. A handler that takes `force:` gated behind `@requires(:admin)` will see `force: false` (its default) for non-admins even if the MCP client sends `force: true` in the raw JSON-RPC payload.
|
|
24
|
+
- **Projects the handler's return value** onto the user's compiled output schema. A variant hidden by `@requires` has its shape unavailable, so if the handler erroneously emits that variant's extra fields, they are stripped before serialization. A handler bug or refactor accident cannot leak admin-only fields to a non-admin.
|
|
25
|
+
|
|
26
|
+
This means handler authors don't have to remember to re-check `can?` at every branch -- the schema *is* the boundary. `can?` inside `#call` is still useful for logic that changes behavior (not just field visibility), but it is no longer load-bearing for security.
|
|
27
|
+
|
|
19
28
|
## Install
|
|
20
29
|
|
|
21
30
|
```ruby
|
|
@@ -420,13 +429,32 @@ Tag any field in a `#:` annotation or `@rbs type` record to add JSON Schema cons
|
|
|
420
429
|
| `@read_only()` | `readOnly: true` | Read-only field |
|
|
421
430
|
| `@write_only()` | `writeOnly: true` | Write-only field |
|
|
422
431
|
|
|
423
|
-
**Authorization:**
|
|
432
|
+
**Authorization & predicate filters:**
|
|
424
433
|
|
|
425
434
|
| Tag | Purpose |
|
|
426
435
|
|---|---|
|
|
427
|
-
| `@requires(:flag)` | Field/variant excluded when `
|
|
436
|
+
| `@requires(:flag)` | Field/variant excluded when `server_context.requires?(:flag)` returns false. Legacy fallback: if `requires?` is not defined, falls back to `current_user.can?(:flag)`. |
|
|
437
|
+
| `@feature(:flag)` | Field/variant excluded when `server_context.feature?(:flag)` returns false (account-level feature flags) |
|
|
428
438
|
| `@depends_on(:field)` | Emits `dependentRequired` — field only required when parent field is present |
|
|
429
439
|
|
|
440
|
+
Any `@tag(:value)` not in the known constraint list above is a **generic predicate filter**. At schema compile time, the gem calls `server_context.tag_name?(value)` — if it returns false, the field is excluded. If `server_context` doesn't respond to the method, the predicate is skipped (permissive).
|
|
441
|
+
|
|
442
|
+
This makes the gem infinitely extensible. Define any predicate on your server context:
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
# In your app's server context:
|
|
446
|
+
def requires?(flag) = current_user.can?(flag.to_sym)
|
|
447
|
+
def feature?(flag) = current_account.feature_enabled?(flag.to_s)
|
|
448
|
+
def tier?(name) = current_account.plan_tier?(name.to_s)
|
|
449
|
+
def beta?(flag) = current_account.beta_enrolled?(flag.to_s)
|
|
450
|
+
|
|
451
|
+
# In your handler:
|
|
452
|
+
#: (?status: "active" | "inactive" | "unlisted" @feature(:opening_status_v2)) -> output
|
|
453
|
+
#: (?force: bool @requires(:admin) @tier(:enterprise)) -> output
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
Multiple predicates on the same field are AND-ed — all must pass for the field to appear.
|
|
457
|
+
|
|
430
458
|
**Niche:**
|
|
431
459
|
|
|
432
460
|
| Tag | JSON Schema |
|
|
@@ -22,6 +22,20 @@ module McpAuthorization
|
|
|
22
22
|
# current_user.can?(:symbol) # required — gates field/tool visibility
|
|
23
23
|
# current_user.default_for(:symbol) # optional — populates @default_for tags
|
|
24
24
|
#
|
|
25
|
+
# The context object itself can implement predicate methods for generic
|
|
26
|
+
# tag filtering. Any +@tag(:value)+ not in the known constraint list
|
|
27
|
+
# calls +context.tag?(value)+:
|
|
28
|
+
#
|
|
29
|
+
# context.requires?(flag) # optional — for @requires, falls back to current_user.can?
|
|
30
|
+
# context.feature?(flag) # optional — for @feature (account-level feature flags)
|
|
31
|
+
# context.tier?(name) # optional — for @tier (plan-level gating)
|
|
32
|
+
#
|
|
33
|
+
# For public/anonymous MCP interfaces, supply a context with minimum-viable
|
|
34
|
+
# permissions rather than +current_user: nil+. A nil user causes +@requires+
|
|
35
|
+
# fields to be silently excluded (no user = no permissions).
|
|
36
|
+
#
|
|
37
|
+
# See RbsSchemaCompiler.predicate_excluded? for the full protocol.
|
|
38
|
+
#
|
|
25
39
|
class Configuration
|
|
26
40
|
# Server name reported in the MCP +initialize+ handshake.
|
|
27
41
|
#: String
|
|
@@ -81,6 +81,45 @@ module McpAuthorization
|
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# Filter incoming params against the user's compiled input schema.
|
|
85
|
+
#
|
|
86
|
+
# Any key that is not in the schema for this user is dropped — including
|
|
87
|
+
# +@requires+-gated fields the user lacks permission for, and any
|
|
88
|
+
# unknown fields not declared in the schema. This is the runtime
|
|
89
|
+
# enforcement counterpart to the input-shaping that +compile_input+ did.
|
|
90
|
+
#
|
|
91
|
+
# @param handler_class [Class]
|
|
92
|
+
# @param params [Hash] Params as received from the MCP client.
|
|
93
|
+
# @param server_context [Object] Per-request context.
|
|
94
|
+
# @return [Hash] Filtered params safe to pass to the handler.
|
|
95
|
+
#: (untyped, Hash[untyped, untyped], server_context: untyped) -> Hash[untyped, untyped]
|
|
96
|
+
def filter_input(handler_class, params, server_context:)
|
|
97
|
+
return params unless params.is_a?(Hash)
|
|
98
|
+
schema = compile_input_for_filter(handler_class, server_context: server_context)
|
|
99
|
+
return params unless schema
|
|
100
|
+
project_against_schema(params, schema, defs_from(schema))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Filter the handler's return value against the user's compiled output
|
|
104
|
+
# schema. Any field/variant not visible to this user is stripped from
|
|
105
|
+
# the result.
|
|
106
|
+
#
|
|
107
|
+
# This is the runtime counterpart to +compile_output+: even if a handler
|
|
108
|
+
# bug or auth confusion causes it to emit fields the user shouldn't see,
|
|
109
|
+
# they never cross the wire. Passes the result through unchanged if no
|
|
110
|
+
# +@rbs type output+ is defined.
|
|
111
|
+
#
|
|
112
|
+
# @param handler_class [Class]
|
|
113
|
+
# @param result [Object] Handler return value (hash/array/primitive).
|
|
114
|
+
# @param server_context [Object] Per-request context.
|
|
115
|
+
# @return [Object] Projected result, matching the user's output schema.
|
|
116
|
+
#: (untyped, untyped, server_context: untyped) -> untyped
|
|
117
|
+
def filter_output(handler_class, result, server_context:)
|
|
118
|
+
schema = compile_output_for_filter(handler_class, server_context: server_context)
|
|
119
|
+
return result unless schema
|
|
120
|
+
project_against_schema(result, schema, defs_from(schema))
|
|
121
|
+
end
|
|
122
|
+
|
|
84
123
|
# Strip JSON Schema keywords unsupported by Anthropic's strict tool
|
|
85
124
|
# use mode, and add additionalProperties: false to all objects.
|
|
86
125
|
# Converts oneOf to anyOf (strict mode supports anyOf but not oneOf).
|
|
@@ -152,6 +191,132 @@ module McpAuthorization
|
|
|
152
191
|
|
|
153
192
|
private
|
|
154
193
|
|
|
194
|
+
# ---------------------------------------------------------------
|
|
195
|
+
# Runtime enforcement — project values against the user's schema
|
|
196
|
+
# ---------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
# Like +compile_input+ but keeps +$defs+ inline-resolvable and skips
|
|
199
|
+
# the LLM-facing +strict_sanitize+ pass. Used by +filter_input+ so
|
|
200
|
+
# enforcement operates on the same semantic schema the LLM was given
|
|
201
|
+
# without any strict-mode transforms that would lose type info.
|
|
202
|
+
#: (untyped, server_context: untyped) -> Hash[Symbol, untyped]
|
|
203
|
+
def compile_input_for_filter(handler_class, server_context:)
|
|
204
|
+
cached = cache_for(handler_class)
|
|
205
|
+
|
|
206
|
+
schema = if cached[:raw_input]&.dig(:kind) == :record
|
|
207
|
+
compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context)
|
|
208
|
+
else
|
|
209
|
+
build_input_schema(
|
|
210
|
+
filter_call_signature(cached[:call_params], cached[:type_map], server_context)
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
with_ref_injection(schema, cached[:type_map])
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Like +compile_output+ but skips +strict_sanitize+. Returns nil when
|
|
218
|
+
# the handler has no +# @rbs type output+ declaration.
|
|
219
|
+
#: (untyped, server_context: untyped) -> Hash[Symbol, untyped]?
|
|
220
|
+
def compile_output_for_filter(handler_class, server_context:)
|
|
221
|
+
cached = cache_for(handler_class)
|
|
222
|
+
return nil unless cached[:raw_output]&.dig(:kind) == :union
|
|
223
|
+
|
|
224
|
+
schema = compile_tagged_union(cached[:raw_output][:body], cached[:type_map], server_context)
|
|
225
|
+
with_ref_injection(schema, cached[:type_map])
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Extract the +$defs+ table from a compiled schema for +$ref+ resolution.
|
|
229
|
+
#: (Hash[Symbol, untyped]?) -> Hash[String, Hash[Symbol, untyped]]
|
|
230
|
+
def defs_from(schema)
|
|
231
|
+
return {} unless schema.is_a?(Hash)
|
|
232
|
+
(schema[:"$defs"] || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# If +schema+ is a +$ref+ pointer, resolve it against +defs+. Otherwise
|
|
236
|
+
# return the schema unchanged.
|
|
237
|
+
#: (Hash[Symbol, untyped]?, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]?
|
|
238
|
+
def resolve_ref(schema, defs)
|
|
239
|
+
return schema unless schema.is_a?(Hash)
|
|
240
|
+
ref = schema[:"$ref"] || schema["$ref"]
|
|
241
|
+
return schema unless ref
|
|
242
|
+
name = ref.to_s.sub(%r{\A#/\$defs/}, "")
|
|
243
|
+
defs[name] || schema
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Recursively project a runtime value onto a compiled JSON Schema.
|
|
247
|
+
#
|
|
248
|
+
# The semantics: the schema is authoritative — the result only contains
|
|
249
|
+
# what the schema allows for this user.
|
|
250
|
+
#
|
|
251
|
+
# * Objects keep only declared +properties+; everything else is dropped.
|
|
252
|
+
# * Arrays recurse into +items+.
|
|
253
|
+
# * +oneOf+/+anyOf+ picks the best-matching variant (by present/required
|
|
254
|
+
# keys) and projects against it; unmatched variants are ignored.
|
|
255
|
+
# * Primitives (and un-typed schemas) pass through unchanged.
|
|
256
|
+
#
|
|
257
|
+
# This is how +@requires+-gated fields are enforced at runtime: they
|
|
258
|
+
# are already absent from +schema[:properties]+ for a user who lacks
|
|
259
|
+
# the flag, so the projection simply drops them from the value.
|
|
260
|
+
#: (untyped, Hash[Symbol, untyped]?, Hash[String, Hash[Symbol, untyped]]) -> untyped
|
|
261
|
+
def project_against_schema(value, schema, defs)
|
|
262
|
+
return value if schema.nil?
|
|
263
|
+
schema = resolve_ref(schema, defs)
|
|
264
|
+
return value unless schema.is_a?(Hash)
|
|
265
|
+
|
|
266
|
+
variants = schema[:oneOf] || schema[:anyOf]
|
|
267
|
+
if variants.is_a?(Array) && !variants.empty?
|
|
268
|
+
resolved = variants.map { |v| resolve_ref(v, defs) }
|
|
269
|
+
best = best_variant_for(value, resolved)
|
|
270
|
+
return best ? project_against_schema(value, best, defs) : value
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
case schema[:type]
|
|
274
|
+
when "object"
|
|
275
|
+
return value unless value.is_a?(Hash)
|
|
276
|
+
props = schema[:properties] || {}
|
|
277
|
+
value.each_with_object({}) do |(k, v), acc|
|
|
278
|
+
prop_schema = props[k.to_sym] || props[k.to_s] || props[k]
|
|
279
|
+
next unless prop_schema
|
|
280
|
+
acc[k] = project_against_schema(v, prop_schema, defs)
|
|
281
|
+
end
|
|
282
|
+
when "array"
|
|
283
|
+
return value unless value.is_a?(Array)
|
|
284
|
+
items = schema[:items]
|
|
285
|
+
items ? value.map { |v| project_against_schema(v, items, defs) } : value
|
|
286
|
+
else
|
|
287
|
+
value
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Choose the best-matching variant of a union for a given value.
|
|
292
|
+
#
|
|
293
|
+
# Scoring: count how many of the value's keys appear in the variant's
|
|
294
|
+
# +properties+, minus how many are unknown. Disqualify variants missing
|
|
295
|
+
# any of the value's keys from their +required+ list that isn't present.
|
|
296
|
+
# Returns nil if no variant can accommodate the value — in which case
|
|
297
|
+
# the caller should leave the value unchanged (defensive pass-through).
|
|
298
|
+
#: (untyped, Array[Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]?
|
|
299
|
+
def best_variant_for(value, variants)
|
|
300
|
+
return variants.first unless value.is_a?(Hash)
|
|
301
|
+
|
|
302
|
+
scored = variants.filter_map do |variant|
|
|
303
|
+
next unless variant.is_a?(Hash) && variant[:type] == "object"
|
|
304
|
+
props = variant[:properties] || {}
|
|
305
|
+
prop_keys = props.keys.map(&:to_s)
|
|
306
|
+
value_keys = value.keys.map(&:to_s)
|
|
307
|
+
required = (variant[:required] || []).map(&:to_s)
|
|
308
|
+
|
|
309
|
+
next if (required - value_keys).any?
|
|
310
|
+
|
|
311
|
+
known = (value_keys & prop_keys).size
|
|
312
|
+
unknown = (value_keys - prop_keys).size
|
|
313
|
+
[known - unknown, variant]
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
return nil if scored.empty?
|
|
317
|
+
scored.max_by { |score, _| score }&.last
|
|
318
|
+
end
|
|
319
|
+
|
|
155
320
|
# ---------------------------------------------------------------
|
|
156
321
|
# Tag extraction — unified parser for all @tag(...) annotations
|
|
157
322
|
# ---------------------------------------------------------------
|
|
@@ -174,7 +339,7 @@ module McpAuthorization
|
|
|
174
339
|
# @return [Array(String, Hash)] +[clean_type, tags_hash]+
|
|
175
340
|
#
|
|
176
341
|
# Supported tags:
|
|
177
|
-
# @requires(:symbol) ->
|
|
342
|
+
# @requires(:symbol) -> added to :predicates (calls server_context.requires?)
|
|
178
343
|
# @depends_on(:field) -> { depends_on: "field" }
|
|
179
344
|
# @min(n) -> { min: n }
|
|
180
345
|
# @max(n) -> { max: n }
|
|
@@ -195,6 +360,14 @@ module McpAuthorization
|
|
|
195
360
|
# @closed() / @strict() -> { closed: true }
|
|
196
361
|
# @media_type(type) -> { media_type: "type" }
|
|
197
362
|
# @encoding(enc) -> { encoding: "enc" }
|
|
363
|
+
#
|
|
364
|
+
# Any tag not listed above is treated as a **predicate filter**:
|
|
365
|
+
# @feature(:flag) -> added to predicates, calls server_context.feature?(:flag)
|
|
366
|
+
# @tier(:enterprise) -> added to predicates, calls server_context.tier?(:enterprise)
|
|
367
|
+
# @custom(:value) -> added to predicates, calls server_context.custom?(:value)
|
|
368
|
+
#
|
|
369
|
+
# Predicate filters exclude the field/variant from the schema when the
|
|
370
|
+
# predicate returns false. The server_context must respond to +tag_name?+.
|
|
198
371
|
#: (String) -> [String, Hash[Symbol, untyped]]
|
|
199
372
|
def extract_tags(type_str)
|
|
200
373
|
tags = {}
|
|
@@ -205,7 +378,7 @@ module McpAuthorization
|
|
|
205
378
|
|
|
206
379
|
case tag_name
|
|
207
380
|
when "requires"
|
|
208
|
-
tags[:
|
|
381
|
+
(tags[:predicates] ||= []) << { name: "requires", value: tag_value.delete_prefix(":") }
|
|
209
382
|
when "depends_on"
|
|
210
383
|
tags[:depends_on] = tag_value.delete_prefix(":")
|
|
211
384
|
when "min"
|
|
@@ -246,6 +419,8 @@ module McpAuthorization
|
|
|
246
419
|
tags[:media_type] = tag_value
|
|
247
420
|
when "encoding"
|
|
248
421
|
tags[:encoding] = tag_value
|
|
422
|
+
else
|
|
423
|
+
(tags[:predicates] ||= []) << { name: tag_name, value: tag_value.delete_prefix(":") }
|
|
249
424
|
end
|
|
250
425
|
end
|
|
251
426
|
|
|
@@ -378,18 +553,95 @@ module McpAuthorization
|
|
|
378
553
|
end
|
|
379
554
|
|
|
380
555
|
# ---------------------------------------------------------------
|
|
381
|
-
#
|
|
556
|
+
# Predicate filtering — the per-request compile phase
|
|
382
557
|
# ---------------------------------------------------------------
|
|
383
558
|
|
|
559
|
+
# Returns true if any predicate tag on a field/variant evaluates to
|
|
560
|
+
# false, meaning the field should be excluded from the schema.
|
|
561
|
+
#
|
|
562
|
+
# For each predicate, calls +server_context.tag_name?(value)+.
|
|
563
|
+
# If the server_context doesn't respond to the method, the predicate
|
|
564
|
+
# is skipped (fail-open — unknown predicates don't block). This is
|
|
565
|
+
# intentional: predicates shape the schema for the LLM, they are not
|
|
566
|
+
# a security boundary. Hiding a field by accident (typo) is worse
|
|
567
|
+
# than showing one extra field. Runtime enforcement (+filter_input+)
|
|
568
|
+
# is the actual security layer.
|
|
569
|
+
#
|
|
570
|
+
# Special case: +@requires+ falls back to +current_user.can?+ when
|
|
571
|
+
# the server_context lacks a +requires?+ method, for backward
|
|
572
|
+
# compatibility with consumers that haven't migrated to predicates.
|
|
573
|
+
#
|
|
574
|
+
# Exceptions from individual predicates are rescued and logged so
|
|
575
|
+
# that a single broken predicate doesn't crash the entire tools/list.
|
|
576
|
+
#
|
|
577
|
+
# @param tags [Hash] Parsed tags from +extract_tags+.
|
|
578
|
+
# @param server_context [Object] Per-request context.
|
|
579
|
+
# @return [Boolean] true if the field should be excluded.
|
|
580
|
+
#: (Hash[Symbol, untyped], untyped) -> bool
|
|
581
|
+
def predicate_excluded?(tags, server_context)
|
|
582
|
+
return false unless tags[:predicates] && server_context
|
|
583
|
+
tags[:predicates].any? do |pred|
|
|
584
|
+
method = :"#{pred[:name]}?"
|
|
585
|
+
if server_context.respond_to?(method)
|
|
586
|
+
!server_context.public_send(method, pred[:value])
|
|
587
|
+
elsif pred[:name] == "requires" && server_context.respond_to?(:current_user)
|
|
588
|
+
# Backward compat: fall back to direct user permission check.
|
|
589
|
+
# Note: nil current_user → &.can? returns nil → !nil is true → field excluded.
|
|
590
|
+
# This is intentional: no user = no permissions = hide restricted fields.
|
|
591
|
+
!server_context.current_user&.can?(pred[:value].to_sym)
|
|
592
|
+
else
|
|
593
|
+
warn_unknown_predicate(pred[:name], server_context)
|
|
594
|
+
false # Fail-open: include the field
|
|
595
|
+
end
|
|
596
|
+
rescue => e
|
|
597
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
598
|
+
Rails.logger.error("[McpAuthorization] predicate #{pred[:name]}?(#{pred[:value]}) raised: #{e.message}")
|
|
599
|
+
end
|
|
600
|
+
false # Fail-open on error: include the field
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# Emit a development-mode warning when a predicate method is not
|
|
605
|
+
# found on the server_context. Helps catch typos like @feture(:x).
|
|
606
|
+
#: (String, untyped) -> void
|
|
607
|
+
def warn_unknown_predicate(name, server_context)
|
|
608
|
+
return unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env.local?
|
|
609
|
+
|
|
610
|
+
available = server_context.class.public_instance_methods(true)
|
|
611
|
+
.select { |m| m.to_s.end_with?("?") }
|
|
612
|
+
.map { |m| m.to_s.chomp("?") }
|
|
613
|
+
best = available.min_by { |a| levenshtein(a, name) }
|
|
614
|
+
hint = best && levenshtein(best, name) <= 3 ? " Did you mean @#{best}?" : ""
|
|
615
|
+
Rails.logger&.warn("[McpAuthorization] Predicate '#{name}?' not found on #{server_context.class}.#{hint} Field will be shown to all users.")
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Minimal Levenshtein distance for typo suggestions.
|
|
619
|
+
#: (String, String) -> Integer
|
|
620
|
+
def levenshtein(a, b)
|
|
621
|
+
m, n = a.length, b.length
|
|
622
|
+
d = Array.new(m + 1) { |i| i }
|
|
623
|
+
(1..n).each do |j|
|
|
624
|
+
prev = d[0]
|
|
625
|
+
d[0] = j
|
|
626
|
+
(1..m).each do |i|
|
|
627
|
+
cost = a[i - 1] == b[j - 1] ? 0 : 1
|
|
628
|
+
temp = d[i]
|
|
629
|
+
d[i] = [d[i] + 1, d[i - 1] + 1, prev + cost].min
|
|
630
|
+
prev = temp
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
d[m]
|
|
634
|
+
end
|
|
635
|
+
|
|
384
636
|
# Compile a record-style input type (+# @rbs type input = { ... }+)
|
|
385
|
-
# with field-level
|
|
637
|
+
# with field-level predicate filtering.
|
|
386
638
|
#
|
|
387
|
-
# Fields whose
|
|
388
|
-
# omitted from the resulting JSON Schema
|
|
639
|
+
# Fields whose predicate tags (e.g. +@requires+, +@feature+) evaluate
|
|
640
|
+
# to false are silently omitted from the resulting JSON Schema.
|
|
389
641
|
#
|
|
390
642
|
# @param raw_body [String] The raw record body, e.g. +"{name: String, force: bool @requires(:admin)}"+.
|
|
391
643
|
# @param type_map [Hash] Resolved type definitions for +$ref+ lookups.
|
|
392
|
-
# @param server_context [Object] Per-request context
|
|
644
|
+
# @param server_context [Object] Per-request context.
|
|
393
645
|
# @return [Hash] JSON Schema object with +properties+, +required+, etc.
|
|
394
646
|
#: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
|
|
395
647
|
def compile_tagged_record(raw_body, type_map, server_context)
|
|
@@ -403,7 +655,7 @@ module McpAuthorization
|
|
|
403
655
|
key, type_str = match[0].to_s, match[1].to_s
|
|
404
656
|
type_str, tags = extract_tags(type_str.strip)
|
|
405
657
|
|
|
406
|
-
next if tags
|
|
658
|
+
next if predicate_excluded?(tags, server_context)
|
|
407
659
|
|
|
408
660
|
optional = key.end_with?("?")
|
|
409
661
|
clean_key = key.delete_suffix("?")
|
|
@@ -425,10 +677,10 @@ module McpAuthorization
|
|
|
425
677
|
end
|
|
426
678
|
|
|
427
679
|
# Compile a union-style output type (+# @rbs type output = success | admin_detail @requires(:admin)+)
|
|
428
|
-
# with variant-level
|
|
680
|
+
# with variant-level predicate filtering.
|
|
429
681
|
#
|
|
430
|
-
# Each union variant (separated by +|+) can carry its own
|
|
431
|
-
#
|
|
682
|
+
# Each union variant (separated by +|+) can carry its own predicate
|
|
683
|
+
# tags. Variants whose predicates evaluate to false are dropped entirely.
|
|
432
684
|
# If only one variant remains, it's returned directly (no +oneOf+
|
|
433
685
|
# wrapper). If zero remain, a bare +{type: "object"}+ fallback is used.
|
|
434
686
|
#
|
|
@@ -442,7 +694,7 @@ module McpAuthorization
|
|
|
442
694
|
|
|
443
695
|
filtered = parts.filter_map do |part|
|
|
444
696
|
part, tags = extract_tags(part)
|
|
445
|
-
next nil if tags
|
|
697
|
+
next nil if predicate_excluded?(tags, server_context)
|
|
446
698
|
resolve_type(part, type_map)
|
|
447
699
|
end
|
|
448
700
|
|
|
@@ -453,7 +705,7 @@ module McpAuthorization
|
|
|
453
705
|
end
|
|
454
706
|
end
|
|
455
707
|
|
|
456
|
-
# Filter method-signature parameters by
|
|
708
|
+
# Filter method-signature parameters by predicate tags and build
|
|
457
709
|
# the input JSON Schema. This is the path used when the handler defines
|
|
458
710
|
# its schema via a +#:+ annotation above +def call+ rather than an
|
|
459
711
|
# explicit +# @rbs type input = { ... }+.
|
|
@@ -469,9 +721,7 @@ module McpAuthorization
|
|
|
469
721
|
dependent_required = {}
|
|
470
722
|
|
|
471
723
|
call_params.each do |param|
|
|
472
|
-
if param[:tags]
|
|
473
|
-
next unless server_context.current_user.can?(param[:tags][:requires])
|
|
474
|
-
end
|
|
724
|
+
next if predicate_excluded?(param[:tags], server_context)
|
|
475
725
|
|
|
476
726
|
schema = rbs_type_to_json_schema(param[:type], type_map)
|
|
477
727
|
properties[param[:name].to_sym] = apply_tags(schema, param[:tags], server_context: server_context)
|
|
@@ -880,15 +1130,28 @@ module McpAuthorization
|
|
|
880
1130
|
# +Method#source_location+ on its +#call+ method. This is how the
|
|
881
1131
|
# compiler finds the RBS annotations to parse.
|
|
882
1132
|
#
|
|
1133
|
+
# When host applications wrap +#call+ via +prepend+ (param coercion,
|
|
1134
|
+
# instrumentation, error mapping, ActiveSupport::Concern patterns,
|
|
1135
|
+
# observability libraries), +source_location+ on the topmost method
|
|
1136
|
+
# points at the wrapper, not the handler. Walk +super_method+ down
|
|
1137
|
+
# past prepended modules until we find the handler's own definition.
|
|
1138
|
+
#
|
|
883
1139
|
# @param handler_class [Class]
|
|
884
1140
|
# @return [String, nil] Absolute file path, or nil.
|
|
885
1141
|
#: (untyped) -> String?
|
|
886
1142
|
def find_source_file(handler_class)
|
|
887
|
-
if handler_class.method_defined?(:call)
|
|
888
|
-
handler_class.instance_method(:call)
|
|
1143
|
+
um = if handler_class.method_defined?(:call) || handler_class.private_method_defined?(:call)
|
|
1144
|
+
handler_class.instance_method(:call)
|
|
889
1145
|
elsif handler_class.respond_to?(:call)
|
|
890
|
-
handler_class.method(:call)
|
|
1146
|
+
handler_class.method(:call)
|
|
891
1147
|
end
|
|
1148
|
+
return nil unless um
|
|
1149
|
+
|
|
1150
|
+
while um.owner != handler_class && um.super_method
|
|
1151
|
+
um = um.super_method
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
um.source_location&.first
|
|
892
1155
|
end
|
|
893
1156
|
|
|
894
1157
|
# Check whether a string has balanced curly braces. Used to detect
|
|
@@ -118,13 +118,30 @@ module McpAuthorization
|
|
|
118
118
|
end
|
|
119
119
|
|
|
120
120
|
# Execute the tool by delegating to the handler.
|
|
121
|
+
#
|
|
122
|
+
# Inputs are filtered against the user's compiled input schema before
|
|
123
|
+
# being passed to the handler, and outputs are filtered against the
|
|
124
|
+
# user's compiled output schema before being returned. Fields and
|
|
125
|
+
# variants gated by +@requires+ that the user lacks permission for
|
|
126
|
+
# never reach the handler (in) or cross the wire (out).
|
|
121
127
|
#: (?server_context: untyped?, **untyped) -> untyped
|
|
122
128
|
def call(server_context: nil, **params)
|
|
123
129
|
raise NotAuthorizedError unless server_context && permitted?(server_context)
|
|
124
|
-
|
|
130
|
+
filtered = McpAuthorization::RbsSchemaCompiler.filter_input(
|
|
131
|
+
_contract_handler, params, server_context: server_context
|
|
132
|
+
)
|
|
133
|
+
result = handler_instance(server_context).call(**symbolize_keys(filtered))
|
|
134
|
+
McpAuthorization::RbsSchemaCompiler.filter_output(
|
|
135
|
+
_contract_handler, result, server_context: server_context
|
|
136
|
+
)
|
|
125
137
|
end
|
|
126
138
|
|
|
127
139
|
# Create an anonymous MCP::Tool subclass with this user's schemas baked in.
|
|
140
|
+
#
|
|
141
|
+
# The materialized +call+ enforces the compiled schema at runtime:
|
|
142
|
+
# input params are stripped of unknown or permission-gated fields
|
|
143
|
+
# before reaching the handler, and the handler's return value is
|
|
144
|
+
# projected onto the user's output schema before being serialized.
|
|
128
145
|
#: (untyped) -> Class?
|
|
129
146
|
def materialize_for(server_context)
|
|
130
147
|
defn = to_mcp_definition(server_context: server_context)
|
|
@@ -132,6 +149,7 @@ module McpAuthorization
|
|
|
132
149
|
|
|
133
150
|
handler = _contract_handler
|
|
134
151
|
ctx = server_context
|
|
152
|
+
symbolize = method(:symbolize_keys)
|
|
135
153
|
|
|
136
154
|
Class.new(MCP::Tool) do
|
|
137
155
|
tool_name defn[:name]
|
|
@@ -142,12 +160,31 @@ module McpAuthorization
|
|
|
142
160
|
|
|
143
161
|
define_singleton_method(:call) do |server_context: nil, **params|
|
|
144
162
|
effective_ctx = server_context || ctx
|
|
145
|
-
|
|
146
|
-
|
|
163
|
+
filtered_params = McpAuthorization::RbsSchemaCompiler.filter_input(
|
|
164
|
+
handler, params, server_context: effective_ctx
|
|
165
|
+
)
|
|
166
|
+
raw = handler.new(server_context: effective_ctx).call(**symbolize.call(filtered_params))
|
|
167
|
+
result = McpAuthorization::RbsSchemaCompiler.filter_output(
|
|
168
|
+
handler, raw, server_context: effective_ctx
|
|
169
|
+
)
|
|
170
|
+
response_args = [{ type: "text", text: result.to_json }]
|
|
171
|
+
if defn[:outputSchema]
|
|
172
|
+
MCP::Tool::Response.new(response_args, structured_content: result)
|
|
173
|
+
else
|
|
174
|
+
MCP::Tool::Response.new(response_args)
|
|
175
|
+
end
|
|
147
176
|
end
|
|
148
177
|
end
|
|
149
178
|
end
|
|
150
179
|
|
|
180
|
+
# Normalize hash keys to symbols so projection output can be splatted
|
|
181
|
+
# into a handler's kwarg-only +#call+ signature.
|
|
182
|
+
#: (Hash[untyped, untyped]) -> Hash[Symbol, untyped]
|
|
183
|
+
def symbolize_keys(hash)
|
|
184
|
+
return {} unless hash.is_a?(Hash)
|
|
185
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
186
|
+
end
|
|
187
|
+
|
|
151
188
|
private
|
|
152
189
|
|
|
153
190
|
#: (**untyped) -> void
|
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.3.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-
|
|
11
|
+
date: 2026-05-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -74,6 +74,7 @@ executables: []
|
|
|
74
74
|
extensions: []
|
|
75
75
|
extra_rdoc_files: []
|
|
76
76
|
files:
|
|
77
|
+
- CHANGELOG.md
|
|
77
78
|
- LICENSE
|
|
78
79
|
- README.md
|
|
79
80
|
- app/controllers/mcp_authorization/mcp_controller.rb
|