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