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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9042f13cf2b294336b81ade0d8f97c31179a6483dde8c73c2258a7459227c7c4
4
- data.tar.gz: 8be4296941e757f985bdab130472aa3f7f1bda837eae5d3b8d924078e9f27bb9
3
+ metadata.gz: 7365a5b287722edcac66ce63fa3ef411f6a6761e17ebb12cdfaf982e1e71c03d
4
+ data.tar.gz: 01cbce16f56b0ca80523014d20caee64a90cd4b82e478c9ebbf85e0cb255397a
5
5
  SHA512:
6
- metadata.gz: f167384e7a8b591bb76e05677ed8f99998bcb253144f509046cb4c46e57170ef8348350b3d926a04620011e4982d91de354e41be4c01e39f08acdcec06b893ff
7
- data.tar.gz: c283b35338579177226136f6fb66d778e982e9748e5d946e8ec0232134149aeaa3571033133923b5caf7c497aba14d9b2798a3c5f784c2dc50a22cdcb7e907a4
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?: Integer
383
+ # ?count: Integer
384
384
  # }
385
- # (count? is optional -- excluded from "required")
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
- 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,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
- inner = raw_body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
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.end_with?("?")
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.split("|").map(&:strip).reject(&:empty?)
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
- $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|
982
1223
  param = param.strip
983
- next unless param =~ /\A(\?)?([\w]+):\s*(.+)\z/
984
- opt, name, type = $1, $2.to_s, $3.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?
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: opt.nil? && !type.end_with?("?"),
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
- inner = body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
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
- inner.scan(/(\w+):\s*([^,}]+)/) do |match|
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
- 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
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 "bool", "TrueClass | FalseClass"
1071
- { type: "boolean" }
1072
- when "true"
1418
+ when "TrueClass"
1073
1419
  { type: "boolean", const: true }
1074
- when "false"
1420
+ when "FalseClass"
1075
1421
  { type: "boolean", const: false }
1076
- when /\AArray\[(.+)\]\z/
1077
- { type: "array", items: rbs_type_to_json_schema($1.to_s, type_map) }
1078
- when /\A(\w+)\?\z/
1079
- rbs_type_to_json_schema($1.to_s, type_map)
1080
- when /\A\{/
1081
- parse_record_type(stripped, type_map)
1082
- when /\|/
1083
- parts = stripped.split("|").map(&:strip)
1084
- if parts.all? { |p| p.start_with?('"') && p.end_with?('"') }
1085
- { type: "string", enum: parts.map { |p| p.delete('"') } }
1086
- else
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[stripped] || { type: "string" }
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"}+
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.1"
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.4.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-26 00:00:00.000000000 Z
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