mcp_authorization 0.1.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: 537c8e48374b9ba9eb9c6876bac1410cb06a2a3cd46925830009a65af94fdee7
4
- data.tar.gz: b2efed20a21e1a8771aea0fe9ffebd32e4debe5a2fda21642a1080bc36f89759
3
+ metadata.gz: 79a43a7ab018242f92e8f0448f73ae5acbae410ebd7397868ec52553490a15ec
4
+ data.tar.gz: 2f41f4e5cfc4a923b26636551f25facf94c2c902be106ea19235a0c46b68dae6
5
5
  SHA512:
6
- metadata.gz: 3152bb3b807c10c64b20ddab0ea45fabb2bbfeb357bba5ea699d97e4544e377f8f1be7f1c200db5ce14ca997f6b8a167724c862d023b35cefc01fa0483cfbdcb
7
- data.tar.gz: c0742d87088a695ee246e99f02fc8bd885cc9e195b42f01fd1ff12e4bef7ea30811400280388c9d24281d3fa7f38981a17d27a4a9a54b2df43b7e63ac546e4d7
6
+ metadata.gz: 07fc7850bfa4960c00aee35f3543e907d5b1037ffe4b45a46b247e002d3d9a8271d4a7070746c031a2cd03d498a93a496bf1a74c998f17c1023fe361f416e27b
7
+ data.tar.gz: f83d36ff6ff7b43fffad639f8e81790a20c450959fe1cadb5d009ff39dee41464199e9d636ad1e1048d32e053eba84b4239d915e9837fcde11a495eb48361642
data/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
5
+ adheres to [Semantic Versioning](https://semver.org/).
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
+
24
+ ## [0.2.1] - 2026-05-13
25
+
26
+ ### Fixed
27
+ - `tools/call` responses now include `structuredContent` when the tool declares an `outputSchema` via `dynamic_contract`. Previously the response carried only text content, which spec-compliant clients rejected as a validation error. (#6)
28
+ - `RbsSchemaCompiler` now finds the handler's source file when `#call` is wrapped via `Module#prepend` (param coercion, instrumentation, `ActiveSupport::Concern`, tracing libraries). It walks `UnboundMethod#super_method` past prepended modules until the owner is the handler class itself, so `# @rbs type` and `#:` annotations are read from the right file instead of raising a contract violation. (#8)
29
+
30
+ ## [0.2.0] - 2026-04-20
31
+
32
+ ### Added
33
+ - `RbsSchemaCompiler.filter_input(handler, params, server_context:)` — projects inbound params onto the user's compiled input schema before the handler runs. Keys gated by `@requires` the user lacks, and any keys not declared in the schema at all, are dropped.
34
+ - `RbsSchemaCompiler.filter_output(handler, result, server_context:)` — projects the handler's return value onto the user's compiled output schema. Hidden `oneOf` variants and their fields are stripped before serialization.
35
+
36
+ ### Changed
37
+ - **`@requires` is now a security boundary, not just a hint to the LLM.** Tool calls through `Tool.call` and the anonymous class produced by `Tool.materialize_for` pipe params through `filter_input` on the way in and results through `filter_output` on the way out. A crafted JSON-RPC request that sends a gated param, and a handler that accidentally emits a gated output field, can no longer leak.
38
+ - README updated to describe enforcement as a guarantee. Handler authors no longer have to remember to re-check `can?` in every branch that touches a gated field — the schema is the boundary.
39
+
40
+ ### Migration notes
41
+ - If your handler's `#call` quietly accepted params that weren't declared in the `#:` annotation, those will now arrive as `nil`/default values. Declare them (with `@requires` if appropriate) or drop them.
42
+ - If your handler's output included fields that weren't in `@rbs type output`, those are now stripped. Add them to the output type definition if they should ship.
43
+
44
+ ## [0.1.1] - 2026-04-02
45
+
46
+ ### Added
47
+ - Added MIT license, homepage, author metadata.
48
+
49
+ ## [0.1.0] - 2026-04-01
50
+
51
+ ### Added
52
+ - Initial gem extraction from the monorepo. Rails engine, `RbsSchemaCompiler`, `Tool` / `ToolRegistry`, `DSL` mixin, `McpController`.
data/README.md CHANGED
@@ -11,11 +11,20 @@ The gem gives you three independent controls over what each user sees:
11
11
  | Layer | Mechanism | Effect |
12
12
  |---|---|---|
13
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 |
15
- | **Output variants** | `@requires(:backward_routing)` on a variant in `@rbs type output` | Variant excluded from the `oneOf` |
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 |
16
16
 
17
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.
18
18
 
19
+ ### Enforcement, not just shaping
20
+
21
+ `@requires` is a security boundary, not a hint. At tool-call time the gem:
22
+
23
+ - **Filters inbound params** against the user's compiled input schema. Gated fields, and any keys not declared in the schema at all, are dropped before the handler's `#call` is invoked. A handler that takes `force:` gated behind `@requires(:admin)` will see `force: false` (its default) for non-admins even if the MCP client sends `force: true` in the raw JSON-RPC payload.
24
+ - **Projects the handler's return value** onto the user's compiled output schema. A variant hidden by `@requires` has its shape unavailable, so if the handler erroneously emits that variant's extra fields, they are stripped before serialization. A handler bug or refactor accident cannot leak admin-only fields to a non-admin.
25
+
26
+ This means handler authors don't have to remember to re-check `can?` at every branch -- the schema *is* the boundary. `can?` inside `#call` is still useful for logic that changes behavior (not just field visibility), but it is no longer load-bearing for security.
27
+
19
28
  ## Install
20
29
 
21
30
  ```ruby
@@ -420,13 +429,32 @@ Tag any field in a `#:` annotation or `@rbs type` record to add JSON Schema cons
420
429
  | `@read_only()` | `readOnly: true` | Read-only field |
421
430
  | `@write_only()` | `writeOnly: true` | Write-only field |
422
431
 
423
- **Authorization:**
432
+ **Authorization & predicate filters:**
424
433
 
425
434
  | Tag | Purpose |
426
435
  |---|---|
427
- | `@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) |
428
438
  | `@depends_on(:field)` | Emits `dependentRequired` — field only required when parent field is present |
429
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
+
430
458
  **Niche:**
431
459
 
432
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
@@ -81,6 +81,45 @@ module McpAuthorization
81
81
  end
82
82
  end
83
83
 
84
+ # Filter incoming params against the user's compiled input schema.
85
+ #
86
+ # Any key that is not in the schema for this user is dropped — including
87
+ # +@requires+-gated fields the user lacks permission for, and any
88
+ # unknown fields not declared in the schema. This is the runtime
89
+ # enforcement counterpart to the input-shaping that +compile_input+ did.
90
+ #
91
+ # @param handler_class [Class]
92
+ # @param params [Hash] Params as received from the MCP client.
93
+ # @param server_context [Object] Per-request context.
94
+ # @return [Hash] Filtered params safe to pass to the handler.
95
+ #: (untyped, Hash[untyped, untyped], server_context: untyped) -> Hash[untyped, untyped]
96
+ def filter_input(handler_class, params, server_context:)
97
+ return params unless params.is_a?(Hash)
98
+ schema = compile_input_for_filter(handler_class, server_context: server_context)
99
+ return params unless schema
100
+ project_against_schema(params, schema, defs_from(schema))
101
+ end
102
+
103
+ # Filter the handler's return value against the user's compiled output
104
+ # schema. Any field/variant not visible to this user is stripped from
105
+ # the result.
106
+ #
107
+ # This is the runtime counterpart to +compile_output+: even if a handler
108
+ # bug or auth confusion causes it to emit fields the user shouldn't see,
109
+ # they never cross the wire. Passes the result through unchanged if no
110
+ # +@rbs type output+ is defined.
111
+ #
112
+ # @param handler_class [Class]
113
+ # @param result [Object] Handler return value (hash/array/primitive).
114
+ # @param server_context [Object] Per-request context.
115
+ # @return [Object] Projected result, matching the user's output schema.
116
+ #: (untyped, untyped, server_context: untyped) -> untyped
117
+ def filter_output(handler_class, result, server_context:)
118
+ schema = compile_output_for_filter(handler_class, server_context: server_context)
119
+ return result unless schema
120
+ project_against_schema(result, schema, defs_from(schema))
121
+ end
122
+
84
123
  # Strip JSON Schema keywords unsupported by Anthropic's strict tool
85
124
  # use mode, and add additionalProperties: false to all objects.
86
125
  # Converts oneOf to anyOf (strict mode supports anyOf but not oneOf).
@@ -152,6 +191,132 @@ module McpAuthorization
152
191
 
153
192
  private
154
193
 
194
+ # ---------------------------------------------------------------
195
+ # Runtime enforcement — project values against the user's schema
196
+ # ---------------------------------------------------------------
197
+
198
+ # Like +compile_input+ but keeps +$defs+ inline-resolvable and skips
199
+ # the LLM-facing +strict_sanitize+ pass. Used by +filter_input+ so
200
+ # enforcement operates on the same semantic schema the LLM was given
201
+ # without any strict-mode transforms that would lose type info.
202
+ #: (untyped, server_context: untyped) -> Hash[Symbol, untyped]
203
+ def compile_input_for_filter(handler_class, server_context:)
204
+ cached = cache_for(handler_class)
205
+
206
+ schema = if cached[:raw_input]&.dig(:kind) == :record
207
+ compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context)
208
+ else
209
+ build_input_schema(
210
+ filter_call_signature(cached[:call_params], cached[:type_map], server_context)
211
+ )
212
+ end
213
+
214
+ with_ref_injection(schema, cached[:type_map])
215
+ end
216
+
217
+ # Like +compile_output+ but skips +strict_sanitize+. Returns nil when
218
+ # the handler has no +# @rbs type output+ declaration.
219
+ #: (untyped, server_context: untyped) -> Hash[Symbol, untyped]?
220
+ def compile_output_for_filter(handler_class, server_context:)
221
+ cached = cache_for(handler_class)
222
+ return nil unless cached[:raw_output]&.dig(:kind) == :union
223
+
224
+ schema = compile_tagged_union(cached[:raw_output][:body], cached[:type_map], server_context)
225
+ with_ref_injection(schema, cached[:type_map])
226
+ end
227
+
228
+ # Extract the +$defs+ table from a compiled schema for +$ref+ resolution.
229
+ #: (Hash[Symbol, untyped]?) -> Hash[String, Hash[Symbol, untyped]]
230
+ def defs_from(schema)
231
+ return {} unless schema.is_a?(Hash)
232
+ (schema[:"$defs"] || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v }
233
+ end
234
+
235
+ # If +schema+ is a +$ref+ pointer, resolve it against +defs+. Otherwise
236
+ # return the schema unchanged.
237
+ #: (Hash[Symbol, untyped]?, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]?
238
+ def resolve_ref(schema, defs)
239
+ return schema unless schema.is_a?(Hash)
240
+ ref = schema[:"$ref"] || schema["$ref"]
241
+ return schema unless ref
242
+ name = ref.to_s.sub(%r{\A#/\$defs/}, "")
243
+ defs[name] || schema
244
+ end
245
+
246
+ # Recursively project a runtime value onto a compiled JSON Schema.
247
+ #
248
+ # The semantics: the schema is authoritative — the result only contains
249
+ # what the schema allows for this user.
250
+ #
251
+ # * Objects keep only declared +properties+; everything else is dropped.
252
+ # * Arrays recurse into +items+.
253
+ # * +oneOf+/+anyOf+ picks the best-matching variant (by present/required
254
+ # keys) and projects against it; unmatched variants are ignored.
255
+ # * Primitives (and un-typed schemas) pass through unchanged.
256
+ #
257
+ # This is how +@requires+-gated fields are enforced at runtime: they
258
+ # are already absent from +schema[:properties]+ for a user who lacks
259
+ # the flag, so the projection simply drops them from the value.
260
+ #: (untyped, Hash[Symbol, untyped]?, Hash[String, Hash[Symbol, untyped]]) -> untyped
261
+ def project_against_schema(value, schema, defs)
262
+ return value if schema.nil?
263
+ schema = resolve_ref(schema, defs)
264
+ return value unless schema.is_a?(Hash)
265
+
266
+ variants = schema[:oneOf] || schema[:anyOf]
267
+ if variants.is_a?(Array) && !variants.empty?
268
+ resolved = variants.map { |v| resolve_ref(v, defs) }
269
+ best = best_variant_for(value, resolved)
270
+ return best ? project_against_schema(value, best, defs) : value
271
+ end
272
+
273
+ case schema[:type]
274
+ when "object"
275
+ return value unless value.is_a?(Hash)
276
+ props = schema[:properties] || {}
277
+ value.each_with_object({}) do |(k, v), acc|
278
+ prop_schema = props[k.to_sym] || props[k.to_s] || props[k]
279
+ next unless prop_schema
280
+ acc[k] = project_against_schema(v, prop_schema, defs)
281
+ end
282
+ when "array"
283
+ return value unless value.is_a?(Array)
284
+ items = schema[:items]
285
+ items ? value.map { |v| project_against_schema(v, items, defs) } : value
286
+ else
287
+ value
288
+ end
289
+ end
290
+
291
+ # Choose the best-matching variant of a union for a given value.
292
+ #
293
+ # Scoring: count how many of the value's keys appear in the variant's
294
+ # +properties+, minus how many are unknown. Disqualify variants missing
295
+ # any of the value's keys from their +required+ list that isn't present.
296
+ # Returns nil if no variant can accommodate the value — in which case
297
+ # the caller should leave the value unchanged (defensive pass-through).
298
+ #: (untyped, Array[Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]?
299
+ def best_variant_for(value, variants)
300
+ return variants.first unless value.is_a?(Hash)
301
+
302
+ scored = variants.filter_map do |variant|
303
+ next unless variant.is_a?(Hash) && variant[:type] == "object"
304
+ props = variant[:properties] || {}
305
+ prop_keys = props.keys.map(&:to_s)
306
+ value_keys = value.keys.map(&:to_s)
307
+ required = (variant[:required] || []).map(&:to_s)
308
+
309
+ next if (required - value_keys).any?
310
+
311
+ known = (value_keys & prop_keys).size
312
+ unknown = (value_keys - prop_keys).size
313
+ [known - unknown, variant]
314
+ end
315
+
316
+ return nil if scored.empty?
317
+ scored.max_by { |score, _| score }&.last
318
+ end
319
+
155
320
  # ---------------------------------------------------------------
156
321
  # Tag extraction — unified parser for all @tag(...) annotations
157
322
  # ---------------------------------------------------------------
@@ -174,7 +339,7 @@ module McpAuthorization
174
339
  # @return [Array(String, Hash)] +[clean_type, tags_hash]+
175
340
  #
176
341
  # Supported tags:
177
- # @requires(:symbol) -> { requires: :symbol }
342
+ # @requires(:symbol) -> added to :predicates (calls server_context.requires?)
178
343
  # @depends_on(:field) -> { depends_on: "field" }
179
344
  # @min(n) -> { min: n }
180
345
  # @max(n) -> { max: n }
@@ -195,6 +360,14 @@ module McpAuthorization
195
360
  # @closed() / @strict() -> { closed: true }
196
361
  # @media_type(type) -> { media_type: "type" }
197
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?+.
198
371
  #: (String) -> [String, Hash[Symbol, untyped]]
199
372
  def extract_tags(type_str)
200
373
  tags = {}
@@ -205,7 +378,7 @@ module McpAuthorization
205
378
 
206
379
  case tag_name
207
380
  when "requires"
208
- tags[:requires] = tag_value.delete_prefix(":").to_sym
381
+ (tags[:predicates] ||= []) << { name: "requires", value: tag_value.delete_prefix(":") }
209
382
  when "depends_on"
210
383
  tags[:depends_on] = tag_value.delete_prefix(":")
211
384
  when "min"
@@ -246,6 +419,8 @@ module McpAuthorization
246
419
  tags[:media_type] = tag_value
247
420
  when "encoding"
248
421
  tags[:encoding] = tag_value
422
+ else
423
+ (tags[:predicates] ||= []) << { name: tag_name, value: tag_value.delete_prefix(":") }
249
424
  end
250
425
  end
251
426
 
@@ -378,18 +553,95 @@ module McpAuthorization
378
553
  end
379
554
 
380
555
  # ---------------------------------------------------------------
381
- # @requires filtering — the per-request compile phase
556
+ # Predicate filtering — the per-request compile phase
382
557
  # ---------------------------------------------------------------
383
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
+
384
636
  # Compile a record-style input type (+# @rbs type input = { ... }+)
385
- # with field-level +@requires+ filtering.
637
+ # with field-level predicate filtering.
386
638
  #
387
- # Fields whose +@requires+ flag the current user lacks are silently
388
- # 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.
389
641
  #
390
642
  # @param raw_body [String] The raw record body, e.g. +"{name: String, force: bool @requires(:admin)}"+.
391
643
  # @param type_map [Hash] Resolved type definitions for +$ref+ lookups.
392
- # @param server_context [Object] Per-request context with +current_user+.
644
+ # @param server_context [Object] Per-request context.
393
645
  # @return [Hash] JSON Schema object with +properties+, +required+, etc.
394
646
  #: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
395
647
  def compile_tagged_record(raw_body, type_map, server_context)
@@ -403,7 +655,7 @@ module McpAuthorization
403
655
  key, type_str = match[0].to_s, match[1].to_s
404
656
  type_str, tags = extract_tags(type_str.strip)
405
657
 
406
- next if tags[:requires] && !server_context.current_user.can?(tags[:requires])
658
+ next if predicate_excluded?(tags, server_context)
407
659
 
408
660
  optional = key.end_with?("?")
409
661
  clean_key = key.delete_suffix("?")
@@ -425,10 +677,10 @@ module McpAuthorization
425
677
  end
426
678
 
427
679
  # Compile a union-style output type (+# @rbs type output = success | admin_detail @requires(:admin)+)
428
- # with variant-level +@requires+ filtering.
680
+ # with variant-level predicate filtering.
429
681
  #
430
- # Each union variant (separated by +|+) can carry its own +@requires+
431
- # 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.
432
684
  # If only one variant remains, it's returned directly (no +oneOf+
433
685
  # wrapper). If zero remain, a bare +{type: "object"}+ fallback is used.
434
686
  #
@@ -442,7 +694,7 @@ module McpAuthorization
442
694
 
443
695
  filtered = parts.filter_map do |part|
444
696
  part, tags = extract_tags(part)
445
- next nil if tags[:requires] && !server_context.current_user.can?(tags[:requires])
697
+ next nil if predicate_excluded?(tags, server_context)
446
698
  resolve_type(part, type_map)
447
699
  end
448
700
 
@@ -453,7 +705,7 @@ module McpAuthorization
453
705
  end
454
706
  end
455
707
 
456
- # Filter method-signature parameters by +@requires+ tags and build
708
+ # Filter method-signature parameters by predicate tags and build
457
709
  # the input JSON Schema. This is the path used when the handler defines
458
710
  # its schema via a +#:+ annotation above +def call+ rather than an
459
711
  # explicit +# @rbs type input = { ... }+.
@@ -469,9 +721,7 @@ module McpAuthorization
469
721
  dependent_required = {}
470
722
 
471
723
  call_params.each do |param|
472
- if param[:tags][:requires] && server_context
473
- next unless server_context.current_user.can?(param[:tags][:requires])
474
- end
724
+ next if predicate_excluded?(param[:tags], server_context)
475
725
 
476
726
  schema = rbs_type_to_json_schema(param[:type], type_map)
477
727
  properties[param[:name].to_sym] = apply_tags(schema, param[:tags], server_context: server_context)
@@ -880,15 +1130,28 @@ module McpAuthorization
880
1130
  # +Method#source_location+ on its +#call+ method. This is how the
881
1131
  # compiler finds the RBS annotations to parse.
882
1132
  #
1133
+ # When host applications wrap +#call+ via +prepend+ (param coercion,
1134
+ # instrumentation, error mapping, ActiveSupport::Concern patterns,
1135
+ # observability libraries), +source_location+ on the topmost method
1136
+ # points at the wrapper, not the handler. Walk +super_method+ down
1137
+ # past prepended modules until we find the handler's own definition.
1138
+ #
883
1139
  # @param handler_class [Class]
884
1140
  # @return [String, nil] Absolute file path, or nil.
885
1141
  #: (untyped) -> String?
886
1142
  def find_source_file(handler_class)
887
- if handler_class.method_defined?(:call)
888
- handler_class.instance_method(:call).source_location&.first
1143
+ um = if handler_class.method_defined?(:call) || handler_class.private_method_defined?(:call)
1144
+ handler_class.instance_method(:call)
889
1145
  elsif handler_class.respond_to?(:call)
890
- handler_class.method(:call).source_location&.first
1146
+ handler_class.method(:call)
891
1147
  end
1148
+ return nil unless um
1149
+
1150
+ while um.owner != handler_class && um.super_method
1151
+ um = um.super_method
1152
+ end
1153
+
1154
+ um.source_location&.first
892
1155
  end
893
1156
 
894
1157
  # Check whether a string has balanced curly braces. Used to detect
@@ -118,13 +118,30 @@ module McpAuthorization
118
118
  end
119
119
 
120
120
  # Execute the tool by delegating to the handler.
121
+ #
122
+ # Inputs are filtered against the user's compiled input schema before
123
+ # being passed to the handler, and outputs are filtered against the
124
+ # user's compiled output schema before being returned. Fields and
125
+ # variants gated by +@requires+ that the user lacks permission for
126
+ # never reach the handler (in) or cross the wire (out).
121
127
  #: (?server_context: untyped?, **untyped) -> untyped
122
128
  def call(server_context: nil, **params)
123
129
  raise NotAuthorizedError unless server_context && permitted?(server_context)
124
- handler_instance(server_context).call(**params)
130
+ filtered = McpAuthorization::RbsSchemaCompiler.filter_input(
131
+ _contract_handler, params, server_context: server_context
132
+ )
133
+ result = handler_instance(server_context).call(**symbolize_keys(filtered))
134
+ McpAuthorization::RbsSchemaCompiler.filter_output(
135
+ _contract_handler, result, server_context: server_context
136
+ )
125
137
  end
126
138
 
127
139
  # Create an anonymous MCP::Tool subclass with this user's schemas baked in.
140
+ #
141
+ # The materialized +call+ enforces the compiled schema at runtime:
142
+ # input params are stripped of unknown or permission-gated fields
143
+ # before reaching the handler, and the handler's return value is
144
+ # projected onto the user's output schema before being serialized.
128
145
  #: (untyped) -> Class?
129
146
  def materialize_for(server_context)
130
147
  defn = to_mcp_definition(server_context: server_context)
@@ -132,6 +149,7 @@ module McpAuthorization
132
149
 
133
150
  handler = _contract_handler
134
151
  ctx = server_context
152
+ symbolize = method(:symbolize_keys)
135
153
 
136
154
  Class.new(MCP::Tool) do
137
155
  tool_name defn[:name]
@@ -142,12 +160,31 @@ module McpAuthorization
142
160
 
143
161
  define_singleton_method(:call) do |server_context: nil, **params|
144
162
  effective_ctx = server_context || ctx
145
- result = handler.new(server_context: effective_ctx).call(**params)
146
- MCP::Tool::Response.new([ { type: "text", text: result.to_json } ])
163
+ filtered_params = McpAuthorization::RbsSchemaCompiler.filter_input(
164
+ handler, params, server_context: effective_ctx
165
+ )
166
+ raw = handler.new(server_context: effective_ctx).call(**symbolize.call(filtered_params))
167
+ result = McpAuthorization::RbsSchemaCompiler.filter_output(
168
+ handler, raw, server_context: effective_ctx
169
+ )
170
+ response_args = [{ type: "text", text: result.to_json }]
171
+ if defn[:outputSchema]
172
+ MCP::Tool::Response.new(response_args, structured_content: result)
173
+ else
174
+ MCP::Tool::Response.new(response_args)
175
+ end
147
176
  end
148
177
  end
149
178
  end
150
179
 
180
+ # Normalize hash keys to symbols so projection output can be splatted
181
+ # into a handler's kwarg-only +#call+ signature.
182
+ #: (Hash[untyped, untyped]) -> Hash[Symbol, untyped]
183
+ def symbolize_keys(hash)
184
+ return {} unless hash.is_a?(Hash)
185
+ hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
186
+ end
187
+
151
188
  private
152
189
 
153
190
  #: (**untyped) -> void
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.1.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.1.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-04-02 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
@@ -74,6 +74,7 @@ executables: []
74
74
  extensions: []
75
75
  extra_rdoc_files: []
76
76
  files:
77
+ - CHANGELOG.md
77
78
  - LICENSE
78
79
  - README.md
79
80
  - app/controllers/mcp_authorization/mcp_controller.rb