mcp_authorization 0.5.3 → 0.5.6
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 +33 -0
- data/README.md +2 -0
- data/lib/mcp_authorization/rbs_schema_compiler.rb +467 -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: d12320957dc293fd39e12e752fc62783a38fba78543d2df56f4cdb382947b262
|
|
4
|
+
data.tar.gz: 05ab5308639f0612edc056c145cea34a7a220c51b31a03cfc746646b5c58ca9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d10be243138d57af94278d70dcf0af30d8d09f6c7b7bee69da31c42155d1e2f46361c45ef2d1aeda1d4631ae661dca96dd200f961207bf1826b458bba243e2b
|
|
7
|
+
data.tar.gz: 5a8f3df13aa95bc67c6c5044137b8d0c43fb72fc74553075eaf254944623c2fe038ab784d3773f87239046d40ee32096976d65c0d738a84817a2c9a826b4e980
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ 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.6] - 2026-06-08
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Single-line and column-aligned `# @rbs type` record aliases are now collected.** A record alias written on one line — `# @rbs type ok = { a: String, b: Integer }` — was silently dropped: `collect_inline_aliases` truncated the body to a bare `{` and only a closing brace on a *following* line ever balanced it. Any union or field referencing such an alias resolved to the `{type: "object"}` fallback (no properties, no per-request gating), so the advertised schema and runtime projection both lost the type's shape. The opening-line body is now captured whole and stored immediately when its braces balance. Relatedly, the alias regex now tolerates arbitrary whitespace around `=`, so column-aligned blocks (`# @rbs type success = { ... }`) parse instead of being skipped. Multi-line aliases are unaffected.
|
|
11
|
+
|
|
12
|
+
## [0.5.5] - 2026-06-04
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **`$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.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
- **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.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **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.
|
|
24
|
+
- **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.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- **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.
|
|
28
|
+
|
|
29
|
+
## [0.5.4] - 2026-06-04
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **Predicate gating (`@requires`/`@feature`/`@hidden`/…) now recurses into nested record types.** ([#23](https://github.com/onboardiq/mcp_authorization/issues/23))
|
|
33
|
+
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
- **`untyped` now compiles to the empty schema `{}` ("any value"), not `{type: "string"}`.** ([#22](https://github.com/onboardiq/mcp_authorization/issues/22))
|
|
37
|
+
|
|
38
|
+
`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.
|
|
39
|
+
|
|
7
40
|
## [0.5.3] - 2026-06-03
|
|
8
41
|
|
|
9
42
|
### Fixed
|
data/README.md
CHANGED
|
@@ -4,6 +4,8 @@ Rails engine for serving MCP tools with per-request schema discrimination compil
|
|
|
4
4
|
|
|
5
5
|
Add it to your Gemfile and your Rails app speaks [MCP](https://modelcontextprotocol.io). Write `@rbs type` comments in plain Ruby service classes, tag fields and variants with `@requires(:flag)`, and the gem compiles tailored JSON Schema per request. The type definitions are the authorization policy.
|
|
6
6
|
|
|
7
|
+
> Looking for task-oriented "how do I X?" recipes rather than reference? See the **[Cookbook](COOKBOOK.md)**.
|
|
8
|
+
|
|
7
9
|
## Three layers of authorization
|
|
8
10
|
|
|
9
11
|
The gem gives you three independent controls over what each user sees:
|
|
@@ -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,16 +1459,39 @@ 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 = +""
|
|
1200
1477
|
|
|
1201
1478
|
content.each_line do |line|
|
|
1202
|
-
if line =~
|
|
1479
|
+
if line =~ /#\s*@rbs type (\w+)\s*=\s*(\{.*)$/
|
|
1480
|
+
# Start of a record alias. Capture the body from the first `{` to
|
|
1481
|
+
# end of line (minus any trailing comment), so a record written on
|
|
1482
|
+
# one line — `# @rbs type ok = { a: String }` — is captured whole
|
|
1483
|
+
# rather than truncated to a bare `{` (which silently dropped the
|
|
1484
|
+
# alias). If the braces already balance it's a single-line record;
|
|
1485
|
+
# otherwise keep accumulating on the following lines. Whitespace
|
|
1486
|
+
# around `=` is tolerated so column-aligned aliases still parse.
|
|
1203
1487
|
current_name = $1.to_s
|
|
1204
|
-
current_body =
|
|
1205
|
-
|
|
1488
|
+
current_body = +strip_rbs_comment($2.to_s)
|
|
1489
|
+
if brace_balanced?(current_body)
|
|
1490
|
+
aliases[current_name] = current_body
|
|
1491
|
+
current_name = nil
|
|
1492
|
+
current_body = +""
|
|
1493
|
+
end
|
|
1494
|
+
elsif line =~ /#\s*@rbs type (\w+)\s*=\s*"([^"]+)"/
|
|
1206
1495
|
aliases[$1.to_s] = parse_string_union($2.to_s, line, content)
|
|
1207
1496
|
elsif current_name
|
|
1208
1497
|
stripped = strip_rbs_comment(line.strip.sub(/^#\s*/, ""))
|
|
@@ -1215,14 +1504,48 @@ module McpAuthorization
|
|
|
1215
1504
|
end
|
|
1216
1505
|
end
|
|
1217
1506
|
|
|
1507
|
+
aliases
|
|
1508
|
+
end
|
|
1509
|
+
|
|
1510
|
+
# The record-body subset of +collect_inline_aliases+: name → raw
|
|
1511
|
+
# +"{ ... }"+ body string for each handler-local record alias.
|
|
1512
|
+
#: (String) -> Hash[String, String]
|
|
1513
|
+
def collect_inline_record_bodies(content)
|
|
1514
|
+
return {} if content.empty?
|
|
1515
|
+
collect_inline_aliases(content).select { |_, v| v.is_a?(String) }
|
|
1516
|
+
end
|
|
1517
|
+
|
|
1518
|
+
# Resolve a collected alias map (name → raw record body | resolved
|
|
1519
|
+
# Hash) into a name → JSON Schema type map. Forward references resolve
|
|
1520
|
+
# via +aliases_to_schemas+ placeholders.
|
|
1521
|
+
#: (Hash[String, untyped], ?source_file: String?) -> Hash[String, Hash[Symbol, untyped]]
|
|
1522
|
+
def resolve_collected_aliases(aliases, source_file: nil)
|
|
1218
1523
|
resolved = {}
|
|
1524
|
+
|
|
1525
|
+
# Pass 1: plain record bodies and already-resolved values (string
|
|
1526
|
+
# unions). Intersection bases are plain records, so this guarantees a
|
|
1527
|
+
# base is fully resolved before any intersection that references it.
|
|
1219
1528
|
aliases.each do |name, value|
|
|
1529
|
+
next if value.is_a?(Hash) && value[:intersection]
|
|
1220
1530
|
resolved[name] = if value.is_a?(String)
|
|
1221
1531
|
parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)), source_file: source_file)
|
|
1222
1532
|
else
|
|
1223
1533
|
value
|
|
1224
1534
|
end
|
|
1225
1535
|
end
|
|
1536
|
+
|
|
1537
|
+
# Pass 2: intersections → allOf[base, record]. The base schema is the
|
|
1538
|
+
# fully-resolved shared type (same object across every member), so
|
|
1539
|
+
# with_ref_injection sees it used many times and hoists it into $defs.
|
|
1540
|
+
aliases.each do |name, value|
|
|
1541
|
+
next unless value.is_a?(Hash) && value[:intersection]
|
|
1542
|
+
base_name, body = value[:intersection]
|
|
1543
|
+
merged = resolved.merge(aliases_to_schemas(aliases, resolved))
|
|
1544
|
+
base_schema = resolved[base_name] || merged[base_name] || { type: "object" }
|
|
1545
|
+
record_schema = parse_record_type(body, merged, source_file: source_file)
|
|
1546
|
+
resolved[name] = { allOf: [base_schema, record_schema] }
|
|
1547
|
+
end
|
|
1548
|
+
|
|
1226
1549
|
resolved
|
|
1227
1550
|
end
|
|
1228
1551
|
|
|
@@ -1411,8 +1734,8 @@ module McpAuthorization
|
|
|
1411
1734
|
# @param rbs_type [String] RBS type expression.
|
|
1412
1735
|
# @param type_map [Hash] Resolved type definitions for named type lookups.
|
|
1413
1736
|
# @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)
|
|
1737
|
+
#: (String, ?Hash[String, Hash[Symbol, untyped]], ?source_file: String?, ?rctx: Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
|
|
1738
|
+
def rbs_type_to_json_schema(rbs_type, type_map = {}, source_file: nil, rctx: nil)
|
|
1416
1739
|
stripped = rbs_type.strip
|
|
1417
1740
|
return { type: "string" } if stripped.empty?
|
|
1418
1741
|
|
|
@@ -1429,7 +1752,7 @@ module McpAuthorization
|
|
|
1429
1752
|
return type_map[stripped] || { type: "string" }
|
|
1430
1753
|
end
|
|
1431
1754
|
|
|
1432
|
-
visit_rbs_type(ast, type_map)
|
|
1755
|
+
visit_rbs_type(ast, type_map, rctx)
|
|
1433
1756
|
end
|
|
1434
1757
|
|
|
1435
1758
|
# AST visitor: convert an +RBS::Types::*+ node into JSON Schema.
|
|
@@ -1443,34 +1766,44 @@ module McpAuthorization
|
|
|
1443
1766
|
# @param node [RBS::Types::t] AST node from +RBS::Parser.parse_type+.
|
|
1444
1767
|
# @param type_map [Hash] Resolved named-type definitions.
|
|
1445
1768
|
# @return [Hash] JSON Schema fragment.
|
|
1446
|
-
#: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1447
|
-
def visit_rbs_type(node, type_map)
|
|
1769
|
+
#: (untyped, Hash[String, Hash[Symbol, untyped]], ?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
|
|
1770
|
+
def visit_rbs_type(node, type_map, rctx = nil)
|
|
1448
1771
|
case node
|
|
1449
1772
|
when RBS::Types::Bases::Bool
|
|
1450
1773
|
{ type: "boolean" }
|
|
1451
1774
|
when RBS::Types::Bases::Any, RBS::Types::Bases::Void, RBS::Types::Bases::Nil
|
|
1452
|
-
# untyped / void / nil → no constraint (LLM can pass anything)
|
|
1453
|
-
{
|
|
1775
|
+
# untyped / void / nil → no constraint (LLM can pass anything).
|
|
1776
|
+
# The empty schema {} is JSON Schema's "any value"; emitting a
|
|
1777
|
+
# concrete type here (e.g. {type: "string"}) silently rejects
|
|
1778
|
+
# objects/arrays at tools/call — see issue #22. In particular
|
|
1779
|
+
# Hash[K, untyped] becomes {type: "object", additionalProperties: {}}
|
|
1780
|
+
# (any property value allowed) rather than forcing string values.
|
|
1781
|
+
{}
|
|
1454
1782
|
when RBS::Types::Literal
|
|
1455
1783
|
visit_rbs_literal(node)
|
|
1456
1784
|
when RBS::Types::Optional
|
|
1457
1785
|
# Optional wraps a type; nullability is handled at field
|
|
1458
1786
|
# level (required-set), not in the JSON Schema type itself.
|
|
1459
|
-
visit_rbs_type(node.type, type_map)
|
|
1787
|
+
visit_rbs_type(node.type, type_map, rctx)
|
|
1460
1788
|
when RBS::Types::Union
|
|
1461
|
-
visit_rbs_union(node, type_map)
|
|
1789
|
+
visit_rbs_union(node, type_map, rctx)
|
|
1790
|
+
when RBS::Types::Intersection
|
|
1791
|
+
# A & B → allOf. Lets a field reuse a shared base type plus extra
|
|
1792
|
+
# constraints; mirrors the alias-level intersection handled by
|
|
1793
|
+
# collect_rbs_file_aliases / resolve_collected_aliases.
|
|
1794
|
+
{ allOf: node.types.map { |t| visit_rbs_type(t, type_map, rctx) } }
|
|
1462
1795
|
when RBS::Types::Record
|
|
1463
|
-
visit_rbs_record(node, type_map)
|
|
1796
|
+
visit_rbs_record(node, type_map, rctx)
|
|
1464
1797
|
when RBS::Types::Tuple
|
|
1465
1798
|
# RBS tuples have heterogeneous element types; JSON Schema's
|
|
1466
1799
|
# closest analog is array with prefixItems, but for simplicity
|
|
1467
1800
|
# we project to a plain array.
|
|
1468
1801
|
{ type: "array" }
|
|
1469
1802
|
when RBS::Types::ClassInstance
|
|
1470
|
-
visit_rbs_class_instance(node, type_map)
|
|
1803
|
+
visit_rbs_class_instance(node, type_map, rctx)
|
|
1471
1804
|
when RBS::Types::Alias
|
|
1472
1805
|
name = node.name.to_s.sub(/\A::/, "")
|
|
1473
|
-
|
|
1806
|
+
resolve_named_type(name, type_map, rctx, { type: "string" })
|
|
1474
1807
|
else
|
|
1475
1808
|
# Interface, Proc, Variable, ClassSingleton, Bases::Self/Class/Instance/Top/Bottom etc.
|
|
1476
1809
|
{ type: "string" }
|
|
@@ -1481,8 +1814,8 @@ module McpAuthorization
|
|
|
1481
1814
|
# Ruby primitives we care about (+String+, +Integer+, +Float+),
|
|
1482
1815
|
# generic +Array[T]+ / +Hash[K, V]+, and falls back to the
|
|
1483
1816
|
# +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)
|
|
1817
|
+
#: (untyped, Hash[String, Hash[Symbol, untyped]], ?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
|
|
1818
|
+
def visit_rbs_class_instance(node, type_map, rctx = nil)
|
|
1486
1819
|
name = node.name.to_s.sub(/\A::/, "")
|
|
1487
1820
|
case name
|
|
1488
1821
|
when "String"
|
|
@@ -1497,17 +1830,17 @@ module McpAuthorization
|
|
|
1497
1830
|
{ type: "boolean", const: false }
|
|
1498
1831
|
when "Array"
|
|
1499
1832
|
inner = node.args.first
|
|
1500
|
-
inner ? { type: "array", items: visit_rbs_type(inner, type_map) } : { type: "array" }
|
|
1833
|
+
inner ? { type: "array", items: visit_rbs_type(inner, type_map, rctx) } : { type: "array" }
|
|
1501
1834
|
when "Hash"
|
|
1502
1835
|
# Hash[K, V] — JSON Schema can express V as additionalProperties.
|
|
1503
1836
|
val = node.args[1]
|
|
1504
|
-
val ? { type: "object", additionalProperties: visit_rbs_type(val, type_map) } : { type: "object" }
|
|
1837
|
+
val ? { type: "object", additionalProperties: visit_rbs_type(val, type_map, rctx) } : { type: "object" }
|
|
1505
1838
|
when "Symbol"
|
|
1506
1839
|
{ type: "string" }
|
|
1507
1840
|
when "NilClass"
|
|
1508
1841
|
{ type: "string" }
|
|
1509
1842
|
else
|
|
1510
|
-
|
|
1843
|
+
resolve_named_type(name, type_map, rctx, { type: "string" })
|
|
1511
1844
|
end
|
|
1512
1845
|
end
|
|
1513
1846
|
|
|
@@ -1530,8 +1863,8 @@ module McpAuthorization
|
|
|
1530
1863
|
# literals becomes a +{type: "string", enum: [...]}+; any other
|
|
1531
1864
|
# mix becomes +{oneOf: [...]}+. This mirrors the prior regex
|
|
1532
1865
|
# 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)
|
|
1866
|
+
#: (untyped, Hash[String, Hash[Symbol, untyped]], ?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
|
|
1867
|
+
def visit_rbs_union(node, type_map, rctx = nil)
|
|
1535
1868
|
types = node.types
|
|
1536
1869
|
|
|
1537
1870
|
# All string literals → enum.
|
|
@@ -1547,7 +1880,7 @@ module McpAuthorization
|
|
|
1547
1880
|
return { type: "boolean" }
|
|
1548
1881
|
end
|
|
1549
1882
|
|
|
1550
|
-
{ oneOf: types.map { |t| visit_rbs_type(t, type_map) } }
|
|
1883
|
+
{ oneOf: types.map { |t| visit_rbs_type(t, type_map, rctx) } }
|
|
1551
1884
|
end
|
|
1552
1885
|
|
|
1553
1886
|
# Map +RBS::Types::Record+ to a JSON Schema object. RBS handles
|
|
@@ -1560,18 +1893,18 @@ module McpAuthorization
|
|
|
1560
1893
|
# records reached via +# @rbs type input = { ... }+ go through
|
|
1561
1894
|
# +compile_tagged_record+ instead so per-field tag extraction can
|
|
1562
1895
|
# happen before the type is parsed.
|
|
1563
|
-
#: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1564
|
-
def visit_rbs_record(node, type_map)
|
|
1896
|
+
#: (untyped, Hash[String, Hash[Symbol, untyped]], ?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
|
|
1897
|
+
def visit_rbs_record(node, type_map, rctx = nil)
|
|
1565
1898
|
properties = {}
|
|
1566
1899
|
required = []
|
|
1567
1900
|
|
|
1568
1901
|
node.fields.each do |name, type|
|
|
1569
1902
|
key = name.to_s
|
|
1570
|
-
properties[key.to_sym] = visit_rbs_type(type, type_map)
|
|
1903
|
+
properties[key.to_sym] = visit_rbs_type(type, type_map, rctx)
|
|
1571
1904
|
required << key
|
|
1572
1905
|
end
|
|
1573
1906
|
node.optional_fields.each do |name, type|
|
|
1574
|
-
properties[name.to_s.to_sym] = visit_rbs_type(type, type_map)
|
|
1907
|
+
properties[name.to_s.to_sym] = visit_rbs_type(type, type_map, rctx)
|
|
1575
1908
|
end
|
|
1576
1909
|
|
|
1577
1910
|
schema = { type: "object", properties: properties } #: Hash[Symbol, untyped]
|
|
@@ -1581,9 +1914,9 @@ module McpAuthorization
|
|
|
1581
1914
|
|
|
1582
1915
|
# Look up a named type in the type map. Returns a bare +{type: "object"}+
|
|
1583
1916
|
# 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
|
-
|
|
1917
|
+
#: (String, Hash[String, Hash[Symbol, untyped]], ?Hash[Symbol, untyped]?) -> Hash[Symbol, untyped]
|
|
1918
|
+
def resolve_type(name, type_map, rctx = nil)
|
|
1919
|
+
resolve_named_type(name, type_map, rctx, { type: "object" })
|
|
1587
1920
|
end
|
|
1588
1921
|
|
|
1589
1922
|
# Wrap a partial schema (with +properties+, +required+, etc.) in a
|
|
@@ -1686,14 +2019,68 @@ module McpAuthorization
|
|
|
1686
2019
|
multi = usage.select { |_, c| c > 1 }
|
|
1687
2020
|
return schema if multi.empty?
|
|
1688
2021
|
|
|
2022
|
+
replaced = deep_replace(schema, multi, type_schemas)
|
|
2023
|
+
|
|
2024
|
+
# Ref-inject *within* each hoisted def too: a def's body may itself
|
|
2025
|
+
# contain another multi-use type (e.g. a shared base that inlines a
|
|
2026
|
+
# template referenced elsewhere). Replace those with $ref — excluding
|
|
2027
|
+
# the def's own name so a body never self-references — so each shared
|
|
2028
|
+
# type is spelled out once in $defs rather than re-inlined inside
|
|
2029
|
+
# another def. Without this, hoisting a large base left the base's
|
|
2030
|
+
# nested types inlined AND duplicated as unreferenced defs.
|
|
1689
2031
|
defs = {}
|
|
1690
|
-
multi.each_key
|
|
2032
|
+
multi.each_key do |name|
|
|
2033
|
+
others = multi.reject { |k, _| k == name }
|
|
2034
|
+
defs[name] = deep_replace(type_schemas[name], others, type_schemas)
|
|
2035
|
+
end
|
|
1691
2036
|
|
|
1692
|
-
|
|
1693
|
-
|
|
2037
|
+
# Drop defs that nothing references (transitively from the main
|
|
2038
|
+
# schema). Hoisting can orphan a type whose every occurrence ended up
|
|
2039
|
+
# inside another def that was itself replaced by a $ref.
|
|
2040
|
+
defs = prune_unreferenced_defs(replaced, defs)
|
|
2041
|
+
|
|
2042
|
+
replaced[:"$defs"] = defs unless defs.empty?
|
|
1694
2043
|
replaced
|
|
1695
2044
|
end
|
|
1696
2045
|
|
|
2046
|
+
# Names of +$defs+ reachable (transitively) from +root+. Used to drop
|
|
2047
|
+
# hoisted-but-unreferenced defs.
|
|
2048
|
+
#: (Hash[Symbol, untyped], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
|
|
2049
|
+
def prune_unreferenced_defs(root, defs)
|
|
2050
|
+
reachable = [] #: Array[String]
|
|
2051
|
+
frontier = referenced_def_names(root)
|
|
2052
|
+
until frontier.empty?
|
|
2053
|
+
name = frontier.shift
|
|
2054
|
+
next if reachable.include?(name)
|
|
2055
|
+
reachable << name
|
|
2056
|
+
referenced_def_names(defs[name]).each { |n| frontier << n } if defs[name]
|
|
2057
|
+
end
|
|
2058
|
+
defs.select { |name, _| reachable.include?(name) }
|
|
2059
|
+
end
|
|
2060
|
+
|
|
2061
|
+
# Collect every +#/$defs/<name>+ target referenced anywhere in +node+.
|
|
2062
|
+
#: (untyped) -> Array[String]
|
|
2063
|
+
def referenced_def_names(node)
|
|
2064
|
+
names = [] #: Array[String]
|
|
2065
|
+
stack = [node] #: Array[untyped]
|
|
2066
|
+
until stack.empty?
|
|
2067
|
+
n = stack.pop
|
|
2068
|
+
case n
|
|
2069
|
+
when Hash
|
|
2070
|
+
n.each do |k, v|
|
|
2071
|
+
if (k == :"$ref" || k == "$ref") && v.is_a?(String)
|
|
2072
|
+
names << v.split("/").last.to_s
|
|
2073
|
+
else
|
|
2074
|
+
stack << v
|
|
2075
|
+
end
|
|
2076
|
+
end
|
|
2077
|
+
when Array
|
|
2078
|
+
n.each { |e| stack << e }
|
|
2079
|
+
end
|
|
2080
|
+
end
|
|
2081
|
+
names
|
|
2082
|
+
end
|
|
2083
|
+
|
|
1697
2084
|
# Walk the schema tree and count how many times each named type's
|
|
1698
2085
|
# schema appears as a value. Only types with count > 1 are worth
|
|
1699
2086
|
# 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.6
|
|
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-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|