mcp_authorization 0.5.0 → 0.5.2
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/lib/mcp_authorization/rbs_schema_compiler.rb +384 -37
- data/lib/mcp_authorization/version.rb +1 -1
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e72fb2f33292962f9820cf0908a278b461d3037d8612240f06a1fd3ba0dd2daa
|
|
4
|
+
data.tar.gz: d1a4af82cd4b4f177a1aec377572e105715f11f822b6bdd86da3ec8715cfa7ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cb10ce2df4b8c6470624219bd33e2740293a999c98f25f9975f6a3cc7b344d2d5189134dc9284ea41d3955820c5af72bfe19a089945f116f84305c2463216dd4
|
|
7
|
+
data.tar.gz: db08f04a73a6ef838af81bbcf215d60568d07f52e5b33a722e4e578e0ae8113941a5b8ffe351c01a7976ae56fd57b4cb5fe0dd1158802ff92f5dc2fd6f472827
|
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.2] - 2026-05-28
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- **Narrowed the `rbs` require set.** `require "rbs"` pulled in ~144 files (CLI, environment loader, definition builder, prototype generators, stdlib type signatures, validator, resolver, ...) — none of which this gem touches. Replaced with a 16-file subset covering only `RBS::Parser.parse_type` and the `RBS::Types::*` AST classes the schema visitor actually visits. Measured against this gem's load path: ~1.2 MB RSS vs ~15 MB, 18 files loaded vs 144. No behavior change — same parser, same AST.
|
|
11
|
+
- **Loosened the `rbs` version constraint** from `>= 3.0, < 4.0` to `>= 3.0`. The upper bound locked consumers out of `rbs 4.x` even though `RBS::Parser.parse_type` is part of rbs's stable surface. Consumers who already depend on `rbs 4` for their own Steep / type-check toolchain can now use this gem without a downgrade. If a future rbs major actually breaks our parser-API usage, we'll add the upper bound back at that point — not preemptively.
|
|
12
|
+
|
|
13
|
+
## [0.5.1] - 2026-05-27
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Tag values containing balanced parens, commas, or pipes no longer break the parser.** ([#15](https://github.com/onboardiq/mcp_authorization/issues/15))
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
20
|
+
Five call sites were affected, all with the same root cause:
|
|
21
|
+
- `extract_tags` — `@desc(foo (bar))` truncated at the inner `)`
|
|
22
|
+
- `compile_tagged_record` — comma inside `@desc(...)` fragmented fields
|
|
23
|
+
- `parse_record_type` — same, for nested/aliased records
|
|
24
|
+
- `parse_call_params` — flat `.split(",")` mistook commas inside `@desc(...)` AND commas inside generic types (`Hash[Symbol, untyped]`) for parameter separators
|
|
25
|
+
- union-splitting in `compile_tagged_union` and `rbs_type_to_json_schema` — pipe inside `@desc(...)` split the union mid-value
|
|
26
|
+
|
|
27
|
+
All four now go through bracket-aware primitives that track `()`, `[]`, `{}` depth while scanning.
|
|
28
|
+
|
|
29
|
+
**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}`.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- **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.
|
|
33
|
+
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- 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.
|
|
38
|
+
- **Runtime dependency on `rbs` (>= 3.0, < 4.0).** Previously transitive via Steep dev dependency; now explicit because the production code path uses it.
|
|
39
|
+
|
|
7
40
|
## [0.5.0] - 2026-05-26
|
|
8
41
|
|
|
9
42
|
### Changed (BREAKING)
|
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
# Narrow require set: we use only RBS::Parser.parse_type and a handful of
|
|
2
|
+
# RBS::Types::* AST classes. Avoid `require "rbs"`, which pulls in ~144
|
|
3
|
+
# files (CLI, environment loader, definition builder, prototype generators,
|
|
4
|
+
# stdlib type signatures, ...) we never touch — ~15 MB RSS vs ~1.2 MB for
|
|
5
|
+
# the subset below. Load order matters: location_aux uses the C-defined
|
|
6
|
+
# RBS::Location, and rbs_extension assumes RBS::AST::* namespaces exist.
|
|
7
|
+
require "rbs/version"
|
|
8
|
+
require "rbs/errors"
|
|
9
|
+
require "rbs/buffer"
|
|
10
|
+
require "rbs/namespace"
|
|
11
|
+
require "rbs/type_name"
|
|
12
|
+
require "rbs/types"
|
|
13
|
+
require "rbs/method_type"
|
|
14
|
+
require "rbs/ast/type_param"
|
|
15
|
+
require "rbs/ast/directives"
|
|
16
|
+
require "rbs/ast/declarations"
|
|
17
|
+
require "rbs/ast/members"
|
|
18
|
+
require "rbs/ast/annotation"
|
|
19
|
+
require "rbs/ast/comment"
|
|
20
|
+
require "rbs_extension"
|
|
21
|
+
require "rbs/location_aux"
|
|
22
|
+
require "rbs/parser_aux"
|
|
23
|
+
|
|
1
24
|
require_relative "diagnostics"
|
|
2
25
|
|
|
3
26
|
module McpAuthorization
|
|
@@ -374,9 +397,11 @@ module McpAuthorization
|
|
|
374
397
|
def extract_tags(type_str)
|
|
375
398
|
tags = {}
|
|
376
399
|
|
|
377
|
-
# Extract all @tag(...) annotations from right to left
|
|
378
|
-
|
|
379
|
-
|
|
400
|
+
# Extract all @tag(...) annotations from right to left, using a
|
|
401
|
+
# bracket-aware peeler so tag values can contain balanced parens,
|
|
402
|
+
# commas, and pipes (e.g. +@desc(foo (bar). Required.)+).
|
|
403
|
+
while (peeled = peel_trailing_tag(type_str))
|
|
404
|
+
type_str, tag_name, tag_value = peeled
|
|
380
405
|
|
|
381
406
|
case tag_name
|
|
382
407
|
when "requires"
|
|
@@ -429,6 +454,158 @@ module McpAuthorization
|
|
|
429
454
|
[type_str, tags]
|
|
430
455
|
end
|
|
431
456
|
|
|
457
|
+
# ---------------------------------------------------------------
|
|
458
|
+
# Bracket-aware parsing primitives
|
|
459
|
+
#
|
|
460
|
+
# The historical regex parser used flat patterns like +[^)]*+,
|
|
461
|
+
# +[^,}]++, and bare +.split("|")+ to find delimiters in RBS-flavored
|
|
462
|
+
# annotations. Those patterns can't track nested brackets — a classic
|
|
463
|
+
# limitation of regular expressions (regex are finite automata with
|
|
464
|
+
# no counter; balanced delimiters are not a regular language). The
|
|
465
|
+
# symptom was silent miscompilation when tag values contained the
|
|
466
|
+
# delimiter characters (e.g. +@desc(foo (bar). baz)+ would terminate
|
|
467
|
+
# the +)+ scan at the inner +)+, leaving the outer +@desc(...)+
|
|
468
|
+
# un-extracted and the field's type misparsed).
|
|
469
|
+
#
|
|
470
|
+
# These helpers walk the string character-by-character tracking
|
|
471
|
+
# +()+, +[]+, +{}+ depth so delimiters inside any balanced bracket
|
|
472
|
+
# pair are skipped. Used by:
|
|
473
|
+
# - +extract_tags+ — locate +@tag(...)+ with balanced value
|
|
474
|
+
# - +compile_tagged_record+ — split fields on +,+ at depth 0
|
|
475
|
+
# - +parse_record_type+ — same
|
|
476
|
+
# - +compile_tagged_union+ — split variants on +|+ at depth 0
|
|
477
|
+
# - +rbs_type_to_json_schema+ — same
|
|
478
|
+
#
|
|
479
|
+
# See the 0.5.1 CHANGELOG for the bug-class history. This is the
|
|
480
|
+
# foundation that Phase 2 of the parser migration will reuse.
|
|
481
|
+
# ---------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
# Find the position of the first character in +delims+ that occurs
|
|
484
|
+
# at bracket depth 0, scanning left-to-right from +start+. Returns
|
|
485
|
+
# +nil+ if no such position exists.
|
|
486
|
+
#
|
|
487
|
+
# Tracks +()+, +[]+, +{}+ as balanced pairs. A delimiter inside any
|
|
488
|
+
# of these pairs is skipped.
|
|
489
|
+
#
|
|
490
|
+
# @example
|
|
491
|
+
# find_at_depth_zero("a, b, (c, d), e", [","]) #=> 1
|
|
492
|
+
# find_at_depth_zero("a, b, (c, d), e", [","], start: 2) #=> 4
|
|
493
|
+
# find_at_depth_zero("(no delim here)", [","]) #=> nil
|
|
494
|
+
#
|
|
495
|
+
#: (String, Array[String], ?start: Integer) -> Integer?
|
|
496
|
+
def find_at_depth_zero(str, delims, start: 0)
|
|
497
|
+
depth = 0
|
|
498
|
+
pos = start
|
|
499
|
+
while pos < str.length
|
|
500
|
+
ch = str[pos].to_s
|
|
501
|
+
return pos if depth.zero? && delims.include?(ch)
|
|
502
|
+
if "([{".include?(ch)
|
|
503
|
+
depth += 1
|
|
504
|
+
elsif ")]}".include?(ch)
|
|
505
|
+
depth -= 1
|
|
506
|
+
end
|
|
507
|
+
pos += 1
|
|
508
|
+
end
|
|
509
|
+
nil
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Split +str+ on every occurrence of +delim+ at bracket depth 0.
|
|
513
|
+
# Returns an array of substrings (NOT stripped, NOT filtered).
|
|
514
|
+
# Callers strip / reject empties as needed to match prior semantics.
|
|
515
|
+
#
|
|
516
|
+
# @example
|
|
517
|
+
# split_at_depth_zero("a, b, (c, d), e", ",")
|
|
518
|
+
# #=> ["a", " b", " (c, d)", " e"]
|
|
519
|
+
#
|
|
520
|
+
#: (String, String) -> Array[String]
|
|
521
|
+
def split_at_depth_zero(str, delim)
|
|
522
|
+
parts = []
|
|
523
|
+
start = 0
|
|
524
|
+
while (pos = find_at_depth_zero(str, [delim], start: start))
|
|
525
|
+
parts << str[start...pos].to_s
|
|
526
|
+
start = pos + 1
|
|
527
|
+
end
|
|
528
|
+
parts << str[start..].to_s
|
|
529
|
+
parts
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Peel the rightmost +@tag(...)+ off +type_str+ if one exists,
|
|
533
|
+
# respecting balanced parentheses inside the tag value.
|
|
534
|
+
#
|
|
535
|
+
# Returns +[remaining_type_str, tag_name, tag_value]+ on success, or
|
|
536
|
+
# +nil+ if no trailing tag is found. The remainder is right-stripped.
|
|
537
|
+
# The tag value is not stripped (preserves intentional whitespace
|
|
538
|
+
# in +@desc(...)+ for example).
|
|
539
|
+
#
|
|
540
|
+
# The +@+ must be preceded by whitespace or be at position 0, so
|
|
541
|
+
# +"@foo"+ in the middle of a type expression isn't mistaken for a
|
|
542
|
+
# tag (this matches the prior regex semantics with +\s+@+).
|
|
543
|
+
#
|
|
544
|
+
# @example
|
|
545
|
+
# peel_trailing_tag("Integer @desc(a (b) c) @min(1)")
|
|
546
|
+
# #=> ["Integer @desc(a (b) c)", "min", "1"]
|
|
547
|
+
#
|
|
548
|
+
# peel_trailing_tag("Integer @desc(a (b) c)")
|
|
549
|
+
# #=> ["Integer", "desc", "a (b) c"]
|
|
550
|
+
#
|
|
551
|
+
#: (String) -> [String, String, String]?
|
|
552
|
+
def peel_trailing_tag(type_str)
|
|
553
|
+
trimmed = type_str.rstrip
|
|
554
|
+
return nil unless trimmed.end_with?(")")
|
|
555
|
+
|
|
556
|
+
close_pos = trimmed.length - 1
|
|
557
|
+
open_pos = find_matching_open_paren(trimmed, close_pos)
|
|
558
|
+
return nil unless open_pos
|
|
559
|
+
return nil if open_pos.zero?
|
|
560
|
+
|
|
561
|
+
# Walk backward from open_pos to capture the tag name (\w+).
|
|
562
|
+
name_end = open_pos
|
|
563
|
+
name_start = name_end
|
|
564
|
+
while name_start > 0 && trimmed[name_start - 1].to_s.match?(/\w/)
|
|
565
|
+
name_start -= 1
|
|
566
|
+
end
|
|
567
|
+
return nil if name_start == name_end
|
|
568
|
+
|
|
569
|
+
# The character immediately before the name must be '@'.
|
|
570
|
+
at_pos = name_start - 1
|
|
571
|
+
return nil unless at_pos >= 0 && trimmed[at_pos] == "@"
|
|
572
|
+
|
|
573
|
+
# The '@' must be preceded by whitespace or be at the start, so we
|
|
574
|
+
# don't accidentally peel an '@' embedded in an identifier.
|
|
575
|
+
return nil unless at_pos.zero? || trimmed[at_pos - 1].to_s.match?(/\s/)
|
|
576
|
+
|
|
577
|
+
tag_name = trimmed[name_start...name_end].to_s
|
|
578
|
+
tag_value = trimmed[(open_pos + 1)...close_pos].to_s
|
|
579
|
+
remainder = trimmed[0...at_pos].to_s.rstrip
|
|
580
|
+
|
|
581
|
+
[remainder, tag_name, tag_value]
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Find the position of the +(+ that matches the +)+ at +close_pos+
|
|
585
|
+
# in +str+. Walks backward, counting nested parens. Returns +nil+
|
|
586
|
+
# if no balanced match exists.
|
|
587
|
+
#
|
|
588
|
+
# Only tracks +()+ — other brackets inside the tag value are
|
|
589
|
+
# transparent (e.g. +@example([1, 2])+ works because +[+ and +]+
|
|
590
|
+
# don't affect paren depth).
|
|
591
|
+
#
|
|
592
|
+
#: (String, Integer) -> Integer?
|
|
593
|
+
def find_matching_open_paren(str, close_pos)
|
|
594
|
+
depth = 1
|
|
595
|
+
i = close_pos - 1
|
|
596
|
+
while i >= 0
|
|
597
|
+
case str[i]
|
|
598
|
+
when ")"
|
|
599
|
+
depth += 1
|
|
600
|
+
when "("
|
|
601
|
+
depth -= 1
|
|
602
|
+
return i if depth.zero?
|
|
603
|
+
end
|
|
604
|
+
i -= 1
|
|
605
|
+
end
|
|
606
|
+
nil
|
|
607
|
+
end
|
|
608
|
+
|
|
432
609
|
# Parse a field-name token (the part before +:+ in a record entry or
|
|
433
610
|
# call signature parameter) into its clean name and an optional flag.
|
|
434
611
|
#
|
|
@@ -715,11 +892,8 @@ module McpAuthorization
|
|
|
715
892
|
required = []
|
|
716
893
|
dependent_required = {}
|
|
717
894
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
inner.scan(/(\??\w+\??)\s*:\s*([^,}]+)/) do |match|
|
|
721
|
-
key, type_str = match[0].to_s, match[1].to_s
|
|
722
|
-
type_str, tags = extract_tags(type_str.strip)
|
|
895
|
+
each_field_in_record(raw_body) do |key, type_str|
|
|
896
|
+
type_str, tags = extract_tags(type_str)
|
|
723
897
|
|
|
724
898
|
next if predicate_excluded?(tags, server_context)
|
|
725
899
|
|
|
@@ -755,7 +929,7 @@ module McpAuthorization
|
|
|
755
929
|
# @return [Hash] JSON Schema — either a single schema or a +oneOf+ wrapper.
|
|
756
930
|
#: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
|
|
757
931
|
def compile_tagged_union(raw_expr, type_map, server_context)
|
|
758
|
-
parts = raw_expr
|
|
932
|
+
parts = split_at_depth_zero(raw_expr, "|").map(&:strip).reject(&:empty?)
|
|
759
933
|
|
|
760
934
|
filtered = parts.filter_map do |part|
|
|
761
935
|
part, tags = extract_tags(part)
|
|
@@ -1064,10 +1238,22 @@ module McpAuthorization
|
|
|
1064
1238
|
|
|
1065
1239
|
params = []
|
|
1066
1240
|
if annotation =~ /\((.+)\)\s*->/m
|
|
1067
|
-
|
|
1241
|
+
# Bracket-aware split — flat `.split(",")` was breaking on commas
|
|
1242
|
+
# inside @desc(...) and on generic types like Hash[Symbol, untyped]
|
|
1243
|
+
# where the comma is part of the type-arg list.
|
|
1244
|
+
split_at_depth_zero($1.to_s, ",").each do |param|
|
|
1068
1245
|
param = param.strip
|
|
1069
|
-
next
|
|
1070
|
-
|
|
1246
|
+
next if param.empty?
|
|
1247
|
+
|
|
1248
|
+
# Find the field-name/type separator at bracket depth 0 so
|
|
1249
|
+
# `flag: Hash[Symbol, untyped]` doesn't get split on the `:`
|
|
1250
|
+
# inside a nested record type.
|
|
1251
|
+
colon = find_at_depth_zero(param, [":"])
|
|
1252
|
+
next unless colon
|
|
1253
|
+
|
|
1254
|
+
raw_key = param[0...colon].to_s.strip
|
|
1255
|
+
type = param[(colon + 1)..].to_s.strip
|
|
1256
|
+
next if raw_key.empty? || type.empty?
|
|
1071
1257
|
|
|
1072
1258
|
name, optional = parse_field_name(raw_key, source_file: source_file)
|
|
1073
1259
|
next if name == "server_context"
|
|
@@ -1114,11 +1300,8 @@ module McpAuthorization
|
|
|
1114
1300
|
properties = {}
|
|
1115
1301
|
required = []
|
|
1116
1302
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
inner.scan(/(\??\w+\??)\s*:\s*([^,}]+)/) do |match|
|
|
1120
|
-
key, type_str = match[0].to_s, match[1].to_s
|
|
1121
|
-
type_str, tags = extract_tags(type_str.strip)
|
|
1303
|
+
each_field_in_record(body) do |key, type_str|
|
|
1304
|
+
type_str, tags = extract_tags(type_str)
|
|
1122
1305
|
clean_key, optional = parse_field_name(key, source_file: source_file)
|
|
1123
1306
|
|
|
1124
1307
|
schema = rbs_type_to_json_schema(type_str, type_map, source_file: source_file)
|
|
@@ -1131,6 +1314,36 @@ module McpAuthorization
|
|
|
1131
1314
|
schema
|
|
1132
1315
|
end
|
|
1133
1316
|
|
|
1317
|
+
# Iterate the fields of a record body, splitting on +,+ at bracket
|
|
1318
|
+
# depth 0 (so commas inside +@desc(...)+ or inside nested records
|
|
1319
|
+
# don't fragment a field). Yields +key, type_str+ already trimmed
|
|
1320
|
+
# for each non-empty field. Outer braces are stripped before
|
|
1321
|
+
# iteration.
|
|
1322
|
+
#
|
|
1323
|
+
# Pre-bracket-aware behavior used +inner.scan(/(\??\w+\??)\s*:\s*([^,}]+)/)+,
|
|
1324
|
+
# which silently dropped any field whose type string contained a
|
|
1325
|
+
# comma — or worse, split that field at the comma.
|
|
1326
|
+
#
|
|
1327
|
+
#: (String) { (String, String) -> void } -> void
|
|
1328
|
+
def each_field_in_record(body)
|
|
1329
|
+
inner = body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
|
|
1330
|
+
return if inner.empty?
|
|
1331
|
+
|
|
1332
|
+
split_at_depth_zero(inner, ",").each do |field|
|
|
1333
|
+
field = field.strip
|
|
1334
|
+
next if field.empty?
|
|
1335
|
+
|
|
1336
|
+
colon = find_at_depth_zero(field, [":"])
|
|
1337
|
+
next unless colon
|
|
1338
|
+
|
|
1339
|
+
key = field[0...colon].to_s.strip
|
|
1340
|
+
type_str = field[(colon + 1)..].to_s.strip
|
|
1341
|
+
next if key.empty? || type_str.empty?
|
|
1342
|
+
|
|
1343
|
+
yield key, type_str
|
|
1344
|
+
end
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1134
1347
|
# Convert a single RBS type expression into its JSON Schema equivalent.
|
|
1135
1348
|
#
|
|
1136
1349
|
# Handles:
|
|
@@ -1147,37 +1360,171 @@ module McpAuthorization
|
|
|
1147
1360
|
#: (String, ?Hash[String, Hash[Symbol, untyped]], ?source_file: String?) -> Hash[Symbol, untyped]
|
|
1148
1361
|
def rbs_type_to_json_schema(rbs_type, type_map = {}, source_file: nil)
|
|
1149
1362
|
stripped = rbs_type.strip
|
|
1150
|
-
|
|
1363
|
+
return { type: "string" } if stripped.empty?
|
|
1364
|
+
|
|
1365
|
+
# Special case preserved for backward compat with the previous
|
|
1366
|
+
# regex parser: "TrueClass | FalseClass" was recognized as a
|
|
1367
|
+
# boolean shorthand. RBS would parse it as Union[ClassInstance,
|
|
1368
|
+
# ClassInstance], which we'd otherwise turn into a oneOf.
|
|
1369
|
+
return { type: "boolean" } if stripped == "TrueClass | FalseClass"
|
|
1370
|
+
|
|
1371
|
+
begin
|
|
1372
|
+
ast = RBS::Parser.parse_type(stripped, require_eof: true)
|
|
1373
|
+
rescue RBS::ParsingError
|
|
1374
|
+
# Unparseable — fall back to type_map lookup or string.
|
|
1375
|
+
return type_map[stripped] || { type: "string" }
|
|
1376
|
+
end
|
|
1377
|
+
|
|
1378
|
+
visit_rbs_type(ast, type_map)
|
|
1379
|
+
end
|
|
1380
|
+
|
|
1381
|
+
# AST visitor: convert an +RBS::Types::*+ node into JSON Schema.
|
|
1382
|
+
#
|
|
1383
|
+
# Replaces the prior regex case-statement parser with a delegation
|
|
1384
|
+
# to the official rbs gem. Each node type maps onto a small JSON
|
|
1385
|
+
# Schema fragment. Named types (+RBS::Types::Alias+, unknown
|
|
1386
|
+
# +ClassInstance+) are looked up in +type_map+, preserving the
|
|
1387
|
+
# gem's named-type indirection for shared and inline aliases.
|
|
1388
|
+
#
|
|
1389
|
+
# @param node [RBS::Types::t] AST node from +RBS::Parser.parse_type+.
|
|
1390
|
+
# @param type_map [Hash] Resolved named-type definitions.
|
|
1391
|
+
# @return [Hash] JSON Schema fragment.
|
|
1392
|
+
#: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1393
|
+
def visit_rbs_type(node, type_map)
|
|
1394
|
+
case node
|
|
1395
|
+
when RBS::Types::Bases::Bool
|
|
1396
|
+
{ type: "boolean" }
|
|
1397
|
+
when RBS::Types::Bases::Any, RBS::Types::Bases::Void, RBS::Types::Bases::Nil
|
|
1398
|
+
# untyped / void / nil → no constraint (LLM can pass anything)
|
|
1399
|
+
{ type: "string" }
|
|
1400
|
+
when RBS::Types::Literal
|
|
1401
|
+
visit_rbs_literal(node)
|
|
1402
|
+
when RBS::Types::Optional
|
|
1403
|
+
# Optional wraps a type; nullability is handled at field
|
|
1404
|
+
# level (required-set), not in the JSON Schema type itself.
|
|
1405
|
+
visit_rbs_type(node.type, type_map)
|
|
1406
|
+
when RBS::Types::Union
|
|
1407
|
+
visit_rbs_union(node, type_map)
|
|
1408
|
+
when RBS::Types::Record
|
|
1409
|
+
visit_rbs_record(node, type_map)
|
|
1410
|
+
when RBS::Types::Tuple
|
|
1411
|
+
# RBS tuples have heterogeneous element types; JSON Schema's
|
|
1412
|
+
# closest analog is array with prefixItems, but for simplicity
|
|
1413
|
+
# we project to a plain array.
|
|
1414
|
+
{ type: "array" }
|
|
1415
|
+
when RBS::Types::ClassInstance
|
|
1416
|
+
visit_rbs_class_instance(node, type_map)
|
|
1417
|
+
when RBS::Types::Alias
|
|
1418
|
+
name = node.name.to_s.sub(/\A::/, "")
|
|
1419
|
+
type_map[name] || { type: "string" }
|
|
1420
|
+
else
|
|
1421
|
+
# Interface, Proc, Variable, ClassSingleton, Bases::Self/Class/Instance/Top/Bottom etc.
|
|
1422
|
+
{ type: "string" }
|
|
1423
|
+
end
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
# Map +RBS::Types::ClassInstance+ to JSON Schema. Recognizes the
|
|
1427
|
+
# Ruby primitives we care about (+String+, +Integer+, +Float+),
|
|
1428
|
+
# generic +Array[T]+ / +Hash[K, V]+, and falls back to the
|
|
1429
|
+
# +type_map+ for user-defined names.
|
|
1430
|
+
#: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1431
|
+
def visit_rbs_class_instance(node, type_map)
|
|
1432
|
+
name = node.name.to_s.sub(/\A::/, "")
|
|
1433
|
+
case name
|
|
1151
1434
|
when "String"
|
|
1152
1435
|
{ type: "string" }
|
|
1153
1436
|
when "Integer"
|
|
1154
1437
|
{ type: "integer" }
|
|
1155
|
-
when "Float"
|
|
1438
|
+
when "Float", "Numeric"
|
|
1156
1439
|
{ type: "number" }
|
|
1157
|
-
when "
|
|
1158
|
-
{ type: "boolean" }
|
|
1159
|
-
when "true"
|
|
1440
|
+
when "TrueClass"
|
|
1160
1441
|
{ type: "boolean", const: true }
|
|
1161
|
-
when "
|
|
1442
|
+
when "FalseClass"
|
|
1162
1443
|
{ type: "boolean", const: false }
|
|
1163
|
-
when
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
{ oneOf: parts.map { |p| rbs_type_to_json_schema(p, type_map, source_file: source_file) } }
|
|
1175
|
-
end
|
|
1444
|
+
when "Array"
|
|
1445
|
+
inner = node.args.first
|
|
1446
|
+
inner ? { type: "array", items: visit_rbs_type(inner, type_map) } : { type: "array" }
|
|
1447
|
+
when "Hash"
|
|
1448
|
+
# Hash[K, V] — JSON Schema can express V as additionalProperties.
|
|
1449
|
+
val = node.args[1]
|
|
1450
|
+
val ? { type: "object", additionalProperties: visit_rbs_type(val, type_map) } : { type: "object" }
|
|
1451
|
+
when "Symbol"
|
|
1452
|
+
{ type: "string" }
|
|
1453
|
+
when "NilClass"
|
|
1454
|
+
{ type: "string" }
|
|
1176
1455
|
else
|
|
1177
|
-
type_map[
|
|
1456
|
+
type_map[name] || { type: "string" }
|
|
1178
1457
|
end
|
|
1179
1458
|
end
|
|
1180
1459
|
|
|
1460
|
+
# Map a +RBS::Types::Literal+ to a JSON Schema +const+ fragment.
|
|
1461
|
+
# In a union of literals this becomes part of an +enum+; see
|
|
1462
|
+
# +visit_rbs_union+ for that aggregation.
|
|
1463
|
+
#: (untyped) -> Hash[Symbol, untyped]
|
|
1464
|
+
def visit_rbs_literal(node)
|
|
1465
|
+
case node.literal
|
|
1466
|
+
when true then { type: "boolean", const: true }
|
|
1467
|
+
when false then { type: "boolean", const: false }
|
|
1468
|
+
when String then { type: "string", const: node.literal }
|
|
1469
|
+
when Symbol then { type: "string", const: node.literal.to_s }
|
|
1470
|
+
when Integer then { type: "integer", const: node.literal }
|
|
1471
|
+
else { type: "string", const: node.literal.to_s }
|
|
1472
|
+
end
|
|
1473
|
+
end
|
|
1474
|
+
|
|
1475
|
+
# Map +RBS::Types::Union+ to JSON Schema. A union of string
|
|
1476
|
+
# literals becomes a +{type: "string", enum: [...]}+; any other
|
|
1477
|
+
# mix becomes +{oneOf: [...]}+. This mirrors the prior regex
|
|
1478
|
+
# parser's behavior so existing schemas don't drift.
|
|
1479
|
+
#: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1480
|
+
def visit_rbs_union(node, type_map)
|
|
1481
|
+
types = node.types
|
|
1482
|
+
|
|
1483
|
+
# All string literals → enum.
|
|
1484
|
+
if types.all? { |t| t.is_a?(RBS::Types::Literal) && t.literal.is_a?(String) }
|
|
1485
|
+
return { type: "string", enum: types.map { |t| t.literal.to_s } }
|
|
1486
|
+
end
|
|
1487
|
+
|
|
1488
|
+
# TrueClass | FalseClass → boolean. RBS doesn't have a single
|
|
1489
|
+
# "boolean" base class, so this pattern is what users write.
|
|
1490
|
+
if types.size == 2 &&
|
|
1491
|
+
types.all? { |t| t.is_a?(RBS::Types::ClassInstance) } &&
|
|
1492
|
+
types.map { |t| t.name.to_s.sub(/\A::/, "") }.sort == %w[FalseClass TrueClass]
|
|
1493
|
+
return { type: "boolean" }
|
|
1494
|
+
end
|
|
1495
|
+
|
|
1496
|
+
{ oneOf: types.map { |t| visit_rbs_type(t, type_map) } }
|
|
1497
|
+
end
|
|
1498
|
+
|
|
1499
|
+
# Map +RBS::Types::Record+ to a JSON Schema object. RBS handles
|
|
1500
|
+
# +?key:+ optional markers natively — they land in
|
|
1501
|
+
# +node.optional_fields+ rather than +node.fields+. No need for
|
|
1502
|
+
# us to parse the marker manually.
|
|
1503
|
+
#
|
|
1504
|
+
# NOTE: this path is used when a record appears nested inside
|
|
1505
|
+
# another type expression (e.g. +Array[{name: String}]+). Top-level
|
|
1506
|
+
# records reached via +# @rbs type input = { ... }+ go through
|
|
1507
|
+
# +compile_tagged_record+ instead so per-field tag extraction can
|
|
1508
|
+
# happen before the type is parsed.
|
|
1509
|
+
#: (untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
|
1510
|
+
def visit_rbs_record(node, type_map)
|
|
1511
|
+
properties = {}
|
|
1512
|
+
required = []
|
|
1513
|
+
|
|
1514
|
+
node.fields.each do |name, type|
|
|
1515
|
+
key = name.to_s
|
|
1516
|
+
properties[key.to_sym] = visit_rbs_type(type, type_map)
|
|
1517
|
+
required << key
|
|
1518
|
+
end
|
|
1519
|
+
node.optional_fields.each do |name, type|
|
|
1520
|
+
properties[name.to_s.to_sym] = visit_rbs_type(type, type_map)
|
|
1521
|
+
end
|
|
1522
|
+
|
|
1523
|
+
schema = { type: "object", properties: properties } #: Hash[Symbol, untyped]
|
|
1524
|
+
schema[:required] = required if required.any?
|
|
1525
|
+
schema
|
|
1526
|
+
end
|
|
1527
|
+
|
|
1181
1528
|
# Look up a named type in the type map. Returns a bare +{type: "object"}+
|
|
1182
1529
|
# if the name is not found (defensive fallback).
|
|
1183
1530
|
#: (String, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]
|
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.2
|
|
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-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -38,6 +38,20 @@ 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
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.0'
|
|
41
55
|
- !ruby/object:Gem::Dependency
|
|
42
56
|
name: minitest
|
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|