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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbd953dcd628ffaf2d42d3bfa024ed6e6ab5a0b8a1089be84f8505a7158aaf20
4
- data.tar.gz: e510d9b492abbdbc4acb091a0a802dea803b97434923673b16711215de918d38
3
+ metadata.gz: e72fb2f33292962f9820cf0908a278b461d3037d8612240f06a1fd3ba0dd2daa
4
+ data.tar.gz: d1a4af82cd4b4f177a1aec377572e105715f11f822b6bdd86da3ec8715cfa7ce
5
5
  SHA512:
6
- metadata.gz: 71272e773f2cba13d99a1b44fb94ad53e1de079627a5546432d39bb0de0c166a047edd95ab60e43072f91c008121b16ad7bdb3548b01ebde5ffee80305323ada
7
- data.tar.gz: 9e06ca547f65de3b44de14e2538b54ff24d53db6b686faf63195dd1490a88d6d20ba831f28cc69eb0ea14fd266150fe9121380a073df39279ffbdceeb443c0e0
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
- while type_str =~ /\A(.+?)\s+@(\w+)\(([^)]*)\)\s*\z/
379
- type_str, tag_name, tag_value = $1.to_s.strip, $2.to_s, $3.to_s
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
- inner = raw_body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
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.split("|").map(&:strip).reject(&:empty?)
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
- $1.to_s.split(",").each do |param|
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 unless param =~ /\A(\??\w+\??):\s*(.+)\z/
1070
- raw_key, type = $1.to_s, $2.to_s.strip
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
- inner = body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
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
- case stripped
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 "bool", "TrueClass | FalseClass"
1158
- { type: "boolean" }
1159
- when "true"
1440
+ when "TrueClass"
1160
1441
  { type: "boolean", const: true }
1161
- when "false"
1442
+ when "FalseClass"
1162
1443
  { type: "boolean", const: false }
1163
- when /\AArray\[(.+)\]\z/
1164
- { type: "array", items: rbs_type_to_json_schema($1.to_s, type_map, source_file: source_file) }
1165
- when /\A(\w+)\?\z/
1166
- rbs_type_to_json_schema($1.to_s, type_map, source_file: source_file)
1167
- when /\A\{/
1168
- parse_record_type(stripped, type_map, source_file: source_file)
1169
- when /\|/
1170
- parts = stripped.split("|").map(&:strip)
1171
- if parts.all? { |p| p.start_with?('"') && p.end_with?('"') }
1172
- { type: "string", enum: parts.map { |p| p.delete('"') } }
1173
- else
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[stripped] || { type: "string" }
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]
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.5.0"
2
+ VERSION = "0.5.2"
3
3
  end
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.0
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-27 00:00:00.000000000 Z
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