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 +4 -4
- data/CHANGELOG.md +28 -0
- data/lib/mcp_authorization/rbs_schema_compiler.rb +452 -77
- data/lib/mcp_authorization/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6b7e3511b7790b3da9c74c48f4df83c87adc11a875fc0e822c153c33b6a72e9
|
|
4
|
+
data.tar.gz: c00c25c116df33f17126a8b45105db079e8180c9b4b2895f1e50db0131fb9dfe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
#
|
|
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:
|
|
826
|
-
imported
|
|
827
|
-
local
|
|
828
|
-
|
|
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
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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 =
|
|
1061
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1693
|
-
|
|
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+.
|
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.
|
|
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-
|
|
11
|
+
date: 2026-06-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|