mcp_authorization 0.4.0 → 0.5.0

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: dbd953dcd628ffaf2d42d3bfa024ed6e6ab5a0b8a1089be84f8505a7158aaf20
4
+ data.tar.gz: e510d9b492abbdbc4acb091a0a802dea803b97434923673b16711215de918d38
5
5
  SHA512:
6
- metadata.gz: f167384e7a8b591bb76e05677ed8f99998bcb253144f509046cb4c46e57170ef8348350b3d926a04620011e4982d91de354e41be4c01e39f08acdcec06b893ff
7
- data.tar.gz: c283b35338579177226136f6fb66d778e982e9748e5d946e8ec0232134149aeaa3571033133923b5caf7c497aba14d9b2798a3c5f784c2dc50a22cdcb7e907a4
6
+ metadata.gz: 71272e773f2cba13d99a1b44fb94ad53e1de079627a5546432d39bb0de0c166a047edd95ab60e43072f91c008121b16ad7bdb3548b01ebde5ffee80305323ada
7
+ data.tar.gz: 9e06ca547f65de3b44de14e2538b54ff24d53db6b686faf63195dd1490a88d6d20ba831f28cc69eb0ea14fd266150fe9121380a073df39279ffbdceeb443c0e0
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.0] - 2026-05-26
8
+
9
+ ### Changed (BREAKING)
10
+ - **Prefix optional marker (`?key:`) is now honored consistently across all three RBS parsers.**
11
+
12
+ The three sibling parsers handled optional-field markers inconsistently:
13
+ - `parse_call_params` — accepted only prefix `?key:`
14
+ - `compile_tagged_record` — accepted only suffix `key?:`, silently treated prefix `?key:` as required
15
+ - `parse_record_type` — recognized neither form, silently treated all fields as required
16
+
17
+ 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.
18
+
19
+ **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.
20
+
21
+ **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`.
22
+
23
+ ### Deprecated
24
+ - **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.
25
+
26
+ ### Added
27
+ - `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]`).
28
+
29
+ ### Fixed
30
+ - `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.
31
+
32
+ ### Migration notes
33
+ - **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.
34
+ - 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.
35
+ - The README example in the records section was updated to use prefix `?count: Integer` to match the documented canonical form.
36
+
37
+ ### Notes
38
+ - 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+.
39
+
7
40
  ## [0.4.0] - 2026-05-21
8
41
 
9
42
  ### 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]
@@ -58,7 +58,7 @@ module McpAuthorization
58
58
  cached = cache_for(handler_class)
59
59
 
60
60
  schema = if cached[:raw_input]&.dig(:kind) == :record
61
- compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context)
61
+ compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context, source_file: cached[:source_file])
62
62
  else
63
63
  build_input_schema(
64
64
  filter_call_signature(cached[:call_params], cached[:type_map], server_context)
@@ -206,7 +206,7 @@ module McpAuthorization
206
206
  cached = cache_for(handler_class)
207
207
 
208
208
  schema = if cached[:raw_input]&.dig(:kind) == :record
209
- compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context)
209
+ compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context, source_file: cached[:source_file])
210
210
  else
211
211
  build_input_schema(
212
212
  filter_call_signature(cached[:call_params], cached[:type_map], server_context)
@@ -429,6 +429,93 @@ module McpAuthorization
429
429
  [type_str, tags]
430
430
  end
431
431
 
432
+ # Parse a field-name token (the part before +:+ in a record entry or
433
+ # call signature parameter) into its clean name and an optional flag.
434
+ #
435
+ # Recognizes both forms:
436
+ # - Prefix (RBS canonical, per README): +?name:+ -> ["name", true]
437
+ # - Suffix (legacy, deprecated for 0.6.0): +name?:+ -> ["name", true]
438
+ # - Unmarked: +name:+ -> ["name", false]
439
+ #
440
+ # The suffix form was historically accepted by parts of this gem
441
+ # but is not standard RBS. Recognizing it consistently across all
442
+ # three parsers (this method's callers) preserves backward
443
+ # compatibility while a single +Kernel#warn+ with
444
+ # +category: :deprecated+ steers consumers toward the prefix form.
445
+ # Users can silence via +Warning[:deprecated] = false+ or
446
+ # +-W:no-deprecated+ — the standard Ruby mechanisms.
447
+ #
448
+ # Raises +ArgumentError+ on malformed tokens. The helper produces
449
+ # three distinct error messages — categories grouped by which
450
+ # branch raises:
451
+ # - empty, whitespace-only, or nil input -> "empty field name"
452
+ # - double-marked optional after stripping (e.g. +"?key?"+) ->
453
+ # "is double-marked optional"
454
+ # - any other shape that does not reduce to a bare +\w+
455
+ # identifier (e.g. +"?"+, +"??key"+, +"key??"+, or tokens with
456
+ # non-word characters) -> "invalid field name token"
457
+ #
458
+ # Whitespace adjacent to the marker is tolerated:
459
+ # +" ? key"+ -> ["key", true]. (Note: the three production call
460
+ # sites never produce such a token — their regexes don't permit
461
+ # internal whitespace — so this tolerance only matters if the
462
+ # helper is invoked directly.)
463
+ #
464
+ # @param raw [String] Token before the +:+ separator.
465
+ # @param source_file [String, nil] Path included in the deprecation
466
+ # warning so consumers can locate the offending annotation. The
467
+ # handler source file is read as text during parsing, so it is
468
+ # not on the Ruby call stack — embedding the path in the message
469
+ # is the only way to make the warning actionable.
470
+ # @return [Array(String, Boolean)] +[clean_name, optional?]+.
471
+ #: (String, ?source_file: String?) -> [String, bool]
472
+ def parse_field_name(raw, source_file: nil)
473
+ raise ArgumentError, "empty field name" if raw.nil? || raw.to_s.strip.empty?
474
+
475
+ trimmed = raw.to_s.strip
476
+ prefix = trimmed.start_with?("?")
477
+ suffix = trimmed.end_with?("?")
478
+
479
+ bare = trimmed
480
+ bare = bare.sub(/\A\?/, "").strip if prefix
481
+ bare = bare.sub(/\?\z/, "").strip if suffix
482
+
483
+ unless bare.match?(/\A\w+\z/)
484
+ raise ArgumentError, "invalid field name token: #{raw.inspect}"
485
+ end
486
+
487
+ if prefix && suffix
488
+ raise ArgumentError,
489
+ "field #{bare.inspect} is double-marked optional (both ?prefix and suffix?); pick one"
490
+ end
491
+
492
+ warn_deprecated_suffix_marker(bare, source_file) if suffix
493
+
494
+ [bare, prefix || suffix]
495
+ end
496
+
497
+ # Emit a deprecation warning for the legacy suffix optional marker
498
+ # (+key?:+). Uses +Kernel#warn+ with +category: :deprecated+ so
499
+ # silencing follows the standard Ruby mechanism
500
+ # (+Warning[:deprecated] = false+, +-W:no-deprecated+) and not a
501
+ # gem-specific env var.
502
+ #
503
+ # The source file path is embedded in the message because the
504
+ # handler annotation is parsed as static text — the offending file
505
+ # is not on the Ruby call stack at warn time, so +uplevel:+ cannot
506
+ # surface it. Embedding the path keeps the warning actionable for
507
+ # consumers grepping for the field name.
508
+ #: (String, String?) -> void
509
+ def warn_deprecated_suffix_marker(name, source_file)
510
+ location = source_file ? " (in #{source_file})" : ""
511
+ Kernel.warn(
512
+ "[mcp_authorization] Deprecated optional marker syntax: " \
513
+ "`#{name}?:`#{location}. Use prefix form `?#{name}:` instead. " \
514
+ "The suffix form will be removed in 0.6.0.",
515
+ category: :deprecated
516
+ )
517
+ end
518
+
432
519
  # Coerce a default value string from an annotation into its Ruby type.
433
520
  # Handles booleans, nil/null, integers, floats, and bare strings.
434
521
  #
@@ -542,14 +629,14 @@ module McpAuthorization
542
629
 
543
630
  # Build type map: shared imports first, then handler's own types override
544
631
  imported = load_imports(content)
545
- local = parse_type_aliases(content)
632
+ local = parse_type_aliases(content, source_file: source_file)
546
633
  type_map = imported.merge(local)
547
634
 
548
635
  {
549
636
  type_map: type_map,
550
637
  raw_input: find_raw_type_body(content, "input"),
551
638
  raw_output: find_raw_type_body(content, "output"),
552
- call_params: parse_call_params(content),
639
+ call_params: parse_call_params(content, source_file: source_file),
553
640
  source_file: source_file
554
641
  }
555
642
  end
@@ -622,24 +709,23 @@ module McpAuthorization
622
709
  # @param type_map [Hash] Resolved type definitions for +$ref+ lookups.
623
710
  # @param server_context [Object] Per-request context.
624
711
  # @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)
712
+ #: (String, Hash[String, Hash[Symbol, untyped]], untyped, ?source_file: String?) -> Hash[Symbol, untyped]
713
+ def compile_tagged_record(raw_body, type_map, server_context, source_file: nil)
627
714
  properties = {}
628
715
  required = []
629
716
  dependent_required = {}
630
717
 
631
718
  inner = raw_body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
632
719
 
633
- inner.scan(/(\w+\??)\s*:\s*([^,}]+)/) do |match|
720
+ inner.scan(/(\??\w+\??)\s*:\s*([^,}]+)/) do |match|
634
721
  key, type_str = match[0].to_s, match[1].to_s
635
722
  type_str, tags = extract_tags(type_str.strip)
636
723
 
637
724
  next if predicate_excluded?(tags, server_context)
638
725
 
639
- optional = key.end_with?("?")
640
- clean_key = key.delete_suffix("?")
726
+ clean_key, optional = parse_field_name(key, source_file: source_file)
641
727
 
642
- schema = rbs_type_to_json_schema(type_str, type_map)
728
+ schema = rbs_type_to_json_schema(type_str, type_map, source_file: source_file)
643
729
  properties[clean_key.to_sym] = apply_tags(schema, tags, server_context: server_context)
644
730
  required << clean_key unless optional
645
731
 
@@ -828,7 +914,7 @@ module McpAuthorization
828
914
  resolved = {}
829
915
  aliases.each do |name, value|
830
916
  resolved[name] = if value.is_a?(String)
831
- parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)))
917
+ parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)), source_file: path)
832
918
  else
833
919
  value
834
920
  end
@@ -912,8 +998,8 @@ module McpAuthorization
912
998
  #
913
999
  # @param content [String] Full source file contents.
914
1000
  # @return [Hash{String => Hash}] Type name → resolved JSON Schema.
915
- #: (String) -> Hash[String, Hash[Symbol, untyped]]
916
- def parse_type_aliases(content)
1001
+ #: (String, ?source_file: String?) -> Hash[String, Hash[Symbol, untyped]]
1002
+ def parse_type_aliases(content, source_file: nil)
917
1003
  return {} if content.empty?
918
1004
 
919
1005
  aliases = {}
@@ -940,7 +1026,7 @@ module McpAuthorization
940
1026
  resolved = {}
941
1027
  aliases.each do |name, value|
942
1028
  resolved[name] = if value.is_a?(String)
943
- parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)))
1029
+ parse_record_type(value, resolved.merge(aliases_to_schemas(aliases, resolved)), source_file: source_file)
944
1030
  else
945
1031
  value
946
1032
  end
@@ -961,8 +1047,8 @@ module McpAuthorization
961
1047
  #
962
1048
  # @param content [String] Full source file contents.
963
1049
  # @return [Array<Hash>] Parameter descriptors.
964
- #: (String) -> Array[Hash[Symbol, untyped]]
965
- def parse_call_params(content)
1050
+ #: (String, ?source_file: String?) -> Array[Hash[Symbol, untyped]]
1051
+ def parse_call_params(content, source_file: nil)
966
1052
  return [] if content.empty?
967
1053
 
968
1054
  lines = content.lines
@@ -980,8 +1066,10 @@ module McpAuthorization
980
1066
  if annotation =~ /\((.+)\)\s*->/m
981
1067
  $1.to_s.split(",").each do |param|
982
1068
  param = param.strip
983
- next unless param =~ /\A(\?)?([\w]+):\s*(.+)\z/
984
- opt, name, type = $1, $2.to_s, $3.to_s.strip
1069
+ next unless param =~ /\A(\??\w+\??):\s*(.+)\z/
1070
+ raw_key, type = $1.to_s, $2.to_s.strip
1071
+
1072
+ name, optional = parse_field_name(raw_key, source_file: source_file)
985
1073
  next if name == "server_context"
986
1074
 
987
1075
  type, tags = extract_tags(type)
@@ -989,7 +1077,7 @@ module McpAuthorization
989
1077
  params << {
990
1078
  name: name,
991
1079
  type: type,
992
- required: opt.nil? && !type.end_with?("?"),
1080
+ required: !optional && !type.end_with?("?"),
993
1081
  tags: tags
994
1082
  }
995
1083
  end
@@ -1021,20 +1109,19 @@ module McpAuthorization
1021
1109
  # @param body [String] Record body including surrounding braces.
1022
1110
  # @param type_map [Hash] Resolved types for reference lookups.
1023
1111
  # @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 = {})
1112
+ #: (String, ?Hash[String, Hash[Symbol, untyped]], ?source_file: String?) -> Hash[Symbol, untyped]
1113
+ def parse_record_type(body, type_map = {}, source_file: nil)
1026
1114
  properties = {}
1027
1115
  required = []
1028
1116
 
1029
1117
  inner = body.strip.sub(/\A\{/, "").sub(/\}\z/, "").strip
1030
1118
 
1031
- inner.scan(/(\w+):\s*([^,}]+)/) do |match|
1119
+ inner.scan(/(\??\w+\??)\s*:\s*([^,}]+)/) do |match|
1032
1120
  key, type_str = match[0].to_s, match[1].to_s
1033
1121
  type_str, tags = extract_tags(type_str.strip)
1034
- optional = key.end_with?("?")
1035
- clean_key = key.delete_suffix("?")
1122
+ clean_key, optional = parse_field_name(key, source_file: source_file)
1036
1123
 
1037
- schema = rbs_type_to_json_schema(type_str, type_map)
1124
+ schema = rbs_type_to_json_schema(type_str, type_map, source_file: source_file)
1038
1125
  properties[clean_key.to_sym] = apply_tags(schema, tags)
1039
1126
  required << clean_key unless optional
1040
1127
  end
@@ -1057,8 +1144,8 @@ module McpAuthorization
1057
1144
  # @param rbs_type [String] RBS type expression.
1058
1145
  # @param type_map [Hash] Resolved type definitions for named type lookups.
1059
1146
  # @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 = {})
1147
+ #: (String, ?Hash[String, Hash[Symbol, untyped]], ?source_file: String?) -> Hash[Symbol, untyped]
1148
+ def rbs_type_to_json_schema(rbs_type, type_map = {}, source_file: nil)
1062
1149
  stripped = rbs_type.strip
1063
1150
  case stripped
1064
1151
  when "String"
@@ -1074,17 +1161,17 @@ module McpAuthorization
1074
1161
  when "false"
1075
1162
  { type: "boolean", const: false }
1076
1163
  when /\AArray\[(.+)\]\z/
1077
- { type: "array", items: rbs_type_to_json_schema($1.to_s, type_map) }
1164
+ { type: "array", items: rbs_type_to_json_schema($1.to_s, type_map, source_file: source_file) }
1078
1165
  when /\A(\w+)\?\z/
1079
- rbs_type_to_json_schema($1.to_s, type_map)
1166
+ rbs_type_to_json_schema($1.to_s, type_map, source_file: source_file)
1080
1167
  when /\A\{/
1081
- parse_record_type(stripped, type_map)
1168
+ parse_record_type(stripped, type_map, source_file: source_file)
1082
1169
  when /\|/
1083
1170
  parts = stripped.split("|").map(&:strip)
1084
1171
  if parts.all? { |p| p.start_with?('"') && p.end_with?('"') }
1085
1172
  { type: "string", enum: parts.map { |p| p.delete('"') } }
1086
1173
  else
1087
- { oneOf: parts.map { |p| rbs_type_to_json_schema(p, type_map) } }
1174
+ { oneOf: parts.map { |p| rbs_type_to_json_schema(p, type_map, source_file: source_file) } }
1088
1175
  end
1089
1176
  else
1090
1177
  type_map[stripped] || { type: "string" }
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
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.0
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