mcp_authorization 0.9.0 → 0.11.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: b01924de48adcd82456518091bcfb6309908158c118fe0865e600417a70be766
4
- data.tar.gz: 59ec07c72ae70fd00a77ff4e935b0c9feb03b5344e080b3d83aecfecf799390c
3
+ metadata.gz: 67586221cf3dfeaaffd4bb9b0a7dbc5b1b8f8adf515968b59bdf3c143ee41746
4
+ data.tar.gz: 8f53209e92d19cfdf5b2edb5c5c09189786f302dac31f5269b659fa196a24ac0
5
5
  SHA512:
6
- metadata.gz: b603364bfb0c0aa3174e714f321b99c11394c938ccffd930aed01c950af91e60ab224a91565feafa3d1ee600a3327ec2c4c6d5d3420f69baf6f2e8b0ace25f02
7
- data.tar.gz: a090369d9fe2da4276327b72ac3cf59f9e1626148c980093b42d6d3f85f0486bf496d3a9b6b5a906b1cec5d02e1d53b6b5e920c26c3d604c504655b522305cb5
6
+ metadata.gz: c1cf54717b5866778b8029686086b8a20e9ee348937cd539729e17b24a225fcc2ee8b04c24a392822447c3ffe93c9e7c42d7037790b0114de9388faaa2545cc3
7
+ data.tar.gz: cdbaea06cc7422e464ae0858c5664b9bc10021aae2a41705c9bd8c0dc46654295f07aa82bf6a13fd380b72b31ce849984f20ad1d0d1de1ead4886355d6ce2b9a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,45 @@ 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.11.0]
8
+
9
+ A required param the caller left out is rejected before the handler runs, on
10
+ a facade call as on a direct one. Behavior change, so a minor release.
11
+
12
+ ### Fixed
13
+ - **A facade call no longer runs the target tool without its required params.** The MCP SDK checks `required` against the tool it was asked to call. For a facade that is the facade's own schema (`tool_name`, `arguments`), so the target's required params were never checked: `get_stage` without `workflow_id` reached the handler, which looked up `nil` and answered "Workflow not found". A model reading that concludes the workflow is missing or inaccessible, not that it left a param out. `RbsSchemaCompiler.filter_input` now raises `McpAuthorization::MissingRequiredInputKeysError` for any key in the caller's compiled `required` list that the params lack. A materialized tool returns it in-band, `<tool_name>: Missing required parameter: workflow_id. Nothing was executed. Accepted parameters: ...`, the same way as `UnknownInputKeysError`, so a facade caller sees the inner tool's name.
14
+ - **A required param sent as `null` counts as missing.** The handler receives `nil` either way, so `get_stage(workflow_id: nil)` answered "Workflow not found" like an omitted key. A field that admits `null` through `@nullable()` is exempt. On a direct call the SDK's schema validation already refused that `null`.
15
+ - **The unknown-key check runs first.** A call that sends `stage_id` instead of `to_stage_id` is missing a required key and carries an unknown one. The unknown-key message lists the accepted params, which tells the caller what it meant, so that call still gets it.
16
+
17
+ ### Unchanged
18
+ - **Direct calls.** The SDK already rejected a missing required param before the tool's `call`; that response is unchanged.
19
+ - **Gated fields.** `required` comes from the caller's compiled schema, so a field required behind a predicate the caller fails is not demanded from that caller.
20
+ - **`config.reject_unknown_input_keys = false`** still drops unknown keys; it does not turn off this check, which the SDK already applied to every direct call.
21
+
22
+ ## [0.10.0]
23
+
24
+ Nil-able RBS types compile to schemas that accept `null` in tool output.
25
+ Input keeps 0.9's semantics unless a field opts in with the new
26
+ `@nullable()` tag. This is a minor release because the tag is new and
27
+ output schemas change shape for nil-able fields.
28
+
29
+ ### Fixed
30
+ - **`T?` in output compiles to a nullable schema.** The compiler dropped the `?`, so `note: String?` became `{type: "string"}`. A handler that returned `note: nil`, as its contract allowed, then failed output validation. A scalar now compiles to a type array, `{type: ["string", "null"]}`, which keeps keywords such as `minLength` and `format` on the same schema. JSON Schema applies them to strings only, so `null` still passes. Every other schema is wrapped as `{anyOf: [<schema>, {type: "null"}]}`, including objects, arrays, `$ref`s, records, `Hash[...]`, and `const`/`enum` schemas.
31
+ - **`T | nil` compiles the same as `T?`.** `nil` used to compile to the empty schema, so `String | nil` became `oneOf: [{type: "string"}, {}]`. A string matched both members, and `oneOf` rejected it. Nil members (`nil`, `NilClass`) are now stripped from the union and the rest is widened. On input it narrows to `T` (see below), so a valid `T` is accepted there too, and a `#:` call param typed `T | nil` is left out of `required`, as `T?` is. In 0.9 such a param was required and accepted only `null`; it now accepts a `T` or an omitted key, and `null` only with `@nullable()`.
32
+
33
+ ### Added
34
+ - **`@nullable()` lets an input field accept `null`.** On input, `T?` and `T | nil` compile to `T`, as `T?` did in 0.9: the key may be omitted, and `null` is rejected. For an optional param, an explicit `null` and an omitted key are different requests. A handler that reads `params.fetch(:key, default)` or writes the value to a NOT NULL column handles only the omission. Tag a field `@nullable()` when `null` is a request the handler serves, such as clearing a stored value. The tag works on `#:` call params, `# @rbs type` records, and fields inside shared `.rbs` types, so one shared type can be nullable in one tool's output and strict in another tool's input.
35
+ - **`@nullable()` on a type that is not nil-able raises `ArgumentError` at compile time.** `@nullable()` on `String` or `untyped` is a mistake: either the type is missing its `?`, or the tag is unneeded. Silently ignoring it would hide which one. The tag has no effect on output, which is already nullable.
36
+
37
+ ### Unchanged
38
+ - **Input schemas.** Without the tag, an input field typed `T?` compiles as it did in 0.9. `?key: T` only removes `key` from `required`. A `#:` call param typed `T?` is still left out of `required`. A record field `key: T?` stays required.
39
+ - **Projection.** A nil-able wrapper projects against its non-null branch. `filter_output`, and `filter_input` for a tagged field, still drop undeclared and gated keys inside a nil-able object or a nil-able union of records (`(A | B)?`, `A | B | nil`), and a `null` passes through.
40
+ - **`@closed()` on a nil-able object closes the object.** `additionalProperties: false` goes on a copy of the object branch, not beside the `anyOf`, where it would reject every key.
41
+
42
+ ### Migration notes
43
+ - Code that reads a compiled output schema's `type` directly sees an array for a nil-able scalar. For a nil-able object, array or `$ref`, it sees no `type` at the top of an `anyOf` wrapper. Inside the gem, `apply_tags` (`@min`/`@max`) and the facade's JSON-string coercion of object and array arguments look through both forms.
44
+ - A host that wants an input field to accept `null` adds `@nullable()` to that field. Check the handler first: `null` should reach a code path that handles it, not a `fetch` default or a NOT NULL column.
45
+
7
46
  ## [0.9.0]
8
47
 
9
48
  Top-level params outside the caller's input schema are rejected instead of
data/README.md CHANGED
@@ -692,6 +692,7 @@ end
692
692
  | Tag | JSON Schema |
693
693
  |---|---|
694
694
  | `@closed()` / `@strict()` | `additionalProperties: false` |
695
+ | `@nullable()` | Input only: a nil-able (`T?`) field also accepts `null`. Raises `ArgumentError` on a type that is not nil-able. |
695
696
  | `@media_type(type)` | `contentMediaType` (e.g. `application/json`) |
696
697
  | `@encoding(enc)` | `contentEncoding` (e.g. `base64`) |
697
698
 
@@ -724,6 +725,10 @@ def call(applicant_id:, workflow_id:, stage_id: nil, reason: nil)
724
725
 
725
726
  Prefix a param with `?` to mark it optional. Suffix the type with `?` for nilable types. Both together (`?name: Type?`) means the field is optional and can be nil.
726
727
 
728
+ The two markers compile separately. `?name: String` leaves `name` out of `required`, but a value that is present must be a string. In a `# @rbs type` record, `name: String?` keeps `name` required; a `#:` call param typed `String?` is optional. `String | nil` compiles the same as `String?` in both places.
729
+
730
+ Nullability depends on direction. In a tool's output, `String?` accepts `null`: a scalar compiles to `{"type": ["string", "null"]}`, and anything else to `{"anyOf": [<schema>, {"type": "null"}]}`. In a tool's input, `String?` compiles to `{"type": "string"}` and rejects `null`, unless the field is tagged `@nullable()`. Tag a field when `null` is a request its handler serves, such as `?hire_by: String? @nullable() @desc(Pass null to remove the deadline)`. An explicit `null` and an omitted key are different requests, and `@nullable()` on a type without `?` raises `ArgumentError`.
731
+
727
732
  ### `@depends_on` for conditional required fields
728
733
 
729
734
  Use `@depends_on(:parent_field)` to express that a field is only required when another field is present. This emits JSON Schema `dependentRequired`:
@@ -331,7 +331,7 @@ module McpAuthorization
331
331
  parsed.each_with_object({}) do |(key, value), out|
332
332
  sym = key.to_sym
333
333
  expected = properties[sym] || properties[key.to_s] || {}
334
- expected_type = expected[:type] || expected["type"]
334
+ expected_type = RbsSchemaCompiler.send(:primary_type, expected)
335
335
  if value.is_a?(String) && %w[object array].include?(expected_type.to_s)
336
336
  value = parse_json_blob(value, sym)
337
337
  end
@@ -67,6 +67,22 @@ module McpAuthorization
67
67
  end
68
68
  end
69
69
 
70
+ # Raised by +filter_input+ for a required param the caller left out; a facade call never reaches the SDK's check.
71
+ class MissingRequiredInputKeysError < ArgumentError
72
+ attr_reader :missing_keys, :accepted_keys
73
+
74
+ #: (Array[String], Array[String]) -> void
75
+ def initialize(missing_keys, accepted_keys)
76
+ @missing_keys = missing_keys
77
+ @accepted_keys = accepted_keys
78
+ super(
79
+ "Missing required parameter#{"s" if missing_keys.size > 1}: #{missing_keys.join(", ")}. " \
80
+ "Nothing was executed. Accepted parameters: #{accepted_keys.join(", ")}. " \
81
+ "Re-send the call with #{missing_keys.size > 1 ? "them" : "it"}."
82
+ )
83
+ end
84
+ end
85
+
70
86
  # Compiles RBS-style type annotations in Ruby source files into JSON Schema,
71
87
  # with per-request filtering based on +@requires+ permission tags.
72
88
  #
@@ -107,6 +123,18 @@ module McpAuthorization
107
123
  # - +@desc(text)+, +@title(text)+ — JSON Schema annotation keywords
108
124
  #
109
125
  class RbsSchemaCompiler
126
+ # Scalar JSON types +nullable+ widens with a type array rather than an
127
+ # +anyOf+ wrapper.
128
+ NULLABLE_SCALAR_TYPES = %w[string integer number boolean].freeze
129
+
130
+ # The branch +nullable+ adds to an +anyOf+.
131
+ NULL_SCHEMA = { type: "null" }.freeze
132
+
133
+ # Internal key +apply_tags+ sets on a field tagged +@nullable()+. It
134
+ # survives only until +finalize_for+, which strips it from every
135
+ # compiled schema; it never reaches a caller.
136
+ NULLABLE_INPUT_KEY = :"x-mcp-nullable-input"
137
+
110
138
  class << self
111
139
  # ---------------------------------------------------------------
112
140
  # Public API
@@ -132,7 +160,7 @@ module McpAuthorization
132
160
  )
133
161
  end
134
162
 
135
- schema = with_ref_injection(schema, cached[:type_map])
163
+ schema = finalize_for(:input, with_ref_injection(schema, cached[:type_map]))
136
164
  McpAuthorization.config.strict_schema ? strict_sanitize(schema) : schema
137
165
  end
138
166
 
@@ -145,7 +173,7 @@ module McpAuthorization
145
173
 
146
174
  if cached[:raw_output]&.dig(:kind) == :union
147
175
  schema = compile_tagged_union(cached[:raw_output][:body], cached[:type_map], server_context, rctx: build_rctx(server_context, cached))
148
- schema = with_ref_injection(schema, cached[:type_map])
176
+ schema = finalize_for(:output, with_ref_injection(schema, cached[:type_map]))
149
177
  return McpAuthorization.config.strict_schema ? strict_sanitize(schema) : schema
150
178
  end
151
179
  end
@@ -173,9 +201,25 @@ module McpAuthorization
173
201
  schema = compile_input_for_filter(handler_class, server_context: server_context)
174
202
  return params unless schema
175
203
  reject_unknown_input_keys!(params, schema) if McpAuthorization.config.reject_unknown_input_keys
204
+ reject_missing_required_keys!(params, schema)
176
205
  project_against_schema(params, schema, defs_from(schema))
177
206
  end
178
207
 
208
+ # Runs after the unknown-key check, which names the right key; a null counts as missing unless the field is +@nullable()+.
209
+ #: (Hash[untyped, untyped], Hash[Symbol, untyped]) -> void
210
+ def reject_missing_required_keys!(params, schema)
211
+ properties = schema[:properties] || {}
212
+ sent = params.to_h { |key, value| [key.to_s, value] }
213
+ missing = (schema[:required] || []).map(&:to_s).select do |key|
214
+ next true unless sent.key?(key)
215
+
216
+ sent[key].nil? && !null_admitting?(properties[key.to_sym] || properties[key] || {})
217
+ end
218
+ return if missing.empty?
219
+
220
+ raise MissingRequiredInputKeysError.new(missing, properties.keys.map(&:to_s).sort)
221
+ end
222
+
179
223
  # Raise when +params+ carries a top-level key outside +schema+'s
180
224
  # properties. Both the unknown and the accepted names come from the
181
225
  # caller's own schema, so the message reveals nothing +tools/list+ did
@@ -305,7 +349,7 @@ module McpAuthorization
305
349
  )
306
350
  end
307
351
 
308
- with_ref_injection(schema, cached[:type_map])
352
+ finalize_for(:input, with_ref_injection(schema, cached[:type_map]))
309
353
  end
310
354
 
311
355
  # Like +compile_output+ but skips +strict_sanitize+. Returns nil when
@@ -316,7 +360,7 @@ module McpAuthorization
316
360
  return nil unless cached[:raw_output]&.dig(:kind) == :union
317
361
 
318
362
  schema = compile_tagged_union(cached[:raw_output][:body], cached[:type_map], server_context, rctx: build_rctx(server_context, cached))
319
- with_ref_injection(schema, cached[:type_map])
363
+ finalize_for(:output, with_ref_injection(schema, cached[:type_map]))
320
364
  end
321
365
 
322
366
  # Extract the +$defs+ table from a compiled schema for +$ref+ resolution.
@@ -357,6 +401,12 @@ module McpAuthorization
357
401
  schema = resolve_ref(schema, defs)
358
402
  return value unless schema.is_a?(Hash)
359
403
 
404
+ # A nil-able wrapper projects against its non-null branch, which may
405
+ # itself be a union with no type of its own that best_variant_for
406
+ # could score.
407
+ inner = non_null_variant(schema)
408
+ return (value.nil? ? value : project_against_schema(value, inner, defs)) if inner
409
+
360
410
  variants = schema[:oneOf] || schema[:anyOf]
361
411
  if variants.is_a?(Array) && !variants.empty?
362
412
  # Flatten so intersection (allOf) members project like the object
@@ -587,6 +637,8 @@ module McpAuthorization
587
637
  tags[:unique] = true
588
638
  when "closed", "strict"
589
639
  tags[:closed] = true
640
+ when "nullable"
641
+ tags[:nullable] = true
590
642
  when "media_type"
591
643
  tags[:media_type] = tag_value
592
644
  when "encoding"
@@ -869,15 +921,16 @@ module McpAuthorization
869
921
  #: (Hash[Symbol, untyped], Hash[Symbol, untyped], ?server_context: untyped?) -> Hash[Symbol, untyped]
870
922
  def apply_tags(schema, tags, server_context: nil)
871
923
  # Type-aware min/max
924
+ constrained_type = primary_type(schema)
872
925
  if tags[:min]
873
- case schema[:type]
926
+ case constrained_type
874
927
  when "string" then schema[:minLength] = tags[:min]
875
928
  when "integer", "number" then schema[:minimum] = tags[:min]
876
929
  when "array" then schema[:minItems] = tags[:min]
877
930
  end
878
931
  end
879
932
  if tags[:max]
880
- case schema[:type]
933
+ case constrained_type
881
934
  when "string" then schema[:maxLength] = tags[:max]
882
935
  when "integer", "number" then schema[:maximum] = tags[:max]
883
936
  when "array" then schema[:maxItems] = tags[:max]
@@ -910,8 +963,26 @@ module McpAuthorization
910
963
  schema[:readOnly] = true if tags[:read_only]
911
964
  schema[:writeOnly] = true if tags[:write_only]
912
965
 
966
+ if tags[:nullable]
967
+ unless null_admitting?(schema)
968
+ raise ArgumentError,
969
+ "@nullable() needs a nil-able type (T? or T | nil); got #{schema.inspect}"
970
+ end
971
+ schema[NULLABLE_INPUT_KEY] = true
972
+ end
973
+
913
974
  # Niche constraints
914
- schema[:additionalProperties] = false if tags[:closed]
975
+ if tags[:closed]
976
+ # On a nil-able wrapper the keyword belongs on the object branch:
977
+ # beside anyOf there are no properties, so every key would be
978
+ # additional. Copy the branch, which may be a shared type_map entry.
979
+ inner = non_null_variant(schema)
980
+ if inner
981
+ schema[:anyOf] = [inner.merge(additionalProperties: false), NULL_SCHEMA.dup]
982
+ else
983
+ schema[:additionalProperties] = false
984
+ end
985
+ end
915
986
  schema[:contentMediaType] = tags[:media_type] if tags[:media_type]
916
987
  schema[:contentEncoding] = tags[:encoding] if tags[:encoding]
917
988
 
@@ -1682,7 +1753,7 @@ module McpAuthorization
1682
1753
  params << {
1683
1754
  name: name,
1684
1755
  type: type,
1685
- required: !optional && !type.end_with?("?"),
1756
+ required: !optional && !nilable_type?(type),
1686
1757
  tags: tags
1687
1758
  }
1688
1759
  end
@@ -1804,9 +1875,9 @@ module McpAuthorization
1804
1875
  # Handles:
1805
1876
  # - Primitives: +String+ → +{type: "string"}+, +Integer+ → +{type: "integer"}+, etc.
1806
1877
  # - Arrays: +Array[String]+ → +{type: "array", items: {type: "string"}}+
1807
- # - Optionals: +String?+ → +{type: "string"}+ (nullability is handled at the field level)
1878
+ # - Optionals: +String?+ → +{type: ["string", "null"]}+; see +nullable+
1808
1879
  # - Inline records: +{name: String}+ → nested object schema
1809
- # - Unions: +"a" | "b"+ → string enum; +A | B+ → +oneOf+
1880
+ # - Unions: +"a" | "b"+ → string enum; +A | B+ → +oneOf+; +T | nil+ → same as +T?+
1810
1881
  # - Named types: looked up in +type_map+, falling back to +{type: "string"}+
1811
1882
  #
1812
1883
  # @param rbs_type [String] RBS type expression.
@@ -1860,9 +1931,9 @@ module McpAuthorization
1860
1931
  when RBS::Types::Literal
1861
1932
  visit_rbs_literal(node)
1862
1933
  when RBS::Types::Optional
1863
- # Optional wraps a type; nullability is handled at field
1864
- # level (required-set), not in the JSON Schema type itself.
1865
- visit_rbs_type(node.type, type_map, rctx)
1934
+ # T? admits nil. Whether the key may be omitted is a separate,
1935
+ # field-level question (?key: and the required set).
1936
+ nullable(visit_rbs_type(node.type, type_map, rctx))
1866
1937
  when RBS::Types::Union
1867
1938
  visit_rbs_union(node, type_map, rctx)
1868
1939
  when RBS::Types::Intersection
@@ -1945,6 +2016,20 @@ module McpAuthorization
1945
2016
  def visit_rbs_union(node, type_map, rctx = nil)
1946
2017
  types = node.types
1947
2018
 
2019
+ # T | nil means T? — strip the nil members and widen the rest.
2020
+ # Compiling nil as its own member would emit oneOf [T, {}]; the
2021
+ # empty schema matches every value, so a valid T matched two
2022
+ # members and oneOf rejected it.
2023
+ nils, others = types.partition { |t| nil_type?(t) }
2024
+ if nils.any? && others.any?
2025
+ inner = if others.size == 1
2026
+ visit_rbs_type(others.first, type_map, rctx)
2027
+ else
2028
+ visit_rbs_union(RBS::Types::Union.new(types: others, location: nil), type_map, rctx)
2029
+ end
2030
+ return nullable(inner)
2031
+ end
2032
+
1948
2033
  # All string literals → enum.
1949
2034
  if types.all? { |t| t.is_a?(RBS::Types::Literal) && t.literal.is_a?(String) }
1950
2035
  return { type: "string", enum: types.map { |t| t.literal.to_s } }
@@ -1961,6 +2046,141 @@ module McpAuthorization
1961
2046
  { oneOf: types.map { |t| visit_rbs_type(t, type_map, rctx) } }
1962
2047
  end
1963
2048
 
2049
+ # True when the RBS type +type+ admits nil: +T?+, or a union with a
2050
+ # +nil+ or +NilClass+ member. A call param typed this way may be
2051
+ # omitted, whichever spelling declares it.
2052
+ #: (String) -> bool
2053
+ def nilable_type?(type)
2054
+ node = RBS::Parser.parse_type(type, require_eof: true)
2055
+ node.is_a?(RBS::Types::Optional) ||
2056
+ (node.is_a?(RBS::Types::Union) && node.types.any? { |t| nil_type?(t) })
2057
+ rescue RBS::ParsingError
2058
+ type.end_with?("?")
2059
+ end
2060
+
2061
+ # True for the RBS spellings of nil: the +nil+ base type and +NilClass+.
2062
+ #: (untyped) -> bool
2063
+ def nil_type?(node)
2064
+ node.is_a?(RBS::Types::Bases::Nil) ||
2065
+ (node.is_a?(RBS::Types::ClassInstance) && node.name.to_s.sub(/\A::/, "") == "NilClass")
2066
+ end
2067
+
2068
+ # Widen +schema+ to also accept +null+.
2069
+ #
2070
+ # A plain scalar (+string+, +integer+, +number+, +boolean+) takes the
2071
+ # type-array form, +{type: ["string", "null"]}+, so the keywords
2072
+ # +apply_tags+ adds next to it (+minLength+, +format+, +pattern+, ...)
2073
+ # stay on the same schema. They constrain strings only, so a null
2074
+ # still passes.
2075
+ #
2076
+ # Everything else is wrapped: +{anyOf: [schema, {type: "null"}]}+.
2077
+ # Objects and arrays keep +type: "object"+ / +type: "array"+ inside
2078
+ # the wrapper, which +project_against_schema+ and +best_variant_for+
2079
+ # depend on to keep dropping undeclared and gated keys. A +const+ or
2080
+ # +enum+ schema is wrapped too, since its value list would otherwise
2081
+ # reject null. The empty schema already accepts null and is returned
2082
+ # unchanged.
2083
+ #: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
2084
+ def nullable(schema)
2085
+ return schema if schema.empty?
2086
+
2087
+ type = schema[:type]
2088
+ if type.is_a?(Array)
2089
+ return type.include?("null") ? schema : schema.merge(type: type + ["null"])
2090
+ end
2091
+
2092
+ if NULLABLE_SCALAR_TYPES.include?(type) && !schema.key?(:const) && !schema.key?(:enum)
2093
+ return schema.merge(type: [type, "null"])
2094
+ end
2095
+
2096
+ variants = schema[:anyOf]
2097
+ return schema if variants.is_a?(Array) && variants.include?(NULL_SCHEMA)
2098
+
2099
+ { anyOf: [schema, NULL_SCHEMA.dup] }
2100
+ end
2101
+
2102
+ # The non-null branch of an +anyOf+ wrapper +nullable+ produced, or nil
2103
+ # when +schema+ is not one.
2104
+ #: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]?
2105
+ def non_null_variant(schema)
2106
+ variants = schema[:anyOf]
2107
+ return nil unless variants.is_a?(Array) && variants.size == 2 && variants.include?(NULL_SCHEMA)
2108
+
2109
+ variants.find { |v| v != NULL_SCHEMA }
2110
+ end
2111
+
2112
+ # True when +schema+ is one of the forms +nullable+ produces.
2113
+ #: (Hash[Symbol, untyped]) -> bool
2114
+ def null_admitting?(schema)
2115
+ type = schema[:type]
2116
+ return true if type.is_a?(Array) && type.include?("null")
2117
+
2118
+ !non_null_variant(schema).nil?
2119
+ end
2120
+
2121
+ # Settle nullability for one direction of a compiled schema.
2122
+ #
2123
+ # Output keeps every +T?+ / +T | nil+ nullable: a handler may return
2124
+ # nil where its contract says so. Input narrows each of them back to
2125
+ # +T+, unless the field is tagged +@nullable()+. For an optional
2126
+ # param an explicit +null+ and an omitted key are different requests,
2127
+ # and a handler that reads +params.fetch(:key, default)+ or writes
2128
+ # the value to a NOT NULL column handles only the second. A shared
2129
+ # type can therefore be nullable in a tool's output and strict in
2130
+ # another tool's input. Either way the internal tag marker is
2131
+ # removed. Returns a new schema; the cached type map is not touched.
2132
+ #: (Symbol, untyped) -> untyped
2133
+ def finalize_for(direction, schema)
2134
+ case schema
2135
+ when Hash
2136
+ keep = schema[NULLABLE_INPUT_KEY]
2137
+ result = schema.each_with_object({}) do |(key, value), out|
2138
+ next if key == NULLABLE_INPUT_KEY
2139
+ out[key] = finalize_for(direction, value)
2140
+ end
2141
+ direction == :input && !keep ? non_nullable(result) : result
2142
+ when Array
2143
+ schema.map { |item| finalize_for(direction, item) }
2144
+ else
2145
+ schema
2146
+ end
2147
+ end
2148
+
2149
+ # Undo +nullable+ on one schema node: drop "null" from a type array,
2150
+ # or unwrap an +anyOf: [schema, {type: "null"}]+ wrapper, keeping the
2151
+ # wrapper's own keywords (description, examples) on the result.
2152
+ #: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
2153
+ def non_nullable(schema)
2154
+ type = schema[:type]
2155
+ if type.is_a?(Array) && type.include?("null")
2156
+ rest = type - ["null"]
2157
+ return schema.merge(type: rest.size == 1 ? rest.first : rest)
2158
+ end
2159
+
2160
+ inner = non_null_variant(schema)
2161
+ return schema unless inner
2162
+
2163
+ inner.merge(schema.reject { |key, _| key == :anyOf })
2164
+ end
2165
+
2166
+ # The non-null JSON type a schema describes, or nil. Looks through the
2167
+ # forms +nullable+ produces: a +[T, "null"]+ type array, and an
2168
+ # +anyOf+ of one schema plus +{type: "null"}+.
2169
+ #: (Hash[Symbol, untyped]?) -> String?
2170
+ def primary_type(schema)
2171
+ return nil unless schema.is_a?(Hash)
2172
+
2173
+ type = schema[:type] || schema["type"]
2174
+ return type.to_s if type.is_a?(String) || type.is_a?(Symbol)
2175
+ return (type.map(&:to_s) - ["null"]).first if type.is_a?(Array)
2176
+
2177
+ variants = schema[:anyOf] || schema["anyOf"]
2178
+ return nil unless variants.is_a?(Array)
2179
+
2180
+ others = variants.reject { |v| v == NULL_SCHEMA || v == { "type" => "null" } }
2181
+ others.size == 1 ? primary_type(others.first) : nil
2182
+ end
2183
+
1964
2184
  # Map +RBS::Types::Record+ to a JSON Schema object. RBS handles
1965
2185
  # +?key:+ optional markers natively — they land in
1966
2186
  # +node.optional_fields+ rather than +node.fields+. No need for
@@ -241,7 +241,7 @@ module McpAuthorization
241
241
  filtered_params = McpAuthorization::RbsSchemaCompiler.filter_input(
242
242
  handler, params, server_context: effective_ctx
243
243
  )
244
- rescue McpAuthorization::UnknownInputKeysError => e
244
+ rescue McpAuthorization::UnknownInputKeysError, McpAuthorization::MissingRequiredInputKeysError => e
245
245
  # In-band tool error (isError: true), not a JSON-RPC failure, so the
246
246
  # model reads the guidance. tool_name, not defn[:name]: a host may
247
247
  # rename the materialized class, and the caller knows the new name.
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.9.0"
2
+ VERSION = "0.11.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.9.0
4
+ version: 0.11.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-09-28 00:00:00.000000000 Z
11
+ date: 2026-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails