mcp_authorization 0.5.3 → 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: 003724dd47fae4de46be5fc319fb0b72f7154fff97f491dcca2251abadf81f32
4
- data.tar.gz: 4df5f0cc01cb38310a3c2d1a27e3beedcb8c6bb3538a2dd109702fa7a8d9c261
3
+ metadata.gz: d6b7e3511b7790b3da9c74c48f4df83c87adc11a875fc0e822c153c33b6a72e9
4
+ data.tar.gz: c00c25c116df33f17126a8b45105db079e8180c9b4b2895f1e50db0131fb9dfe
5
5
  SHA512:
6
- metadata.gz: 96ec8e376316d4fd63ed9cb1350b730138c233152f54dd1fea15ff8608dada5679efbefb0d698bb32eaa6d843a1b15f62d54e5a342f425aaba6dd50cab71c1e7
7
- data.tar.gz: 7215cf662c1bf0a87aab6887fbc88cb59cd5cac24f48d96dc7f490fd99acf7835a813bc9f621b36904a09c1ac9639cf2a66330ff189b1dee0350e57f45cc50fb
6
+ metadata.gz: 2784b842b815610bf06752607b26332568076ce84b675dbc902e1ea0a858eac18e1e41d3b8cce812bce64b95f76266885fb6b43c4388742ac89e861d9d4f4639
7
+ data.tar.gz: 5fcb42dac47e116c9223c23aabc5c76884e756c06c2b8dc1ace1be6014fbd44560746458f54890653e597aaa4152bdb2e881c6b32d5ec1fba7f957e86939a2ed
data/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ 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
+
7
35
  ## [0.5.3] - 2026-06-03
8
36
 
9
37
  ### Fixed
@@ -97,12 +97,13 @@ module McpAuthorization
97
97
  #: (untyped, server_context: untyped) -> Hash[Symbol, untyped]
98
98
  def compile_input(handler_class, server_context:)
99
99
  cached = cache_for(handler_class)
100
+ rctx = build_rctx(server_context, cached)
100
101
 
101
102
  schema = if cached[:raw_input]&.dig(:kind) == :record
102
- 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)
103
104
  else
104
105
  build_input_schema(
105
- 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)
106
107
  )
107
108
  end
108
109
 
@@ -118,7 +119,7 @@ module McpAuthorization
118
119
  cached = cache_for(handler_class)
119
120
 
120
121
  if cached[:raw_output]&.dig(:kind) == :union
121
- 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))
122
123
  schema = with_ref_injection(schema, cached[:type_map])
123
124
  return McpAuthorization.config.strict_schema ? strict_sanitize(schema) : schema
124
125
  end
@@ -245,12 +246,13 @@ module McpAuthorization
245
246
  #: (untyped, server_context: untyped) -> Hash[Symbol, untyped]
246
247
  def compile_input_for_filter(handler_class, server_context:)
247
248
  cached = cache_for(handler_class)
249
+ rctx = build_rctx(server_context, cached)
248
250
 
249
251
  schema = if cached[:raw_input]&.dig(:kind) == :record
250
- 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)
251
253
  else
252
254
  build_input_schema(
253
- 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)
254
256
  )
255
257
  end
256
258
 
@@ -264,7 +266,7 @@ module McpAuthorization
264
266
  cached = cache_for(handler_class)
265
267
  return nil unless cached[:raw_output]&.dig(:kind) == :union
266
268
 
267
- 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))
268
270
  with_ref_injection(schema, cached[:type_map])
269
271
  end
270
272
 
@@ -308,19 +310,40 @@ module McpAuthorization
308
310
 
309
311
  variants = schema[:oneOf] || schema[:anyOf]
310
312
  if variants.is_a?(Array) && !variants.empty?
311
- 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) }
312
317
  best = best_variant_for(value, resolved)
313
318
  return best ? project_against_schema(value, best, defs) : value
314
319
  end
315
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
+
316
324
  case schema[:type]
317
325
  when "object"
318
326
  return value unless value.is_a?(Hash)
319
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]
320
336
  value.each_with_object({}) do |(k, v), acc|
321
337
  prop_schema = props[k.to_sym] || props[k.to_s] || props[k]
322
- next unless prop_schema
323
- 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
324
347
  end
325
348
  when "array"
326
349
  return value unless value.is_a?(Array)
@@ -333,7 +356,14 @@ module McpAuthorization
333
356
 
334
357
  # Choose the best-matching variant of a union for a given value.
335
358
  #
336
- # 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
337
367
  # +properties+, minus how many are unknown. Disqualify variants missing
338
368
  # any of the value's keys from their +required+ list that isn't present.
339
369
  # Returns nil if no variant can accommodate the value — in which case
@@ -342,14 +372,15 @@ module McpAuthorization
342
372
  def best_variant_for(value, variants)
343
373
  return variants.first unless value.is_a?(Hash)
344
374
 
375
+ value_keys = value.keys.map(&:to_s)
345
376
  scored = variants.filter_map do |variant|
346
377
  next unless variant.is_a?(Hash) && variant[:type] == "object"
347
378
  props = variant[:properties] || {}
348
379
  prop_keys = props.keys.map(&:to_s)
349
- value_keys = value.keys.map(&:to_s)
350
380
  required = (variant[:required] || []).map(&:to_s)
351
381
 
352
382
  next if (required - value_keys).any?
383
+ next if const_discriminator_mismatch?(value, props)
353
384
 
354
385
  known = (value_keys & prop_keys).size
355
386
  unknown = (value_keys - prop_keys).size
@@ -360,6 +391,53 @@ module McpAuthorization
360
391
  scored.max_by { |score, _| score }&.last
361
392
  end
362
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
+
363
441
  # ---------------------------------------------------------------
364
442
  # Tag extraction — unified parser for all @tag(...) annotations
365
443
  # ---------------------------------------------------------------
@@ -822,17 +900,28 @@ module McpAuthorization
822
900
  source_file = find_source_file(handler_class)
823
901
  content = source_file && File.exist?(source_file) ? File.read(source_file) : ""
824
902
 
825
- # Build type map: shared imports first, then handler's own types override
826
- imported = load_imports(content)
827
- local = parse_type_aliases(content, source_file: source_file)
828
- 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))
829
917
 
830
918
  {
831
919
  type_map: type_map,
832
920
  raw_input: find_raw_type_body(content, "input"),
833
921
  raw_output: find_raw_type_body(content, "output"),
834
922
  call_params: parse_call_params(content, source_file: source_file),
835
- source_file: source_file
923
+ source_file: source_file,
924
+ raw_record_bodies: raw_record_bodies
836
925
  }
837
926
  end
838
927
 
@@ -840,6 +929,50 @@ module McpAuthorization
840
929
  # Predicate filtering — the per-request compile phase
841
930
  # ---------------------------------------------------------------
842
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
+
843
976
  # Returns true if any predicate tag on a field/variant evaluates to
844
977
  # false, meaning the field should be excluded from the schema.
845
978
  #
@@ -904,20 +1037,45 @@ module McpAuthorization
904
1037
  # @param type_map [Hash] Resolved type definitions for +$ref+ lookups.
905
1038
  # @param server_context [Object] Per-request context.
906
1039
  # @return [Hash] JSON Schema object with +properties+, +required+, etc.
907
- #: (String, Hash[String, Hash[Symbol, untyped]], untyped, ?source_file: String?) -> Hash[Symbol, untyped]
908
- 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: [] }
909
1043
  properties = {}
910
1044
  required = []
911
1045
  dependent_required = {}
912
1046
 
913
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
+
914
1072
  type_str, tags = extract_tags(type_str)
915
1073
 
916
1074
  next if predicate_excluded?(tags, server_context)
917
1075
 
918
1076
  clean_key, optional = parse_field_name(key, source_file: source_file)
919
1077
 
920
- 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)
921
1079
  properties[clean_key.to_sym] = apply_tags(schema, tags, server_context: server_context)
922
1080
  required << clean_key unless optional
923
1081
 
@@ -933,6 +1091,29 @@ module McpAuthorization
933
1091
  schema
934
1092
  end
935
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
+
936
1117
  # Compile a union-style output type (+# @rbs type output = success | admin_detail @requires(:admin)+)
937
1118
  # with variant-level predicate filtering.
938
1119
  #
@@ -945,14 +1126,15 @@ module McpAuthorization
945
1126
  # @param type_map [Hash] Resolved type definitions.
946
1127
  # @param server_context [Object] Per-request context.
947
1128
  # @return [Hash] JSON Schema — either a single schema or a +oneOf+ wrapper.
948
- #: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
949
- 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: [] }
950
1132
  parts = split_at_depth_zero(raw_expr, "|").map(&:strip).reject(&:empty?)
951
1133
 
952
1134
  filtered = parts.filter_map do |part|
953
1135
  part, tags = extract_tags(part)
954
1136
  next nil if predicate_excluded?(tags, server_context)
955
- resolve_type(part, type_map)
1137
+ resolve_type(part, type_map, rctx)
956
1138
  end
957
1139
 
958
1140
  case filtered.size
@@ -971,8 +1153,9 @@ module McpAuthorization
971
1153
  # @param type_map [Hash] Resolved type definitions.
972
1154
  # @param server_context [Object] Per-request context.
973
1155
  # @return [Hash] Partial JSON Schema (+properties+, +required+, etc.).
974
- #: (Array[Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
975
- 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: [] }
976
1159
  properties = {}
977
1160
  required = []
978
1161
  dependent_required = {}
@@ -980,7 +1163,7 @@ module McpAuthorization
980
1163
  call_params.each do |param|
981
1164
  next if predicate_excluded?(param[:tags], server_context)
982
1165
 
983
- schema = rbs_type_to_json_schema(param[:type], type_map)
1166
+ schema = rbs_type_to_json_schema(param[:type], type_map, rctx: rctx)
984
1167
  properties[param[:name].to_sym] = apply_tags(schema, param[:tags], server_context: server_context)
985
1168
  required << param[:name] if param[:required]
986
1169
 
@@ -1028,6 +1211,27 @@ module McpAuthorization
1028
1211
  type_map
1029
1212
  end
1030
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
+
1031
1235
  # Parse a shared +.rbs+ file with mtime-based caching. If the file
1032
1236
  # hasn't changed since the last parse, the cached result is returned.
1033
1237
  #
@@ -1035,16 +1239,62 @@ module McpAuthorization
1035
1239
  # @return [Hash{String => Hash}] Type name → JSON Schema map.
1036
1240
  #: (String) -> Hash[String, Hash[Symbol, untyped]]
1037
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)
1038
1259
  mtime = File.mtime(path)
1039
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
1040
1272
 
1041
- if cached && cached[:mtime] == mtime
1042
- return cached[:result]
1043
- 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
1044
1280
 
1045
- result = parse_rbs_file(path)
1046
- shared_type_cache[path] = { mtime: mtime, result: result }
1047
- 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
1048
1298
  end
1049
1299
 
1050
1300
  # Resolve a bare import name (e.g. +"common_types"+) to an absolute
@@ -1054,11 +1304,16 @@ module McpAuthorization
1054
1304
  # @return [String, nil] Absolute file path, or nil if not found.
1055
1305
  #: (String) -> String?
1056
1306
  def resolve_import_path(import_path)
1057
- return nil unless defined?(Rails)
1058
-
1059
1307
  McpAuthorization.config.shared_type_paths.each do |base|
1060
- candidate = Rails.root.join(base, "#{import_path}.rbs")
1061
- 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)
1062
1317
  end
1063
1318
  nil
1064
1319
  end
@@ -1080,38 +1335,49 @@ module McpAuthorization
1080
1335
  # @return [Hash{String => Hash}] Type name → resolved JSON Schema.
1081
1336
  #: (String) -> Hash[String, Hash[Symbol, untyped]]
1082
1337
  def parse_rbs_file(path)
1083
- 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)
1084
1347
  aliases = {}
1085
1348
  current_name = nil #: String?
1349
+ current_base = nil #: String?
1086
1350
  current_body = +""
1087
1351
 
1088
1352
  content.each_line do |line|
1089
1353
  stripped = line.strip
1090
1354
 
1091
- 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).
1092
1360
  current_name = $1.to_s
1361
+ current_base = $2.to_s
1362
+ current_body = "{"
1363
+ elsif stripped =~ /\Atype (\w+) = \{/
1364
+ current_name = $1.to_s
1365
+ current_base = nil
1093
1366
  current_body = "{"
1094
1367
  elsif stripped =~ /\Atype (\w+) = "([^"]+)"/
1095
1368
  aliases[$1.to_s] = parse_rbs_string_union($2.to_s, line, content)
1096
1369
  elsif current_name
1097
1370
  current_body << strip_rbs_comment(stripped)
1098
1371
  if brace_balanced?(current_body)
1099
- aliases[current_name] = current_body
1372
+ aliases[current_name] = current_base ? { intersection: [current_base, current_body] } : current_body
1100
1373
  current_name = nil
1374
+ current_base = nil
1101
1375
  current_body = +""
1102
1376
  end
1103
1377
  end
1104
1378
  end
1105
1379
 
1106
- resolved = {}
1107
- aliases.each do |name, value|
1108
- resolved[name] = if value.is_a?(String)
1109
- parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)), source_file: path)
1110
- else
1111
- value
1112
- end
1113
- end
1114
- resolved
1380
+ aliases
1115
1381
  end
1116
1382
 
1117
1383
  # Parse a multi-line string literal union from an .rbs file:
@@ -1193,7 +1459,18 @@ module McpAuthorization
1193
1459
  #: (String, ?source_file: String?) -> Hash[String, Hash[Symbol, untyped]]
1194
1460
  def parse_type_aliases(content, source_file: nil)
1195
1461
  return {} if content.empty?
1462
+ resolve_collected_aliases(collect_inline_aliases(content), source_file: source_file)
1463
+ end
1196
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)
1197
1474
  aliases = {}
1198
1475
  current_name = nil #: String?
1199
1476
  current_body = +""
@@ -1215,14 +1492,48 @@ module McpAuthorization
1215
1492
  end
1216
1493
  end
1217
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)
1218
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.
1219
1516
  aliases.each do |name, value|
1517
+ next if value.is_a?(Hash) && value[:intersection]
1220
1518
  resolved[name] = if value.is_a?(String)
1221
1519
  parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)), source_file: source_file)
1222
1520
  else
1223
1521
  value
1224
1522
  end
1225
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
+
1226
1537
  resolved
1227
1538
  end
1228
1539
 
@@ -1411,8 +1722,8 @@ module McpAuthorization
1411
1722
  # @param rbs_type [String] RBS type expression.
1412
1723
  # @param type_map [Hash] Resolved type definitions for named type lookups.
1413
1724
  # @return [Hash] JSON Schema hash.
1414
- #: (String, ?Hash[String, Hash[Symbol, untyped]], ?source_file: String?) -> Hash[Symbol, untyped]
1415
- 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)
1416
1727
  stripped = rbs_type.strip
1417
1728
  return { type: "string" } if stripped.empty?
1418
1729
 
@@ -1429,7 +1740,7 @@ module McpAuthorization
1429
1740
  return type_map[stripped] || { type: "string" }
1430
1741
  end
1431
1742
 
1432
- visit_rbs_type(ast, type_map)
1743
+ visit_rbs_type(ast, type_map, rctx)
1433
1744
  end
1434
1745
 
1435
1746
  # AST visitor: convert an +RBS::Types::*+ node into JSON Schema.
@@ -1443,34 +1754,44 @@ module McpAuthorization
1443
1754
  # @param node [RBS::Types::t] AST node from +RBS::Parser.parse_type+.
1444
1755
  # @param type_map [Hash] Resolved named-type definitions.
1445
1756
  # @return [Hash] JSON Schema fragment.
1446
- #: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
1447
- 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)
1448
1759
  case node
1449
1760
  when RBS::Types::Bases::Bool
1450
1761
  { type: "boolean" }
1451
1762
  when RBS::Types::Bases::Any, RBS::Types::Bases::Void, RBS::Types::Bases::Nil
1452
- # untyped / void / nil → no constraint (LLM can pass anything)
1453
- { 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
+ {}
1454
1770
  when RBS::Types::Literal
1455
1771
  visit_rbs_literal(node)
1456
1772
  when RBS::Types::Optional
1457
1773
  # Optional wraps a type; nullability is handled at field
1458
1774
  # level (required-set), not in the JSON Schema type itself.
1459
- visit_rbs_type(node.type, type_map)
1775
+ visit_rbs_type(node.type, type_map, rctx)
1460
1776
  when RBS::Types::Union
1461
- 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) } }
1462
1783
  when RBS::Types::Record
1463
- visit_rbs_record(node, type_map)
1784
+ visit_rbs_record(node, type_map, rctx)
1464
1785
  when RBS::Types::Tuple
1465
1786
  # RBS tuples have heterogeneous element types; JSON Schema's
1466
1787
  # closest analog is array with prefixItems, but for simplicity
1467
1788
  # we project to a plain array.
1468
1789
  { type: "array" }
1469
1790
  when RBS::Types::ClassInstance
1470
- visit_rbs_class_instance(node, type_map)
1791
+ visit_rbs_class_instance(node, type_map, rctx)
1471
1792
  when RBS::Types::Alias
1472
1793
  name = node.name.to_s.sub(/\A::/, "")
1473
- type_map[name] || { type: "string" }
1794
+ resolve_named_type(name, type_map, rctx, { type: "string" })
1474
1795
  else
1475
1796
  # Interface, Proc, Variable, ClassSingleton, Bases::Self/Class/Instance/Top/Bottom etc.
1476
1797
  { type: "string" }
@@ -1481,8 +1802,8 @@ module McpAuthorization
1481
1802
  # Ruby primitives we care about (+String+, +Integer+, +Float+),
1482
1803
  # generic +Array[T]+ / +Hash[K, V]+, and falls back to the
1483
1804
  # +type_map+ for user-defined names.
1484
- #: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
1485
- 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)
1486
1807
  name = node.name.to_s.sub(/\A::/, "")
1487
1808
  case name
1488
1809
  when "String"
@@ -1497,17 +1818,17 @@ module McpAuthorization
1497
1818
  { type: "boolean", const: false }
1498
1819
  when "Array"
1499
1820
  inner = node.args.first
1500
- 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" }
1501
1822
  when "Hash"
1502
1823
  # Hash[K, V] — JSON Schema can express V as additionalProperties.
1503
1824
  val = node.args[1]
1504
- 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" }
1505
1826
  when "Symbol"
1506
1827
  { type: "string" }
1507
1828
  when "NilClass"
1508
1829
  { type: "string" }
1509
1830
  else
1510
- type_map[name] || { type: "string" }
1831
+ resolve_named_type(name, type_map, rctx, { type: "string" })
1511
1832
  end
1512
1833
  end
1513
1834
 
@@ -1530,8 +1851,8 @@ module McpAuthorization
1530
1851
  # literals becomes a +{type: "string", enum: [...]}+; any other
1531
1852
  # mix becomes +{oneOf: [...]}+. This mirrors the prior regex
1532
1853
  # parser's behavior so existing schemas don't drift.
1533
- #: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
1534
- 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)
1535
1856
  types = node.types
1536
1857
 
1537
1858
  # All string literals → enum.
@@ -1547,7 +1868,7 @@ module McpAuthorization
1547
1868
  return { type: "boolean" }
1548
1869
  end
1549
1870
 
1550
- { oneOf: types.map { |t| visit_rbs_type(t, type_map) } }
1871
+ { oneOf: types.map { |t| visit_rbs_type(t, type_map, rctx) } }
1551
1872
  end
1552
1873
 
1553
1874
  # Map +RBS::Types::Record+ to a JSON Schema object. RBS handles
@@ -1560,18 +1881,18 @@ module McpAuthorization
1560
1881
  # records reached via +# @rbs type input = { ... }+ go through
1561
1882
  # +compile_tagged_record+ instead so per-field tag extraction can
1562
1883
  # happen before the type is parsed.
1563
- #: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
1564
- 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)
1565
1886
  properties = {}
1566
1887
  required = []
1567
1888
 
1568
1889
  node.fields.each do |name, type|
1569
1890
  key = name.to_s
1570
- properties[key.to_sym] = visit_rbs_type(type, type_map)
1891
+ properties[key.to_sym] = visit_rbs_type(type, type_map, rctx)
1571
1892
  required << key
1572
1893
  end
1573
1894
  node.optional_fields.each do |name, type|
1574
- 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)
1575
1896
  end
1576
1897
 
1577
1898
  schema = { type: "object", properties: properties } #: Hash[Symbol, untyped]
@@ -1581,9 +1902,9 @@ module McpAuthorization
1581
1902
 
1582
1903
  # Look up a named type in the type map. Returns a bare +{type: "object"}+
1583
1904
  # if the name is not found (defensive fallback).
1584
- #: (String, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
1585
- def resolve_type(name, type_map)
1586
- 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" })
1587
1908
  end
1588
1909
 
1589
1910
  # Wrap a partial schema (with +properties+, +required+, etc.) in a
@@ -1686,14 +2007,68 @@ module McpAuthorization
1686
2007
  multi = usage.select { |_, c| c > 1 }
1687
2008
  return schema if multi.empty?
1688
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.
1689
2019
  defs = {}
1690
- 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
1691
2024
 
1692
- replaced = deep_replace(schema, multi, type_schemas)
1693
- 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?
1694
2031
  replaced
1695
2032
  end
1696
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
+
1697
2072
  # Walk the schema tree and count how many times each named type's
1698
2073
  # schema appears as a value. Only types with count > 1 are worth
1699
2074
  # extracting into +$defs+.
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.5.3"
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.3
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-06-04 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