mcp_authorization 0.3.0 → 0.5.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 +57 -0
- data/README.md +33 -8
- data/lib/mcp_authorization/diagnostics.rb +84 -0
- data/lib/mcp_authorization/rbs_schema_compiler.rb +124 -58
- 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: dbd953dcd628ffaf2d42d3bfa024ed6e6ab5a0b8a1089be84f8505a7158aaf20
|
|
4
|
+
data.tar.gz: e510d9b492abbdbc4acb091a0a802dea803b97434923673b16711215de918d38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 71272e773f2cba13d99a1b44fb94ad53e1de079627a5546432d39bb0de0c166a047edd95ab60e43072f91c008121b16ad7bdb3548b01ebde5ffee80305323ada
|
|
7
|
+
data.tar.gz: 9e06ca547f65de3b44de14e2538b54ff24d53db6b686faf63195dd1490a88d6d20ba831f28cc69eb0ea14fd266150fe9121380a073df39279ffbdceeb443c0e0
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,63 @@ 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.5.0] - 2026-05-26
|
|
8
|
+
|
|
9
|
+
### Changed (BREAKING)
|
|
10
|
+
- **Prefix optional marker (`?key:`) is now honored consistently across all three RBS parsers.**
|
|
11
|
+
|
|
12
|
+
The three sibling parsers handled optional-field markers inconsistently:
|
|
13
|
+
- `parse_call_params` — accepted only prefix `?key:`
|
|
14
|
+
- `compile_tagged_record` — accepted only suffix `key?:`, silently treated prefix `?key:` as required
|
|
15
|
+
- `parse_record_type` — recognized neither form, silently treated all fields as required
|
|
16
|
+
|
|
17
|
+
The README documents prefix as canonical ("Prefix a param with `?` to mark it optional"), so handlers that followed the docs got unexpectedly-required fields in their compiled schemas.
|
|
18
|
+
|
|
19
|
+
**Effect on consumers:** any field declared with prefix `?key:` in a `# @rbs type input = { ... }` record, a nested/aliased record (`# @rbs type foo = { ?bar: ... }`), or an inline record inside a `#:` signature is now correctly marked optional in the JSON Schema (omitted from `required`). For a consuming monolith with ~616 such fields, the schema's `required` array shrinks accordingly and clients (e.g. LLMs producing tool calls) will no longer treat these fields as mandatory.
|
|
20
|
+
|
|
21
|
+
**If you relied on the buggy behavior** (prefix marker silently making the field required), declare the field with no marker (`key:`) to keep it required, or enforce presence inside `#call`.
|
|
22
|
+
|
|
23
|
+
### Deprecated
|
|
24
|
+
- **Suffix optional marker (`key?:`) is deprecated; will be removed in 0.6.0.** Suffix `key?:` continues to work in 0.5.0 (record types, call signatures, and nested aliased records) but now emits a single `Kernel#warn` per use with `category: :deprecated`. Silence with `Warning[:deprecated] = false` or `ruby -W:no-deprecated`. The warning embeds the handler's source-file path because the annotation is parsed as static text — the offending file is not on the Ruby call stack when the warning is emitted, so `uplevel:` cannot surface it.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- `RbsSchemaCompiler.parse_field_name(raw, source_file: nil)` — internal helper that turns a raw field-name token (everything before the `:`) into `[clean_name, optional?]`. Single source of truth for the three parsers. Raises `ArgumentError` on malformed input (empty, bare `"?"`, double-marked `"?key?"`, `"??key"`, `"key??"`). Tolerates whitespace around the marker (`" ? key"` → `["key", true]`).
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- `parse_record_type` now recognizes optional markers at all. Previously, nested aliased record types like `# @rbs type foo = { ?bar: ... }` produced schemas where every field landed in `required` regardless of the marker. Caught only because the same bug existed in the sibling parsers under different shapes, hiding the test gap.
|
|
31
|
+
|
|
32
|
+
### Migration notes
|
|
33
|
+
- **No code changes required.** Suffix `key?:` annotations keep parsing; you'll see a deprecation warning per call site on first cache build of each handler. Migrate to prefix `?key:` at your own pace before 0.6.0.
|
|
34
|
+
- If you've been relying on prefix `?key:` being silently treated as required (the buggy behavior), audit your schemas: declared-optional fields that the handler still requires must be enforced inside `#call`, not by the schema.
|
|
35
|
+
- The README example in the records section was updated to use prefix `?count: Integer` to match the documented canonical form.
|
|
36
|
+
|
|
37
|
+
### Notes
|
|
38
|
+
- The `fountain/monolith` consumer (gem's primary downstream) has a separate migration PR tracking the suffix→prefix rewrite for ~616 affected fields, including `sig/shared/option_bank_result.rbs` and friends. That work is out of scope for this gem release and will land in the monolith repo once it bumps the `mcp_authorization` gem to 0.5.0+.
|
|
39
|
+
|
|
40
|
+
## [0.4.0] - 2026-05-21
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- **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`.
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class BulkSendSmsTool < McpAuthorization::Tool
|
|
47
|
+
authorization :communications # RBAC permission (existing behavior preserved)
|
|
48
|
+
gate :feature, :sms # hide tool unless current_account.sms_enabled?
|
|
49
|
+
gate :requires, :super_user # extra check beyond authorization
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- `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?").
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
- **`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.
|
|
57
|
+
- `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.
|
|
58
|
+
|
|
59
|
+
### Migration notes
|
|
60
|
+
- No breaking changes for end users. Tools that use `authorization :perm` continue to work exactly as before — the only difference is the internal pipeline.
|
|
61
|
+
- If you read `tool_class._permission` for introspection, it still returns the declared symbol.
|
|
62
|
+
- Existing field-level `@requires`/`@feature` semantics are unchanged.
|
|
63
|
+
|
|
7
64
|
## [0.3.0] - 2026-05-14
|
|
8
65
|
|
|
9
66
|
### Added
|
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
|
|
@@ -375,9 +380,9 @@ The `@rbs type` comments compile to JSON Schema:
|
|
|
375
380
|
# @rbs type result = {
|
|
376
381
|
# success: bool,
|
|
377
382
|
# message: String,
|
|
378
|
-
# count
|
|
383
|
+
# ?count: Integer
|
|
379
384
|
# }
|
|
380
|
-
# (count
|
|
385
|
+
# (?count is optional -- excluded from "required")
|
|
381
386
|
|
|
382
387
|
# Arrays
|
|
383
388
|
# @rbs type items = Array[String]
|
|
@@ -455,6 +460,26 @@ def beta?(flag) = current_account.beta_enrolled?(flag.to_s)
|
|
|
455
460
|
|
|
456
461
|
Multiple predicates on the same field are AND-ed — all must pass for the field to appear.
|
|
457
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
|
+
|
|
458
483
|
**Niche:**
|
|
459
484
|
|
|
460
485
|
| Tag | JSON Schema |
|
|
@@ -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.
|
|
@@ -56,7 +58,7 @@ module McpAuthorization
|
|
|
56
58
|
cached = cache_for(handler_class)
|
|
57
59
|
|
|
58
60
|
schema = if cached[:raw_input]&.dig(:kind) == :record
|
|
59
|
-
compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context)
|
|
61
|
+
compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context, source_file: cached[:source_file])
|
|
60
62
|
else
|
|
61
63
|
build_input_schema(
|
|
62
64
|
filter_call_signature(cached[:call_params], cached[:type_map], server_context)
|
|
@@ -204,7 +206,7 @@ module McpAuthorization
|
|
|
204
206
|
cached = cache_for(handler_class)
|
|
205
207
|
|
|
206
208
|
schema = if cached[:raw_input]&.dig(:kind) == :record
|
|
207
|
-
compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context)
|
|
209
|
+
compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context, source_file: cached[:source_file])
|
|
208
210
|
else
|
|
209
211
|
build_input_schema(
|
|
210
212
|
filter_call_signature(cached[:call_params], cached[:type_map], server_context)
|
|
@@ -427,6 +429,93 @@ module McpAuthorization
|
|
|
427
429
|
[type_str, tags]
|
|
428
430
|
end
|
|
429
431
|
|
|
432
|
+
# Parse a field-name token (the part before +:+ in a record entry or
|
|
433
|
+
# call signature parameter) into its clean name and an optional flag.
|
|
434
|
+
#
|
|
435
|
+
# Recognizes both forms:
|
|
436
|
+
# - Prefix (RBS canonical, per README): +?name:+ -> ["name", true]
|
|
437
|
+
# - Suffix (legacy, deprecated for 0.6.0): +name?:+ -> ["name", true]
|
|
438
|
+
# - Unmarked: +name:+ -> ["name", false]
|
|
439
|
+
#
|
|
440
|
+
# The suffix form was historically accepted by parts of this gem
|
|
441
|
+
# but is not standard RBS. Recognizing it consistently across all
|
|
442
|
+
# three parsers (this method's callers) preserves backward
|
|
443
|
+
# compatibility while a single +Kernel#warn+ with
|
|
444
|
+
# +category: :deprecated+ steers consumers toward the prefix form.
|
|
445
|
+
# Users can silence via +Warning[:deprecated] = false+ or
|
|
446
|
+
# +-W:no-deprecated+ — the standard Ruby mechanisms.
|
|
447
|
+
#
|
|
448
|
+
# Raises +ArgumentError+ on malformed tokens. The helper produces
|
|
449
|
+
# three distinct error messages — categories grouped by which
|
|
450
|
+
# branch raises:
|
|
451
|
+
# - empty, whitespace-only, or nil input -> "empty field name"
|
|
452
|
+
# - double-marked optional after stripping (e.g. +"?key?"+) ->
|
|
453
|
+
# "is double-marked optional"
|
|
454
|
+
# - any other shape that does not reduce to a bare +\w+
|
|
455
|
+
# identifier (e.g. +"?"+, +"??key"+, +"key??"+, or tokens with
|
|
456
|
+
# non-word characters) -> "invalid field name token"
|
|
457
|
+
#
|
|
458
|
+
# Whitespace adjacent to the marker is tolerated:
|
|
459
|
+
# +" ? key"+ -> ["key", true]. (Note: the three production call
|
|
460
|
+
# sites never produce such a token — their regexes don't permit
|
|
461
|
+
# internal whitespace — so this tolerance only matters if the
|
|
462
|
+
# helper is invoked directly.)
|
|
463
|
+
#
|
|
464
|
+
# @param raw [String] Token before the +:+ separator.
|
|
465
|
+
# @param source_file [String, nil] Path included in the deprecation
|
|
466
|
+
# warning so consumers can locate the offending annotation. The
|
|
467
|
+
# handler source file is read as text during parsing, so it is
|
|
468
|
+
# not on the Ruby call stack — embedding the path in the message
|
|
469
|
+
# is the only way to make the warning actionable.
|
|
470
|
+
# @return [Array(String, Boolean)] +[clean_name, optional?]+.
|
|
471
|
+
#: (String, ?source_file: String?) -> [String, bool]
|
|
472
|
+
def parse_field_name(raw, source_file: nil)
|
|
473
|
+
raise ArgumentError, "empty field name" if raw.nil? || raw.to_s.strip.empty?
|
|
474
|
+
|
|
475
|
+
trimmed = raw.to_s.strip
|
|
476
|
+
prefix = trimmed.start_with?("?")
|
|
477
|
+
suffix = trimmed.end_with?("?")
|
|
478
|
+
|
|
479
|
+
bare = trimmed
|
|
480
|
+
bare = bare.sub(/\A\?/, "").strip if prefix
|
|
481
|
+
bare = bare.sub(/\?\z/, "").strip if suffix
|
|
482
|
+
|
|
483
|
+
unless bare.match?(/\A\w+\z/)
|
|
484
|
+
raise ArgumentError, "invalid field name token: #{raw.inspect}"
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
if prefix && suffix
|
|
488
|
+
raise ArgumentError,
|
|
489
|
+
"field #{bare.inspect} is double-marked optional (both ?prefix and suffix?); pick one"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
warn_deprecated_suffix_marker(bare, source_file) if suffix
|
|
493
|
+
|
|
494
|
+
[bare, prefix || suffix]
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Emit a deprecation warning for the legacy suffix optional marker
|
|
498
|
+
# (+key?:+). Uses +Kernel#warn+ with +category: :deprecated+ so
|
|
499
|
+
# silencing follows the standard Ruby mechanism
|
|
500
|
+
# (+Warning[:deprecated] = false+, +-W:no-deprecated+) and not a
|
|
501
|
+
# gem-specific env var.
|
|
502
|
+
#
|
|
503
|
+
# The source file path is embedded in the message because the
|
|
504
|
+
# handler annotation is parsed as static text — the offending file
|
|
505
|
+
# is not on the Ruby call stack at warn time, so +uplevel:+ cannot
|
|
506
|
+
# surface it. Embedding the path keeps the warning actionable for
|
|
507
|
+
# consumers grepping for the field name.
|
|
508
|
+
#: (String, String?) -> void
|
|
509
|
+
def warn_deprecated_suffix_marker(name, source_file)
|
|
510
|
+
location = source_file ? " (in #{source_file})" : ""
|
|
511
|
+
Kernel.warn(
|
|
512
|
+
"[mcp_authorization] Deprecated optional marker syntax: " \
|
|
513
|
+
"`#{name}?:`#{location}. Use prefix form `?#{name}:` instead. " \
|
|
514
|
+
"The suffix form will be removed in 0.6.0.",
|
|
515
|
+
category: :deprecated
|
|
516
|
+
)
|
|
517
|
+
end
|
|
518
|
+
|
|
430
519
|
# Coerce a default value string from an annotation into its Ruby type.
|
|
431
520
|
# Handles booleans, nil/null, integers, floats, and bare strings.
|
|
432
521
|
#
|
|
@@ -540,14 +629,14 @@ module McpAuthorization
|
|
|
540
629
|
|
|
541
630
|
# Build type map: shared imports first, then handler's own types override
|
|
542
631
|
imported = load_imports(content)
|
|
543
|
-
local = parse_type_aliases(content)
|
|
632
|
+
local = parse_type_aliases(content, source_file: source_file)
|
|
544
633
|
type_map = imported.merge(local)
|
|
545
634
|
|
|
546
635
|
{
|
|
547
636
|
type_map: type_map,
|
|
548
637
|
raw_input: find_raw_type_body(content, "input"),
|
|
549
638
|
raw_output: find_raw_type_body(content, "output"),
|
|
550
|
-
call_params: parse_call_params(content),
|
|
639
|
+
call_params: parse_call_params(content, source_file: source_file),
|
|
551
640
|
source_file: source_file
|
|
552
641
|
}
|
|
553
642
|
end
|
|
@@ -602,35 +691,12 @@ module McpAuthorization
|
|
|
602
691
|
end
|
|
603
692
|
|
|
604
693
|
# Emit a development-mode warning when a predicate method is not
|
|
605
|
-
# found on the server_context.
|
|
694
|
+
# found on the server_context. Delegates to the shared diagnostic
|
|
695
|
+
# helper so the field-level (+@predicate+) and tool-level (+gate+)
|
|
696
|
+
# warning shapes stay in sync.
|
|
606
697
|
#: (String, untyped) -> void
|
|
607
698
|
def warn_unknown_predicate(name, server_context)
|
|
608
|
-
|
|
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]
|
|
699
|
+
McpAuthorization::Diagnostics.warn_unknown_predicate(name, server_context, site: :field)
|
|
634
700
|
end
|
|
635
701
|
|
|
636
702
|
# Compile a record-style input type (+# @rbs type input = { ... }+)
|
|
@@ -643,24 +709,23 @@ module McpAuthorization
|
|
|
643
709
|
# @param type_map [Hash] Resolved type definitions for +$ref+ lookups.
|
|
644
710
|
# @param server_context [Object] Per-request context.
|
|
645
711
|
# @return [Hash] JSON Schema object with +properties+, +required+, etc.
|
|
646
|
-
#: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
|
|
647
|
-
def compile_tagged_record(raw_body, type_map, server_context)
|
|
712
|
+
#: (String, Hash[String, Hash[Symbol, untyped]], untyped, ?source_file: String?) -> Hash[Symbol, untyped]
|
|
713
|
+
def compile_tagged_record(raw_body, type_map, server_context, source_file: nil)
|
|
648
714
|
properties = {}
|
|
649
715
|
required = []
|
|
650
716
|
dependent_required = {}
|
|
651
717
|
|
|
652
718
|
inner = raw_body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
|
|
653
719
|
|
|
654
|
-
inner.scan(/(
|
|
720
|
+
inner.scan(/(\??\w+\??)\s*:\s*([^,}]+)/) do |match|
|
|
655
721
|
key, type_str = match[0].to_s, match[1].to_s
|
|
656
722
|
type_str, tags = extract_tags(type_str.strip)
|
|
657
723
|
|
|
658
724
|
next if predicate_excluded?(tags, server_context)
|
|
659
725
|
|
|
660
|
-
optional = key
|
|
661
|
-
clean_key = key.delete_suffix("?")
|
|
726
|
+
clean_key, optional = parse_field_name(key, source_file: source_file)
|
|
662
727
|
|
|
663
|
-
schema = rbs_type_to_json_schema(type_str, type_map)
|
|
728
|
+
schema = rbs_type_to_json_schema(type_str, type_map, source_file: source_file)
|
|
664
729
|
properties[clean_key.to_sym] = apply_tags(schema, tags, server_context: server_context)
|
|
665
730
|
required << clean_key unless optional
|
|
666
731
|
|
|
@@ -849,7 +914,7 @@ module McpAuthorization
|
|
|
849
914
|
resolved = {}
|
|
850
915
|
aliases.each do |name, value|
|
|
851
916
|
resolved[name] = if value.is_a?(String)
|
|
852
|
-
parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)))
|
|
917
|
+
parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)), source_file: path)
|
|
853
918
|
else
|
|
854
919
|
value
|
|
855
920
|
end
|
|
@@ -933,8 +998,8 @@ module McpAuthorization
|
|
|
933
998
|
#
|
|
934
999
|
# @param content [String] Full source file contents.
|
|
935
1000
|
# @return [Hash{String => Hash}] Type name → resolved JSON Schema.
|
|
936
|
-
#: (String) -> Hash[String, Hash[Symbol, untyped]]
|
|
937
|
-
def parse_type_aliases(content)
|
|
1001
|
+
#: (String, ?source_file: String?) -> Hash[String, Hash[Symbol, untyped]]
|
|
1002
|
+
def parse_type_aliases(content, source_file: nil)
|
|
938
1003
|
return {} if content.empty?
|
|
939
1004
|
|
|
940
1005
|
aliases = {}
|
|
@@ -961,7 +1026,7 @@ module McpAuthorization
|
|
|
961
1026
|
resolved = {}
|
|
962
1027
|
aliases.each do |name, value|
|
|
963
1028
|
resolved[name] = if value.is_a?(String)
|
|
964
|
-
parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)))
|
|
1029
|
+
parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)), source_file: source_file)
|
|
965
1030
|
else
|
|
966
1031
|
value
|
|
967
1032
|
end
|
|
@@ -982,8 +1047,8 @@ module McpAuthorization
|
|
|
982
1047
|
#
|
|
983
1048
|
# @param content [String] Full source file contents.
|
|
984
1049
|
# @return [Array<Hash>] Parameter descriptors.
|
|
985
|
-
#: (String) -> Array[Hash[Symbol, untyped]]
|
|
986
|
-
def parse_call_params(content)
|
|
1050
|
+
#: (String, ?source_file: String?) -> Array[Hash[Symbol, untyped]]
|
|
1051
|
+
def parse_call_params(content, source_file: nil)
|
|
987
1052
|
return [] if content.empty?
|
|
988
1053
|
|
|
989
1054
|
lines = content.lines
|
|
@@ -1001,8 +1066,10 @@ module McpAuthorization
|
|
|
1001
1066
|
if annotation =~ /\((.+)\)\s*->/m
|
|
1002
1067
|
$1.to_s.split(",").each do |param|
|
|
1003
1068
|
param = param.strip
|
|
1004
|
-
next unless param =~ /\A(
|
|
1005
|
-
|
|
1069
|
+
next unless param =~ /\A(\??\w+\??):\s*(.+)\z/
|
|
1070
|
+
raw_key, type = $1.to_s, $2.to_s.strip
|
|
1071
|
+
|
|
1072
|
+
name, optional = parse_field_name(raw_key, source_file: source_file)
|
|
1006
1073
|
next if name == "server_context"
|
|
1007
1074
|
|
|
1008
1075
|
type, tags = extract_tags(type)
|
|
@@ -1010,7 +1077,7 @@ module McpAuthorization
|
|
|
1010
1077
|
params << {
|
|
1011
1078
|
name: name,
|
|
1012
1079
|
type: type,
|
|
1013
|
-
required:
|
|
1080
|
+
required: !optional && !type.end_with?("?"),
|
|
1014
1081
|
tags: tags
|
|
1015
1082
|
}
|
|
1016
1083
|
end
|
|
@@ -1042,20 +1109,19 @@ module McpAuthorization
|
|
|
1042
1109
|
# @param body [String] Record body including surrounding braces.
|
|
1043
1110
|
# @param type_map [Hash] Resolved types for reference lookups.
|
|
1044
1111
|
# @return [Hash] JSON Schema object with +properties+ and +required+.
|
|
1045
|
-
#: (String, ?Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1046
|
-
def parse_record_type(body, type_map = {})
|
|
1112
|
+
#: (String, ?Hash[String, Hash[Symbol, untyped]], ?source_file: String?) -> Hash[Symbol, untyped]
|
|
1113
|
+
def parse_record_type(body, type_map = {}, source_file: nil)
|
|
1047
1114
|
properties = {}
|
|
1048
1115
|
required = []
|
|
1049
1116
|
|
|
1050
1117
|
inner = body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
|
|
1051
1118
|
|
|
1052
|
-
inner.scan(/(
|
|
1119
|
+
inner.scan(/(\??\w+\??)\s*:\s*([^,}]+)/) do |match|
|
|
1053
1120
|
key, type_str = match[0].to_s, match[1].to_s
|
|
1054
1121
|
type_str, tags = extract_tags(type_str.strip)
|
|
1055
|
-
optional = key
|
|
1056
|
-
clean_key = key.delete_suffix("?")
|
|
1122
|
+
clean_key, optional = parse_field_name(key, source_file: source_file)
|
|
1057
1123
|
|
|
1058
|
-
schema = rbs_type_to_json_schema(type_str, type_map)
|
|
1124
|
+
schema = rbs_type_to_json_schema(type_str, type_map, source_file: source_file)
|
|
1059
1125
|
properties[clean_key.to_sym] = apply_tags(schema, tags)
|
|
1060
1126
|
required << clean_key unless optional
|
|
1061
1127
|
end
|
|
@@ -1078,8 +1144,8 @@ module McpAuthorization
|
|
|
1078
1144
|
# @param rbs_type [String] RBS type expression.
|
|
1079
1145
|
# @param type_map [Hash] Resolved type definitions for named type lookups.
|
|
1080
1146
|
# @return [Hash] JSON Schema hash.
|
|
1081
|
-
#: (String, ?Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1082
|
-
def rbs_type_to_json_schema(rbs_type, type_map = {})
|
|
1147
|
+
#: (String, ?Hash[String, Hash[Symbol, untyped]], ?source_file: String?) -> Hash[Symbol, untyped]
|
|
1148
|
+
def rbs_type_to_json_schema(rbs_type, type_map = {}, source_file: nil)
|
|
1083
1149
|
stripped = rbs_type.strip
|
|
1084
1150
|
case stripped
|
|
1085
1151
|
when "String"
|
|
@@ -1095,17 +1161,17 @@ module McpAuthorization
|
|
|
1095
1161
|
when "false"
|
|
1096
1162
|
{ type: "boolean", const: false }
|
|
1097
1163
|
when /\AArray\[(.+)\]\z/
|
|
1098
|
-
{ type: "array", items: rbs_type_to_json_schema($1.to_s, type_map) }
|
|
1164
|
+
{ type: "array", items: rbs_type_to_json_schema($1.to_s, type_map, source_file: source_file) }
|
|
1099
1165
|
when /\A(\w+)\?\z/
|
|
1100
|
-
rbs_type_to_json_schema($1.to_s, type_map)
|
|
1166
|
+
rbs_type_to_json_schema($1.to_s, type_map, source_file: source_file)
|
|
1101
1167
|
when /\A\{/
|
|
1102
|
-
parse_record_type(stripped, type_map)
|
|
1168
|
+
parse_record_type(stripped, type_map, source_file: source_file)
|
|
1103
1169
|
when /\|/
|
|
1104
1170
|
parts = stripped.split("|").map(&:strip)
|
|
1105
1171
|
if parts.all? { |p| p.start_with?('"') && p.end_with?('"') }
|
|
1106
1172
|
{ type: "string", enum: parts.map { |p| p.delete('"') } }
|
|
1107
1173
|
else
|
|
1108
|
-
{ oneOf: parts.map { |p| rbs_type_to_json_schema(p, type_map) } }
|
|
1174
|
+
{ oneOf: parts.map { |p| rbs_type_to_json_schema(p, type_map, source_file: source_file) } }
|
|
1109
1175
|
end
|
|
1110
1176
|
else
|
|
1111
1177
|
type_map[stripped] || { type: "string" }
|
|
@@ -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.5.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-27 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
|