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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79a43a7ab018242f92e8f0448f73ae5acbae410ebd7397868ec52553490a15ec
4
- data.tar.gz: 2f41f4e5cfc4a923b26636551f25facf94c2c902be106ea19235a0c46b68dae6
3
+ metadata.gz: dbd953dcd628ffaf2d42d3bfa024ed6e6ab5a0b8a1089be84f8505a7158aaf20
4
+ data.tar.gz: e510d9b492abbdbc4acb091a0a802dea803b97434923673b16711215de918d38
5
5
  SHA512:
6
- metadata.gz: 07fc7850bfa4960c00aee35f3543e907d5b1037ffe4b45a46b247e002d3d9a8271d4a7070746c031a2cd03d498a93a496bf1a74c998f17c1023fe361f416e27b
7
- data.tar.gz: f83d36ff6ff7b43fffad639f8e81790a20c450959fe1cadb5d009ff39dee41464199e9d636ad1e1048d32e053eba84b4239d915e9837fcde11a495eb48361642
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 lack the flag |
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
- 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.
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 # tool hidden when can?(:some_flag) is false
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?: Integer
383
+ # ?count: Integer
379
384
  # }
380
- # (count? is optional -- excluded from "required")
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. Helps catch typos like @feture(:x).
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
- 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]
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(/(\w+\??)\s*:\s*([^,}]+)/) do |match|
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.end_with?("?")
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(\?)?([\w]+):\s*(.+)\z/
1005
- opt, name, type = $1, $2.to_s, $3.to_s.strip
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: opt.nil? && !type.end_with?("?"),
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(/(\w+):\s*([^,}]+)/) do |match|
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.end_with?("?")
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
- return true if _permission.nil?
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)
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.3.0"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -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.3.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-14 00:00:00.000000000 Z
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(:flag) tags and the gem compiles per-user JSON Schema automatically. Feature
71
- flags, permissions, and plan tiers all work through a single can?(:symbol) predicate.
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