mcp_authorization 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9adb27c3a4074869c90737b393772a425931683c8b8128864525902e7283aed6
4
- data.tar.gz: 1d0e2d793b46ff13952142d3e3a21b0a0f6008039e184446506cf90b9e5a1083
3
+ metadata.gz: 79a43a7ab018242f92e8f0448f73ae5acbae410ebd7397868ec52553490a15ec
4
+ data.tar.gz: 2f41f4e5cfc4a923b26636551f25facf94c2c902be106ea19235a0c46b68dae6
5
5
  SHA512:
6
- metadata.gz: 1b46015ca220581022647d667f1d86809c83e433ef5cd2ea500d8b81a2fc2eb9a58346746b74c0a3dbe78209f51728e0b7b2593f5dc7e9fd7663c2fb19638df9
7
- data.tar.gz: ec9e5d04a5a5d749a995f73026d05bd003e4ab2421fc97d872d67af07bf4fd3f909d1aaa77053c6aca644ffbbbaf89257fc77774bf0800d648ebaf0cb3d428ae
6
+ metadata.gz: 07fc7850bfa4960c00aee35f3543e907d5b1037ffe4b45a46b247e002d3d9a8271d4a7070746c031a2cd03d498a93a496bf1a74c998f17c1023fe361f416e27b
7
+ data.tar.gz: f83d36ff6ff7b43fffad639f8e81790a20c450959fe1cadb5d009ff39dee41464199e9d636ad1e1048d32e053eba84b4239d915e9837fcde11a495eb48361642
data/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ 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.3.0] - 2026-05-14
8
+
9
+ ### Added
10
+ - **Generic predicate tags.** Any `@tag(:value)` annotation not in the known constraint list becomes a predicate filter: the compiler calls `server_context.tag_name?(value)` at schema compile time. If the predicate returns false, the field/variant is excluded from the JSON Schema. This makes the gem infinitely extensible — define `feature?`, `tier?`, `beta?`, or any predicate on your server context without gem changes.
11
+ - Backward-compat fallback for `@requires`: if the server context lacks a `requires?` method, the compiler falls back to `server_context.current_user.can?(:flag)` directly. No deploy-ordering constraint.
12
+ - Error isolation: exceptions from individual predicates are rescued and logged. A single broken predicate no longer crashes the entire `tools/list` response.
13
+ - Development-mode warning with DidYouMean suggestion when a predicate method is not found on the server context (e.g., `@feture(:x)` warns "Did you mean @feature?").
14
+
15
+ ### Changed
16
+ - `@requires` is now handled through the generic predicate path. The `tags[:requires]` key is removed; `@requires(:flag)` is stored only in `tags[:predicates]` like any other predicate.
17
+ - `predicate_excluded?` replaces the three hardcoded `current_user.can?` filter lines in `compile_tagged_record`, `compile_tagged_union`, and `filter_call_signature`.
18
+ - Configuration docs updated to describe the predicate protocol on server context objects.
19
+
20
+ ### Migration notes
21
+ - Consumers using `OpenStruct` as server context continue to work — `@requires` falls back to `current_user.can?`. To use `@feature` or custom predicates, define the corresponding `?` methods on your server context.
22
+ - If you read `tags[:requires]` from parsed tag hashes (unlikely outside the gem), switch to `tags[:predicates].find { |p| p[:name] == "requires" }`.
23
+
7
24
  ## [0.2.1] - 2026-05-13
8
25
 
9
26
  ### Fixed
data/README.md CHANGED
@@ -429,13 +429,32 @@ Tag any field in a `#:` annotation or `@rbs type` record to add JSON Schema cons
429
429
  | `@read_only()` | `readOnly: true` | Read-only field |
430
430
  | `@write_only()` | `writeOnly: true` | Write-only field |
431
431
 
432
- **Authorization:**
432
+ **Authorization & predicate filters:**
433
433
 
434
434
  | Tag | Purpose |
435
435
  |---|---|
436
- | `@requires(:flag)` | Field/variant excluded when `can?(:flag)` is false |
436
+ | `@requires(:flag)` | Field/variant excluded when `server_context.requires?(:flag)` returns false. Legacy fallback: if `requires?` is not defined, falls back to `current_user.can?(:flag)`. |
437
+ | `@feature(:flag)` | Field/variant excluded when `server_context.feature?(:flag)` returns false (account-level feature flags) |
437
438
  | `@depends_on(:field)` | Emits `dependentRequired` — field only required when parent field is present |
438
439
 
440
+ Any `@tag(:value)` not in the known constraint list above is a **generic predicate filter**. At schema compile time, the gem calls `server_context.tag_name?(value)` — if it returns false, the field is excluded. If `server_context` doesn't respond to the method, the predicate is skipped (permissive).
441
+
442
+ This makes the gem infinitely extensible. Define any predicate on your server context:
443
+
444
+ ```ruby
445
+ # In your app's server context:
446
+ def requires?(flag) = current_user.can?(flag.to_sym)
447
+ def feature?(flag) = current_account.feature_enabled?(flag.to_s)
448
+ def tier?(name) = current_account.plan_tier?(name.to_s)
449
+ def beta?(flag) = current_account.beta_enrolled?(flag.to_s)
450
+
451
+ # In your handler:
452
+ #: (?status: "active" | "inactive" | "unlisted" @feature(:opening_status_v2)) -> output
453
+ #: (?force: bool @requires(:admin) @tier(:enterprise)) -> output
454
+ ```
455
+
456
+ Multiple predicates on the same field are AND-ed — all must pass for the field to appear.
457
+
439
458
  **Niche:**
440
459
 
441
460
  | Tag | JSON Schema |
@@ -22,6 +22,20 @@ module McpAuthorization
22
22
  # current_user.can?(:symbol) # required — gates field/tool visibility
23
23
  # current_user.default_for(:symbol) # optional — populates @default_for tags
24
24
  #
25
+ # The context object itself can implement predicate methods for generic
26
+ # tag filtering. Any +@tag(:value)+ not in the known constraint list
27
+ # calls +context.tag?(value)+:
28
+ #
29
+ # context.requires?(flag) # optional — for @requires, falls back to current_user.can?
30
+ # context.feature?(flag) # optional — for @feature (account-level feature flags)
31
+ # context.tier?(name) # optional — for @tier (plan-level gating)
32
+ #
33
+ # For public/anonymous MCP interfaces, supply a context with minimum-viable
34
+ # permissions rather than +current_user: nil+. A nil user causes +@requires+
35
+ # fields to be silently excluded (no user = no permissions).
36
+ #
37
+ # See RbsSchemaCompiler.predicate_excluded? for the full protocol.
38
+ #
25
39
  class Configuration
26
40
  # Server name reported in the MCP +initialize+ handshake.
27
41
  #: String
@@ -339,7 +339,7 @@ module McpAuthorization
339
339
  # @return [Array(String, Hash)] +[clean_type, tags_hash]+
340
340
  #
341
341
  # Supported tags:
342
- # @requires(:symbol) -> { requires: :symbol }
342
+ # @requires(:symbol) -> added to :predicates (calls server_context.requires?)
343
343
  # @depends_on(:field) -> { depends_on: "field" }
344
344
  # @min(n) -> { min: n }
345
345
  # @max(n) -> { max: n }
@@ -360,6 +360,14 @@ module McpAuthorization
360
360
  # @closed() / @strict() -> { closed: true }
361
361
  # @media_type(type) -> { media_type: "type" }
362
362
  # @encoding(enc) -> { encoding: "enc" }
363
+ #
364
+ # Any tag not listed above is treated as a **predicate filter**:
365
+ # @feature(:flag) -> added to predicates, calls server_context.feature?(:flag)
366
+ # @tier(:enterprise) -> added to predicates, calls server_context.tier?(:enterprise)
367
+ # @custom(:value) -> added to predicates, calls server_context.custom?(:value)
368
+ #
369
+ # Predicate filters exclude the field/variant from the schema when the
370
+ # predicate returns false. The server_context must respond to +tag_name?+.
363
371
  #: (String) -> [String, Hash[Symbol, untyped]]
364
372
  def extract_tags(type_str)
365
373
  tags = {}
@@ -370,7 +378,7 @@ module McpAuthorization
370
378
 
371
379
  case tag_name
372
380
  when "requires"
373
- tags[:requires] = tag_value.delete_prefix(":").to_sym
381
+ (tags[:predicates] ||= []) << { name: "requires", value: tag_value.delete_prefix(":") }
374
382
  when "depends_on"
375
383
  tags[:depends_on] = tag_value.delete_prefix(":")
376
384
  when "min"
@@ -411,6 +419,8 @@ module McpAuthorization
411
419
  tags[:media_type] = tag_value
412
420
  when "encoding"
413
421
  tags[:encoding] = tag_value
422
+ else
423
+ (tags[:predicates] ||= []) << { name: tag_name, value: tag_value.delete_prefix(":") }
414
424
  end
415
425
  end
416
426
 
@@ -543,18 +553,95 @@ module McpAuthorization
543
553
  end
544
554
 
545
555
  # ---------------------------------------------------------------
546
- # @requires filtering — the per-request compile phase
556
+ # Predicate filtering — the per-request compile phase
547
557
  # ---------------------------------------------------------------
548
558
 
559
+ # Returns true if any predicate tag on a field/variant evaluates to
560
+ # false, meaning the field should be excluded from the schema.
561
+ #
562
+ # For each predicate, calls +server_context.tag_name?(value)+.
563
+ # If the server_context doesn't respond to the method, the predicate
564
+ # is skipped (fail-open — unknown predicates don't block). This is
565
+ # intentional: predicates shape the schema for the LLM, they are not
566
+ # a security boundary. Hiding a field by accident (typo) is worse
567
+ # than showing one extra field. Runtime enforcement (+filter_input+)
568
+ # is the actual security layer.
569
+ #
570
+ # Special case: +@requires+ falls back to +current_user.can?+ when
571
+ # the server_context lacks a +requires?+ method, for backward
572
+ # compatibility with consumers that haven't migrated to predicates.
573
+ #
574
+ # Exceptions from individual predicates are rescued and logged so
575
+ # that a single broken predicate doesn't crash the entire tools/list.
576
+ #
577
+ # @param tags [Hash] Parsed tags from +extract_tags+.
578
+ # @param server_context [Object] Per-request context.
579
+ # @return [Boolean] true if the field should be excluded.
580
+ #: (Hash[Symbol, untyped], untyped) -> bool
581
+ def predicate_excluded?(tags, server_context)
582
+ return false unless tags[:predicates] && server_context
583
+ tags[:predicates].any? do |pred|
584
+ method = :"#{pred[:name]}?"
585
+ if server_context.respond_to?(method)
586
+ !server_context.public_send(method, pred[:value])
587
+ elsif pred[:name] == "requires" && server_context.respond_to?(:current_user)
588
+ # Backward compat: fall back to direct user permission check.
589
+ # Note: nil current_user → &.can? returns nil → !nil is true → field excluded.
590
+ # This is intentional: no user = no permissions = hide restricted fields.
591
+ !server_context.current_user&.can?(pred[:value].to_sym)
592
+ else
593
+ warn_unknown_predicate(pred[:name], server_context)
594
+ false # Fail-open: include the field
595
+ end
596
+ rescue => e
597
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
598
+ Rails.logger.error("[McpAuthorization] predicate #{pred[:name]}?(#{pred[:value]}) raised: #{e.message}")
599
+ end
600
+ false # Fail-open on error: include the field
601
+ end
602
+ end
603
+
604
+ # Emit a development-mode warning when a predicate method is not
605
+ # found on the server_context. Helps catch typos like @feture(:x).
606
+ #: (String, untyped) -> void
607
+ def warn_unknown_predicate(name, server_context)
608
+ return unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env.local?
609
+
610
+ available = server_context.class.public_instance_methods(true)
611
+ .select { |m| m.to_s.end_with?("?") }
612
+ .map { |m| m.to_s.chomp("?") }
613
+ best = available.min_by { |a| levenshtein(a, name) }
614
+ hint = best && levenshtein(best, name) <= 3 ? " Did you mean @#{best}?" : ""
615
+ Rails.logger&.warn("[McpAuthorization] Predicate '#{name}?' not found on #{server_context.class}.#{hint} Field will be shown to all users.")
616
+ end
617
+
618
+ # Minimal Levenshtein distance for typo suggestions.
619
+ #: (String, String) -> Integer
620
+ def levenshtein(a, b)
621
+ m, n = a.length, b.length
622
+ d = Array.new(m + 1) { |i| i }
623
+ (1..n).each do |j|
624
+ prev = d[0]
625
+ d[0] = j
626
+ (1..m).each do |i|
627
+ cost = a[i - 1] == b[j - 1] ? 0 : 1
628
+ temp = d[i]
629
+ d[i] = [d[i] + 1, d[i - 1] + 1, prev + cost].min
630
+ prev = temp
631
+ end
632
+ end
633
+ d[m]
634
+ end
635
+
549
636
  # Compile a record-style input type (+# @rbs type input = { ... }+)
550
- # with field-level +@requires+ filtering.
637
+ # with field-level predicate filtering.
551
638
  #
552
- # Fields whose +@requires+ flag the current user lacks are silently
553
- # omitted from the resulting JSON Schema, so the LLM never sees them.
639
+ # Fields whose predicate tags (e.g. +@requires+, +@feature+) evaluate
640
+ # to false are silently omitted from the resulting JSON Schema.
554
641
  #
555
642
  # @param raw_body [String] The raw record body, e.g. +"{name: String, force: bool @requires(:admin)}"+.
556
643
  # @param type_map [Hash] Resolved type definitions for +$ref+ lookups.
557
- # @param server_context [Object] Per-request context with +current_user+.
644
+ # @param server_context [Object] Per-request context.
558
645
  # @return [Hash] JSON Schema object with +properties+, +required+, etc.
559
646
  #: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
560
647
  def compile_tagged_record(raw_body, type_map, server_context)
@@ -568,7 +655,7 @@ module McpAuthorization
568
655
  key, type_str = match[0].to_s, match[1].to_s
569
656
  type_str, tags = extract_tags(type_str.strip)
570
657
 
571
- next if tags[:requires] && !server_context.current_user.can?(tags[:requires])
658
+ next if predicate_excluded?(tags, server_context)
572
659
 
573
660
  optional = key.end_with?("?")
574
661
  clean_key = key.delete_suffix("?")
@@ -590,10 +677,10 @@ module McpAuthorization
590
677
  end
591
678
 
592
679
  # Compile a union-style output type (+# @rbs type output = success | admin_detail @requires(:admin)+)
593
- # with variant-level +@requires+ filtering.
680
+ # with variant-level predicate filtering.
594
681
  #
595
- # Each union variant (separated by +|+) can carry its own +@requires+
596
- # tag. Variants the user lacks permission for are dropped entirely.
682
+ # Each union variant (separated by +|+) can carry its own predicate
683
+ # tags. Variants whose predicates evaluate to false are dropped entirely.
597
684
  # If only one variant remains, it's returned directly (no +oneOf+
598
685
  # wrapper). If zero remain, a bare +{type: "object"}+ fallback is used.
599
686
  #
@@ -607,7 +694,7 @@ module McpAuthorization
607
694
 
608
695
  filtered = parts.filter_map do |part|
609
696
  part, tags = extract_tags(part)
610
- next nil if tags[:requires] && !server_context.current_user.can?(tags[:requires])
697
+ next nil if predicate_excluded?(tags, server_context)
611
698
  resolve_type(part, type_map)
612
699
  end
613
700
 
@@ -618,7 +705,7 @@ module McpAuthorization
618
705
  end
619
706
  end
620
707
 
621
- # Filter method-signature parameters by +@requires+ tags and build
708
+ # Filter method-signature parameters by predicate tags and build
622
709
  # the input JSON Schema. This is the path used when the handler defines
623
710
  # its schema via a +#:+ annotation above +def call+ rather than an
624
711
  # explicit +# @rbs type input = { ... }+.
@@ -634,9 +721,7 @@ module McpAuthorization
634
721
  dependent_required = {}
635
722
 
636
723
  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
724
+ next if predicate_excluded?(param[:tags], server_context)
640
725
 
641
726
  schema = rbs_type_to_json_schema(param[:type], type_map)
642
727
  properties[param[:name].to_sym] = apply_tags(schema, param[:tags], server_context: server_context)
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - AndyGauge
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-13 00:00:00.000000000 Z
11
+ date: 2026-05-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails