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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +21 -2
- data/lib/mcp_authorization/configuration.rb +14 -0
- data/lib/mcp_authorization/rbs_schema_compiler.rb +101 -16
- data/lib/mcp_authorization/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 79a43a7ab018242f92e8f0448f73ae5acbae410ebd7397868ec52553490a15ec
|
|
4
|
+
data.tar.gz: 2f41f4e5cfc4a923b26636551f25facf94c2c902be106ea19235a0c46b68dae6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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) ->
|
|
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[:
|
|
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
|
-
#
|
|
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
|
|
637
|
+
# with field-level predicate filtering.
|
|
551
638
|
#
|
|
552
|
-
# Fields whose
|
|
553
|
-
# omitted from the resulting JSON Schema
|
|
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
|
|
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
|
|
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
|
|
680
|
+
# with variant-level predicate filtering.
|
|
594
681
|
#
|
|
595
|
-
# Each union variant (separated by +|+) can carry its own
|
|
596
|
-
#
|
|
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
|
|
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
|
|
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]
|
|
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)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcp_authorization
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.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-
|
|
11
|
+
date: 2026-05-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|