mcp_authorization 0.4.0 → 0.5.1
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 +60 -0
- data/README.md +2 -2
- data/lib/mcp_authorization/rbs_schema_compiler.rb +472 -60
- data/lib/mcp_authorization/version.rb +1 -1
- metadata +22 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7365a5b287722edcac66ce63fa3ef411f6a6761e17ebb12cdfaf982e1e71c03d
|
|
4
|
+
data.tar.gz: 01cbce16f56b0ca80523014d20caee64a90cd4b82e478c9ebbf85e0cb255397a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 374d094408606c87c9c7886d3e5bb22f05794076d001054d703431e604740fb914596d2c75dbb4bc7a07eb0461f3ac660ea121d134c850b0439d18f0fe7f9542
|
|
7
|
+
data.tar.gz: 793eca82f7a52d56e65134366ed300d07d07bb9331daa49f566294e9f14af46d40ada269ebd4efcfd0a8f65733ee8981a0cbc039d3520eec4f4c43c500e3421c
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,66 @@ 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.1] - 2026-05-27
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Tag values containing balanced parens, commas, or pipes no longer break the parser.** ([#15](https://github.com/onboardiq/mcp_authorization/issues/15))
|
|
11
|
+
|
|
12
|
+
The historical regex parser used flat patterns (`[^)]*`, `[^,}]+`, bare `.split("|")`) to find delimiters. These patterns are not bracket-aware — a fundamental limitation of regular expressions (balanced delimiters are not a regular language). Symptom: silent miscompilation when any tag value contained the delimiter character.
|
|
13
|
+
|
|
14
|
+
Five call sites were affected, all with the same root cause:
|
|
15
|
+
- `extract_tags` — `@desc(foo (bar))` truncated at the inner `)`
|
|
16
|
+
- `compile_tagged_record` — comma inside `@desc(...)` fragmented fields
|
|
17
|
+
- `parse_record_type` — same, for nested/aliased records
|
|
18
|
+
- `parse_call_params` — flat `.split(",")` mistook commas inside `@desc(...)` AND commas inside generic types (`Hash[Symbol, untyped]`) for parameter separators
|
|
19
|
+
- union-splitting in `compile_tagged_union` and `rbs_type_to_json_schema` — pipe inside `@desc(...)` split the union mid-value
|
|
20
|
+
|
|
21
|
+
All four now go through bracket-aware primitives that track `()`, `[]`, `{}` depth while scanning.
|
|
22
|
+
|
|
23
|
+
**Concrete impact:** a field annotated `Integer @desc(The ID (NOT the question id)) @min(1)` previously compiled to `{type: "string", minLength: 1}` (wrong type AND wrong constraint keyword). Now compiles to `{type: "integer", description: "The ID (NOT the question id)", minimum: 1}`.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **Type-expression parsing now delegates to the official `rbs` gem.** `rbs_type_to_json_schema` previously dispatched via a regex case statement (`when "String"`, `when /\AArray\[(.+)\]\z/`, etc.). It now calls `RBS::Parser.parse_type` and walks the resulting AST through a small visitor (`visit_rbs_type`, `visit_rbs_class_instance`, `visit_rbs_union`, `visit_rbs_record`, `visit_rbs_literal`). This aligns the gem's runtime type interpretation with Steep's static interpretation — both now use the same parser, eliminating an entire class of silent divergence where the regex case statement misinterpreted types that Steep accepted.
|
|
27
|
+
|
|
28
|
+
Side benefit: types that previously fell through to the `{type: "string"}` fallback because the regex case statement didn't recognize them (e.g. `Hash[K, V]` becomes `{type: "object", additionalProperties: …}` instead of `{type: "string"}`) now have correct JSON Schema mappings. No external behavior change for the type expressions exercised by tools shipped before 0.5.1.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- Internal helpers: `find_at_depth_zero`, `split_at_depth_zero`, `peel_trailing_tag`, `find_matching_open_paren`, `each_field_in_record`. Bracket-aware primitives used by `extract_tags` and the record/union splitters.
|
|
32
|
+
- **Runtime dependency on `rbs` (>= 3.0, < 4.0).** Previously transitive via Steep dev dependency; now explicit because the production code path uses it.
|
|
33
|
+
|
|
34
|
+
## [0.5.0] - 2026-05-26
|
|
35
|
+
|
|
36
|
+
### Changed (BREAKING)
|
|
37
|
+
- **Prefix optional marker (`?key:`) is now honored consistently across all three RBS parsers.**
|
|
38
|
+
|
|
39
|
+
The three sibling parsers handled optional-field markers inconsistently:
|
|
40
|
+
- `parse_call_params` — accepted only prefix `?key:`
|
|
41
|
+
- `compile_tagged_record` — accepted only suffix `key?:`, silently treated prefix `?key:` as required
|
|
42
|
+
- `parse_record_type` — recognized neither form, silently treated all fields as required
|
|
43
|
+
|
|
44
|
+
The README documents prefix as canonical ("Prefix a param with `?` to mark it optional"), so handlers that followed the docs got unexpectedly-required fields in their compiled schemas.
|
|
45
|
+
|
|
46
|
+
**Effect on consumers:** any field declared with prefix `?key:` in a `# @rbs type input = { ... }` record, a nested/aliased record (`# @rbs type foo = { ?bar: ... }`), or an inline record inside a `#:` signature is now correctly marked optional in the JSON Schema (omitted from `required`). For a consuming monolith with ~616 such fields, the schema's `required` array shrinks accordingly and clients (e.g. LLMs producing tool calls) will no longer treat these fields as mandatory.
|
|
47
|
+
|
|
48
|
+
**If you relied on the buggy behavior** (prefix marker silently making the field required), declare the field with no marker (`key:`) to keep it required, or enforce presence inside `#call`.
|
|
49
|
+
|
|
50
|
+
### Deprecated
|
|
51
|
+
- **Suffix optional marker (`key?:`) is deprecated; will be removed in 0.6.0.** Suffix `key?:` continues to work in 0.5.0 (record types, call signatures, and nested aliased records) but now emits a single `Kernel#warn` per use with `category: :deprecated`. Silence with `Warning[:deprecated] = false` or `ruby -W:no-deprecated`. The warning embeds the handler's source-file path because the annotation is parsed as static text — the offending file is not on the Ruby call stack when the warning is emitted, so `uplevel:` cannot surface it.
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
- `RbsSchemaCompiler.parse_field_name(raw, source_file: nil)` — internal helper that turns a raw field-name token (everything before the `:`) into `[clean_name, optional?]`. Single source of truth for the three parsers. Raises `ArgumentError` on malformed input (empty, bare `"?"`, double-marked `"?key?"`, `"??key"`, `"key??"`). Tolerates whitespace around the marker (`" ? key"` → `["key", true]`).
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
- `parse_record_type` now recognizes optional markers at all. Previously, nested aliased record types like `# @rbs type foo = { ?bar: ... }` produced schemas where every field landed in `required` regardless of the marker. Caught only because the same bug existed in the sibling parsers under different shapes, hiding the test gap.
|
|
58
|
+
|
|
59
|
+
### Migration notes
|
|
60
|
+
- **No code changes required.** Suffix `key?:` annotations keep parsing; you'll see a deprecation warning per call site on first cache build of each handler. Migrate to prefix `?key:` at your own pace before 0.6.0.
|
|
61
|
+
- If you've been relying on prefix `?key:` being silently treated as required (the buggy behavior), audit your schemas: declared-optional fields that the handler still requires must be enforced inside `#call`, not by the schema.
|
|
62
|
+
- The README example in the records section was updated to use prefix `?count: Integer` to match the documented canonical form.
|
|
63
|
+
|
|
64
|
+
### Notes
|
|
65
|
+
- The `fountain/monolith` consumer (gem's primary downstream) has a separate migration PR tracking the suffix→prefix rewrite for ~616 affected fields, including `sig/shared/option_bank_result.rbs` and friends. That work is out of scope for this gem release and will land in the monolith repo once it bumps the `mcp_authorization` gem to 0.5.0+.
|
|
66
|
+
|
|
7
67
|
## [0.4.0] - 2026-05-21
|
|
8
68
|
|
|
9
69
|
### Added
|
data/README.md
CHANGED
|
@@ -380,9 +380,9 @@ The `@rbs type` comments compile to JSON Schema:
|
|
|
380
380
|
# @rbs type result = {
|
|
381
381
|
# success: bool,
|
|
382
382
|
# message: String,
|
|
383
|
-
# count
|
|
383
|
+
# ?count: Integer
|
|
384
384
|
# }
|
|
385
|
-
# (count
|
|
385
|
+
# (?count is optional -- excluded from "required")
|
|
386
386
|
|
|
387
387
|
# Arrays
|
|
388
388
|
# @rbs type items = Array[String]
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "rbs"
|
|
1
2
|
require_relative "diagnostics"
|
|
2
3
|
|
|
3
4
|
module McpAuthorization
|
|
@@ -58,7 +59,7 @@ module McpAuthorization
|
|
|
58
59
|
cached = cache_for(handler_class)
|
|
59
60
|
|
|
60
61
|
schema = if cached[:raw_input]&.dig(:kind) == :record
|
|
61
|
-
compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context)
|
|
62
|
+
compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context, source_file: cached[:source_file])
|
|
62
63
|
else
|
|
63
64
|
build_input_schema(
|
|
64
65
|
filter_call_signature(cached[:call_params], cached[:type_map], server_context)
|
|
@@ -206,7 +207,7 @@ module McpAuthorization
|
|
|
206
207
|
cached = cache_for(handler_class)
|
|
207
208
|
|
|
208
209
|
schema = if cached[:raw_input]&.dig(:kind) == :record
|
|
209
|
-
compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context)
|
|
210
|
+
compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context, source_file: cached[:source_file])
|
|
210
211
|
else
|
|
211
212
|
build_input_schema(
|
|
212
213
|
filter_call_signature(cached[:call_params], cached[:type_map], server_context)
|
|
@@ -374,9 +375,11 @@ module McpAuthorization
|
|
|
374
375
|
def extract_tags(type_str)
|
|
375
376
|
tags = {}
|
|
376
377
|
|
|
377
|
-
# Extract all @tag(...) annotations from right to left
|
|
378
|
-
|
|
379
|
-
|
|
378
|
+
# Extract all @tag(...) annotations from right to left, using a
|
|
379
|
+
# bracket-aware peeler so tag values can contain balanced parens,
|
|
380
|
+
# commas, and pipes (e.g. +@desc(foo (bar). Required.)+).
|
|
381
|
+
while (peeled = peel_trailing_tag(type_str))
|
|
382
|
+
type_str, tag_name, tag_value = peeled
|
|
380
383
|
|
|
381
384
|
case tag_name
|
|
382
385
|
when "requires"
|
|
@@ -429,6 +432,245 @@ module McpAuthorization
|
|
|
429
432
|
[type_str, tags]
|
|
430
433
|
end
|
|
431
434
|
|
|
435
|
+
# ---------------------------------------------------------------
|
|
436
|
+
# Bracket-aware parsing primitives
|
|
437
|
+
#
|
|
438
|
+
# The historical regex parser used flat patterns like +[^)]*+,
|
|
439
|
+
# +[^,}]++, and bare +.split("|")+ to find delimiters in RBS-flavored
|
|
440
|
+
# annotations. Those patterns can't track nested brackets — a classic
|
|
441
|
+
# limitation of regular expressions (regex are finite automata with
|
|
442
|
+
# no counter; balanced delimiters are not a regular language). The
|
|
443
|
+
# symptom was silent miscompilation when tag values contained the
|
|
444
|
+
# delimiter characters (e.g. +@desc(foo (bar). baz)+ would terminate
|
|
445
|
+
# the +)+ scan at the inner +)+, leaving the outer +@desc(...)+
|
|
446
|
+
# un-extracted and the field's type misparsed).
|
|
447
|
+
#
|
|
448
|
+
# These helpers walk the string character-by-character tracking
|
|
449
|
+
# +()+, +[]+, +{}+ depth so delimiters inside any balanced bracket
|
|
450
|
+
# pair are skipped. Used by:
|
|
451
|
+
# - +extract_tags+ — locate +@tag(...)+ with balanced value
|
|
452
|
+
# - +compile_tagged_record+ — split fields on +,+ at depth 0
|
|
453
|
+
# - +parse_record_type+ — same
|
|
454
|
+
# - +compile_tagged_union+ — split variants on +|+ at depth 0
|
|
455
|
+
# - +rbs_type_to_json_schema+ — same
|
|
456
|
+
#
|
|
457
|
+
# See the 0.5.1 CHANGELOG for the bug-class history. This is the
|
|
458
|
+
# foundation that Phase 2 of the parser migration will reuse.
|
|
459
|
+
# ---------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
# Find the position of the first character in +delims+ that occurs
|
|
462
|
+
# at bracket depth 0, scanning left-to-right from +start+. Returns
|
|
463
|
+
# +nil+ if no such position exists.
|
|
464
|
+
#
|
|
465
|
+
# Tracks +()+, +[]+, +{}+ as balanced pairs. A delimiter inside any
|
|
466
|
+
# of these pairs is skipped.
|
|
467
|
+
#
|
|
468
|
+
# @example
|
|
469
|
+
# find_at_depth_zero("a, b, (c, d), e", [","]) #=> 1
|
|
470
|
+
# find_at_depth_zero("a, b, (c, d), e", [","], start: 2) #=> 4
|
|
471
|
+
# find_at_depth_zero("(no delim here)", [","]) #=> nil
|
|
472
|
+
#
|
|
473
|
+
#: (String, Array[String], ?start: Integer) -> Integer?
|
|
474
|
+
def find_at_depth_zero(str, delims, start: 0)
|
|
475
|
+
depth = 0
|
|
476
|
+
pos = start
|
|
477
|
+
while pos < str.length
|
|
478
|
+
ch = str[pos].to_s
|
|
479
|
+
return pos if depth.zero? && delims.include?(ch)
|
|
480
|
+
if "([{".include?(ch)
|
|
481
|
+
depth += 1
|
|
482
|
+
elsif ")]}".include?(ch)
|
|
483
|
+
depth -= 1
|
|
484
|
+
end
|
|
485
|
+
pos += 1
|
|
486
|
+
end
|
|
487
|
+
nil
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Split +str+ on every occurrence of +delim+ at bracket depth 0.
|
|
491
|
+
# Returns an array of substrings (NOT stripped, NOT filtered).
|
|
492
|
+
# Callers strip / reject empties as needed to match prior semantics.
|
|
493
|
+
#
|
|
494
|
+
# @example
|
|
495
|
+
# split_at_depth_zero("a, b, (c, d), e", ",")
|
|
496
|
+
# #=> ["a", " b", " (c, d)", " e"]
|
|
497
|
+
#
|
|
498
|
+
#: (String, String) -> Array[String]
|
|
499
|
+
def split_at_depth_zero(str, delim)
|
|
500
|
+
parts = []
|
|
501
|
+
start = 0
|
|
502
|
+
while (pos = find_at_depth_zero(str, [delim], start: start))
|
|
503
|
+
parts << str[start...pos].to_s
|
|
504
|
+
start = pos + 1
|
|
505
|
+
end
|
|
506
|
+
parts << str[start..].to_s
|
|
507
|
+
parts
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Peel the rightmost +@tag(...)+ off +type_str+ if one exists,
|
|
511
|
+
# respecting balanced parentheses inside the tag value.
|
|
512
|
+
#
|
|
513
|
+
# Returns +[remaining_type_str, tag_name, tag_value]+ on success, or
|
|
514
|
+
# +nil+ if no trailing tag is found. The remainder is right-stripped.
|
|
515
|
+
# The tag value is not stripped (preserves intentional whitespace
|
|
516
|
+
# in +@desc(...)+ for example).
|
|
517
|
+
#
|
|
518
|
+
# The +@+ must be preceded by whitespace or be at position 0, so
|
|
519
|
+
# +"@foo"+ in the middle of a type expression isn't mistaken for a
|
|
520
|
+
# tag (this matches the prior regex semantics with +\s+@+).
|
|
521
|
+
#
|
|
522
|
+
# @example
|
|
523
|
+
# peel_trailing_tag("Integer @desc(a (b) c) @min(1)")
|
|
524
|
+
# #=> ["Integer @desc(a (b) c)", "min", "1"]
|
|
525
|
+
#
|
|
526
|
+
# peel_trailing_tag("Integer @desc(a (b) c)")
|
|
527
|
+
# #=> ["Integer", "desc", "a (b) c"]
|
|
528
|
+
#
|
|
529
|
+
#: (String) -> [String, String, String]?
|
|
530
|
+
def peel_trailing_tag(type_str)
|
|
531
|
+
trimmed = type_str.rstrip
|
|
532
|
+
return nil unless trimmed.end_with?(")")
|
|
533
|
+
|
|
534
|
+
close_pos = trimmed.length - 1
|
|
535
|
+
open_pos = find_matching_open_paren(trimmed, close_pos)
|
|
536
|
+
return nil unless open_pos
|
|
537
|
+
return nil if open_pos.zero?
|
|
538
|
+
|
|
539
|
+
# Walk backward from open_pos to capture the tag name (\w+).
|
|
540
|
+
name_end = open_pos
|
|
541
|
+
name_start = name_end
|
|
542
|
+
while name_start > 0 && trimmed[name_start - 1].to_s.match?(/\w/)
|
|
543
|
+
name_start -= 1
|
|
544
|
+
end
|
|
545
|
+
return nil if name_start == name_end
|
|
546
|
+
|
|
547
|
+
# The character immediately before the name must be '@'.
|
|
548
|
+
at_pos = name_start - 1
|
|
549
|
+
return nil unless at_pos >= 0 && trimmed[at_pos] == "@"
|
|
550
|
+
|
|
551
|
+
# The '@' must be preceded by whitespace or be at the start, so we
|
|
552
|
+
# don't accidentally peel an '@' embedded in an identifier.
|
|
553
|
+
return nil unless at_pos.zero? || trimmed[at_pos - 1].to_s.match?(/\s/)
|
|
554
|
+
|
|
555
|
+
tag_name = trimmed[name_start...name_end].to_s
|
|
556
|
+
tag_value = trimmed[(open_pos + 1)...close_pos].to_s
|
|
557
|
+
remainder = trimmed[0...at_pos].to_s.rstrip
|
|
558
|
+
|
|
559
|
+
[remainder, tag_name, tag_value]
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Find the position of the +(+ that matches the +)+ at +close_pos+
|
|
563
|
+
# in +str+. Walks backward, counting nested parens. Returns +nil+
|
|
564
|
+
# if no balanced match exists.
|
|
565
|
+
#
|
|
566
|
+
# Only tracks +()+ — other brackets inside the tag value are
|
|
567
|
+
# transparent (e.g. +@example([1, 2])+ works because +[+ and +]+
|
|
568
|
+
# don't affect paren depth).
|
|
569
|
+
#
|
|
570
|
+
#: (String, Integer) -> Integer?
|
|
571
|
+
def find_matching_open_paren(str, close_pos)
|
|
572
|
+
depth = 1
|
|
573
|
+
i = close_pos - 1
|
|
574
|
+
while i >= 0
|
|
575
|
+
case str[i]
|
|
576
|
+
when ")"
|
|
577
|
+
depth += 1
|
|
578
|
+
when "("
|
|
579
|
+
depth -= 1
|
|
580
|
+
return i if depth.zero?
|
|
581
|
+
end
|
|
582
|
+
i -= 1
|
|
583
|
+
end
|
|
584
|
+
nil
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Parse a field-name token (the part before +:+ in a record entry or
|
|
588
|
+
# call signature parameter) into its clean name and an optional flag.
|
|
589
|
+
#
|
|
590
|
+
# Recognizes both forms:
|
|
591
|
+
# - Prefix (RBS canonical, per README): +?name:+ -> ["name", true]
|
|
592
|
+
# - Suffix (legacy, deprecated for 0.6.0): +name?:+ -> ["name", true]
|
|
593
|
+
# - Unmarked: +name:+ -> ["name", false]
|
|
594
|
+
#
|
|
595
|
+
# The suffix form was historically accepted by parts of this gem
|
|
596
|
+
# but is not standard RBS. Recognizing it consistently across all
|
|
597
|
+
# three parsers (this method's callers) preserves backward
|
|
598
|
+
# compatibility while a single +Kernel#warn+ with
|
|
599
|
+
# +category: :deprecated+ steers consumers toward the prefix form.
|
|
600
|
+
# Users can silence via +Warning[:deprecated] = false+ or
|
|
601
|
+
# +-W:no-deprecated+ — the standard Ruby mechanisms.
|
|
602
|
+
#
|
|
603
|
+
# Raises +ArgumentError+ on malformed tokens. The helper produces
|
|
604
|
+
# three distinct error messages — categories grouped by which
|
|
605
|
+
# branch raises:
|
|
606
|
+
# - empty, whitespace-only, or nil input -> "empty field name"
|
|
607
|
+
# - double-marked optional after stripping (e.g. +"?key?"+) ->
|
|
608
|
+
# "is double-marked optional"
|
|
609
|
+
# - any other shape that does not reduce to a bare +\w+
|
|
610
|
+
# identifier (e.g. +"?"+, +"??key"+, +"key??"+, or tokens with
|
|
611
|
+
# non-word characters) -> "invalid field name token"
|
|
612
|
+
#
|
|
613
|
+
# Whitespace adjacent to the marker is tolerated:
|
|
614
|
+
# +" ? key"+ -> ["key", true]. (Note: the three production call
|
|
615
|
+
# sites never produce such a token — their regexes don't permit
|
|
616
|
+
# internal whitespace — so this tolerance only matters if the
|
|
617
|
+
# helper is invoked directly.)
|
|
618
|
+
#
|
|
619
|
+
# @param raw [String] Token before the +:+ separator.
|
|
620
|
+
# @param source_file [String, nil] Path included in the deprecation
|
|
621
|
+
# warning so consumers can locate the offending annotation. The
|
|
622
|
+
# handler source file is read as text during parsing, so it is
|
|
623
|
+
# not on the Ruby call stack — embedding the path in the message
|
|
624
|
+
# is the only way to make the warning actionable.
|
|
625
|
+
# @return [Array(String, Boolean)] +[clean_name, optional?]+.
|
|
626
|
+
#: (String, ?source_file: String?) -> [String, bool]
|
|
627
|
+
def parse_field_name(raw, source_file: nil)
|
|
628
|
+
raise ArgumentError, "empty field name" if raw.nil? || raw.to_s.strip.empty?
|
|
629
|
+
|
|
630
|
+
trimmed = raw.to_s.strip
|
|
631
|
+
prefix = trimmed.start_with?("?")
|
|
632
|
+
suffix = trimmed.end_with?("?")
|
|
633
|
+
|
|
634
|
+
bare = trimmed
|
|
635
|
+
bare = bare.sub(/\A\?/, "").strip if prefix
|
|
636
|
+
bare = bare.sub(/\?\z/, "").strip if suffix
|
|
637
|
+
|
|
638
|
+
unless bare.match?(/\A\w+\z/)
|
|
639
|
+
raise ArgumentError, "invalid field name token: #{raw.inspect}"
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
if prefix && suffix
|
|
643
|
+
raise ArgumentError,
|
|
644
|
+
"field #{bare.inspect} is double-marked optional (both ?prefix and suffix?); pick one"
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
warn_deprecated_suffix_marker(bare, source_file) if suffix
|
|
648
|
+
|
|
649
|
+
[bare, prefix || suffix]
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# Emit a deprecation warning for the legacy suffix optional marker
|
|
653
|
+
# (+key?:+). Uses +Kernel#warn+ with +category: :deprecated+ so
|
|
654
|
+
# silencing follows the standard Ruby mechanism
|
|
655
|
+
# (+Warning[:deprecated] = false+, +-W:no-deprecated+) and not a
|
|
656
|
+
# gem-specific env var.
|
|
657
|
+
#
|
|
658
|
+
# The source file path is embedded in the message because the
|
|
659
|
+
# handler annotation is parsed as static text — the offending file
|
|
660
|
+
# is not on the Ruby call stack at warn time, so +uplevel:+ cannot
|
|
661
|
+
# surface it. Embedding the path keeps the warning actionable for
|
|
662
|
+
# consumers grepping for the field name.
|
|
663
|
+
#: (String, String?) -> void
|
|
664
|
+
def warn_deprecated_suffix_marker(name, source_file)
|
|
665
|
+
location = source_file ? " (in #{source_file})" : ""
|
|
666
|
+
Kernel.warn(
|
|
667
|
+
"[mcp_authorization] Deprecated optional marker syntax: " \
|
|
668
|
+
"`#{name}?:`#{location}. Use prefix form `?#{name}:` instead. " \
|
|
669
|
+
"The suffix form will be removed in 0.6.0.",
|
|
670
|
+
category: :deprecated
|
|
671
|
+
)
|
|
672
|
+
end
|
|
673
|
+
|
|
432
674
|
# Coerce a default value string from an annotation into its Ruby type.
|
|
433
675
|
# Handles booleans, nil/null, integers, floats, and bare strings.
|
|
434
676
|
#
|
|
@@ -542,14 +784,14 @@ module McpAuthorization
|
|
|
542
784
|
|
|
543
785
|
# Build type map: shared imports first, then handler's own types override
|
|
544
786
|
imported = load_imports(content)
|
|
545
|
-
local = parse_type_aliases(content)
|
|
787
|
+
local = parse_type_aliases(content, source_file: source_file)
|
|
546
788
|
type_map = imported.merge(local)
|
|
547
789
|
|
|
548
790
|
{
|
|
549
791
|
type_map: type_map,
|
|
550
792
|
raw_input: find_raw_type_body(content, "input"),
|
|
551
793
|
raw_output: find_raw_type_body(content, "output"),
|
|
552
|
-
call_params: parse_call_params(content),
|
|
794
|
+
call_params: parse_call_params(content, source_file: source_file),
|
|
553
795
|
source_file: source_file
|
|
554
796
|
}
|
|
555
797
|
end
|
|
@@ -622,24 +864,20 @@ module McpAuthorization
|
|
|
622
864
|
# @param type_map [Hash] Resolved type definitions for +$ref+ lookups.
|
|
623
865
|
# @param server_context [Object] Per-request context.
|
|
624
866
|
# @return [Hash] JSON Schema object with +properties+, +required+, etc.
|
|
625
|
-
#: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
|
|
626
|
-
def compile_tagged_record(raw_body, type_map, server_context)
|
|
867
|
+
#: (String, Hash[String, Hash[Symbol, untyped]], untyped, ?source_file: String?) -> Hash[Symbol, untyped]
|
|
868
|
+
def compile_tagged_record(raw_body, type_map, server_context, source_file: nil)
|
|
627
869
|
properties = {}
|
|
628
870
|
required = []
|
|
629
871
|
dependent_required = {}
|
|
630
872
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
inner.scan(/(\w+\??)\s*:\s*([^,}]+)/) do |match|
|
|
634
|
-
key, type_str = match[0].to_s, match[1].to_s
|
|
635
|
-
type_str, tags = extract_tags(type_str.strip)
|
|
873
|
+
each_field_in_record(raw_body) do |key, type_str|
|
|
874
|
+
type_str, tags = extract_tags(type_str)
|
|
636
875
|
|
|
637
876
|
next if predicate_excluded?(tags, server_context)
|
|
638
877
|
|
|
639
|
-
optional = key
|
|
640
|
-
clean_key = key.delete_suffix("?")
|
|
878
|
+
clean_key, optional = parse_field_name(key, source_file: source_file)
|
|
641
879
|
|
|
642
|
-
schema = rbs_type_to_json_schema(type_str, type_map)
|
|
880
|
+
schema = rbs_type_to_json_schema(type_str, type_map, source_file: source_file)
|
|
643
881
|
properties[clean_key.to_sym] = apply_tags(schema, tags, server_context: server_context)
|
|
644
882
|
required << clean_key unless optional
|
|
645
883
|
|
|
@@ -669,7 +907,7 @@ module McpAuthorization
|
|
|
669
907
|
# @return [Hash] JSON Schema — either a single schema or a +oneOf+ wrapper.
|
|
670
908
|
#: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
|
|
671
909
|
def compile_tagged_union(raw_expr, type_map, server_context)
|
|
672
|
-
parts = raw_expr
|
|
910
|
+
parts = split_at_depth_zero(raw_expr, "|").map(&:strip).reject(&:empty?)
|
|
673
911
|
|
|
674
912
|
filtered = parts.filter_map do |part|
|
|
675
913
|
part, tags = extract_tags(part)
|
|
@@ -828,7 +1066,7 @@ module McpAuthorization
|
|
|
828
1066
|
resolved = {}
|
|
829
1067
|
aliases.each do |name, value|
|
|
830
1068
|
resolved[name] = if value.is_a?(String)
|
|
831
|
-
parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)))
|
|
1069
|
+
parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)), source_file: path)
|
|
832
1070
|
else
|
|
833
1071
|
value
|
|
834
1072
|
end
|
|
@@ -912,8 +1150,8 @@ module McpAuthorization
|
|
|
912
1150
|
#
|
|
913
1151
|
# @param content [String] Full source file contents.
|
|
914
1152
|
# @return [Hash{String => Hash}] Type name → resolved JSON Schema.
|
|
915
|
-
#: (String) -> Hash[String, Hash[Symbol, untyped]]
|
|
916
|
-
def parse_type_aliases(content)
|
|
1153
|
+
#: (String, ?source_file: String?) -> Hash[String, Hash[Symbol, untyped]]
|
|
1154
|
+
def parse_type_aliases(content, source_file: nil)
|
|
917
1155
|
return {} if content.empty?
|
|
918
1156
|
|
|
919
1157
|
aliases = {}
|
|
@@ -940,7 +1178,7 @@ module McpAuthorization
|
|
|
940
1178
|
resolved = {}
|
|
941
1179
|
aliases.each do |name, value|
|
|
942
1180
|
resolved[name] = if value.is_a?(String)
|
|
943
|
-
parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)))
|
|
1181
|
+
parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)), source_file: source_file)
|
|
944
1182
|
else
|
|
945
1183
|
value
|
|
946
1184
|
end
|
|
@@ -961,8 +1199,8 @@ module McpAuthorization
|
|
|
961
1199
|
#
|
|
962
1200
|
# @param content [String] Full source file contents.
|
|
963
1201
|
# @return [Array<Hash>] Parameter descriptors.
|
|
964
|
-
#: (String) -> Array[Hash[Symbol, untyped]]
|
|
965
|
-
def parse_call_params(content)
|
|
1202
|
+
#: (String, ?source_file: String?) -> Array[Hash[Symbol, untyped]]
|
|
1203
|
+
def parse_call_params(content, source_file: nil)
|
|
966
1204
|
return [] if content.empty?
|
|
967
1205
|
|
|
968
1206
|
lines = content.lines
|
|
@@ -978,10 +1216,24 @@ module McpAuthorization
|
|
|
978
1216
|
|
|
979
1217
|
params = []
|
|
980
1218
|
if annotation =~ /\((.+)\)\s*->/m
|
|
981
|
-
|
|
1219
|
+
# Bracket-aware split — flat `.split(",")` was breaking on commas
|
|
1220
|
+
# inside @desc(...) and on generic types like Hash[Symbol, untyped]
|
|
1221
|
+
# where the comma is part of the type-arg list.
|
|
1222
|
+
split_at_depth_zero($1.to_s, ",").each do |param|
|
|
982
1223
|
param = param.strip
|
|
983
|
-
next
|
|
984
|
-
|
|
1224
|
+
next if param.empty?
|
|
1225
|
+
|
|
1226
|
+
# Find the field-name/type separator at bracket depth 0 so
|
|
1227
|
+
# `flag: Hash[Symbol, untyped]` doesn't get split on the `:`
|
|
1228
|
+
# inside a nested record type.
|
|
1229
|
+
colon = find_at_depth_zero(param, [":"])
|
|
1230
|
+
next unless colon
|
|
1231
|
+
|
|
1232
|
+
raw_key = param[0...colon].to_s.strip
|
|
1233
|
+
type = param[(colon + 1)..].to_s.strip
|
|
1234
|
+
next if raw_key.empty? || type.empty?
|
|
1235
|
+
|
|
1236
|
+
name, optional = parse_field_name(raw_key, source_file: source_file)
|
|
985
1237
|
next if name == "server_context"
|
|
986
1238
|
|
|
987
1239
|
type, tags = extract_tags(type)
|
|
@@ -989,7 +1241,7 @@ module McpAuthorization
|
|
|
989
1241
|
params << {
|
|
990
1242
|
name: name,
|
|
991
1243
|
type: type,
|
|
992
|
-
required:
|
|
1244
|
+
required: !optional && !type.end_with?("?"),
|
|
993
1245
|
tags: tags
|
|
994
1246
|
}
|
|
995
1247
|
end
|
|
@@ -1021,20 +1273,16 @@ module McpAuthorization
|
|
|
1021
1273
|
# @param body [String] Record body including surrounding braces.
|
|
1022
1274
|
# @param type_map [Hash] Resolved types for reference lookups.
|
|
1023
1275
|
# @return [Hash] JSON Schema object with +properties+ and +required+.
|
|
1024
|
-
#: (String, ?Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1025
|
-
def parse_record_type(body, type_map = {})
|
|
1276
|
+
#: (String, ?Hash[String, Hash[Symbol, untyped]], ?source_file: String?) -> Hash[Symbol, untyped]
|
|
1277
|
+
def parse_record_type(body, type_map = {}, source_file: nil)
|
|
1026
1278
|
properties = {}
|
|
1027
1279
|
required = []
|
|
1028
1280
|
|
|
1029
|
-
|
|
1281
|
+
each_field_in_record(body) do |key, type_str|
|
|
1282
|
+
type_str, tags = extract_tags(type_str)
|
|
1283
|
+
clean_key, optional = parse_field_name(key, source_file: source_file)
|
|
1030
1284
|
|
|
1031
|
-
|
|
1032
|
-
key, type_str = match[0].to_s, match[1].to_s
|
|
1033
|
-
type_str, tags = extract_tags(type_str.strip)
|
|
1034
|
-
optional = key.end_with?("?")
|
|
1035
|
-
clean_key = key.delete_suffix("?")
|
|
1036
|
-
|
|
1037
|
-
schema = rbs_type_to_json_schema(type_str, type_map)
|
|
1285
|
+
schema = rbs_type_to_json_schema(type_str, type_map, source_file: source_file)
|
|
1038
1286
|
properties[clean_key.to_sym] = apply_tags(schema, tags)
|
|
1039
1287
|
required << clean_key unless optional
|
|
1040
1288
|
end
|
|
@@ -1044,6 +1292,36 @@ module McpAuthorization
|
|
|
1044
1292
|
schema
|
|
1045
1293
|
end
|
|
1046
1294
|
|
|
1295
|
+
# Iterate the fields of a record body, splitting on +,+ at bracket
|
|
1296
|
+
# depth 0 (so commas inside +@desc(...)+ or inside nested records
|
|
1297
|
+
# don't fragment a field). Yields +key, type_str+ already trimmed
|
|
1298
|
+
# for each non-empty field. Outer braces are stripped before
|
|
1299
|
+
# iteration.
|
|
1300
|
+
#
|
|
1301
|
+
# Pre-bracket-aware behavior used +inner.scan(/(\??\w+\??)\s*:\s*([^,}]+)/)+,
|
|
1302
|
+
# which silently dropped any field whose type string contained a
|
|
1303
|
+
# comma — or worse, split that field at the comma.
|
|
1304
|
+
#
|
|
1305
|
+
#: (String) { (String, String) -> void } -> void
|
|
1306
|
+
def each_field_in_record(body)
|
|
1307
|
+
inner = body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
|
|
1308
|
+
return if inner.empty?
|
|
1309
|
+
|
|
1310
|
+
split_at_depth_zero(inner, ",").each do |field|
|
|
1311
|
+
field = field.strip
|
|
1312
|
+
next if field.empty?
|
|
1313
|
+
|
|
1314
|
+
colon = find_at_depth_zero(field, [":"])
|
|
1315
|
+
next unless colon
|
|
1316
|
+
|
|
1317
|
+
key = field[0...colon].to_s.strip
|
|
1318
|
+
type_str = field[(colon + 1)..].to_s.strip
|
|
1319
|
+
next if key.empty? || type_str.empty?
|
|
1320
|
+
|
|
1321
|
+
yield key, type_str
|
|
1322
|
+
end
|
|
1323
|
+
end
|
|
1324
|
+
|
|
1047
1325
|
# Convert a single RBS type expression into its JSON Schema equivalent.
|
|
1048
1326
|
#
|
|
1049
1327
|
# Handles:
|
|
@@ -1057,38 +1335,172 @@ module McpAuthorization
|
|
|
1057
1335
|
# @param rbs_type [String] RBS type expression.
|
|
1058
1336
|
# @param type_map [Hash] Resolved type definitions for named type lookups.
|
|
1059
1337
|
# @return [Hash] JSON Schema hash.
|
|
1060
|
-
#: (String, ?Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1061
|
-
def rbs_type_to_json_schema(rbs_type, type_map = {})
|
|
1338
|
+
#: (String, ?Hash[String, Hash[Symbol, untyped]], ?source_file: String?) -> Hash[Symbol, untyped]
|
|
1339
|
+
def rbs_type_to_json_schema(rbs_type, type_map = {}, source_file: nil)
|
|
1062
1340
|
stripped = rbs_type.strip
|
|
1063
|
-
|
|
1341
|
+
return { type: "string" } if stripped.empty?
|
|
1342
|
+
|
|
1343
|
+
# Special case preserved for backward compat with the previous
|
|
1344
|
+
# regex parser: "TrueClass | FalseClass" was recognized as a
|
|
1345
|
+
# boolean shorthand. RBS would parse it as Union[ClassInstance,
|
|
1346
|
+
# ClassInstance], which we'd otherwise turn into a oneOf.
|
|
1347
|
+
return { type: "boolean" } if stripped == "TrueClass | FalseClass"
|
|
1348
|
+
|
|
1349
|
+
begin
|
|
1350
|
+
ast = RBS::Parser.parse_type(stripped, require_eof: true)
|
|
1351
|
+
rescue RBS::ParsingError
|
|
1352
|
+
# Unparseable — fall back to type_map lookup or string.
|
|
1353
|
+
return type_map[stripped] || { type: "string" }
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1356
|
+
visit_rbs_type(ast, type_map)
|
|
1357
|
+
end
|
|
1358
|
+
|
|
1359
|
+
# AST visitor: convert an +RBS::Types::*+ node into JSON Schema.
|
|
1360
|
+
#
|
|
1361
|
+
# Replaces the prior regex case-statement parser with a delegation
|
|
1362
|
+
# to the official rbs gem. Each node type maps onto a small JSON
|
|
1363
|
+
# Schema fragment. Named types (+RBS::Types::Alias+, unknown
|
|
1364
|
+
# +ClassInstance+) are looked up in +type_map+, preserving the
|
|
1365
|
+
# gem's named-type indirection for shared and inline aliases.
|
|
1366
|
+
#
|
|
1367
|
+
# @param node [RBS::Types::t] AST node from +RBS::Parser.parse_type+.
|
|
1368
|
+
# @param type_map [Hash] Resolved named-type definitions.
|
|
1369
|
+
# @return [Hash] JSON Schema fragment.
|
|
1370
|
+
#: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1371
|
+
def visit_rbs_type(node, type_map)
|
|
1372
|
+
case node
|
|
1373
|
+
when RBS::Types::Bases::Bool
|
|
1374
|
+
{ type: "boolean" }
|
|
1375
|
+
when RBS::Types::Bases::Any, RBS::Types::Bases::Void, RBS::Types::Bases::Nil
|
|
1376
|
+
# untyped / void / nil → no constraint (LLM can pass anything)
|
|
1377
|
+
{ type: "string" }
|
|
1378
|
+
when RBS::Types::Literal
|
|
1379
|
+
visit_rbs_literal(node)
|
|
1380
|
+
when RBS::Types::Optional
|
|
1381
|
+
# Optional wraps a type; nullability is handled at field
|
|
1382
|
+
# level (required-set), not in the JSON Schema type itself.
|
|
1383
|
+
visit_rbs_type(node.type, type_map)
|
|
1384
|
+
when RBS::Types::Union
|
|
1385
|
+
visit_rbs_union(node, type_map)
|
|
1386
|
+
when RBS::Types::Record
|
|
1387
|
+
visit_rbs_record(node, type_map)
|
|
1388
|
+
when RBS::Types::Tuple
|
|
1389
|
+
# RBS tuples have heterogeneous element types; JSON Schema's
|
|
1390
|
+
# closest analog is array with prefixItems, but for simplicity
|
|
1391
|
+
# we project to a plain array.
|
|
1392
|
+
{ type: "array" }
|
|
1393
|
+
when RBS::Types::ClassInstance
|
|
1394
|
+
visit_rbs_class_instance(node, type_map)
|
|
1395
|
+
when RBS::Types::Alias
|
|
1396
|
+
name = node.name.to_s.sub(/\A::/, "")
|
|
1397
|
+
type_map[name] || { type: "string" }
|
|
1398
|
+
else
|
|
1399
|
+
# Interface, Proc, Variable, ClassSingleton, Bases::Self/Class/Instance/Top/Bottom etc.
|
|
1400
|
+
{ type: "string" }
|
|
1401
|
+
end
|
|
1402
|
+
end
|
|
1403
|
+
|
|
1404
|
+
# Map +RBS::Types::ClassInstance+ to JSON Schema. Recognizes the
|
|
1405
|
+
# Ruby primitives we care about (+String+, +Integer+, +Float+),
|
|
1406
|
+
# generic +Array[T]+ / +Hash[K, V]+, and falls back to the
|
|
1407
|
+
# +type_map+ for user-defined names.
|
|
1408
|
+
#: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1409
|
+
def visit_rbs_class_instance(node, type_map)
|
|
1410
|
+
name = node.name.to_s.sub(/\A::/, "")
|
|
1411
|
+
case name
|
|
1064
1412
|
when "String"
|
|
1065
1413
|
{ type: "string" }
|
|
1066
1414
|
when "Integer"
|
|
1067
1415
|
{ type: "integer" }
|
|
1068
|
-
when "Float"
|
|
1416
|
+
when "Float", "Numeric"
|
|
1069
1417
|
{ type: "number" }
|
|
1070
|
-
when "
|
|
1071
|
-
{ type: "boolean" }
|
|
1072
|
-
when "true"
|
|
1418
|
+
when "TrueClass"
|
|
1073
1419
|
{ type: "boolean", const: true }
|
|
1074
|
-
when "
|
|
1420
|
+
when "FalseClass"
|
|
1075
1421
|
{ type: "boolean", const: false }
|
|
1076
|
-
when
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
{ oneOf: parts.map { |p| rbs_type_to_json_schema(p, type_map) } }
|
|
1088
|
-
end
|
|
1422
|
+
when "Array"
|
|
1423
|
+
inner = node.args.first
|
|
1424
|
+
inner ? { type: "array", items: visit_rbs_type(inner, type_map) } : { type: "array" }
|
|
1425
|
+
when "Hash"
|
|
1426
|
+
# Hash[K, V] — JSON Schema can express V as additionalProperties.
|
|
1427
|
+
val = node.args[1]
|
|
1428
|
+
val ? { type: "object", additionalProperties: visit_rbs_type(val, type_map) } : { type: "object" }
|
|
1429
|
+
when "Symbol"
|
|
1430
|
+
{ type: "string" }
|
|
1431
|
+
when "NilClass"
|
|
1432
|
+
{ type: "string" }
|
|
1089
1433
|
else
|
|
1090
|
-
type_map[
|
|
1434
|
+
type_map[name] || { type: "string" }
|
|
1435
|
+
end
|
|
1436
|
+
end
|
|
1437
|
+
|
|
1438
|
+
# Map a +RBS::Types::Literal+ to a JSON Schema +const+ fragment.
|
|
1439
|
+
# In a union of literals this becomes part of an +enum+; see
|
|
1440
|
+
# +visit_rbs_union+ for that aggregation.
|
|
1441
|
+
#: (untyped) -> Hash[Symbol, untyped]
|
|
1442
|
+
def visit_rbs_literal(node)
|
|
1443
|
+
case node.literal
|
|
1444
|
+
when true then { type: "boolean", const: true }
|
|
1445
|
+
when false then { type: "boolean", const: false }
|
|
1446
|
+
when String then { type: "string", const: node.literal }
|
|
1447
|
+
when Symbol then { type: "string", const: node.literal.to_s }
|
|
1448
|
+
when Integer then { type: "integer", const: node.literal }
|
|
1449
|
+
else { type: "string", const: node.literal.to_s }
|
|
1450
|
+
end
|
|
1451
|
+
end
|
|
1452
|
+
|
|
1453
|
+
# Map +RBS::Types::Union+ to JSON Schema. A union of string
|
|
1454
|
+
# literals becomes a +{type: "string", enum: [...]}+; any other
|
|
1455
|
+
# mix becomes +{oneOf: [...]}+. This mirrors the prior regex
|
|
1456
|
+
# parser's behavior so existing schemas don't drift.
|
|
1457
|
+
#: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1458
|
+
def visit_rbs_union(node, type_map)
|
|
1459
|
+
types = node.types
|
|
1460
|
+
|
|
1461
|
+
# All string literals → enum.
|
|
1462
|
+
if types.all? { |t| t.is_a?(RBS::Types::Literal) && t.literal.is_a?(String) }
|
|
1463
|
+
return { type: "string", enum: types.map { |t| t.literal.to_s } }
|
|
1464
|
+
end
|
|
1465
|
+
|
|
1466
|
+
# TrueClass | FalseClass → boolean. RBS doesn't have a single
|
|
1467
|
+
# "boolean" base class, so this pattern is what users write.
|
|
1468
|
+
if types.size == 2 &&
|
|
1469
|
+
types.all? { |t| t.is_a?(RBS::Types::ClassInstance) } &&
|
|
1470
|
+
types.map { |t| t.name.to_s.sub(/\A::/, "") }.sort == %w[FalseClass TrueClass]
|
|
1471
|
+
return { type: "boolean" }
|
|
1091
1472
|
end
|
|
1473
|
+
|
|
1474
|
+
{ oneOf: types.map { |t| visit_rbs_type(t, type_map) } }
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
# Map +RBS::Types::Record+ to a JSON Schema object. RBS handles
|
|
1478
|
+
# +?key:+ optional markers natively — they land in
|
|
1479
|
+
# +node.optional_fields+ rather than +node.fields+. No need for
|
|
1480
|
+
# us to parse the marker manually.
|
|
1481
|
+
#
|
|
1482
|
+
# NOTE: this path is used when a record appears nested inside
|
|
1483
|
+
# another type expression (e.g. +Array[{name: String}]+). Top-level
|
|
1484
|
+
# records reached via +# @rbs type input = { ... }+ go through
|
|
1485
|
+
# +compile_tagged_record+ instead so per-field tag extraction can
|
|
1486
|
+
# happen before the type is parsed.
|
|
1487
|
+
#: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1488
|
+
def visit_rbs_record(node, type_map)
|
|
1489
|
+
properties = {}
|
|
1490
|
+
required = []
|
|
1491
|
+
|
|
1492
|
+
node.fields.each do |name, type|
|
|
1493
|
+
key = name.to_s
|
|
1494
|
+
properties[key.to_sym] = visit_rbs_type(type, type_map)
|
|
1495
|
+
required << key
|
|
1496
|
+
end
|
|
1497
|
+
node.optional_fields.each do |name, type|
|
|
1498
|
+
properties[name.to_s.to_sym] = visit_rbs_type(type, type_map)
|
|
1499
|
+
end
|
|
1500
|
+
|
|
1501
|
+
schema = { type: "object", properties: properties } #: Hash[Symbol, untyped]
|
|
1502
|
+
schema[:required] = required if required.any?
|
|
1503
|
+
schema
|
|
1092
1504
|
end
|
|
1093
1505
|
|
|
1094
1506
|
# Look up a named type in the type map. Returns a bare +{type: "object"}+
|
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.
|
|
4
|
+
version: 0.5.1
|
|
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-05-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -38,6 +38,26 @@ dependencies:
|
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '0.10'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rbs
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.0'
|
|
48
|
+
- - "<"
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '4.0'
|
|
51
|
+
type: :runtime
|
|
52
|
+
prerelease: false
|
|
53
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '3.0'
|
|
58
|
+
- - "<"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '4.0'
|
|
41
61
|
- !ruby/object:Gem::Dependency
|
|
42
62
|
name: minitest
|
|
43
63
|
requirement: !ruby/object:Gem::Requirement
|