mcp_authorization 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbd953dcd628ffaf2d42d3bfa024ed6e6ab5a0b8a1089be84f8505a7158aaf20
4
- data.tar.gz: e510d9b492abbdbc4acb091a0a802dea803b97434923673b16711215de918d38
3
+ metadata.gz: 7365a5b287722edcac66ce63fa3ef411f6a6761e17ebb12cdfaf982e1e71c03d
4
+ data.tar.gz: 01cbce16f56b0ca80523014d20caee64a90cd4b82e478c9ebbf85e0cb255397a
5
5
  SHA512:
6
- metadata.gz: 71272e773f2cba13d99a1b44fb94ad53e1de079627a5546432d39bb0de0c166a047edd95ab60e43072f91c008121b16ad7bdb3548b01ebde5ffee80305323ada
7
- data.tar.gz: 9e06ca547f65de3b44de14e2538b54ff24d53db6b686faf63195dd1490a88d6d20ba831f28cc69eb0ea14fd266150fe9121380a073df39279ffbdceeb443c0e0
6
+ metadata.gz: 374d094408606c87c9c7886d3e5bb22f05794076d001054d703431e604740fb914596d2c75dbb4bc7a07eb0461f3ac660ea121d134c850b0439d18f0fe7f9542
7
+ data.tar.gz: 793eca82f7a52d56e65134366ed300d07d07bb9331daa49f566294e9f14af46d40ada269ebd4efcfd0a8f65733ee8981a0cbc039d3520eec4f4c43c500e3421c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ 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
+
7
34
  ## [0.5.0] - 2026-05-26
8
35
 
9
36
  ### Changed (BREAKING)
@@ -1,3 +1,4 @@
1
+ require "rbs"
1
2
  require_relative "diagnostics"
2
3
 
3
4
  module McpAuthorization
@@ -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
- 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
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,158 @@ 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
+
432
587
  # Parse a field-name token (the part before +:+ in a record entry or
433
588
  # call signature parameter) into its clean name and an optional flag.
434
589
  #
@@ -715,11 +870,8 @@ module McpAuthorization
715
870
  required = []
716
871
  dependent_required = {}
717
872
 
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)
873
+ each_field_in_record(raw_body) do |key, type_str|
874
+ type_str, tags = extract_tags(type_str)
723
875
 
724
876
  next if predicate_excluded?(tags, server_context)
725
877
 
@@ -755,7 +907,7 @@ module McpAuthorization
755
907
  # @return [Hash] JSON Schema — either a single schema or a +oneOf+ wrapper.
756
908
  #: (String, Hash[String, Hash[Symbol, untyped]], untyped) -> Hash[Symbol, untyped]
757
909
  def compile_tagged_union(raw_expr, type_map, server_context)
758
- parts = raw_expr.split("|").map(&:strip).reject(&:empty?)
910
+ parts = split_at_depth_zero(raw_expr, "|").map(&:strip).reject(&:empty?)
759
911
 
760
912
  filtered = parts.filter_map do |part|
761
913
  part, tags = extract_tags(part)
@@ -1064,10 +1216,22 @@ module McpAuthorization
1064
1216
 
1065
1217
  params = []
1066
1218
  if annotation =~ /\((.+)\)\s*->/m
1067
- $1.to_s.split(",").each do |param|
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|
1068
1223
  param = param.strip
1069
- next unless param =~ /\A(\??\w+\??):\s*(.+)\z/
1070
- raw_key, type = $1.to_s, $2.to_s.strip
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?
1071
1235
 
1072
1236
  name, optional = parse_field_name(raw_key, source_file: source_file)
1073
1237
  next if name == "server_context"
@@ -1114,11 +1278,8 @@ module McpAuthorization
1114
1278
  properties = {}
1115
1279
  required = []
1116
1280
 
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)
1281
+ each_field_in_record(body) do |key, type_str|
1282
+ type_str, tags = extract_tags(type_str)
1122
1283
  clean_key, optional = parse_field_name(key, source_file: source_file)
1123
1284
 
1124
1285
  schema = rbs_type_to_json_schema(type_str, type_map, source_file: source_file)
@@ -1131,6 +1292,36 @@ module McpAuthorization
1131
1292
  schema
1132
1293
  end
1133
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
+
1134
1325
  # Convert a single RBS type expression into its JSON Schema equivalent.
1135
1326
  #
1136
1327
  # Handles:
@@ -1147,37 +1338,171 @@ module McpAuthorization
1147
1338
  #: (String, ?Hash[String, Hash[Symbol, untyped]], ?source_file: String?) -> Hash[Symbol, untyped]
1148
1339
  def rbs_type_to_json_schema(rbs_type, type_map = {}, source_file: nil)
1149
1340
  stripped = rbs_type.strip
1150
- case stripped
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
1151
1412
  when "String"
1152
1413
  { type: "string" }
1153
1414
  when "Integer"
1154
1415
  { type: "integer" }
1155
- when "Float"
1416
+ when "Float", "Numeric"
1156
1417
  { type: "number" }
1157
- when "bool", "TrueClass | FalseClass"
1158
- { type: "boolean" }
1159
- when "true"
1418
+ when "TrueClass"
1160
1419
  { type: "boolean", const: true }
1161
- when "false"
1420
+ when "FalseClass"
1162
1421
  { 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
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" }
1176
1433
  else
1177
- type_map[stripped] || { type: "string" }
1434
+ type_map[name] || { type: "string" }
1178
1435
  end
1179
1436
  end
1180
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" }
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
1504
+ end
1505
+
1181
1506
  # Look up a named type in the type map. Returns a bare +{type: "object"}+
1182
1507
  # if the name is not found (defensive fallback).
1183
1508
  #: (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.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - AndyGauge
@@ -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