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 +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +5 -0
- data/lib/mcp_authorization/facade_builder.rb +1 -1
- data/lib/mcp_authorization/rbs_schema_compiler.rb +233 -13
- data/lib/mcp_authorization/tool.rb +1 -1
- data/lib/mcp_authorization/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 67586221cf3dfeaaffd4bb9b0a7dbc5b1b8f8adf515968b59bdf3c143ee41746
|
|
4
|
+
data.tar.gz: 8f53209e92d19cfdf5b2edb5c5c09189786f302dac31f5269b659fa196a24ac0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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 && !
|
|
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"}
|
|
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
|
-
#
|
|
1864
|
-
# level (
|
|
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.
|
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
|
+
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-
|
|
11
|
+
date: 2026-10-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|