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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9adb27c3a4074869c90737b393772a425931683c8b8128864525902e7283aed6
4
- data.tar.gz: 1d0e2d793b46ff13952142d3e3a21b0a0f6008039e184446506cf90b9e5a1083
3
+ metadata.gz: 9042f13cf2b294336b81ade0d8f97c31179a6483dde8c73c2258a7459227c7c4
4
+ data.tar.gz: 8be4296941e757f985bdab130472aa3f7f1bda837eae5d3b8d924078e9f27bb9
5
5
  SHA512:
6
- metadata.gz: 1b46015ca220581022647d667f1d86809c83e433ef5cd2ea500d8b81a2fc2eb9a58346746b74c0a3dbe78209f51728e0b7b2593f5dc7e9fd7663c2fb19638df9
7
- data.tar.gz: ec9e5d04a5a5d749a995f73026d05bd003e4ab2421fc97d872d67af07bf4fd3f909d1aaa77053c6aca644ffbbbaf89257fc77774bf0800d648ebaf0cb3d428ae
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 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
@@ -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 `can?(:flag)` is false |
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) -> { 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[:requires] = tag_value.delete_prefix(":").to_sym
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
- # @requires filtering — the per-request compile phase
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 +@requires+ filtering.
616
+ # with field-level predicate filtering.
551
617
  #
552
- # Fields whose +@requires+ flag the current user lacks are silently
553
- # omitted from the resulting JSON Schema, so the LLM never sees them.
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 with +current_user+.
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[:requires] && !server_context.current_user.can?(tags[:requires])
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 +@requires+ filtering.
659
+ # with variant-level predicate filtering.
594
660
  #
595
- # Each union variant (separated by +|+) can carry its own +@requires+
596
- # tag. Variants the user lacks permission for are dropped entirely.
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[:requires] && !server_context.current_user.can?(tags[:requires])
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 +@requires+ tags and build
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][:requires] && server_context
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
- 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.2.1"
2
+ VERSION = "0.4.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.2.1
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-13 00:00:00.000000000 Z
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(: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