mcp_authorization 0.8.0 → 0.9.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: 68b3436ab106d889c6ec4c889e9fef24235db5a5fcd64d840a0365fa3fa3b313
4
- data.tar.gz: 36946f67a327d9f7ab7a2cea555fb35b16d12d0afab370bdfe80a9dc3b20f5db
3
+ metadata.gz: b01924de48adcd82456518091bcfb6309908158c118fe0865e600417a70be766
4
+ data.tar.gz: 59ec07c72ae70fd00a77ff4e935b0c9feb03b5344e080b3d83aecfecf799390c
5
5
  SHA512:
6
- metadata.gz: 873fd4f953cfcf45d561b897da001d54155315a3f1add2fab2785b4abc11018af972c8aaeaf6b196031ecac8dad44e1ba52d33b0c945d63309beac0b2332c092
7
- data.tar.gz: 8da192fdc1e11c65dc84bfbc0ec4c5ed80042279283c784bfa7d7fe55958ce11f216b10ec7b523aa3723b23e685ae3eac67e8f73f3e3466266c65916daeead12
6
+ metadata.gz: b603364bfb0c0aa3174e714f321b99c11394c938ccffd930aed01c950af91e60ab224a91565feafa3d1ee600a3327ec2c4c6d5d3420f69baf6f2e8b0ace25f02
7
+ data.tar.gz: a090369d9fe2da4276327b72ac3cf59f9e1626148c980093b42d6d3f85f0486bf496d3a9b6b5a906b1cec5d02e1d53b6b5e920c26c3d604c504655b522305cb5
data/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ 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.9.0]
8
+
9
+ Top-level params outside the caller's input schema are rejected instead of
10
+ silently dropped. Behavior change on by default — opt out with
11
+ `config.reject_unknown_input_keys = false`.
12
+
13
+ ### Changed
14
+ - **`RbsSchemaCompiler.filter_input` raises `McpAuthorization::UnknownInputKeysError`** when the params carry a top-level key that is not in the caller's compiled input schema. Previously every such key was dropped by the closed-by-default projection, so a `tools/call` with an invented filter (`list_applicants(stage_id:, data: { booked_meeting: "true" }, limit: 100)`) returned `success: true` with the *unfiltered* page — and the calling agent moved every applicant on it, reporting the filter as applied. The same projection let a non-admin's `force: true` through as a silent no-op the agent then reported as done. The caller's schema is now the contract: a key the tool never declared and a key gated behind a predicate this caller fails are both outside it and are both rejected. The handler never runs, so a gated field is still never applied for a caller who lacks it. The error's `unknown_keys` / `accepted_keys` and its message name the offending keys and the params in this caller's schema — nothing `tools/list` did not already show that caller. Keys nested inside a declared object param are still projected silently; this release covers the top level only.
15
+ - **The message is written for the model that reads it.** It states that nothing was executed, lists the accepted params, and tells the caller not to re-run the call without the rejected parameter (the result would be unfiltered) and not to approximate it with another one. A message that said "retry with accepted parameters" would have reproduced the incident.
16
+ - **Materialized tools return the rejection in-band.** `Tool.materialize_for`'s `call` rescues `UnknownInputKeysError` and returns `MCP::Tool::Response.new([...], error: true)` with the text `<tool_name>: <message>`, rather than the SDK wrapping it as a JSON-RPC `-32603` that names the facade instead of the tool. Facade dispatch reaches the target's materialized `call`, so a facade caller — which only ever saw `arguments: object` — gets the same in-band error naming the inner tool. The non-materialized `Tool.call` lets the error propagate.
17
+ - **Facade dispatch rejects keys beside `tool_name` and `arguments`.** The facade schema declares only those two, and dispatch read only `arguments`, so `{ tool_name:, arguments:, data: {...}, limit: 100 }` ran the target unfiltered and returned success. The facade now answers in-band naming itself. Its guidance differs from a tool's on purpose: a flattened parameter is supported and merely misplaced, so the caller is told to re-send it inside `arguments`, not to give up on it. `reject_unknown_input_keys = false` restores the old behavior here too.
18
+ - **A facade `arguments` that is not an object is rejected instead of dropped.** `coerce_arguments` returned `{}` for an array, a scalar, a raw JSON blob of either, or `nil`, so the target ran with no arguments at all and the unfiltered default came back as a success — the same shape as the incident above, on the same surface.
19
+ - **An `arguments.server_context` can no longer replace the request's context.** Dispatch splatted the caller's arguments *after* the `server_context:` keyword, and a later duplicate keyword wins in Ruby, so a caller-supplied `server_context` key bound the context used to compile the schema. Every predicate then ran against that object and fell open, admitting `@requires`/`@feature`-gated fields. The keyword is now passed last. Present since facades shipped in 0.7.0; direct (non-facade) calls were never affected, since the SDK passes `server_context:` last.
20
+ - **An unroutable `tool_name` and a malformed `arguments` JSON blob come back in-band** with the facade's name and the list of tools the caller may use, instead of raising out of band.
21
+
22
+ ### Added
23
+ - **`config.reject_unknown_input_keys`** (default `true`). `false` restores the 0.8 drop-everything behavior for both unknown and gated keys.
24
+ - **`config.ignored_input_keys`** (default `[]`). Names the envelope keys a host's client attaches to every call — a UI annotation, not a tool parameter — which are dropped rather than rejected. Applied wherever a key is checked against a schema: beside `tool_name` and `arguments` on a facade call, and at the top level of any tool's params. A client that annotates every call reaches a flat domain directly, where there is no facade to exempt it, so one setting covers both surfaces.
25
+
26
+ ### Migration notes
27
+ - **Check what your client puts beside `tool_name` and `arguments` before upgrading.** A client that annotates every facade call with an envelope key had it silently ignored through 0.8 and now has every call rejected. Name the key in `config.ignored_input_keys`, or declare it on the tools. Measuring the model's tool calls is not enough to find these: a host's own envelope is added by code, so it rides beside `arguments` where a survey of invented parameter names cannot see it, and a call the host makes on its own behalf may never appear in the conversation log at all. Read the client's source.
28
+ - **Migrating a tool onto this gem is now a contract change.** A tool that used to be served by a hand-rolled endpoint, where an unknown key fell into a permit list and vanished, starts rejecting whatever its caller adds. When that caller is code rather than a model there is nobody to read the error and retry, so the key has to be declared or ignored in the same change that migrates the tool.
29
+ - `reject_unknown_input_keys = false` does not cover the non-object `arguments` rejection: `arguments` is `required` in the facade schema, so an array, scalar or `nil` is rejected whatever the flag says. A host that leaves the MCP SDK's `validate_tool_call_arguments` on never reaches it, because the SDK rejects first.
30
+ - A host that overrides `materialize_for` (or calls `filter_input` directly) must `rescue McpAuthorization::UnknownInputKeysError` and build its own error response. Until it does, the rejection still happens, but what the caller receives depends on the MCP SDK version — an in-band `Internal error calling tool <name>` response on some, a JSON-RPC internal error on others — and the host's exception reporter fires on every model typo.
31
+ - Any client or test that relied on a top-level extra key being ignored — invented or gated — now sees an error. That reliance was the bug this release removes. A client that sends the full field set regardless of the caller's permissions must send only the fields in that caller's `tools/list` schema.
32
+
7
33
  ## [0.8.0]
8
34
 
9
35
  A supported seam for tool classes the host generates at runtime instead of
data/README.md CHANGED
@@ -18,7 +18,7 @@ The gem gives you three independent controls over what each user sees:
18
18
  | Layer | Mechanism | Effect |
19
19
  |---|---|---|
20
20
  | **Tool visibility** | `authorization :manage_workflows` (RBAC) or `gate :feature, :sms` (any predicate) on the tool class | Tool hidden entirely from users who fail any check |
21
- | **Input fields** | `@requires(:backward_routing)` or `@feature(:sms)` on a param in `#:` annotation | Field excluded from the input schema *and* stripped from inbound params at call time |
21
+ | **Input fields** | `@requires(:backward_routing)` or `@feature(:sms)` on a param in `#:` annotation | Field excluded from the input schema; a top-level one sent anyway is rejected at call time, a nested one is stripped |
22
22
  | **Output variants** | `@requires(:backward_routing)` or `@feature(:sms)` on a variant in `@rbs type output` | Variant excluded from the `oneOf` *and* fields projected out of the handler's return value before it crosses the wire |
23
23
 
24
24
  Tool-level `authorization :perm` is RBAC (calls `current_user.can?`). Tool-level `gate :predicate, :value` and the field-level annotations are generic — any predicate name works, as long as the server context implements `{predicate}?(value)`. See [Generic predicate tags](#generic-predicate-tags) below.
@@ -27,7 +27,13 @@ Tool-level `authorization :perm` is RBAC (calls `current_user.can?`). Tool-level
27
27
 
28
28
  `@requires` is a security boundary, not a hint. At tool-call time the gem:
29
29
 
30
- - **Filters inbound params** against the user's compiled input schema. Gated fields, and any keys not declared in the schema at all, are dropped before the handler's `#call` is invoked. A handler that takes `force:` gated behind `@requires(:admin)` will see `force: false` (its default) for non-admins even if the MCP client sends `force: true` in the raw JSON-RPC payload.
30
+ - **Rejects inbound params outside the caller's compiled input schema.** The schema the caller received from `tools/list` is the contract. A top-level key outside it — one the tool never declared, or one gated behind a predicate this caller fails — does not exist from the caller's point of view. Such a key used to be dropped silently, which returned a success-shaped result that ignored part of the request: an LLM that never saw the per-tool schema (e.g. behind a [facade](#tool-grouping-facades)) invented a filter, got the *unfiltered* page back as `success: true`, and acted on it; a non-admin sending `force: true` was told the flag was applied. So `filter_input` raises `McpAuthorization::UnknownInputKeysError` and the materialized tool returns it as an in-band tool error (`isError: true`). The text is written for a model that will read it as a tool result:
31
+
32
+ ```
33
+ list_applicants: Unknown parameters: data, limit. Nothing was executed. Accepted parameters: funnel_id, page, per_page, query, stage_id. If your task depends on a rejected parameter (for example filtering by it), do not re-run this call without it: the result would be unfiltered. Do not substitute another parameter to approximate it. Use a tool that supports it, or tell the user it is not possible.
34
+ ```
35
+
36
+ Both lists come from the caller's own schema, so the message reveals nothing `tools/list` did not already show that caller. The handler never runs, so a gated field is still never applied for a caller who lacks it — the boundary holds, it just stops being silent. A facade call is checked twice: a key beside `tool_name` and `arguments` is rejected by the facade, which tells the caller to re-send it inside `arguments` (a flattened parameter is supported, just misplaced), and a key inside `arguments` by the target tool, which tells it not to re-run without the parameter. Name a client's envelope key in `config.ignored_input_keys` to have it dropped instead, on both surfaces. Keys nested inside a declared object param are still projected silently. With `strict_schema` on, the compiled root carries `additionalProperties: false`, so an MCP SDK that validates arguments (`validate_tool_call_arguments`, on by default in the `mcp` gem) rejects the call with its own generic message before this one is produced; turn that validation off on the server if the model must read the guidance. Set `config.reject_unknown_input_keys = false` to restore the pre-0.9 drop-everything behavior. A host that overrides `materialize_for` or calls `filter_input` directly must rescue `UnknownInputKeysError` itself; unrescued it surfaces as a JSON-RPC internal error carrying the same message. The non-materialized `Tool.call` lets it propagate.
31
37
  - **Projects the handler's return value** onto the user's compiled output schema. A variant hidden by `@requires` has its shape unavailable, so if the handler erroneously emits that variant's extra fields, they are stripped before serialization. A handler bug or refactor accident cannot leak admin-only fields to a non-admin.
32
38
 
33
39
  This means handler authors don't have to remember to re-check `can?` at every branch -- the schema *is* the boundary. `can?` inside `#call` is still useful for logic that changes behavior (not just field visibility), but it is no longer load-bearing for security.
@@ -76,6 +82,8 @@ end
76
82
  | `context_builder` | *required* | `(request) -> context` |
77
83
  | `cli_context_builder` | `nil` | `(domain:, role:) -> context` for rake tasks |
78
84
  | `strict_schema` | `false` | Emit stricter compiled schemas |
85
+ | `reject_unknown_input_keys` | `true` | Reject a `tools/call` carrying a top-level param outside the caller's compiled input schema (undeclared, or gated behind a predicate the caller fails) instead of silently dropping it — see [Enforcement, not just shaping](#enforcement-not-just-shaping) |
86
+ | `ignored_input_keys` | `[]` | Envelope keys a client attaches to every call, dropped rather than rejected — beside `tool_name`/`arguments` on a facade, and at the top level on any tool |
79
87
  | `tools_list_cache` | `nil` | `:memory`, `:redis`, or any object responding to `get`/`set` — see [Caching `tools/list`](#caching-toolslist) |
80
88
  | `tools_list_cache_ttl` | `3600` | Per-entry TTL in seconds |
81
89
  | `tools_list_cache_redis` | `nil` | Explicit Redis client for the `:redis` store |
@@ -100,6 +100,25 @@ module McpAuthorization
100
100
  #: bool
101
101
  attr_accessor :strict_schema
102
102
 
103
+ # When true (the default), a +tools/call+ whose params carry a top-level
104
+ # key outside the caller's compiled input schema is rejected with
105
+ # +McpAuthorization::UnknownInputKeysError+ instead of having the key
106
+ # silently dropped. A field gated by a predicate this caller fails is
107
+ # outside that schema and is rejected the same way — the caller's
108
+ # schema is the contract. Set to false to restore the pre-0.9
109
+ # drop-everything behavior.
110
+ #: bool
111
+ attr_accessor :reject_unknown_input_keys
112
+
113
+ # Keys a host's client attaches to every call as an envelope — a UI
114
+ # annotation, not a tool parameter — which are dropped rather than
115
+ # rejected. Empty by default. Applied wherever a key is checked against a
116
+ # schema: beside +tool_name+ and +arguments+ on a facade call, and at the
117
+ # top level of any tool's params, so the same envelope is exempt on a
118
+ # facade and on a flat domain alike.
119
+ #: Array[String]
120
+ attr_accessor :ignored_input_keys
121
+
103
122
  # Cache for the +tools/list+ response. Opt-in; defaults to no caching.
104
123
  # Accepts:
105
124
  # nil / false — no caching (default)
@@ -179,6 +198,8 @@ module McpAuthorization
179
198
  @context_builder = nil
180
199
  @cli_context_builder = nil
181
200
  @strict_schema = false
201
+ @reject_unknown_input_keys = true
202
+ @ignored_input_keys = []
182
203
  @tools_list_cache = nil
183
204
  @tools_list_cache_ttl = 3600
184
205
  @tools_list_cache_redis = nil
@@ -25,9 +25,19 @@ module McpAuthorization
25
25
  # registered tool name. Renaming the category is the fix.
26
26
  class FacadeNameCollisionError < StandardError; end
27
27
 
28
+ # A facade call the caller can fix from the error alone: an unroutable
29
+ # +tool_name+, a malformed +arguments+, or a key beside the two the
30
+ # facade declares. Returned in-band so the message reaches the model.
31
+ # Raised only before dispatch reaches the target, so it never relabels a
32
+ # target's own error with the facade's name.
33
+ class FacadeCallError < ArgumentError; end
34
+
28
35
  # Fallback group for uncategorized tools (default mode).
29
36
  FALLBACK_CATEGORY = :uncategorized #: Symbol
30
37
 
38
+ # The only two keys a facade's inputSchema declares.
39
+ FACADE_KEYS = %w[arguments tool_name].freeze #: Array[String]
40
+
31
41
  class << self
32
42
  # All facades for a domain, one per non-empty group the caller has at
33
43
  # least one permitted tool in. Empty groups produce no facade — which
@@ -153,6 +163,9 @@ module McpAuthorization
153
163
 
154
164
  define_singleton_method(:call) do |server_context: nil, **params|
155
165
  builder.send(:dispatch, domain, advertised, params, server_context || ctx)
166
+ rescue McpAuthorization::FacadeBuilder::FacadeCallError => e
167
+ # Raised before the target runs, so the facade's name is the right label.
168
+ MCP::Tool::Response.new([{ type: "text", text: "#{name}: #{e.message}" }], error: true)
156
169
  end
157
170
  end
158
171
  end
@@ -235,10 +248,12 @@ module McpAuthorization
235
248
  # the same code path as a direct call.
236
249
  #: (String, Set[String], Hash[Symbol, untyped], untyped) -> untyped
237
250
  def dispatch(domain, advertised, params, server_context)
251
+ reject_unknown_facade_keys!(params)
238
252
  tool_name = (params[:tool_name] || params["tool_name"]).to_s
239
253
  unless advertised.include?(tool_name)
240
- raise ArgumentError,
241
- "unknown tool_name #{tool_name.inspect}; expected one of #{advertised.to_a.sort.inspect}"
254
+ raise FacadeCallError,
255
+ "unknown tool_name #{tool_name.inspect}. Nothing was executed. " \
256
+ "Available tools: #{advertised.to_a.sort.join(", ")}."
242
257
  end
243
258
 
244
259
  candidates = McpAuthorization::ToolRegistry.tools_by_domain[domain] || []
@@ -249,7 +264,46 @@ module McpAuthorization
249
264
  raise McpAuthorization::Tool::NotAuthorizedError unless original && target
250
265
 
251
266
  arguments = coerce_arguments(original, params[:arguments] || params["arguments"], server_context)
252
- target.call(server_context: server_context, **arguments)
267
+ reject_reserved_argument_keys!(arguments)
268
+ # server_context last: a later duplicate keyword wins in Ruby, so an
269
+ # `arguments.server_context` cannot replace the request's own context.
270
+ target.call(**arguments, server_context: server_context)
271
+ end
272
+
273
+ # The facade schema declares only tool_name and arguments; a sibling key
274
+ # is outside every caller's contract and was silently ignored, the same
275
+ # gap filter_input closes for the target tool. The guidance differs from
276
+ # the target's: a flattened parameter is supported, it is just in the
277
+ # wrong place, so the caller is told to nest it rather than to give up.
278
+ # +config.ignored_input_keys+ names host envelope keys to drop instead.
279
+ #: (Hash[Symbol, untyped]) -> void
280
+ def reject_unknown_facade_keys!(params)
281
+ return unless McpAuthorization.config.reject_unknown_input_keys
282
+
283
+ ignored = McpAuthorization.config.ignored_input_keys.map(&:to_s)
284
+ unknown = params.keys.map(&:to_s) - FACADE_KEYS - ignored
285
+ return if unknown.empty?
286
+
287
+ list = unknown.join(", ")
288
+ raise FacadeCallError,
289
+ "Unknown parameter#{"s" if unknown.size > 1}: #{list}. Nothing was executed. " \
290
+ "This tool accepts only tool_name and arguments; a tool's own parameters go " \
291
+ "inside arguments. Re-send with #{list} inside arguments if the tool declares " \
292
+ "#{unknown.size > 1 ? "them" : "it"}, and omit #{unknown.size > 1 ? "them" : "it"} otherwise."
293
+ end
294
+
295
+ # +server_context+ binds the target's own keyword, so it can never be a
296
+ # tool parameter and +filter_input+ never sees it. Rejecting it keeps the
297
+ # release's guarantee — no key outside the schema is quietly ignored —
298
+ # and makes the caller's intent visible rather than silently discarded.
299
+ #: (Hash[Symbol, untyped]) -> void
300
+ def reject_reserved_argument_keys!(arguments)
301
+ return unless McpAuthorization.config.reject_unknown_input_keys
302
+ return unless arguments.key?(:server_context)
303
+
304
+ raise FacadeCallError,
305
+ "server_context is not a tool parameter and cannot be supplied by a caller. " \
306
+ "Nothing was executed. Remove it from arguments and re-send."
253
307
  end
254
308
 
255
309
  # Coerce a facade's +arguments+ blob against the target tool's
@@ -257,12 +311,19 @@ module McpAuthorization
257
311
  # objects as JSON strings; the facade's own contract only knows
258
312
  # `arguments: object`, so string blobs are parsed here — both the
259
313
  # blob itself and any top-level value whose target type is an object
260
- # or array. Unknown / permission-gated fields are then stripped by
261
- # the target's filter_input as in a direct call.
314
+ # or array. Keys outside the target's schema are then rejected by the
315
+ # target's filter_input as in a direct call.
262
316
  #: (singleton(McpAuthorization::Tool), untyped, untyped) -> Hash[Symbol, untyped]
263
317
  def coerce_arguments(tool_class, raw, server_context)
264
318
  parsed = raw.is_a?(String) ? parse_json_blob(raw, "arguments") : raw
265
- return {} unless parsed.is_a?(Hash)
319
+ # An empty hash here used to mean "call the target with no arguments",
320
+ # so a caller that sent an array, a scalar or nil got the unfiltered
321
+ # default result reported as a success.
322
+ unless parsed.is_a?(Hash)
323
+ raise FacadeCallError,
324
+ "arguments must be an object mapping parameter names to values; got " \
325
+ "#{parsed.nil? ? "null" : parsed.class.name.downcase}. Nothing was executed."
326
+ end
266
327
 
267
328
  schema = tool_class.dynamic_input_schema(server_context: server_context)
268
329
  properties = schema.is_a?(Hash) ? (schema[:properties] || schema["properties"] || {}) : {}
@@ -282,7 +343,8 @@ module McpAuthorization
282
343
  def parse_json_blob(string, field)
283
344
  JSON.parse(string)
284
345
  rescue JSON::ParserError => e
285
- raise ArgumentError, "#{field} was sent as a string but is not valid JSON: #{e.message}"
346
+ raise FacadeCallError,
347
+ "#{field} was sent as a string but is not valid JSON: #{e.message}. Nothing was executed."
286
348
  end
287
349
  end
288
350
  end
@@ -42,6 +42,31 @@ require "rbs/parser_aux"
42
42
  require_relative "diagnostics"
43
43
 
44
44
  module McpAuthorization
45
+ # Raised by +RbsSchemaCompiler.filter_input+ when the caller sent a
46
+ # top-level param outside its compiled input schema. +unknown_keys+ are
47
+ # the offending names; +accepted_keys+ are the names in the caller's
48
+ # schema. The message is written for an LLM that will read it as a tool
49
+ # result: it must not suggest re-running without the parameter, because
50
+ # for a filter that means acting on an unfiltered result.
51
+ class UnknownInputKeysError < ArgumentError
52
+ attr_reader :unknown_keys, :accepted_keys
53
+
54
+ #: (Array[String], Array[String]) -> void
55
+ def initialize(unknown_keys, accepted_keys)
56
+ @unknown_keys = unknown_keys
57
+ @accepted_keys = accepted_keys
58
+ accepted = accepted_keys.empty? ? "none" : accepted_keys.join(", ")
59
+ super(
60
+ "Unknown parameter#{"s" if unknown_keys.size > 1}: #{unknown_keys.join(", ")}. " \
61
+ "Nothing was executed. Accepted parameters: #{accepted}. " \
62
+ "If your task depends on a rejected parameter (for example filtering by it), " \
63
+ "do not re-run this call without it: the result would be unfiltered. " \
64
+ "Do not substitute another parameter to approximate it. " \
65
+ "Use a tool that supports it, or tell the user it is not possible."
66
+ )
67
+ end
68
+ end
69
+
45
70
  # Compiles RBS-style type annotations in Ruby source files into JSON Schema,
46
71
  # with per-request filtering based on +@requires+ permission tags.
47
72
  #
@@ -127,10 +152,16 @@ module McpAuthorization
127
152
 
128
153
  # Filter incoming params against the user's compiled input schema.
129
154
  #
130
- # Any key that is not in the schema for this user is dropped — including
131
- # +@requires+-gated fields the user lacks permission for, and any
132
- # unknown fields not declared in the schema. This is the runtime
133
- # enforcement counterpart to the input-shaping that +compile_input+ did.
155
+ # This is the runtime enforcement counterpart to the input-shaping
156
+ # that +compile_input+ did. The caller's schema is the contract: a
157
+ # top-level key outside it — whether the tool never declared it or
158
+ # declared it behind a predicate this caller fails — does not exist
159
+ # from the caller's point of view. Such a key used to be dropped
160
+ # silently, which returned a successful-looking result that ignored
161
+ # part of the request (an invented filter, a gated flag). By default
162
+ # it now raises +UnknownInputKeysError+; see
163
+ # +config.reject_unknown_input_keys+. Keys nested inside a declared
164
+ # object param are still projected silently.
134
165
  #
135
166
  # @param handler_class [Class]
136
167
  # @param params [Hash] Params as received from the MCP client.
@@ -141,9 +172,27 @@ module McpAuthorization
141
172
  return params unless params.is_a?(Hash)
142
173
  schema = compile_input_for_filter(handler_class, server_context: server_context)
143
174
  return params unless schema
175
+ reject_unknown_input_keys!(params, schema) if McpAuthorization.config.reject_unknown_input_keys
144
176
  project_against_schema(params, schema, defs_from(schema))
145
177
  end
146
178
 
179
+ # Raise when +params+ carries a top-level key outside +schema+'s
180
+ # properties. Both the unknown and the accepted names come from the
181
+ # caller's own schema, so the message reveals nothing +tools/list+ did
182
+ # not already show this caller. A host's envelope key named in
183
+ # +config.ignored_input_keys+ is dropped here as it is on a facade: a
184
+ # client that annotates every call reaches a flat domain directly, where
185
+ # there is no facade to exempt it.
186
+ #: (Hash[untyped, untyped], Hash[Symbol, untyped]) -> void
187
+ def reject_unknown_input_keys!(params, schema)
188
+ accepted = (schema[:properties] || {}).keys.map(&:to_s)
189
+ ignored = McpAuthorization.config.ignored_input_keys.map(&:to_s)
190
+ unknown = params.keys.map(&:to_s) - accepted - ignored
191
+ return if unknown.empty?
192
+
193
+ raise UnknownInputKeysError.new(unknown, accepted.sort)
194
+ end
195
+
147
196
  # Filter the handler's return value against the user's compiled output
148
197
  # schema. Any field/variant not visible to this user is stripped from
149
198
  # the result.
@@ -215,9 +215,10 @@ module McpAuthorization
215
215
  # Create an anonymous MCP::Tool subclass with this user's schemas baked in.
216
216
  #
217
217
  # The materialized +call+ enforces the compiled schema at runtime:
218
- # input params are stripped of unknown or permission-gated fields
219
- # before reaching the handler, and the handler's return value is
220
- # projected onto the user's output schema before being serialized.
218
+ # a top-level param outside the caller's input schema is rejected
219
+ # in-band (+UnknownInputKeysError+ → +isError: true+) before the
220
+ # handler runs, and the handler's return value is projected onto the
221
+ # user's output schema before being serialized.
221
222
  #: (untyped) -> Class?
222
223
  def materialize_for(server_context)
223
224
  defn = to_mcp_definition(server_context: server_context)
@@ -236,9 +237,16 @@ module McpAuthorization
236
237
 
237
238
  define_singleton_method(:call) do |server_context: nil, **params|
238
239
  effective_ctx = server_context || ctx
239
- filtered_params = McpAuthorization::RbsSchemaCompiler.filter_input(
240
- handler, params, server_context: effective_ctx
241
- )
240
+ begin
241
+ filtered_params = McpAuthorization::RbsSchemaCompiler.filter_input(
242
+ handler, params, server_context: effective_ctx
243
+ )
244
+ rescue McpAuthorization::UnknownInputKeysError => e
245
+ # In-band tool error (isError: true), not a JSON-RPC failure, so the
246
+ # model reads the guidance. tool_name, not defn[:name]: a host may
247
+ # rename the materialized class, and the caller knows the new name.
248
+ next MCP::Tool::Response.new([{ type: "text", text: "#{tool_name}: #{e.message}" }], error: true)
249
+ end
242
250
  raw = handler.new(server_context: effective_ctx).call(**symbolize.call(filtered_params))
243
251
  result = McpAuthorization::RbsSchemaCompiler.filter_output(
244
252
  handler, raw, server_context: effective_ctx
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.8.0"
2
+ VERSION = "0.9.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.8.0
4
+ version: 0.9.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-08-11 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -128,7 +128,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
128
128
  - !ruby/object:Gem::Version
129
129
  version: '0'
130
130
  requirements: []
131
- rubygems_version: 3.4.10
131
+ rubygems_version: 3.3.27
132
132
  signing_key:
133
133
  specification_version: 4
134
134
  summary: Rails engine for MCP tools with per-request schema discrimination