mcp_authorization 0.5.2 → 0.5.5

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: e72fb2f33292962f9820cf0908a278b461d3037d8612240f06a1fd3ba0dd2daa
4
- data.tar.gz: d1a4af82cd4b4f177a1aec377572e105715f11f822b6bdd86da3ec8715cfa7ce
3
+ metadata.gz: d6b7e3511b7790b3da9c74c48f4df83c87adc11a875fc0e822c153c33b6a72e9
4
+ data.tar.gz: c00c25c116df33f17126a8b45105db079e8180c9b4b2895f1e50db0131fb9dfe
5
5
  SHA512:
6
- metadata.gz: cb10ce2df4b8c6470624219bd33e2740293a999c98f25f9975f6a3cc7b344d2d5189134dc9284ea41d3955820c5af72bfe19a089945f116f84305c2463216dd4
7
- data.tar.gz: db08f04a73a6ef838af81bbcf215d60568d07f52e5b33a722e4e578e0ae8113941a5b8ffe351c01a7976ae56fd57b4cb5fe0dd1158802ff92f5dc2fd6f472827
6
+ metadata.gz: 2784b842b815610bf06752607b26332568076ce84b675dbc902e1ea0a858eac18e1e41d3b8cce812bce64b95f76266885fb6b43c4388742ac89e861d9d4f4639
7
+ data.tar.gz: 5fcb42dac47e116c9223c23aabc5c76884e756c06c2b8dc1ace1be6014fbd44560746458f54890653e597aaa4152bdb2e881c6b32d5ec1fba7f957e86939a2ed
data/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ All notable changes to this gem are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.5.5] - 2026-06-04
8
+
9
+ ### Fixed
10
+ - **`$defs` deduplication now ref-injects *inside* hoisted defs and prunes unreferenced ones.** `with_ref_injection` hoisted a multi-use type into `$defs` and `$ref`d it from the main schema, but left the def's *body* untouched — so a nested multi-use type (e.g. a shared base that inlines a template used elsewhere) stayed fully inlined inside the hoisted base **and** got hoisted again into a separate, never-referenced def. Each def body is now itself ref-injected (replacing nested multi-use types with `$ref`, excluding self), and any def left unreferenced (transitively from the root) is dropped. For a large discriminated union with a shared base, this removes both the duplicated nested types and the dead defs — a substantial `tools/list` size reduction with identical semantics.
11
+
12
+ ### Added
13
+ - **Per-member predicate gating also reaches a union inside `Array[...]`.** A field typed `Array[a @feature(x) | b @feature(y)]` now gates the element union per request (the `|` is at bracket depth 1, so the plain field-union path didn't see it). `compile_tagged_record` detects `Array[<tagged union>]`, gates the inner union via `compile_tagged_union`, and re-wraps it as `{type: "array", items: …}`. Lets a list-of-discriminated-variants input (e.g. bulk create) expose only the variants available to the current account/user.
14
+ - **Per-member predicate gating now works for a union nested in a record field.** Previously, variant-level gating (`a | b @requires(:x)`) only applied to a top-level `# @rbs type output`/`input` union; a union inside a record field (`# @rbs type input = { stage: a @feature(x) | b @feature(y) }`) went through the RBS-library path and couldn't carry per-member tags. `compile_tagged_record` now detects a multi-member union field that carries a predicate tag and routes it through `compile_tagged_union`, so each variant is filtered per request (variants whose predicate is false are dropped). Untagged union fields are unchanged (still the full RBS path, so inline records etc. keep working). This lets, e.g., a discriminated-union input expose only the variants available to the current account/user.
15
+ - **Record intersection (`type x = base & { ... }`) compiles to `allOf`.** A shared `.rbs` type alias may now intersect a base type with an inline record — e.g. `type scheduler_stage = stage_common & { type: "SchedulerStage", ... }`. The base resolves first and, because it appears identically across every intersection that uses it, `with_ref_injection` hoists it into `$defs` once and `$ref`s it from each member. For a large discriminated union whose members share most of their fields, this collapses the duplicated common fields into a single `$def` (major token reduction in `tools/list`) while keeping full per-type typing. Field-level `&` in RBS type expressions also maps to `allOf`. Runtime projection (`filter_input`/`filter_output`) flattens `allOf` members — merging base + own `properties`/`required` and resolving `$ref`s — so discriminator-`const` matching and field projection work through the intersection.
16
+
17
+ ### Fixed
18
+ - **Output projection honors `const` discriminators on union members.** When a `# @rbs type output` union's members are tagged by a property pinned to a literal (e.g. `type: "SchedulerStage"`), runtime projection (`filter_output`) now selects the member whose `const` matches the value's tag, rather than the member with the most incidental field overlap. A value whose tag matches no member falls through to the existing defensive pass-through (returned unchanged) instead of being mis-projected onto an unrelated variant and having its fields stripped. Non-discriminated unions (no `const` properties) are unaffected — they still pick the best-overlap variant. This makes large discriminated unions (one record per subtype, sharing a single tag field) project losslessly.
19
+ - **Shared types can now reference types defined in another imported file.** A `# @rbs import`ed `sig/shared/*.rbs` file may reference a type declared in a *different* imported file (as long as the handler imports both) — e.g. a per-type contract referencing a shared `move_rule` / `template` alias. Previously each shared file was parsed and resolved in isolation, so a cross-file reference degraded to a fallback (`{type: "object"}`/`{type: "string"}`). `build_cache` now collects the *raw* (unresolved) aliases from every imported file plus the handler's own inline `# @rbs type` definitions, merges them (local overrides imported), and resolves the whole set together, so cross-file references resolve. Within-file references and `$defs` deduplication are unchanged.
20
+
21
+ ### Changed
22
+ - **Shared-type import resolution no longer requires Rails.** `resolve_import_path` now resolves absolute `shared_type_paths` directly and falls back to the current working directory when Rails is absent, instead of returning `nil` whenever `Rails` is undefined. Rails hosts using a relative path (e.g. `"sig/shared"`) are unaffected (still resolved against `Rails.root`); this makes shared imports usable — and testable — outside Rails.
23
+
24
+ ## [0.5.4] - 2026-06-04
25
+
26
+ ### Fixed
27
+ - **Predicate gating (`@requires`/`@feature`/`@hidden`/…) now recurses into nested record types.** ([#23](https://github.com/onboardiq/mcp_authorization/issues/23))
28
+
29
+ Per-request gating was only applied to the top-level fields of a handler's own input record / `#:` params and to top-level output-union variants. A gated field *inside* a nested record alias (a `# @rbs type foo = { ... }` referenced as `Array[foo]`, as a nested property, or imported from `sig/shared/*.rbs`) was resolved from the statically-compiled `type_map` and never filtered against `server_context`, so it leaked into the schema for every user — silently defeating the field-shaping the DSL advertises. The compiler now threads a per-request resolution context (`server_context` + the retained, tag-intact raw record bodies) through the type visitor: when a named record alias is referenced, it is recompiled with `compile_tagged_record` so its own predicate-gated fields are filtered at any nesting depth, including across imports. A `visiting` stack guards against recursive types. Runtime enforcement (`filter_input`/`filter_output`) inherits the fix, since it projects against the same per-request schema. Predicate-free aliases still dedupe into `$defs` exactly as before.
30
+
31
+ - **`untyped` now compiles to the empty schema `{}` ("any value"), not `{type: "string"}`.** ([#22](https://github.com/onboardiq/mcp_authorization/issues/22))
32
+
33
+ `untyped` (RBS `Bases::Any`/`Void`/`Nil`) emitted `{type: "string"}` despite the inline comment promising "no constraint". The most common casualty was `Hash[K, untyped]`, which compiled to `{type: "object", additionalProperties: {type: "string"}}` — forcing *every* property value to be a string. A payload carrying a nested object or array under such a param listed fine in `tools/list` but was rejected server-side at `tools/call` (`"… did not match the following type: string"`). `untyped` now maps to `{}`, so `Hash[K, untyped]` becomes `{type: "object", additionalProperties: {}}` (any value allowed) and bare `untyped` becomes `{}`. Relatedly, `project_against_schema` now honors `additionalProperties`: an explicitly-open object (e.g. an `untyped` hash) keeps its undeclared keys through runtime projection instead of being emptied before the handler runs, while objects with no `additionalProperties` (or `additionalProperties: false`, e.g. `@closed`) keep the closed-by-default projection that enforces `@requires` gating.
34
+
35
+ ## [0.5.3] - 2026-06-03
36
+
37
+ ### Fixed
38
+ - **`#` comments inside an RBS record type no longer break schema compilation.** ([#20](https://github.com/onboardiq/mcp_authorization/issues/20))
39
+
40
+ A comment line inside a record body — whether in an inline `# @rbs type` annotation or an imported `sig/shared/*.rbs` alias — raised `ArgumentError: invalid field name token`. The line-based readers (`find_raw_type_body`, `parse_type_aliases`, `parse_rbs_file`) concatenate record-body lines without a newline separator, so a comment folded into the next field name (`"# a note describing the fields belowid"`). Comments are valid anywhere in RBS — the official lexer discards `#`-to-end-of-line everywhere — so the readers now strip line comments before splitting fields, via a new `strip_rbs_comment` helper that leaves `#` inside string literals and bracketed annotation values (e.g. `@desc(...)`) untouched.
41
+
42
+ **Impact:** because `tools/list` maps `to_mcp_definition` over every tool in a domain, a single tool with an in-record comment took down discovery for the entire domain — and the offending comment could live far away in a shared `.rbs` alias that several tools import.
43
+
44
+ - **Narrowed `rbs` require set now loads under `rbs` 4.x.** The 0.5.2 narrowing (16-file subset instead of `require "rbs"`) was validated against `rbs` 3.x but raised `NameError: uninitialized constant RBS::AST::Ruby` on a fresh install resolving `rbs` 4.x — the same 4.x that 0.5.2's loosened `>= 3.0` constraint explicitly allows. `rbs` 4.x's C extension references the new `RBS::AST::Ruby::*` namespace and its `parser_aux` references `Pathname` at load time. The require block now loads `pathname` and the `rbs/ast/ruby/*` files when present (guarded by `rescue LoadError` so `rbs` 3.x, which lacks them, is unaffected). Verified against `rbs` 3.10 and 4.0.
45
+
46
+ ### Added
47
+ - **CI workflow** (`.github/workflows/ci.yml`) running `bundle exec rake test` across Ruby 3.1–3.4 on push and pull request, plus a `sentinel check` job that fails if the committed `sig/generated/*.rbs` drift from the inline `#:` annotations. `rbs-sentinel` is now pinned as a development dependency so the signature formatting CI checks against is reproducible.
48
+
7
49
  ## [0.5.2] - 2026-05-28
8
50
 
9
51
  ### Changed
@@ -4,6 +4,7 @@
4
4
  # stdlib type signatures, ...) we never touch — ~15 MB RSS vs ~1.2 MB for
5
5
  # the subset below. Load order matters: location_aux uses the C-defined
6
6
  # RBS::Location, and rbs_extension assumes RBS::AST::* namespaces exist.
7
+ require "pathname" # rbs 4.x's parser_aux references Pathname at load time
7
8
  require "rbs/version"
8
9
  require "rbs/errors"
9
10
  require "rbs/buffer"
@@ -17,6 +18,23 @@ require "rbs/ast/declarations"
17
18
  require "rbs/ast/members"
18
19
  require "rbs/ast/annotation"
19
20
  require "rbs/ast/comment"
21
+ # rbs 4.x's C extension (rbs_extension) references the RBS::AST::Ruby::*
22
+ # namespace, which did not exist on rbs 3.x. Require those files when the
23
+ # installed rbs ships them so the narrow load path stays correct across
24
+ # the gemspec's supported range (rbs >= 3.0); ignore on versions that
25
+ # predate the namespace, where rbs_extension does not need it.
26
+ %w[
27
+ rbs/ast/ruby/helpers/constant_helper
28
+ rbs/ast/ruby/helpers/location_helper
29
+ rbs/ast/ruby/annotations
30
+ rbs/ast/ruby/comment_block
31
+ rbs/ast/ruby/declarations
32
+ rbs/ast/ruby/members
33
+ ].each do |feature|
34
+ require feature
35
+ rescue LoadError
36
+ # rbs < 4: file absent and rbs_extension does not reference it.
37
+ end
20
38
  require "rbs_extension"
21
39
  require "rbs/location_aux"
22
40
  require "rbs/parser_aux"
@@ -79,12 +97,13 @@ module McpAuthorization
79
97
  #: (untyped, server_context: untyped) -> Hash[Symbol, untyped]
80
98
  def compile_input(handler_class, server_context:)
81
99
  cached = cache_for(handler_class)
100
+ rctx = build_rctx(server_context, cached)
82
101
 
83
102
  schema = if cached[:raw_input]&.dig(:kind) == :record
84
- compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context, source_file: cached[:source_file])
103
+ compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context, source_file: cached[:source_file], rctx: rctx)
85
104
  else
86
105
  build_input_schema(
87
- filter_call_signature(cached[:call_params], cached[:type_map], server_context)
106
+ filter_call_signature(cached[:call_params], cached[:type_map], server_context, rctx: rctx)
88
107
  )
89
108
  end
90
109
 
@@ -100,7 +119,7 @@ module McpAuthorization
100
119
  cached = cache_for(handler_class)
101
120
 
102
121
  if cached[:raw_output]&.dig(:kind) == :union
103
- schema = compile_tagged_union(cached[:raw_output][:body], cached[:type_map], server_context)
122
+ schema = compile_tagged_union(cached[:raw_output][:body], cached[:type_map], server_context, rctx: build_rctx(server_context, cached))
104
123
  schema = with_ref_injection(schema, cached[:type_map])
105
124
  return McpAuthorization.config.strict_schema ? strict_sanitize(schema) : schema
106
125
  end
@@ -227,12 +246,13 @@ module McpAuthorization
227
246
  #: (untyped, server_context: untyped) -> Hash[Symbol, untyped]
228
247
  def compile_input_for_filter(handler_class, server_context:)
229
248
  cached = cache_for(handler_class)
249
+ rctx = build_rctx(server_context, cached)
230
250
 
231
251
  schema = if cached[:raw_input]&.dig(:kind) == :record
232
- compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context, source_file: cached[:source_file])
252
+ compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context, source_file: cached[:source_file], rctx: rctx)
233
253
  else
234
254
  build_input_schema(
235
- filter_call_signature(cached[:call_params], cached[:type_map], server_context)
255
+ filter_call_signature(cached[:call_params], cached[:type_map], server_context, rctx: rctx)
236
256
  )
237
257
  end
238
258
 
@@ -246,7 +266,7 @@ module McpAuthorization
246
266
  cached = cache_for(handler_class)
247
267
  return nil unless cached[:raw_output]&.dig(:kind) == :union
248
268
 
249
- schema = compile_tagged_union(cached[:raw_output][:body], cached[:type_map], server_context)
269
+ schema = compile_tagged_union(cached[:raw_output][:body], cached[:type_map], server_context, rctx: build_rctx(server_context, cached))
250
270
  with_ref_injection(schema, cached[:type_map])
251
271
  end
252
272
 
@@ -290,19 +310,40 @@ module McpAuthorization
290
310
 
291
311
  variants = schema[:oneOf] || schema[:anyOf]
292
312
  if variants.is_a?(Array) && !variants.empty?
293
- resolved = variants.map { |v| resolve_ref(v, defs) }
313
+ # Flatten so intersection (allOf) members project like the object
314
+ # they describe — their discriminator const and merged fields live
315
+ # across the allOf branches.
316
+ resolved = variants.map { |v| flatten_all_of(v, defs) }
294
317
  best = best_variant_for(value, resolved)
295
318
  return best ? project_against_schema(value, best, defs) : value
296
319
  end
297
320
 
321
+ # A bare intersection (allOf) — merge its branches and project.
322
+ return project_against_schema(value, flatten_all_of(schema, defs), defs) if schema[:allOf].is_a?(Array)
323
+
298
324
  case schema[:type]
299
325
  when "object"
300
326
  return value unless value.is_a?(Hash)
301
327
  props = schema[:properties] || {}
328
+ # additionalProperties governs keys with no declared property.
329
+ # Absent (nil) keeps the closed-by-default projection that drops
330
+ # undeclared keys — the enforcement that hides @requires-gated
331
+ # fields. An explicit non-false value (a schema or true) means the
332
+ # author opted the value open (e.g. Hash[K, untyped] compiles to
333
+ # additionalProperties: {}), so unknown keys are preserved and
334
+ # projected against that schema rather than stripped (issue #22).
335
+ addl = schema[:additionalProperties]
302
336
  value.each_with_object({}) do |(k, v), acc|
303
337
  prop_schema = props[k.to_sym] || props[k.to_s] || props[k]
304
- next unless prop_schema
305
- acc[k] = project_against_schema(v, prop_schema, defs)
338
+ if prop_schema
339
+ acc[k] = project_against_schema(v, prop_schema, defs)
340
+ elsif addl == false || addl.nil?
341
+ next
342
+ elsif addl.is_a?(Hash) && !addl.empty?
343
+ acc[k] = project_against_schema(v, addl, defs)
344
+ else
345
+ acc[k] = v
346
+ end
306
347
  end
307
348
  when "array"
308
349
  return value unless value.is_a?(Array)
@@ -315,7 +356,14 @@ module McpAuthorization
315
356
 
316
357
  # Choose the best-matching variant of a union for a given value.
317
358
  #
318
- # Scoring: count how many of the value's keys appear in the variant's
359
+ # Discriminated unions come first: if a variant pins a property to a
360
+ # +const+ (e.g. +type: "SchedulerStage"+) and the value carries that key
361
+ # with a different value, the variant is disqualified outright. This makes
362
+ # tagged unions project onto the exact matching member instead of the one
363
+ # with the most incidental field overlap — and lets a value whose tag
364
+ # matches no variant fall through to the defensive pass-through below.
365
+ #
366
+ # Otherwise: count how many of the value's keys appear in the variant's
319
367
  # +properties+, minus how many are unknown. Disqualify variants missing
320
368
  # any of the value's keys from their +required+ list that isn't present.
321
369
  # Returns nil if no variant can accommodate the value — in which case
@@ -324,14 +372,15 @@ module McpAuthorization
324
372
  def best_variant_for(value, variants)
325
373
  return variants.first unless value.is_a?(Hash)
326
374
 
375
+ value_keys = value.keys.map(&:to_s)
327
376
  scored = variants.filter_map do |variant|
328
377
  next unless variant.is_a?(Hash) && variant[:type] == "object"
329
378
  props = variant[:properties] || {}
330
379
  prop_keys = props.keys.map(&:to_s)
331
- value_keys = value.keys.map(&:to_s)
332
380
  required = (variant[:required] || []).map(&:to_s)
333
381
 
334
382
  next if (required - value_keys).any?
383
+ next if const_discriminator_mismatch?(value, props)
335
384
 
336
385
  known = (value_keys & prop_keys).size
337
386
  unknown = (value_keys - prop_keys).size
@@ -342,6 +391,53 @@ module McpAuthorization
342
391
  scored.max_by { |score, _| score }&.last
343
392
  end
344
393
 
394
+ # Merge an +allOf+ (intersection) schema into a single object schema by
395
+ # unioning the +properties+/+required+ of every branch (each resolved
396
+ # through +$ref+). Non-intersection schemas are returned resolved and
397
+ # unchanged. This lets the union projection treat +allOf: [{$ref base},
398
+ # {own fields}]+ — the shape produced by a +base & { ... }+ contract —
399
+ # as the flat record it logically is, so the discriminator const and the
400
+ # merged field set are visible to +best_variant_for+ and projection.
401
+ #: (untyped, Hash[String, Hash[Symbol, untyped]]) -> untyped
402
+ def flatten_all_of(schema, defs)
403
+ schema = resolve_ref(schema, defs)
404
+ return schema unless schema.is_a?(Hash) && schema[:allOf].is_a?(Array)
405
+
406
+ props = {}
407
+ required = [] #: Array[untyped]
408
+ additional = nil
409
+ schema[:allOf].each do |branch|
410
+ b = flatten_all_of(branch, defs)
411
+ next unless b.is_a?(Hash)
412
+ props.merge!(b[:properties]) if b[:properties].is_a?(Hash)
413
+ required.concat(Array(b[:required]))
414
+ additional = b[:additionalProperties] if b.key?(:additionalProperties)
415
+ end
416
+
417
+ merged = { type: "object", properties: props } #: Hash[Symbol, untyped]
418
+ merged[:required] = required.uniq unless required.empty?
419
+ merged[:additionalProperties] = additional unless additional.nil?
420
+ # Preserve any sibling keys set alongside allOf (rare, defensive).
421
+ schema.each { |k, v| merged[k] ||= v unless k == :allOf }
422
+ merged
423
+ end
424
+
425
+ # True when +value+ carries a key that +props+ pins to a +const+ but with
426
+ # a different value — i.e. a discriminated-union tag mismatch. Keys absent
427
+ # from the value never disqualify (that's a +required+ concern).
428
+ #: (Hash[untyped, untyped], Hash[untyped, untyped]) -> bool
429
+ def const_discriminator_mismatch?(value, props)
430
+ props.any? do |key, schema|
431
+ next false unless schema.is_a?(Hash) && schema.key?(:const)
432
+
433
+ present = value.key?(key.to_sym) || value.key?(key.to_s)
434
+ next false unless present
435
+
436
+ actual = value.key?(key.to_sym) ? value[key.to_sym] : value[key.to_s]
437
+ actual.to_s != schema[:const].to_s
438
+ end
439
+ end
440
+
345
441
  # ---------------------------------------------------------------
346
442
  # Tag extraction — unified parser for all @tag(...) annotations
347
443
  # ---------------------------------------------------------------
@@ -804,17 +900,28 @@ module McpAuthorization
804
900
  source_file = find_source_file(handler_class)
805
901
  content = source_file && File.exist?(source_file) ? File.read(source_file) : ""
806
902
 
807
- # Build type map: shared imports first, then handler's own types override
808
- imported = load_imports(content)
809
- local = parse_type_aliases(content, source_file: source_file)
810
- type_map = imported.merge(local)
903
+ # Build type map: collect the *raw* (unresolved) aliases from every
904
+ # imported shared file plus the handler's own inline `# @rbs type`
905
+ # definitions, merge them (local overrides imported), then resolve the
906
+ # whole set together. Resolving as one set — rather than per file — is
907
+ # what lets a shared type reference a type defined in another imported
908
+ # file (e.g. a per-stage contract referencing a shared `move_rule`).
909
+ raw_aliases = load_import_aliases(content).merge(collect_inline_aliases(content))
910
+ type_map = resolve_collected_aliases(raw_aliases, source_file: source_file)
911
+
912
+ # Retain the un-stripped record bodies (tags intact) so nested
913
+ # record aliases can be recompiled per request with predicate
914
+ # filtering — see resolve_named_type / build_rctx (issue #23).
915
+ # Local definitions override imported ones, matching type_map.
916
+ raw_record_bodies = load_import_raw_bodies(content).merge(collect_inline_record_bodies(content))
811
917
 
812
918
  {
813
919
  type_map: type_map,
814
920
  raw_input: find_raw_type_body(content, "input"),
815
921
  raw_output: find_raw_type_body(content, "output"),
816
922
  call_params: parse_call_params(content, source_file: source_file),
817
- source_file: source_file
923
+ source_file: source_file,
924
+ raw_record_bodies: raw_record_bodies
818
925
  }
819
926
  end
820
927
 
@@ -822,6 +929,50 @@ module McpAuthorization
822
929
  # Predicate filtering — the per-request compile phase
823
930
  # ---------------------------------------------------------------
824
931
 
932
+ # Build the per-request resolution context threaded through the type
933
+ # visitor so that predicate filtering (+@requires+, +@feature+, ...)
934
+ # recurses into nested record-type aliases — not just the top-level
935
+ # fields (issue #23).
936
+ #
937
+ # +:raw_bodies+ holds the *un-stripped* record body for each named
938
+ # record alias (tags intact), so a referenced alias can be recompiled
939
+ # per request against +server_context+ via +compile_tagged_record+
940
+ # rather than served from the statically-resolved +type_map+ (which
941
+ # has already discarded its predicate tags). +:visiting+ tracks alias
942
+ # names currently being expanded so a self- or mutually-recursive type
943
+ # falls back to the static schema instead of looping forever.
944
+ #
945
+ #: (untyped, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
946
+ def build_rctx(server_context, cached)
947
+ {
948
+ server_context: server_context,
949
+ raw_bodies: cached[:raw_record_bodies] || {},
950
+ source_file: cached[:source_file],
951
+ visiting: []
952
+ }
953
+ end
954
+
955
+ # Resolve a named type reference during per-request compilation.
956
+ #
957
+ # If the name maps to a record alias whose raw body we retained, the
958
+ # alias is recompiled with +compile_tagged_record+ so its own
959
+ # predicate-gated fields are filtered against this request's
960
+ # +server_context+ — making gating work at any nesting depth. A name
961
+ # already on the +visiting+ stack (recursive type) or absent from
962
+ # +raw_bodies+ falls back to the statically-resolved +type_map+ entry,
963
+ # then to +fallback+.
964
+ #
965
+ #: (String, Hash[String, Hash[Symbol, untyped]], Hash[Symbol, untyped]?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
966
+ def resolve_named_type(name, type_map, rctx, fallback)
967
+ raw = rctx && rctx[:raw_bodies] && rctx[:raw_bodies][name]
968
+ if raw && !rctx[:visiting].include?(name)
969
+ child = rctx.merge(visiting: rctx[:visiting] + [name])
970
+ compile_tagged_record(raw, type_map, rctx[:server_context], source_file: rctx[:source_file], rctx: child)
971
+ else
972
+ type_map[name] || fallback
973
+ end
974
+ end
975
+
825
976
  # Returns true if any predicate tag on a field/variant evaluates to
826
977
  # false, meaning the field should be excluded from the schema.
827
978
  #
@@ -886,20 +1037,45 @@ module McpAuthorization
886
1037
  # @param type_map [Hash] Resolved type definitions for +$ref+ lookups.
887
1038
  # @param server_context [Object] Per-request context.
888
1039
  # @return [Hash] JSON Schema object with +properties+, +required+, etc.
889
- #: (String, Hash[String, Hash[Symbol, untyped]], untyped, ?source_file: String?) -> Hash[Symbol, untyped]
890
- def compile_tagged_record(raw_body, type_map, server_context, source_file: nil)
1040
+ #: (String, Hash[String, Hash[Symbol, untyped]], untyped, ?source_file: String?, ?rctx: Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
1041
+ def compile_tagged_record(raw_body, type_map, server_context, source_file: nil, rctx: nil)
1042
+ rctx ||= { server_context: server_context, raw_bodies: {}, source_file: source_file, visiting: [] }
891
1043
  properties = {}
892
1044
  required = []
893
1045
  dependent_required = {}
894
1046
 
895
1047
  each_field_in_record(raw_body) do |key, type_str|
1048
+ # A union field whose members carry their own predicate tags (e.g.
1049
+ # account-gated variants: `stage: a @feature(x) | b @feature(y)`)
1050
+ # routes through compile_tagged_union so each member is filtered per
1051
+ # request. The normal path's field-level extract_tags would otherwise
1052
+ # bind a member's tag to the whole field. Only triggers when the field
1053
+ # is a multi-member union AND carries a tag, so untagged union fields
1054
+ # keep the full RBS-library path (which handles inline records, etc.).
1055
+ if tagged_union_field?(type_str)
1056
+ clean_key, optional = parse_field_name(key, source_file: source_file)
1057
+ properties[clean_key.to_sym] = compile_tagged_union(type_str, type_map, server_context, rctx: rctx)
1058
+ required << clean_key unless optional
1059
+ next
1060
+ end
1061
+
1062
+ # Array of a tagged union — `items: Array[a @feature(x) | b @feature(y)]`.
1063
+ # The union's `|` is at bracket depth 1, so tagged_union_field? misses
1064
+ # it; gate the element union and wrap it back in an array schema.
1065
+ if (inner = tagged_array_union_inner(type_str))
1066
+ clean_key, optional = parse_field_name(key, source_file: source_file)
1067
+ properties[clean_key.to_sym] = { type: "array", items: compile_tagged_union(inner, type_map, server_context, rctx: rctx) }
1068
+ required << clean_key unless optional
1069
+ next
1070
+ end
1071
+
896
1072
  type_str, tags = extract_tags(type_str)
897
1073
 
898
1074
  next if predicate_excluded?(tags, server_context)
899
1075
 
900
1076
  clean_key, optional = parse_field_name(key, source_file: source_file)
901
1077
 
902
- schema = rbs_type_to_json_schema(type_str, type_map, source_file: source_file)
1078
+ schema = rbs_type_to_json_schema(type_str, type_map, source_file: source_file, rctx: rctx)
903
1079
  properties[clean_key.to_sym] = apply_tags(schema, tags, server_context: server_context)
904
1080
  required << clean_key unless optional
905
1081
 
@@ -915,6 +1091,29 @@ module McpAuthorization
915
1091
  schema
916
1092
  end
917
1093
 
1094
+ # True when a record field's type is a multi-member union that carries a
1095
+ # predicate tag — i.e. per-member gating is intended. A `|` at bracket
1096
+ # depth 0 marks a real union (not one inside a nested generic/record).
1097
+ #: (String) -> bool
1098
+ def tagged_union_field?(type_str)
1099
+ return false unless type_str.include?("@")
1100
+ split_at_depth_zero(type_str, "|").size > 1
1101
+ end
1102
+
1103
+ # If a field type is +Array[<multi-member tagged union>]+, return the inner
1104
+ # union expression so its members can be gated per request; else nil. The
1105
+ # union lives inside the +[]+ (bracket depth 1), so tagged_union_field?
1106
+ # (which only sees depth 0) does not match it.
1107
+ #: (String) -> String?
1108
+ def tagged_array_union_inner(type_str)
1109
+ return nil unless type_str.include?("@")
1110
+ m = type_str.strip.match(/\AArray\[(.+)\]\z/m)
1111
+ return nil unless m
1112
+
1113
+ inner = m[1].to_s
1114
+ split_at_depth_zero(inner, "|").size > 1 ? inner : nil
1115
+ end
1116
+
918
1117
  # Compile a union-style output type (+# @rbs type output = success | admin_detail @requires(:admin)+)
919
1118
  # with variant-level predicate filtering.
920
1119
  #
@@ -927,14 +1126,15 @@ module McpAuthorization
927
1126
  # @param type_map [Hash] Resolved type definitions.
928
1127
  # @param server_context [Object] Per-request context.
929
1128
  # @return [Hash] JSON Schema — either a single schema or a +oneOf+ wrapper.
930
- #: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
931
- def compile_tagged_union(raw_expr, type_map, server_context)
1129
+ #: (String, Hash[String, Hash[Symbol, untyped]], untyped, ?rctx: Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
1130
+ def compile_tagged_union(raw_expr, type_map, server_context, rctx: nil)
1131
+ rctx ||= { server_context: server_context, raw_bodies: {}, source_file: nil, visiting: [] }
932
1132
  parts = split_at_depth_zero(raw_expr, "|").map(&:strip).reject(&:empty?)
933
1133
 
934
1134
  filtered = parts.filter_map do |part|
935
1135
  part, tags = extract_tags(part)
936
1136
  next nil if predicate_excluded?(tags, server_context)
937
- resolve_type(part, type_map)
1137
+ resolve_type(part, type_map, rctx)
938
1138
  end
939
1139
 
940
1140
  case filtered.size
@@ -953,8 +1153,9 @@ module McpAuthorization
953
1153
  # @param type_map [Hash] Resolved type definitions.
954
1154
  # @param server_context [Object] Per-request context.
955
1155
  # @return [Hash] Partial JSON Schema (+properties+, +required+, etc.).
956
- #: (Array[Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
957
- def filter_call_signature(call_params, type_map, server_context)
1156
+ #: (Array[Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]], untyped, ?rctx: Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
1157
+ def filter_call_signature(call_params, type_map, server_context, rctx: nil)
1158
+ rctx ||= { server_context: server_context, raw_bodies: {}, source_file: nil, visiting: [] }
958
1159
  properties = {}
959
1160
  required = []
960
1161
  dependent_required = {}
@@ -962,7 +1163,7 @@ module McpAuthorization
962
1163
  call_params.each do |param|
963
1164
  next if predicate_excluded?(param[:tags], server_context)
964
1165
 
965
- schema = rbs_type_to_json_schema(param[:type], type_map)
1166
+ schema = rbs_type_to_json_schema(param[:type], type_map, rctx: rctx)
966
1167
  properties[param[:name].to_sym] = apply_tags(schema, param[:tags], server_context: server_context)
967
1168
  required << param[:name] if param[:required]
968
1169
 
@@ -1010,6 +1211,27 @@ module McpAuthorization
1010
1211
  type_map
1011
1212
  end
1012
1213
 
1214
+ # Like +load_imports+, but returns the raw record bodies (tags
1215
+ # intact) from each imported +.rbs+ file so a predicate-gated field
1216
+ # inside a shared type is filtered per request — see +build_rctx+
1217
+ # (issue #23). Without this, gates authored in +sig/shared/*.rbs+
1218
+ # would parse but never fire.
1219
+ #: (String) -> Hash[String, String]
1220
+ def load_import_raw_bodies(content)
1221
+ return {} if content.empty?
1222
+
1223
+ imports = content.scan(/# @rbs import (\S+)/).flatten
1224
+ return {} if imports.empty?
1225
+
1226
+ raw = {}
1227
+ imports.each do |import_path|
1228
+ rbs_file = resolve_import_path(import_path)
1229
+ next unless rbs_file && File.exist?(rbs_file)
1230
+ raw.merge!(cached_rbs_record_bodies(rbs_file))
1231
+ end
1232
+ raw
1233
+ end
1234
+
1013
1235
  # Parse a shared +.rbs+ file with mtime-based caching. If the file
1014
1236
  # hasn't changed since the last parse, the cached result is returned.
1015
1237
  #
@@ -1017,16 +1239,62 @@ module McpAuthorization
1017
1239
  # @return [Hash{String => Hash}] Type name → JSON Schema map.
1018
1240
  #: (String) -> Hash[String, Hash[Symbol, untyped]]
1019
1241
  def cached_parse_rbs_file(path)
1242
+ cached_rbs_entry(path)[:result]
1243
+ end
1244
+
1245
+ # The raw record-body subset of a shared +.rbs+ file's aliases:
1246
+ # name → raw +"{ ... }"+ body string (tags intact). Shares the
1247
+ # mtime-keyed +shared_type_cache+ entry with +cached_parse_rbs_file+
1248
+ # so a file is read and parsed at most once per mtime.
1249
+ #: (String) -> Hash[String, String]
1250
+ def cached_rbs_record_bodies(path)
1251
+ cached_rbs_entry(path)[:raw_record_bodies]
1252
+ end
1253
+
1254
+ # Build (or fetch) the mtime-keyed cache entry for a shared +.rbs+
1255
+ # file, holding both the resolved type map (+:result+) and the raw
1256
+ # record bodies (+:raw_record_bodies+).
1257
+ #: (String) -> Hash[Symbol, untyped]
1258
+ def cached_rbs_entry(path)
1020
1259
  mtime = File.mtime(path)
1021
1260
  cached = shared_type_cache[path]
1261
+ return cached if cached && cached[:mtime] == mtime
1262
+
1263
+ aliases = collect_rbs_file_aliases(File.read(path))
1264
+ entry = {
1265
+ mtime: mtime,
1266
+ result: resolve_collected_aliases(aliases, source_file: path),
1267
+ raw_record_bodies: aliases.select { |_, v| v.is_a?(String) },
1268
+ raw_aliases: aliases
1269
+ }
1270
+ shared_type_cache[path] = entry
1271
+ end
1022
1272
 
1023
- if cached && cached[:mtime] == mtime
1024
- return cached[:result]
1025
- end
1273
+ # The raw (unresolved) alias map of a shared +.rbs+ file — both record
1274
+ # bodies (String) and pre-resolved string-union schemas. Used to resolve
1275
+ # imports as one merged set so cross-file references resolve.
1276
+ #: (String) -> Hash[String, untyped]
1277
+ def cached_rbs_aliases(path)
1278
+ cached_rbs_entry(path)[:raw_aliases]
1279
+ end
1026
1280
 
1027
- result = parse_rbs_file(path)
1028
- shared_type_cache[path] = { mtime: mtime, result: result }
1029
- result
1281
+ # Merge the raw aliases from every +# @rbs import+ed file in +content+.
1282
+ # Returned unresolved so the caller can resolve imports + local aliases
1283
+ # together (cross-file references resolve; see build_cache).
1284
+ #: (String) -> Hash[String, untyped]
1285
+ def load_import_aliases(content)
1286
+ return {} if content.empty?
1287
+
1288
+ imports = content.scan(/# @rbs import (\S+)/).flatten
1289
+ return {} if imports.empty?
1290
+
1291
+ raw = {}
1292
+ imports.each do |import_path|
1293
+ rbs_file = resolve_import_path(import_path)
1294
+ next unless rbs_file && File.exist?(rbs_file)
1295
+ raw.merge!(cached_rbs_aliases(rbs_file))
1296
+ end
1297
+ raw
1030
1298
  end
1031
1299
 
1032
1300
  # Resolve a bare import name (e.g. +"common_types"+) to an absolute
@@ -1036,11 +1304,16 @@ module McpAuthorization
1036
1304
  # @return [String, nil] Absolute file path, or nil if not found.
1037
1305
  #: (String) -> String?
1038
1306
  def resolve_import_path(import_path)
1039
- return nil unless defined?(Rails)
1040
-
1041
1307
  McpAuthorization.config.shared_type_paths.each do |base|
1042
- candidate = Rails.root.join(base, "#{import_path}.rbs")
1043
- return candidate.to_s if File.exist?(candidate)
1308
+ candidate =
1309
+ if base.to_s.start_with?(File::SEPARATOR) # absolute base (tests / non-Rails hosts)
1310
+ File.join(base.to_s, "#{import_path}.rbs")
1311
+ elsif defined?(Rails)
1312
+ Rails.root.join(base, "#{import_path}.rbs").to_s
1313
+ else # relative base, no Rails → resolve from CWD
1314
+ File.join(Dir.pwd, base.to_s, "#{import_path}.rbs")
1315
+ end
1316
+ return candidate if File.exist?(candidate)
1044
1317
  end
1045
1318
  nil
1046
1319
  end
@@ -1062,38 +1335,49 @@ module McpAuthorization
1062
1335
  # @return [Hash{String => Hash}] Type name → resolved JSON Schema.
1063
1336
  #: (String) -> Hash[String, Hash[Symbol, untyped]]
1064
1337
  def parse_rbs_file(path)
1065
- content = File.read(path)
1338
+ resolve_collected_aliases(collect_rbs_file_aliases(File.read(path)), source_file: path)
1339
+ end
1340
+
1341
+ # Collect the +type X = ...+ aliases from a bare +.rbs+ file (no +#+
1342
+ # comment markers) into a name → raw value map, mirroring
1343
+ # +collect_inline_aliases+ for the shared-types format. Record bodies
1344
+ # stay raw (tags intact); string-literal unions resolve eagerly.
1345
+ #: (String) -> Hash[String, untyped]
1346
+ def collect_rbs_file_aliases(content)
1066
1347
  aliases = {}
1067
1348
  current_name = nil #: String?
1349
+ current_base = nil #: String?
1068
1350
  current_body = +""
1069
1351
 
1070
1352
  content.each_line do |line|
1071
1353
  stripped = line.strip
1072
1354
 
1073
- if stripped =~ /\Atype (\w+) = \{/
1355
+ if stripped =~ /\Atype (\w+) = (\w+) & \{/
1356
+ # Intersection: a base alias merged with an inline record, e.g.
1357
+ # type scheduler_stage = stage_common & { type: "SchedulerStage", ... }
1358
+ # Captured as {intersection: [base, body]} and resolved to an
1359
+ # allOf so the shared base hoists into $defs once (token dedup).
1360
+ current_name = $1.to_s
1361
+ current_base = $2.to_s
1362
+ current_body = "{"
1363
+ elsif stripped =~ /\Atype (\w+) = \{/
1074
1364
  current_name = $1.to_s
1365
+ current_base = nil
1075
1366
  current_body = "{"
1076
1367
  elsif stripped =~ /\Atype (\w+) = "([^"]+)"/
1077
1368
  aliases[$1.to_s] = parse_rbs_string_union($2.to_s, line, content)
1078
1369
  elsif current_name
1079
- current_body << stripped
1370
+ current_body << strip_rbs_comment(stripped)
1080
1371
  if brace_balanced?(current_body)
1081
- aliases[current_name] = current_body
1372
+ aliases[current_name] = current_base ? { intersection: [current_base, current_body] } : current_body
1082
1373
  current_name = nil
1374
+ current_base = nil
1083
1375
  current_body = +""
1084
1376
  end
1085
1377
  end
1086
1378
  end
1087
1379
 
1088
- resolved = {}
1089
- aliases.each do |name, value|
1090
- resolved[name] = if value.is_a?(String)
1091
- parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)), source_file: path)
1092
- else
1093
- value
1094
- end
1095
- end
1096
- resolved
1380
+ aliases
1097
1381
  end
1098
1382
 
1099
1383
  # Parse a multi-line string literal union from an .rbs file:
@@ -1138,7 +1422,7 @@ module McpAuthorization
1138
1422
  if line =~ /# @rbs type #{pattern} = \{/
1139
1423
  body = "{"
1140
1424
  rest.each do |next_line|
1141
- stripped = next_line.strip.sub(/^#\s*/, "")
1425
+ stripped = strip_rbs_comment(next_line.strip.sub(/^#\s*/, ""))
1142
1426
  body << stripped
1143
1427
  return { kind: :record, body: body } if brace_balanced?(body)
1144
1428
  end
@@ -1175,7 +1459,18 @@ module McpAuthorization
1175
1459
  #: (String, ?source_file: String?) -> Hash[String, Hash[Symbol, untyped]]
1176
1460
  def parse_type_aliases(content, source_file: nil)
1177
1461
  return {} if content.empty?
1462
+ resolve_collected_aliases(collect_inline_aliases(content), source_file: source_file)
1463
+ end
1178
1464
 
1465
+ # Collect the +# @rbs type+ aliases declared in handler source into a
1466
+ # map of name → raw value: record types stay as their un-resolved
1467
+ # body string (+"{ ... }"+, tags intact); string-literal unions are
1468
+ # resolved eagerly to +{type: "string", enum: [...]}+ since they
1469
+ # carry no per-request predicates. Shared between +parse_type_aliases+
1470
+ # (which resolves the bodies) and +collect_inline_record_bodies+
1471
+ # (which keeps them raw for nested per-request filtering).
1472
+ #: (String) -> Hash[String, untyped]
1473
+ def collect_inline_aliases(content)
1179
1474
  aliases = {}
1180
1475
  current_name = nil #: String?
1181
1476
  current_body = +""
@@ -1187,7 +1482,7 @@ module McpAuthorization
1187
1482
  elsif line =~ /# @rbs type (\w+) = "([^"]+)"/
1188
1483
  aliases[$1.to_s] = parse_string_union($2.to_s, line, content)
1189
1484
  elsif current_name
1190
- stripped = line.strip.sub(/^#\s*/, "")
1485
+ stripped = strip_rbs_comment(line.strip.sub(/^#\s*/, ""))
1191
1486
  current_body << stripped
1192
1487
  if brace_balanced?(current_body)
1193
1488
  aliases[current_name] = current_body
@@ -1197,14 +1492,48 @@ module McpAuthorization
1197
1492
  end
1198
1493
  end
1199
1494
 
1495
+ aliases
1496
+ end
1497
+
1498
+ # The record-body subset of +collect_inline_aliases+: name → raw
1499
+ # +"{ ... }"+ body string for each handler-local record alias.
1500
+ #: (String) -> Hash[String, String]
1501
+ def collect_inline_record_bodies(content)
1502
+ return {} if content.empty?
1503
+ collect_inline_aliases(content).select { |_, v| v.is_a?(String) }
1504
+ end
1505
+
1506
+ # Resolve a collected alias map (name → raw record body | resolved
1507
+ # Hash) into a name → JSON Schema type map. Forward references resolve
1508
+ # via +aliases_to_schemas+ placeholders.
1509
+ #: (Hash[String, untyped], ?source_file: String?) -> Hash[String, Hash[Symbol, untyped]]
1510
+ def resolve_collected_aliases(aliases, source_file: nil)
1200
1511
  resolved = {}
1512
+
1513
+ # Pass 1: plain record bodies and already-resolved values (string
1514
+ # unions). Intersection bases are plain records, so this guarantees a
1515
+ # base is fully resolved before any intersection that references it.
1201
1516
  aliases.each do |name, value|
1517
+ next if value.is_a?(Hash) && value[:intersection]
1202
1518
  resolved[name] = if value.is_a?(String)
1203
1519
  parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)), source_file: source_file)
1204
1520
  else
1205
1521
  value
1206
1522
  end
1207
1523
  end
1524
+
1525
+ # Pass 2: intersections → allOf[base, record]. The base schema is the
1526
+ # fully-resolved shared type (same object across every member), so
1527
+ # with_ref_injection sees it used many times and hoists it into $defs.
1528
+ aliases.each do |name, value|
1529
+ next unless value.is_a?(Hash) && value[:intersection]
1530
+ base_name, body = value[:intersection]
1531
+ merged = resolved.merge(aliases_to_schemas(aliases, resolved))
1532
+ base_schema = resolved[base_name] || merged[base_name] || { type: "object" }
1533
+ record_schema = parse_record_type(body, merged, source_file: source_file)
1534
+ resolved[name] = { allOf: [base_schema, record_schema] }
1535
+ end
1536
+
1208
1537
  resolved
1209
1538
  end
1210
1539
 
@@ -1324,6 +1653,42 @@ module McpAuthorization
1324
1653
  # which silently dropped any field whose type string contained a
1325
1654
  # comma — or worse, split that field at the comma.
1326
1655
  #
1656
+ # Strip an RBS line comment (+#+ to end-of-line) from a single line.
1657
+ #
1658
+ # RBS treats +#+ as a comment marker everywhere outside string
1659
+ # literals — the official lexer discards it before parsing. The
1660
+ # line-based readers here (+find_raw_type_body+, +parse_type_aliases+,
1661
+ # +parse_rbs_file+) concatenate record-body lines *without* a newline
1662
+ # separator, so a comment authored inside a record body
1663
+ # (+{ # note\n id: String }+) would otherwise fold into the next
1664
+ # field name and blow up +parse_field_name+ (issue #20).
1665
+ #
1666
+ # We scan character by character so a +#+ inside a string literal or
1667
+ # inside a bracketed annotation value (e.g. +@desc(a # b)+) is left
1668
+ # untouched; only a +#+ at bracket depth 0 outside any string starts
1669
+ # a comment.
1670
+ #: (String) -> String
1671
+ def strip_rbs_comment(line)
1672
+ depth = 0
1673
+ in_string = nil #: String?
1674
+
1675
+ line.each_char.with_index do |ch, i|
1676
+ if in_string
1677
+ in_string = nil if ch == in_string
1678
+ elsif ch == '"' || ch == "'"
1679
+ in_string = ch
1680
+ elsif ch == "(" || ch == "[" || ch == "{"
1681
+ depth += 1
1682
+ elsif ch == ")" || ch == "]" || ch == "}"
1683
+ depth -= 1
1684
+ elsif ch == "#" && depth <= 0
1685
+ return line[0...i].to_s.rstrip
1686
+ end
1687
+ end
1688
+
1689
+ line
1690
+ end
1691
+
1327
1692
  #: (String) { (String, String) -> void } -> void
1328
1693
  def each_field_in_record(body)
1329
1694
  inner = body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
@@ -1357,8 +1722,8 @@ module McpAuthorization
1357
1722
  # @param rbs_type [String] RBS type expression.
1358
1723
  # @param type_map [Hash] Resolved type definitions for named type lookups.
1359
1724
  # @return [Hash] JSON Schema hash.
1360
- #: (String, ?Hash[String, Hash[Symbol, untyped]], ?source_file: String?) -> Hash[Symbol, untyped]
1361
- def rbs_type_to_json_schema(rbs_type, type_map = {}, source_file: nil)
1725
+ #: (String, ?Hash[String, Hash[Symbol, untyped]], ?source_file: String?, ?rctx: Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
1726
+ def rbs_type_to_json_schema(rbs_type, type_map = {}, source_file: nil, rctx: nil)
1362
1727
  stripped = rbs_type.strip
1363
1728
  return { type: "string" } if stripped.empty?
1364
1729
 
@@ -1375,7 +1740,7 @@ module McpAuthorization
1375
1740
  return type_map[stripped] || { type: "string" }
1376
1741
  end
1377
1742
 
1378
- visit_rbs_type(ast, type_map)
1743
+ visit_rbs_type(ast, type_map, rctx)
1379
1744
  end
1380
1745
 
1381
1746
  # AST visitor: convert an +RBS::Types::*+ node into JSON Schema.
@@ -1389,34 +1754,44 @@ module McpAuthorization
1389
1754
  # @param node [RBS::Types::t] AST node from +RBS::Parser.parse_type+.
1390
1755
  # @param type_map [Hash] Resolved named-type definitions.
1391
1756
  # @return [Hash] JSON Schema fragment.
1392
- #: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
1393
- def visit_rbs_type(node, type_map)
1757
+ #: (untyped, Hash[String, Hash[Symbol, untyped]], ?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
1758
+ def visit_rbs_type(node, type_map, rctx = nil)
1394
1759
  case node
1395
1760
  when RBS::Types::Bases::Bool
1396
1761
  { type: "boolean" }
1397
1762
  when RBS::Types::Bases::Any, RBS::Types::Bases::Void, RBS::Types::Bases::Nil
1398
- # untyped / void / nil → no constraint (LLM can pass anything)
1399
- { type: "string" }
1763
+ # untyped / void / nil → no constraint (LLM can pass anything).
1764
+ # The empty schema {} is JSON Schema's "any value"; emitting a
1765
+ # concrete type here (e.g. {type: "string"}) silently rejects
1766
+ # objects/arrays at tools/call — see issue #22. In particular
1767
+ # Hash[K, untyped] becomes {type: "object", additionalProperties: {}}
1768
+ # (any property value allowed) rather than forcing string values.
1769
+ {}
1400
1770
  when RBS::Types::Literal
1401
1771
  visit_rbs_literal(node)
1402
1772
  when RBS::Types::Optional
1403
1773
  # Optional wraps a type; nullability is handled at field
1404
1774
  # level (required-set), not in the JSON Schema type itself.
1405
- visit_rbs_type(node.type, type_map)
1775
+ visit_rbs_type(node.type, type_map, rctx)
1406
1776
  when RBS::Types::Union
1407
- visit_rbs_union(node, type_map)
1777
+ visit_rbs_union(node, type_map, rctx)
1778
+ when RBS::Types::Intersection
1779
+ # A & B → allOf. Lets a field reuse a shared base type plus extra
1780
+ # constraints; mirrors the alias-level intersection handled by
1781
+ # collect_rbs_file_aliases / resolve_collected_aliases.
1782
+ { allOf: node.types.map { |t| visit_rbs_type(t, type_map, rctx) } }
1408
1783
  when RBS::Types::Record
1409
- visit_rbs_record(node, type_map)
1784
+ visit_rbs_record(node, type_map, rctx)
1410
1785
  when RBS::Types::Tuple
1411
1786
  # RBS tuples have heterogeneous element types; JSON Schema's
1412
1787
  # closest analog is array with prefixItems, but for simplicity
1413
1788
  # we project to a plain array.
1414
1789
  { type: "array" }
1415
1790
  when RBS::Types::ClassInstance
1416
- visit_rbs_class_instance(node, type_map)
1791
+ visit_rbs_class_instance(node, type_map, rctx)
1417
1792
  when RBS::Types::Alias
1418
1793
  name = node.name.to_s.sub(/\A::/, "")
1419
- type_map[name] || { type: "string" }
1794
+ resolve_named_type(name, type_map, rctx, { type: "string" })
1420
1795
  else
1421
1796
  # Interface, Proc, Variable, ClassSingleton, Bases::Self/Class/Instance/Top/Bottom etc.
1422
1797
  { type: "string" }
@@ -1427,8 +1802,8 @@ module McpAuthorization
1427
1802
  # Ruby primitives we care about (+String+, +Integer+, +Float+),
1428
1803
  # generic +Array[T]+ / +Hash[K, V]+, and falls back to the
1429
1804
  # +type_map+ for user-defined names.
1430
- #: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
1431
- def visit_rbs_class_instance(node, type_map)
1805
+ #: (untyped, Hash[String, Hash[Symbol, untyped]], ?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
1806
+ def visit_rbs_class_instance(node, type_map, rctx = nil)
1432
1807
  name = node.name.to_s.sub(/\A::/, "")
1433
1808
  case name
1434
1809
  when "String"
@@ -1443,17 +1818,17 @@ module McpAuthorization
1443
1818
  { type: "boolean", const: false }
1444
1819
  when "Array"
1445
1820
  inner = node.args.first
1446
- inner ? { type: "array", items: visit_rbs_type(inner, type_map) } : { type: "array" }
1821
+ inner ? { type: "array", items: visit_rbs_type(inner, type_map, rctx) } : { type: "array" }
1447
1822
  when "Hash"
1448
1823
  # Hash[K, V] — JSON Schema can express V as additionalProperties.
1449
1824
  val = node.args[1]
1450
- val ? { type: "object", additionalProperties: visit_rbs_type(val, type_map) } : { type: "object" }
1825
+ val ? { type: "object", additionalProperties: visit_rbs_type(val, type_map, rctx) } : { type: "object" }
1451
1826
  when "Symbol"
1452
1827
  { type: "string" }
1453
1828
  when "NilClass"
1454
1829
  { type: "string" }
1455
1830
  else
1456
- type_map[name] || { type: "string" }
1831
+ resolve_named_type(name, type_map, rctx, { type: "string" })
1457
1832
  end
1458
1833
  end
1459
1834
 
@@ -1476,8 +1851,8 @@ module McpAuthorization
1476
1851
  # literals becomes a +{type: "string", enum: [...]}+; any other
1477
1852
  # mix becomes +{oneOf: [...]}+. This mirrors the prior regex
1478
1853
  # parser's behavior so existing schemas don't drift.
1479
- #: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
1480
- def visit_rbs_union(node, type_map)
1854
+ #: (untyped, Hash[String, Hash[Symbol, untyped]], ?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
1855
+ def visit_rbs_union(node, type_map, rctx = nil)
1481
1856
  types = node.types
1482
1857
 
1483
1858
  # All string literals → enum.
@@ -1493,7 +1868,7 @@ module McpAuthorization
1493
1868
  return { type: "boolean" }
1494
1869
  end
1495
1870
 
1496
- { oneOf: types.map { |t| visit_rbs_type(t, type_map) } }
1871
+ { oneOf: types.map { |t| visit_rbs_type(t, type_map, rctx) } }
1497
1872
  end
1498
1873
 
1499
1874
  # Map +RBS::Types::Record+ to a JSON Schema object. RBS handles
@@ -1506,18 +1881,18 @@ module McpAuthorization
1506
1881
  # records reached via +# @rbs type input = { ... }+ go through
1507
1882
  # +compile_tagged_record+ instead so per-field tag extraction can
1508
1883
  # happen before the type is parsed.
1509
- #: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
1510
- def visit_rbs_record(node, type_map)
1884
+ #: (untyped, Hash[String, Hash[Symbol, untyped]], ?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
1885
+ def visit_rbs_record(node, type_map, rctx = nil)
1511
1886
  properties = {}
1512
1887
  required = []
1513
1888
 
1514
1889
  node.fields.each do |name, type|
1515
1890
  key = name.to_s
1516
- properties[key.to_sym] = visit_rbs_type(type, type_map)
1891
+ properties[key.to_sym] = visit_rbs_type(type, type_map, rctx)
1517
1892
  required << key
1518
1893
  end
1519
1894
  node.optional_fields.each do |name, type|
1520
- properties[name.to_s.to_sym] = visit_rbs_type(type, type_map)
1895
+ properties[name.to_s.to_sym] = visit_rbs_type(type, type_map, rctx)
1521
1896
  end
1522
1897
 
1523
1898
  schema = { type: "object", properties: properties } #: Hash[Symbol, untyped]
@@ -1527,9 +1902,9 @@ module McpAuthorization
1527
1902
 
1528
1903
  # Look up a named type in the type map. Returns a bare +{type: "object"}+
1529
1904
  # if the name is not found (defensive fallback).
1530
- #: (String, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
1531
- def resolve_type(name, type_map)
1532
- type_map[name] || { type: "object" }
1905
+ #: (String, Hash[String, Hash[Symbol, untyped]], ?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
1906
+ def resolve_type(name, type_map, rctx = nil)
1907
+ resolve_named_type(name, type_map, rctx, { type: "object" })
1533
1908
  end
1534
1909
 
1535
1910
  # Wrap a partial schema (with +properties+, +required+, etc.) in a
@@ -1632,14 +2007,68 @@ module McpAuthorization
1632
2007
  multi = usage.select { |_, c| c > 1 }
1633
2008
  return schema if multi.empty?
1634
2009
 
2010
+ replaced = deep_replace(schema, multi, type_schemas)
2011
+
2012
+ # Ref-inject *within* each hoisted def too: a def's body may itself
2013
+ # contain another multi-use type (e.g. a shared base that inlines a
2014
+ # template referenced elsewhere). Replace those with $ref — excluding
2015
+ # the def's own name so a body never self-references — so each shared
2016
+ # type is spelled out once in $defs rather than re-inlined inside
2017
+ # another def. Without this, hoisting a large base left the base's
2018
+ # nested types inlined AND duplicated as unreferenced defs.
1635
2019
  defs = {}
1636
- multi.each_key { |name| defs[name] = type_schemas[name] }
2020
+ multi.each_key do |name|
2021
+ others = multi.reject { |k, _| k == name }
2022
+ defs[name] = deep_replace(type_schemas[name], others, type_schemas)
2023
+ end
1637
2024
 
1638
- replaced = deep_replace(schema, multi, type_schemas)
1639
- replaced[:"$defs"] = defs
2025
+ # Drop defs that nothing references (transitively from the main
2026
+ # schema). Hoisting can orphan a type whose every occurrence ended up
2027
+ # inside another def that was itself replaced by a $ref.
2028
+ defs = prune_unreferenced_defs(replaced, defs)
2029
+
2030
+ replaced[:"$defs"] = defs unless defs.empty?
1640
2031
  replaced
1641
2032
  end
1642
2033
 
2034
+ # Names of +$defs+ reachable (transitively) from +root+. Used to drop
2035
+ # hoisted-but-unreferenced defs.
2036
+ #: (Hash[Symbol, untyped], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
2037
+ def prune_unreferenced_defs(root, defs)
2038
+ reachable = [] #: Array[String]
2039
+ frontier = referenced_def_names(root)
2040
+ until frontier.empty?
2041
+ name = frontier.shift
2042
+ next if reachable.include?(name)
2043
+ reachable << name
2044
+ referenced_def_names(defs[name]).each { |n| frontier << n } if defs[name]
2045
+ end
2046
+ defs.select { |name, _| reachable.include?(name) }
2047
+ end
2048
+
2049
+ # Collect every +#/$defs/<name>+ target referenced anywhere in +node+.
2050
+ #: (untyped) -> Array[String]
2051
+ def referenced_def_names(node)
2052
+ names = [] #: Array[String]
2053
+ stack = [node] #: Array[untyped]
2054
+ until stack.empty?
2055
+ n = stack.pop
2056
+ case n
2057
+ when Hash
2058
+ n.each do |k, v|
2059
+ if (k == :"$ref" || k == "$ref") && v.is_a?(String)
2060
+ names << v.split("/").last.to_s
2061
+ else
2062
+ stack << v
2063
+ end
2064
+ end
2065
+ when Array
2066
+ n.each { |e| stack << e }
2067
+ end
2068
+ end
2069
+ names
2070
+ end
2071
+
1643
2072
  # Walk the schema tree and count how many times each named type's
1644
2073
  # schema appears as a value. Only types with count > 1 are worth
1645
2074
  # extracting into +$defs+.
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.5.2"
2
+ VERSION = "0.5.5"
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.5.2
4
+ version: 0.5.5
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-28 00:00:00.000000000 Z
11
+ date: 2026-06-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails