mcp_authorization 0.2.1 → 0.4.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 +41 -0
- data/README.md +52 -8
- data/lib/mcp_authorization/configuration.rb +14 -0
- data/lib/mcp_authorization/diagnostics.rb +84 -0
- data/lib/mcp_authorization/rbs_schema_compiler.rb +80 -16
- data/lib/mcp_authorization/tool.rb +109 -4
- data/lib/mcp_authorization/version.rb +1 -1
- data/lib/mcp_authorization.rb +1 -0
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9042f13cf2b294336b81ade0d8f97c31179a6483dde8c73c2258a7459227c7c4
|
|
4
|
+
data.tar.gz: 8be4296941e757f985bdab130472aa3f7f1bda837eae5d3b8d924078e9f27bb9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f167384e7a8b591bb76e05677ed8f99998bcb253144f509046cb4c46e57170ef8348350b3d926a04620011e4982d91de354e41be4c01e39f08acdcec06b893ff
|
|
7
|
+
data.tar.gz: c283b35338579177226136f6fb66d778e982e9748e5d946e8ec0232134149aeaa3571033133923b5caf7c497aba14d9b2798a3c5f784c2dc50a22cdcb7e907a4
|
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.4.0] - 2026-05-21
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Tool-level generic predicate gates.** `Tool` subclasses can now declare any number of `gate :predicate_name, :value` calls in addition to `authorization :perm`. The gate is evaluated at request time by calling `server_context.{predicate_name}?(value)`; if any gate returns false, the tool is hidden from `tools/list` and rejected from `tools/call`. This is the tool-level counterpart of the field-level `@predicate(:value)` system introduced in 0.3.0 — same semantics, same fail-open + error-isolation behavior, same backward-compat fallback for `gate :requires`.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
class BulkSendSmsTool < McpAuthorization::Tool
|
|
14
|
+
authorization :communications # RBAC permission (existing behavior preserved)
|
|
15
|
+
gate :feature, :sms # hide tool unless current_account.sms_enabled?
|
|
16
|
+
gate :requires, :super_user # extra check beyond authorization
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- `McpAuthorization::Diagnostics` module — shared helper for the development-mode "Did you mean?" warning previously duplicated between field-level (`@predicate`) and tool-level (`gate`) sites. Single Levenshtein implementation, single warning phrasing per call site (`gate :feture, :sms` warns "Did you mean gate :feature?", `@feture(:sms)` warns "Did you mean @feature?").
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- **`authorization` migrated to the generic gate pipeline.** `authorization :perm` is now a convenience alias for `gate :requires, :perm`. The legacy dual-path in `Tool.permitted?` (one branch for `_permission`, another for gates) is gone; there is one pipeline now. Mirrors the field-level migration done in 0.3.0 (#12), where `@requires` was migrated through the same generic predicate pipeline rather than carrying its own special-cased branch. `_permission` remains exposed as before for introspection.
|
|
24
|
+
- `permitted?(nil_context)` now denies when gates are declared (previously crashed). A nil context reaching `permitted?` is a programmer error; fail-closed avoids silently exposing the tool.
|
|
25
|
+
|
|
26
|
+
### Migration notes
|
|
27
|
+
- No breaking changes for end users. Tools that use `authorization :perm` continue to work exactly as before — the only difference is the internal pipeline.
|
|
28
|
+
- If you read `tool_class._permission` for introspection, it still returns the declared symbol.
|
|
29
|
+
- Existing field-level `@requires`/`@feature` semantics are unchanged.
|
|
30
|
+
|
|
31
|
+
## [0.3.0] - 2026-05-14
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- **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.
|
|
35
|
+
- 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.
|
|
36
|
+
- Error isolation: exceptions from individual predicates are rescued and logged. A single broken predicate no longer crashes the entire `tools/list` response.
|
|
37
|
+
- 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?").
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- `@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.
|
|
41
|
+
- `predicate_excluded?` replaces the three hardcoded `current_user.can?` filter lines in `compile_tagged_record`, `compile_tagged_union`, and `filter_call_signature`.
|
|
42
|
+
- Configuration docs updated to describe the predicate protocol on server context objects.
|
|
43
|
+
|
|
44
|
+
### Migration notes
|
|
45
|
+
- 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.
|
|
46
|
+
- If you read `tags[:requires]` from parsed tag hashes (unlikely outside the gem), switch to `tags[:predicates].find { |p| p[:name] == "requires" }`.
|
|
47
|
+
|
|
7
48
|
## [0.2.1] - 2026-05-13
|
|
8
49
|
|
|
9
50
|
### Fixed
|
data/README.md
CHANGED
|
@@ -10,11 +10,11 @@ The gem gives you three independent controls over what each user sees:
|
|
|
10
10
|
|
|
11
11
|
| Layer | Mechanism | Effect |
|
|
12
12
|
|---|---|---|
|
|
13
|
-
| **Tool visibility** | `authorization :manage_workflows` on the tool class | Tool hidden entirely from users who
|
|
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 |
|
|
13
|
+
| **Tool visibility** | `authorization :manage_workflows` (RBAC) or `gate :feature, :sms` (any predicate) on the tool class | Tool hidden entirely from users who fail any check |
|
|
14
|
+
| **Input fields** | `@requires(:backward_routing)` or `@feature(:sms)` 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)` or `@feature(:sms)` 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
|
+
Tool-level `authorization :perm` is RBAC (calls `current_user.can?`). Tool-level `gate :predicate, :value` and the field-level annotations are generic — any predicate name works, as long as the server context implements `{predicate}?(value)`. See [Generic predicate tags](#generic-predicate-tags) below.
|
|
18
18
|
|
|
19
19
|
### Enforcement, not just shaping
|
|
20
20
|
|
|
@@ -287,7 +287,9 @@ Shared types define **shapes**. Authorization (`@requires`) stays on the handler
|
|
|
287
287
|
```ruby
|
|
288
288
|
class MyTool < McpAuthorization::Tool
|
|
289
289
|
tool_name "my_tool"
|
|
290
|
-
authorization :some_flag #
|
|
290
|
+
authorization :some_flag # RBAC: hidden unless current_user.can?(:some_flag)
|
|
291
|
+
gate :feature, :order_tracking # any predicate: hidden unless server_context.feature?(:order_tracking)
|
|
292
|
+
gate :tier, :enterprise # multiple gates AND together
|
|
291
293
|
tags "recruiting", "operations" # which domains this tool appears in
|
|
292
294
|
read_only! # MCP annotation hints
|
|
293
295
|
dynamic_contract MyService # handler class
|
|
@@ -297,7 +299,8 @@ end
|
|
|
297
299
|
| Method | Purpose |
|
|
298
300
|
|---|---|
|
|
299
301
|
| `tool_name "name"` | MCP tool name |
|
|
300
|
-
| `authorization :sym` | Tool-level visibility gate. Omit for public tools. |
|
|
302
|
+
| `authorization :sym` | Tool-level RBAC visibility gate. Convenience alias for `gate :requires, :sym` — routes through the generic gate pipeline and falls back to `current_user.can?(:sym)` when the server context lacks a `requires?` method. Omit for public tools. |
|
|
303
|
+
| `gate :predicate, :value` | Tool-level generic predicate gate. Calls `server_context.{predicate}?(value)`. Repeat for AND. Fail-open when the predicate method is missing (warning logged in dev). |
|
|
301
304
|
| `tags "domain1", ...` | Domain(s) this tool appears under. Defaults to `["default"]`. |
|
|
302
305
|
| `dynamic_contract HandlerClass` | Handler providing description, schemas, and execution |
|
|
303
306
|
| `read_only!` | Annotation: tool only reads data |
|
|
@@ -307,6 +310,8 @@ end
|
|
|
307
310
|
| `open_world!` | Annotation: tool may access external services |
|
|
308
311
|
| `closed_world!` | Annotation: tool stays within the system |
|
|
309
312
|
|
|
313
|
+
`authorization :perm` is just a convenience for `gate :requires, :perm` — internally there is one gate pipeline, not two. Multiple `gate` declarations AND together with `authorization`: the tool is shown only when every check passes. This makes tool-level gating symmetric with the field-level annotations (`@requires`, `@feature`, any custom predicate).
|
|
314
|
+
|
|
310
315
|
Tools self-register when loaded. Put them anywhere under `tool_paths` (default: `app/mcp/`).
|
|
311
316
|
|
|
312
317
|
## Contract validation
|
|
@@ -429,13 +434,52 @@ Tag any field in a `#:` annotation or `@rbs type` record to add JSON Schema cons
|
|
|
429
434
|
| `@read_only()` | `readOnly: true` | Read-only field |
|
|
430
435
|
| `@write_only()` | `writeOnly: true` | Write-only field |
|
|
431
436
|
|
|
432
|
-
**Authorization:**
|
|
437
|
+
**Authorization & predicate filters:**
|
|
433
438
|
|
|
434
439
|
| Tag | Purpose |
|
|
435
440
|
|---|---|
|
|
436
|
-
| `@requires(:flag)` | Field/variant excluded when `
|
|
441
|
+
| `@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)`. |
|
|
442
|
+
| `@feature(:flag)` | Field/variant excluded when `server_context.feature?(:flag)` returns false (account-level feature flags) |
|
|
437
443
|
| `@depends_on(:field)` | Emits `dependentRequired` — field only required when parent field is present |
|
|
438
444
|
|
|
445
|
+
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).
|
|
446
|
+
|
|
447
|
+
This makes the gem infinitely extensible. Define any predicate on your server context:
|
|
448
|
+
|
|
449
|
+
```ruby
|
|
450
|
+
# In your app's server context:
|
|
451
|
+
def requires?(flag) = current_user.can?(flag.to_sym)
|
|
452
|
+
def feature?(flag) = current_account.feature_enabled?(flag.to_s)
|
|
453
|
+
def tier?(name) = current_account.plan_tier?(name.to_s)
|
|
454
|
+
def beta?(flag) = current_account.beta_enrolled?(flag.to_s)
|
|
455
|
+
|
|
456
|
+
# In your handler:
|
|
457
|
+
#: (?status: "active" | "inactive" | "unlisted" @feature(:opening_status_v2)) -> output
|
|
458
|
+
#: (?force: bool @requires(:admin) @tier(:enterprise)) -> output
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Multiple predicates on the same field are AND-ed — all must pass for the field to appear.
|
|
462
|
+
|
|
463
|
+
### Tool-level gates
|
|
464
|
+
|
|
465
|
+
The same predicate vocabulary is available at the **tool wrapper** level via `gate :predicate, :value`:
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
class BulkSendSmsTool < McpAuthorization::Tool
|
|
469
|
+
authorization :communications # RBAC permission
|
|
470
|
+
gate :feature, :sms # hide tool unless account has SMS configured
|
|
471
|
+
gate :requires, :super_user # extra RBAC check beyond authorization
|
|
472
|
+
end
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
`gate` is the tool-level counterpart of `@predicate(:value)` on a field. Semantics:
|
|
476
|
+
|
|
477
|
+
- Calls `server_context.{predicate_name}?(value)` at request time.
|
|
478
|
+
- All gates AND together with `authorization`. The tool is shown only when every check passes.
|
|
479
|
+
- Fail-open when the predicate method is missing on the server context (warning logged in development).
|
|
480
|
+
- `gate :requires, :perm` falls back to `current_user.can?(:perm)` when the context lacks a `requires?` method (matching the field-level backward-compat path).
|
|
481
|
+
- Exceptions raised by a predicate are rescued and logged — a broken predicate never crashes `tools/list`.
|
|
482
|
+
|
|
439
483
|
**Niche:**
|
|
440
484
|
|
|
441
485
|
| 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
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module McpAuthorization
|
|
2
|
+
# Shared diagnostic helpers used by both the field-level
|
|
3
|
+
# +RbsSchemaCompiler#predicate_excluded?+ and the tool-level
|
|
4
|
+
# +Tool#gates_pass?+ paths.
|
|
5
|
+
#
|
|
6
|
+
# Lives in its own module so the two predicate sites — field-level
|
|
7
|
+
# (compile time) and tool-level (request time) — share a single
|
|
8
|
+
# "Did you mean?" warning implementation. Avoids two copies of
|
|
9
|
+
# Levenshtein drifting.
|
|
10
|
+
module Diagnostics
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Emit a development-mode warning when a predicate method is not
|
|
14
|
+
# found on the server context. Helps catch typos like
|
|
15
|
+
# +@feture(:x)+ or +gate :feture, :sms+.
|
|
16
|
+
#
|
|
17
|
+
# @param name [String, Symbol] The predicate name attempted (e.g. +"feature"+ or +:gate+).
|
|
18
|
+
# @param server_context [Object] The context the predicate was looked up on.
|
|
19
|
+
# @param site [Symbol] +:field+ or +:tool+ — controls the suggestion phrasing.
|
|
20
|
+
# @return [void]
|
|
21
|
+
#: (String | Symbol, untyped, Symbol) -> void
|
|
22
|
+
def warn_unknown_predicate(name, server_context, site:)
|
|
23
|
+
return unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env.local?
|
|
24
|
+
|
|
25
|
+
str = name.to_s
|
|
26
|
+
available = server_context.class.public_instance_methods(true)
|
|
27
|
+
.select { |m| m.to_s.end_with?("?") }
|
|
28
|
+
.map { |m| m.to_s.chomp("?") }
|
|
29
|
+
best = available.min_by { |a| levenshtein(a, str) }
|
|
30
|
+
suggestion = best && levenshtein(best, str) <= 3 ? best : nil
|
|
31
|
+
|
|
32
|
+
hint =
|
|
33
|
+
case site
|
|
34
|
+
when :tool then suggestion ? " Did you mean gate :#{suggestion}?" : ""
|
|
35
|
+
when :field then suggestion ? " Did you mean @#{suggestion}?" : ""
|
|
36
|
+
else suggestion ? " Did you mean #{suggestion}?" : ""
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
surface =
|
|
40
|
+
case site
|
|
41
|
+
when :tool then "Gate predicate"
|
|
42
|
+
when :field then "Predicate"
|
|
43
|
+
else "Predicate"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
fallthrough =
|
|
47
|
+
case site
|
|
48
|
+
when :tool then "Tool will be shown to all users."
|
|
49
|
+
when :field then "Field will be shown to all users."
|
|
50
|
+
else ""
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Rails.logger&.warn(
|
|
54
|
+
"[McpAuthorization] #{surface} '#{str}?' not found on #{server_context.class}.#{hint} #{fallthrough}".strip
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Minimal Levenshtein distance for typo suggestions.
|
|
59
|
+
#
|
|
60
|
+
# Iterative two-row implementation — O(m*n) time, O(m) extra space.
|
|
61
|
+
# Used only in development for "Did you mean?" suggestions, so the
|
|
62
|
+
# naive version is fine.
|
|
63
|
+
#
|
|
64
|
+
# @param a [String]
|
|
65
|
+
# @param b [String]
|
|
66
|
+
# @return [Integer]
|
|
67
|
+
#: (String, String) -> Integer
|
|
68
|
+
def levenshtein(a, b)
|
|
69
|
+
m, n = a.length, b.length
|
|
70
|
+
d = Array.new(m + 1) { |i| i }
|
|
71
|
+
(1..n).each do |j|
|
|
72
|
+
prev = d[0]
|
|
73
|
+
d[0] = j
|
|
74
|
+
(1..m).each do |i|
|
|
75
|
+
cost = a[i - 1] == b[j - 1] ? 0 : 1
|
|
76
|
+
temp = d[i]
|
|
77
|
+
d[i] = [d[i] + 1, d[i - 1] + 1, prev + cost].min
|
|
78
|
+
prev = temp
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
d[m]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "diagnostics"
|
|
2
|
+
|
|
1
3
|
module McpAuthorization
|
|
2
4
|
# Compiles RBS-style type annotations in Ruby source files into JSON Schema,
|
|
3
5
|
# with per-request filtering based on +@requires+ permission tags.
|
|
@@ -339,7 +341,7 @@ module McpAuthorization
|
|
|
339
341
|
# @return [Array(String, Hash)] +[clean_type, tags_hash]+
|
|
340
342
|
#
|
|
341
343
|
# Supported tags:
|
|
342
|
-
# @requires(:symbol) ->
|
|
344
|
+
# @requires(:symbol) -> added to :predicates (calls server_context.requires?)
|
|
343
345
|
# @depends_on(:field) -> { depends_on: "field" }
|
|
344
346
|
# @min(n) -> { min: n }
|
|
345
347
|
# @max(n) -> { max: n }
|
|
@@ -360,6 +362,14 @@ module McpAuthorization
|
|
|
360
362
|
# @closed() / @strict() -> { closed: true }
|
|
361
363
|
# @media_type(type) -> { media_type: "type" }
|
|
362
364
|
# @encoding(enc) -> { encoding: "enc" }
|
|
365
|
+
#
|
|
366
|
+
# Any tag not listed above is treated as a **predicate filter**:
|
|
367
|
+
# @feature(:flag) -> added to predicates, calls server_context.feature?(:flag)
|
|
368
|
+
# @tier(:enterprise) -> added to predicates, calls server_context.tier?(:enterprise)
|
|
369
|
+
# @custom(:value) -> added to predicates, calls server_context.custom?(:value)
|
|
370
|
+
#
|
|
371
|
+
# Predicate filters exclude the field/variant from the schema when the
|
|
372
|
+
# predicate returns false. The server_context must respond to +tag_name?+.
|
|
363
373
|
#: (String) -> [String, Hash[Symbol, untyped]]
|
|
364
374
|
def extract_tags(type_str)
|
|
365
375
|
tags = {}
|
|
@@ -370,7 +380,7 @@ module McpAuthorization
|
|
|
370
380
|
|
|
371
381
|
case tag_name
|
|
372
382
|
when "requires"
|
|
373
|
-
tags[:
|
|
383
|
+
(tags[:predicates] ||= []) << { name: "requires", value: tag_value.delete_prefix(":") }
|
|
374
384
|
when "depends_on"
|
|
375
385
|
tags[:depends_on] = tag_value.delete_prefix(":")
|
|
376
386
|
when "min"
|
|
@@ -411,6 +421,8 @@ module McpAuthorization
|
|
|
411
421
|
tags[:media_type] = tag_value
|
|
412
422
|
when "encoding"
|
|
413
423
|
tags[:encoding] = tag_value
|
|
424
|
+
else
|
|
425
|
+
(tags[:predicates] ||= []) << { name: tag_name, value: tag_value.delete_prefix(":") }
|
|
414
426
|
end
|
|
415
427
|
end
|
|
416
428
|
|
|
@@ -543,18 +555,72 @@ module McpAuthorization
|
|
|
543
555
|
end
|
|
544
556
|
|
|
545
557
|
# ---------------------------------------------------------------
|
|
546
|
-
#
|
|
558
|
+
# Predicate filtering — the per-request compile phase
|
|
547
559
|
# ---------------------------------------------------------------
|
|
548
560
|
|
|
561
|
+
# Returns true if any predicate tag on a field/variant evaluates to
|
|
562
|
+
# false, meaning the field should be excluded from the schema.
|
|
563
|
+
#
|
|
564
|
+
# For each predicate, calls +server_context.tag_name?(value)+.
|
|
565
|
+
# If the server_context doesn't respond to the method, the predicate
|
|
566
|
+
# is skipped (fail-open — unknown predicates don't block). This is
|
|
567
|
+
# intentional: predicates shape the schema for the LLM, they are not
|
|
568
|
+
# a security boundary. Hiding a field by accident (typo) is worse
|
|
569
|
+
# than showing one extra field. Runtime enforcement (+filter_input+)
|
|
570
|
+
# is the actual security layer.
|
|
571
|
+
#
|
|
572
|
+
# Special case: +@requires+ falls back to +current_user.can?+ when
|
|
573
|
+
# the server_context lacks a +requires?+ method, for backward
|
|
574
|
+
# compatibility with consumers that haven't migrated to predicates.
|
|
575
|
+
#
|
|
576
|
+
# Exceptions from individual predicates are rescued and logged so
|
|
577
|
+
# that a single broken predicate doesn't crash the entire tools/list.
|
|
578
|
+
#
|
|
579
|
+
# @param tags [Hash] Parsed tags from +extract_tags+.
|
|
580
|
+
# @param server_context [Object] Per-request context.
|
|
581
|
+
# @return [Boolean] true if the field should be excluded.
|
|
582
|
+
#: (Hash[Symbol, untyped], untyped) -> bool
|
|
583
|
+
def predicate_excluded?(tags, server_context)
|
|
584
|
+
return false unless tags[:predicates] && server_context
|
|
585
|
+
tags[:predicates].any? do |pred|
|
|
586
|
+
method = :"#{pred[:name]}?"
|
|
587
|
+
if server_context.respond_to?(method)
|
|
588
|
+
!server_context.public_send(method, pred[:value])
|
|
589
|
+
elsif pred[:name] == "requires" && server_context.respond_to?(:current_user)
|
|
590
|
+
# Backward compat: fall back to direct user permission check.
|
|
591
|
+
# Note: nil current_user → &.can? returns nil → !nil is true → field excluded.
|
|
592
|
+
# This is intentional: no user = no permissions = hide restricted fields.
|
|
593
|
+
!server_context.current_user&.can?(pred[:value].to_sym)
|
|
594
|
+
else
|
|
595
|
+
warn_unknown_predicate(pred[:name], server_context)
|
|
596
|
+
false # Fail-open: include the field
|
|
597
|
+
end
|
|
598
|
+
rescue => e
|
|
599
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
600
|
+
Rails.logger.error("[McpAuthorization] predicate #{pred[:name]}?(#{pred[:value]}) raised: #{e.message}")
|
|
601
|
+
end
|
|
602
|
+
false # Fail-open on error: include the field
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Emit a development-mode warning when a predicate method is not
|
|
607
|
+
# found on the server_context. Delegates to the shared diagnostic
|
|
608
|
+
# helper so the field-level (+@predicate+) and tool-level (+gate+)
|
|
609
|
+
# warning shapes stay in sync.
|
|
610
|
+
#: (String, untyped) -> void
|
|
611
|
+
def warn_unknown_predicate(name, server_context)
|
|
612
|
+
McpAuthorization::Diagnostics.warn_unknown_predicate(name, server_context, site: :field)
|
|
613
|
+
end
|
|
614
|
+
|
|
549
615
|
# Compile a record-style input type (+# @rbs type input = { ... }+)
|
|
550
|
-
# with field-level
|
|
616
|
+
# with field-level predicate filtering.
|
|
551
617
|
#
|
|
552
|
-
# Fields whose
|
|
553
|
-
# omitted from the resulting JSON Schema
|
|
618
|
+
# Fields whose predicate tags (e.g. +@requires+, +@feature+) evaluate
|
|
619
|
+
# to false are silently omitted from the resulting JSON Schema.
|
|
554
620
|
#
|
|
555
621
|
# @param raw_body [String] The raw record body, e.g. +"{name: String, force: bool @requires(:admin)}"+.
|
|
556
622
|
# @param type_map [Hash] Resolved type definitions for +$ref+ lookups.
|
|
557
|
-
# @param server_context [Object] Per-request context
|
|
623
|
+
# @param server_context [Object] Per-request context.
|
|
558
624
|
# @return [Hash] JSON Schema object with +properties+, +required+, etc.
|
|
559
625
|
#: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
|
|
560
626
|
def compile_tagged_record(raw_body, type_map, server_context)
|
|
@@ -568,7 +634,7 @@ module McpAuthorization
|
|
|
568
634
|
key, type_str = match[0].to_s, match[1].to_s
|
|
569
635
|
type_str, tags = extract_tags(type_str.strip)
|
|
570
636
|
|
|
571
|
-
next if tags
|
|
637
|
+
next if predicate_excluded?(tags, server_context)
|
|
572
638
|
|
|
573
639
|
optional = key.end_with?("?")
|
|
574
640
|
clean_key = key.delete_suffix("?")
|
|
@@ -590,10 +656,10 @@ module McpAuthorization
|
|
|
590
656
|
end
|
|
591
657
|
|
|
592
658
|
# Compile a union-style output type (+# @rbs type output = success | admin_detail @requires(:admin)+)
|
|
593
|
-
# with variant-level
|
|
659
|
+
# with variant-level predicate filtering.
|
|
594
660
|
#
|
|
595
|
-
# Each union variant (separated by +|+) can carry its own
|
|
596
|
-
#
|
|
661
|
+
# Each union variant (separated by +|+) can carry its own predicate
|
|
662
|
+
# tags. Variants whose predicates evaluate to false are dropped entirely.
|
|
597
663
|
# If only one variant remains, it's returned directly (no +oneOf+
|
|
598
664
|
# wrapper). If zero remain, a bare +{type: "object"}+ fallback is used.
|
|
599
665
|
#
|
|
@@ -607,7 +673,7 @@ module McpAuthorization
|
|
|
607
673
|
|
|
608
674
|
filtered = parts.filter_map do |part|
|
|
609
675
|
part, tags = extract_tags(part)
|
|
610
|
-
next nil if tags
|
|
676
|
+
next nil if predicate_excluded?(tags, server_context)
|
|
611
677
|
resolve_type(part, type_map)
|
|
612
678
|
end
|
|
613
679
|
|
|
@@ -618,7 +684,7 @@ module McpAuthorization
|
|
|
618
684
|
end
|
|
619
685
|
end
|
|
620
686
|
|
|
621
|
-
# Filter method-signature parameters by
|
|
687
|
+
# Filter method-signature parameters by predicate tags and build
|
|
622
688
|
# the input JSON Schema. This is the path used when the handler defines
|
|
623
689
|
# its schema via a +#:+ annotation above +def call+ rather than an
|
|
624
690
|
# explicit +# @rbs type input = { ... }+.
|
|
@@ -634,9 +700,7 @@ module McpAuthorization
|
|
|
634
700
|
dependent_required = {}
|
|
635
701
|
|
|
636
702
|
call_params.each do |param|
|
|
637
|
-
if param[:tags]
|
|
638
|
-
next unless server_context.current_user.can?(param[:tags][:requires])
|
|
639
|
-
end
|
|
703
|
+
next if predicate_excluded?(param[:tags], server_context)
|
|
640
704
|
|
|
641
705
|
schema = rbs_type_to_json_schema(param[:type], type_map)
|
|
642
706
|
properties[param[:name].to_sym] = apply_tags(schema, param[:tags], server_context: server_context)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "diagnostics"
|
|
2
|
+
|
|
1
3
|
module McpAuthorization
|
|
2
4
|
# Base class for MCP tools with schema-shaping authorization.
|
|
3
5
|
#
|
|
@@ -10,13 +12,18 @@ module McpAuthorization
|
|
|
10
12
|
#
|
|
11
13
|
# class Tools::ListOrders < McpAuthorization::Tool
|
|
12
14
|
# tool_name "list_orders"
|
|
13
|
-
# authorization :view_orders
|
|
15
|
+
# authorization :view_orders # RBAC permission (legacy, still supported)
|
|
16
|
+
# gate :feature, :order_tracking # generic predicate gate (any predicate name)
|
|
14
17
|
# tags "operator", "fulfillment"
|
|
15
18
|
# read_only!
|
|
16
19
|
#
|
|
17
20
|
# dynamic_contract Handlers::ListOrders
|
|
18
21
|
# end
|
|
19
22
|
#
|
|
23
|
+
# Both +authorization+ and any number of +gate+ declarations contribute to
|
|
24
|
+
# visibility — the tool is shown only when every check passes. See
|
|
25
|
+
# +permitted?+ for the resolution order.
|
|
26
|
+
#
|
|
20
27
|
class Tool < MCP::Tool
|
|
21
28
|
class NotAuthorizedError < StandardError; end
|
|
22
29
|
|
|
@@ -27,6 +34,9 @@ module McpAuthorization
|
|
|
27
34
|
#: Array[String]?
|
|
28
35
|
attr_reader :_tags
|
|
29
36
|
|
|
37
|
+
#: Array[Hash[Symbol, untyped]]?
|
|
38
|
+
attr_reader :_gates
|
|
39
|
+
|
|
30
40
|
#: untyped
|
|
31
41
|
attr_reader :_contract_handler
|
|
32
42
|
|
|
@@ -36,10 +46,23 @@ module McpAuthorization
|
|
|
36
46
|
McpAuthorization::ToolRegistry.register(subclass)
|
|
37
47
|
end
|
|
38
48
|
|
|
39
|
-
# Declare the permission flag required to see this tool.
|
|
49
|
+
# Declare the RBAC permission flag required to see this tool.
|
|
50
|
+
#
|
|
51
|
+
# Convenience alias for +gate :requires, permission+. The generic
|
|
52
|
+
# gate pipeline handles dispatch — calling
|
|
53
|
+
# +server_context.requires?(permission)+ when defined, otherwise
|
|
54
|
+
# falling back to +current_user.can?(permission)+. This mirrors the
|
|
55
|
+
# field-level migration done in 0.3.0 (#12): +@requires+ also went
|
|
56
|
+
# through the generic predicate pipeline rather than carrying its
|
|
57
|
+
# own special-cased branch.
|
|
58
|
+
#
|
|
59
|
+
# +_permission+ remains exposed for introspection — the value is
|
|
60
|
+
# written there as before — but the actual gating goes through the
|
|
61
|
+
# gate list at +permitted?+ time, just like every other check.
|
|
40
62
|
#: (Symbol) -> void
|
|
41
63
|
def authorization(permission)
|
|
42
64
|
@_permission = permission
|
|
65
|
+
gate :requires, permission
|
|
43
66
|
end
|
|
44
67
|
|
|
45
68
|
# Declare which MCP domains this tool belongs to.
|
|
@@ -48,6 +71,30 @@ module McpAuthorization
|
|
|
48
71
|
@_tags = list.flatten
|
|
49
72
|
end
|
|
50
73
|
|
|
74
|
+
# Declare a generic predicate gate that must pass for this tool to be
|
|
75
|
+
# visible. The gate calls +server_context.{predicate}?(value)+ at
|
|
76
|
+
# request time. If the predicate returns false, the tool is hidden
|
|
77
|
+
# from +tools/list+ and rejected from +tools/call+.
|
|
78
|
+
#
|
|
79
|
+
# Mirrors the field-level +@predicate(:value)+ system: any predicate
|
|
80
|
+
# name works, as long as the +server_context+ implements
|
|
81
|
+
# +{predicate}?(value)+.
|
|
82
|
+
#
|
|
83
|
+
# class BulkSendSmsTool < McpAuthorization::Tool
|
|
84
|
+
# authorization :communications # RBAC (existing)
|
|
85
|
+
# gate :feature, :sms # hide tool unless account has SMS configured
|
|
86
|
+
# gate :requires, :super_user # extra RBAC check beyond authorization
|
|
87
|
+
# end
|
|
88
|
+
#
|
|
89
|
+
# Multiple +gate+ calls AND together — every gate must pass.
|
|
90
|
+
#
|
|
91
|
+
# @param predicate_name [Symbol] Predicate name; resolved to +{predicate_name}?+ on the context.
|
|
92
|
+
# @param value [Symbol, String] Argument passed to the predicate method.
|
|
93
|
+
#: (Symbol, untyped) -> void
|
|
94
|
+
def gate(predicate_name, value)
|
|
95
|
+
(@_gates ||= []) << { name: predicate_name.to_sym, value: value }
|
|
96
|
+
end
|
|
97
|
+
|
|
51
98
|
# MCP annotation hint shorthands
|
|
52
99
|
#: () -> void
|
|
53
100
|
def read_only!; merge_annotations(read_only_hint: true) end
|
|
@@ -94,10 +141,17 @@ module McpAuthorization
|
|
|
94
141
|
end
|
|
95
142
|
|
|
96
143
|
# Check whether the current user is allowed to see this tool.
|
|
144
|
+
#
|
|
145
|
+
# Evaluates every declared gate against the server context. A tool
|
|
146
|
+
# is permitted only when every gate passes. With no gates declared,
|
|
147
|
+
# the tool is unconditionally visible.
|
|
148
|
+
#
|
|
149
|
+
# +authorization :perm+ contributes a +gate :requires, :perm+
|
|
150
|
+
# internally, so it goes through the same pipeline as every other
|
|
151
|
+
# predicate. There is one code path for gating, not two.
|
|
97
152
|
#: (untyped) -> bool
|
|
98
153
|
def permitted?(server_context)
|
|
99
|
-
|
|
100
|
-
server_context.current_user.can?(_permission)
|
|
154
|
+
gates_pass?(server_context)
|
|
101
155
|
end
|
|
102
156
|
|
|
103
157
|
# Build the full MCP tool definition hash for +tools/list+.
|
|
@@ -187,6 +241,57 @@ module McpAuthorization
|
|
|
187
241
|
|
|
188
242
|
private
|
|
189
243
|
|
|
244
|
+
# Evaluate all declared +gate+ predicates against the server context.
|
|
245
|
+
# Returns true if every gate passes.
|
|
246
|
+
#
|
|
247
|
+
# No gates declared → permissive (tool is public). Gates declared
|
|
248
|
+
# but no server context → deny (a programmer error reaching this
|
|
249
|
+
# method should not silently expose the tool).
|
|
250
|
+
#
|
|
251
|
+
# Symmetric with field-level +RbsSchemaCompiler#predicate_excluded?+:
|
|
252
|
+
# - Unknown predicate (server_context does not implement +{name}?+):
|
|
253
|
+
# fail-open per-gate (gate passes, tool shown). A warning is
|
|
254
|
+
# logged in development environments.
|
|
255
|
+
# - +requires+ predicate without a +requires?+ method: backward-compat
|
|
256
|
+
# fallback to +current_user.can?(value)+.
|
|
257
|
+
# - Predicate raises an exception: fail-open per-gate and log.
|
|
258
|
+
#: (untyped) -> bool
|
|
259
|
+
def gates_pass?(server_context)
|
|
260
|
+
return true if _gates.nil? || _gates.empty?
|
|
261
|
+
return false unless server_context # gates declared + nil context → deny
|
|
262
|
+
_gates.all? { |gate| evaluate_gate(gate, server_context) }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
#: (Hash[Symbol, untyped], untyped) -> bool
|
|
266
|
+
def evaluate_gate(gate, server_context)
|
|
267
|
+
method = :"#{gate[:name]}?"
|
|
268
|
+
if server_context.respond_to?(method)
|
|
269
|
+
!!server_context.public_send(method, gate[:value])
|
|
270
|
+
elsif gate[:name] == :requires && server_context.respond_to?(:current_user)
|
|
271
|
+
# Backward compat: fall back to direct user permission check.
|
|
272
|
+
# Mirrors RbsSchemaCompiler#predicate_excluded? handling for the
|
|
273
|
+
# OpenStruct-style contexts that predate ServerContext.
|
|
274
|
+
!!server_context.current_user&.can?(gate[:value].to_sym)
|
|
275
|
+
else
|
|
276
|
+
warn_unknown_gate(gate[:name], server_context)
|
|
277
|
+
true # Fail-open: show the tool
|
|
278
|
+
end
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
281
|
+
Rails.logger.error("[McpAuthorization] tool gate #{gate[:name]}?(#{gate[:value]}) raised: #{e.message}")
|
|
282
|
+
end
|
|
283
|
+
true # Fail-open on error: show the tool
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Emit a development-mode warning when a gate predicate method is not
|
|
287
|
+
# found on the server context. Delegates to the shared diagnostic
|
|
288
|
+
# helper so the field-level (+@predicate+) and tool-level (+gate+)
|
|
289
|
+
# warning shapes stay in sync.
|
|
290
|
+
#: (Symbol, untyped) -> void
|
|
291
|
+
def warn_unknown_gate(name, server_context)
|
|
292
|
+
McpAuthorization::Diagnostics.warn_unknown_predicate(name, server_context, site: :tool)
|
|
293
|
+
end
|
|
294
|
+
|
|
190
295
|
#: (**untyped) -> void
|
|
191
296
|
def merge_annotations(**new_hints)
|
|
192
297
|
hints = (@_annotation_hints || {}).merge(new_hints)
|
data/lib/mcp_authorization.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "mcp"
|
|
2
2
|
require_relative "mcp_authorization/version"
|
|
3
3
|
require_relative "mcp_authorization/configuration"
|
|
4
|
+
require_relative "mcp_authorization/diagnostics"
|
|
4
5
|
require_relative "mcp_authorization/dsl"
|
|
5
6
|
require_relative "mcp_authorization/rbs_schema_compiler"
|
|
6
7
|
require_relative "mcp_authorization/tool_registry"
|
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.4.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-05-
|
|
11
|
+
date: 2026-05-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -67,8 +67,9 @@ dependencies:
|
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
68
|
version: '13.0'
|
|
69
69
|
description: Add MCP tool serving to any Rails app. Write @rbs type annotations with
|
|
70
|
-
@requires
|
|
71
|
-
|
|
70
|
+
predicate tags (@requires, @feature, or custom) and the gem compiles per-user JSON
|
|
71
|
+
Schema automatically — filtering fields by permissions, feature flags, and plan
|
|
72
|
+
tiers at request time.
|
|
72
73
|
email:
|
|
73
74
|
executables: []
|
|
74
75
|
extensions: []
|
|
@@ -80,6 +81,7 @@ files:
|
|
|
80
81
|
- app/controllers/mcp_authorization/mcp_controller.rb
|
|
81
82
|
- lib/mcp_authorization.rb
|
|
82
83
|
- lib/mcp_authorization/configuration.rb
|
|
84
|
+
- lib/mcp_authorization/diagnostics.rb
|
|
83
85
|
- lib/mcp_authorization/dsl.rb
|
|
84
86
|
- lib/mcp_authorization/engine.rb
|
|
85
87
|
- lib/mcp_authorization/rbs_schema_compiler.rb
|