mcp_authorization 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 537c8e48374b9ba9eb9c6876bac1410cb06a2a3cd46925830009a65af94fdee7
4
- data.tar.gz: b2efed20a21e1a8771aea0fe9ffebd32e4debe5a2fda21642a1080bc36f89759
3
+ metadata.gz: 9adb27c3a4074869c90737b393772a425931683c8b8128864525902e7283aed6
4
+ data.tar.gz: 1d0e2d793b46ff13952142d3e3a21b0a0f6008039e184446506cf90b9e5a1083
5
5
  SHA512:
6
- metadata.gz: 3152bb3b807c10c64b20ddab0ea45fabb2bbfeb357bba5ea699d97e4544e377f8f1be7f1c200db5ce14ca997f6b8a167724c862d023b35cefc01fa0483cfbdcb
7
- data.tar.gz: c0742d87088a695ee246e99f02fc8bd885cc9e195b42f01fd1ff12e4bef7ea30811400280388c9d24281d3fa7f38981a17d27a4a9a54b2df43b7e63ac546e4d7
6
+ metadata.gz: 1b46015ca220581022647d667f1d86809c83e433ef5cd2ea500d8b81a2fc2eb9a58346746b74c0a3dbe78209f51728e0b7b2593f5dc7e9fd7663c2fb19638df9
7
+ data.tar.gz: ec9e5d04a5a5d749a995f73026d05bd003e4ab2421fc97d872d67af07bf4fd3f909d1aaa77053c6aca644ffbbbaf89257fc77774bf0800d648ebaf0cb3d428ae
data/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
5
+ adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.2.1] - 2026-05-13
8
+
9
+ ### Fixed
10
+ - `tools/call` responses now include `structuredContent` when the tool declares an `outputSchema` via `dynamic_contract`. Previously the response carried only text content, which spec-compliant clients rejected as a validation error. (#6)
11
+ - `RbsSchemaCompiler` now finds the handler's source file when `#call` is wrapped via `Module#prepend` (param coercion, instrumentation, `ActiveSupport::Concern`, tracing libraries). It walks `UnboundMethod#super_method` past prepended modules until the owner is the handler class itself, so `# @rbs type` and `#:` annotations are read from the right file instead of raising a contract violation. (#8)
12
+
13
+ ## [0.2.0] - 2026-04-20
14
+
15
+ ### Added
16
+ - `RbsSchemaCompiler.filter_input(handler, params, server_context:)` — projects inbound params onto the user's compiled input schema before the handler runs. Keys gated by `@requires` the user lacks, and any keys not declared in the schema at all, are dropped.
17
+ - `RbsSchemaCompiler.filter_output(handler, result, server_context:)` — projects the handler's return value onto the user's compiled output schema. Hidden `oneOf` variants and their fields are stripped before serialization.
18
+
19
+ ### Changed
20
+ - **`@requires` is now a security boundary, not just a hint to the LLM.** Tool calls through `Tool.call` and the anonymous class produced by `Tool.materialize_for` pipe params through `filter_input` on the way in and results through `filter_output` on the way out. A crafted JSON-RPC request that sends a gated param, and a handler that accidentally emits a gated output field, can no longer leak.
21
+ - README updated to describe enforcement as a guarantee. Handler authors no longer have to remember to re-check `can?` in every branch that touches a gated field — the schema is the boundary.
22
+
23
+ ### Migration notes
24
+ - If your handler's `#call` quietly accepted params that weren't declared in the `#:` annotation, those will now arrive as `nil`/default values. Declare them (with `@requires` if appropriate) or drop them.
25
+ - If your handler's output included fields that weren't in `@rbs type output`, those are now stripped. Add them to the output type definition if they should ship.
26
+
27
+ ## [0.1.1] - 2026-04-02
28
+
29
+ ### Added
30
+ - Added MIT license, homepage, author metadata.
31
+
32
+ ## [0.1.0] - 2026-04-01
33
+
34
+ ### Added
35
+ - Initial gem extraction from the monorepo. Rails engine, `RbsSchemaCompiler`, `Tool` / `ToolRegistry`, `DSL` mixin, `McpController`.
data/README.md CHANGED
@@ -11,11 +11,20 @@ The gem gives you three independent controls over what each user sees:
11
11
  | Layer | Mechanism | Effect |
12
12
  |---|---|---|
13
13
  | **Tool visibility** | `authorization :manage_workflows` on the tool class | Tool hidden entirely from users who lack the flag |
14
- | **Input fields** | `@requires(:backward_routing)` on a param in `#:` annotation | Field excluded from the input schema |
15
- | **Output variants** | `@requires(:backward_routing)` on a variant in `@rbs type output` | Variant excluded from the `oneOf` |
14
+ | **Input fields** | `@requires(:backward_routing)` on a param in `#:` annotation | Field excluded from the input schema *and* stripped from inbound params at call time |
15
+ | **Output variants** | `@requires(:backward_routing)` 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 |
16
16
 
17
17
  All three go through the same predicate: `current_user.can?(:symbol)`. The symbol can represent a permission, a feature flag, a plan tier, an A/B bucket -- whatever your app puts behind it.
18
18
 
19
+ ### Enforcement, not just shaping
20
+
21
+ `@requires` is a security boundary, not a hint. At tool-call time the gem:
22
+
23
+ - **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.
24
+ - **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.
25
+
26
+ 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.
27
+
19
28
  ## Install
20
29
 
21
30
  ```ruby
@@ -81,6 +81,45 @@ module McpAuthorization
81
81
  end
82
82
  end
83
83
 
84
+ # Filter incoming params against the user's compiled input schema.
85
+ #
86
+ # Any key that is not in the schema for this user is dropped — including
87
+ # +@requires+-gated fields the user lacks permission for, and any
88
+ # unknown fields not declared in the schema. This is the runtime
89
+ # enforcement counterpart to the input-shaping that +compile_input+ did.
90
+ #
91
+ # @param handler_class [Class]
92
+ # @param params [Hash] Params as received from the MCP client.
93
+ # @param server_context [Object] Per-request context.
94
+ # @return [Hash] Filtered params safe to pass to the handler.
95
+ #: (untyped, Hash[untyped, untyped], server_context: untyped) -> Hash[untyped, untyped]
96
+ def filter_input(handler_class, params, server_context:)
97
+ return params unless params.is_a?(Hash)
98
+ schema = compile_input_for_filter(handler_class, server_context: server_context)
99
+ return params unless schema
100
+ project_against_schema(params, schema, defs_from(schema))
101
+ end
102
+
103
+ # Filter the handler's return value against the user's compiled output
104
+ # schema. Any field/variant not visible to this user is stripped from
105
+ # the result.
106
+ #
107
+ # This is the runtime counterpart to +compile_output+: even if a handler
108
+ # bug or auth confusion causes it to emit fields the user shouldn't see,
109
+ # they never cross the wire. Passes the result through unchanged if no
110
+ # +@rbs type output+ is defined.
111
+ #
112
+ # @param handler_class [Class]
113
+ # @param result [Object] Handler return value (hash/array/primitive).
114
+ # @param server_context [Object] Per-request context.
115
+ # @return [Object] Projected result, matching the user's output schema.
116
+ #: (untyped, untyped, server_context: untyped) -> untyped
117
+ def filter_output(handler_class, result, server_context:)
118
+ schema = compile_output_for_filter(handler_class, server_context: server_context)
119
+ return result unless schema
120
+ project_against_schema(result, schema, defs_from(schema))
121
+ end
122
+
84
123
  # Strip JSON Schema keywords unsupported by Anthropic's strict tool
85
124
  # use mode, and add additionalProperties: false to all objects.
86
125
  # Converts oneOf to anyOf (strict mode supports anyOf but not oneOf).
@@ -152,6 +191,132 @@ module McpAuthorization
152
191
 
153
192
  private
154
193
 
194
+ # ---------------------------------------------------------------
195
+ # Runtime enforcement — project values against the user's schema
196
+ # ---------------------------------------------------------------
197
+
198
+ # Like +compile_input+ but keeps +$defs+ inline-resolvable and skips
199
+ # the LLM-facing +strict_sanitize+ pass. Used by +filter_input+ so
200
+ # enforcement operates on the same semantic schema the LLM was given
201
+ # without any strict-mode transforms that would lose type info.
202
+ #: (untyped, server_context: untyped) -> Hash[Symbol, untyped]
203
+ def compile_input_for_filter(handler_class, server_context:)
204
+ cached = cache_for(handler_class)
205
+
206
+ schema = if cached[:raw_input]&.dig(:kind) == :record
207
+ compile_tagged_record(cached[:raw_input][:body], cached[:type_map], server_context)
208
+ else
209
+ build_input_schema(
210
+ filter_call_signature(cached[:call_params], cached[:type_map], server_context)
211
+ )
212
+ end
213
+
214
+ with_ref_injection(schema, cached[:type_map])
215
+ end
216
+
217
+ # Like +compile_output+ but skips +strict_sanitize+. Returns nil when
218
+ # the handler has no +# @rbs type output+ declaration.
219
+ #: (untyped, server_context: untyped) -> Hash[Symbol, untyped]?
220
+ def compile_output_for_filter(handler_class, server_context:)
221
+ cached = cache_for(handler_class)
222
+ return nil unless cached[:raw_output]&.dig(:kind) == :union
223
+
224
+ schema = compile_tagged_union(cached[:raw_output][:body], cached[:type_map], server_context)
225
+ with_ref_injection(schema, cached[:type_map])
226
+ end
227
+
228
+ # Extract the +$defs+ table from a compiled schema for +$ref+ resolution.
229
+ #: (Hash[Symbol, untyped]?) -> Hash[String, Hash[Symbol, untyped]]
230
+ def defs_from(schema)
231
+ return {} unless schema.is_a?(Hash)
232
+ (schema[:"$defs"] || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v }
233
+ end
234
+
235
+ # If +schema+ is a +$ref+ pointer, resolve it against +defs+. Otherwise
236
+ # return the schema unchanged.
237
+ #: (Hash[Symbol, untyped]?, Hash[String, Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]?
238
+ def resolve_ref(schema, defs)
239
+ return schema unless schema.is_a?(Hash)
240
+ ref = schema[:"$ref"] || schema["$ref"]
241
+ return schema unless ref
242
+ name = ref.to_s.sub(%r{\A#/\$defs/}, "")
243
+ defs[name] || schema
244
+ end
245
+
246
+ # Recursively project a runtime value onto a compiled JSON Schema.
247
+ #
248
+ # The semantics: the schema is authoritative — the result only contains
249
+ # what the schema allows for this user.
250
+ #
251
+ # * Objects keep only declared +properties+; everything else is dropped.
252
+ # * Arrays recurse into +items+.
253
+ # * +oneOf+/+anyOf+ picks the best-matching variant (by present/required
254
+ # keys) and projects against it; unmatched variants are ignored.
255
+ # * Primitives (and un-typed schemas) pass through unchanged.
256
+ #
257
+ # This is how +@requires+-gated fields are enforced at runtime: they
258
+ # are already absent from +schema[:properties]+ for a user who lacks
259
+ # the flag, so the projection simply drops them from the value.
260
+ #: (untyped, Hash[Symbol, untyped]?, Hash[String, Hash[Symbol, untyped]]) -> untyped
261
+ def project_against_schema(value, schema, defs)
262
+ return value if schema.nil?
263
+ schema = resolve_ref(schema, defs)
264
+ return value unless schema.is_a?(Hash)
265
+
266
+ variants = schema[:oneOf] || schema[:anyOf]
267
+ if variants.is_a?(Array) && !variants.empty?
268
+ resolved = variants.map { |v| resolve_ref(v, defs) }
269
+ best = best_variant_for(value, resolved)
270
+ return best ? project_against_schema(value, best, defs) : value
271
+ end
272
+
273
+ case schema[:type]
274
+ when "object"
275
+ return value unless value.is_a?(Hash)
276
+ props = schema[:properties] || {}
277
+ value.each_with_object({}) do |(k, v), acc|
278
+ prop_schema = props[k.to_sym] || props[k.to_s] || props[k]
279
+ next unless prop_schema
280
+ acc[k] = project_against_schema(v, prop_schema, defs)
281
+ end
282
+ when "array"
283
+ return value unless value.is_a?(Array)
284
+ items = schema[:items]
285
+ items ? value.map { |v| project_against_schema(v, items, defs) } : value
286
+ else
287
+ value
288
+ end
289
+ end
290
+
291
+ # Choose the best-matching variant of a union for a given value.
292
+ #
293
+ # Scoring: count how many of the value's keys appear in the variant's
294
+ # +properties+, minus how many are unknown. Disqualify variants missing
295
+ # any of the value's keys from their +required+ list that isn't present.
296
+ # Returns nil if no variant can accommodate the value — in which case
297
+ # the caller should leave the value unchanged (defensive pass-through).
298
+ #: (untyped, Array[Hash[Symbol, untyped]]) -> Hash[Symbol, untyped]?
299
+ def best_variant_for(value, variants)
300
+ return variants.first unless value.is_a?(Hash)
301
+
302
+ scored = variants.filter_map do |variant|
303
+ next unless variant.is_a?(Hash) && variant[:type] == "object"
304
+ props = variant[:properties] || {}
305
+ prop_keys = props.keys.map(&:to_s)
306
+ value_keys = value.keys.map(&:to_s)
307
+ required = (variant[:required] || []).map(&:to_s)
308
+
309
+ next if (required - value_keys).any?
310
+
311
+ known = (value_keys & prop_keys).size
312
+ unknown = (value_keys - prop_keys).size
313
+ [known - unknown, variant]
314
+ end
315
+
316
+ return nil if scored.empty?
317
+ scored.max_by { |score, _| score }&.last
318
+ end
319
+
155
320
  # ---------------------------------------------------------------
156
321
  # Tag extraction — unified parser for all @tag(...) annotations
157
322
  # ---------------------------------------------------------------
@@ -880,15 +1045,28 @@ module McpAuthorization
880
1045
  # +Method#source_location+ on its +#call+ method. This is how the
881
1046
  # compiler finds the RBS annotations to parse.
882
1047
  #
1048
+ # When host applications wrap +#call+ via +prepend+ (param coercion,
1049
+ # instrumentation, error mapping, ActiveSupport::Concern patterns,
1050
+ # observability libraries), +source_location+ on the topmost method
1051
+ # points at the wrapper, not the handler. Walk +super_method+ down
1052
+ # past prepended modules until we find the handler's own definition.
1053
+ #
883
1054
  # @param handler_class [Class]
884
1055
  # @return [String, nil] Absolute file path, or nil.
885
1056
  #: (untyped) -> String?
886
1057
  def find_source_file(handler_class)
887
- if handler_class.method_defined?(:call)
888
- handler_class.instance_method(:call).source_location&.first
1058
+ um = if handler_class.method_defined?(:call) || handler_class.private_method_defined?(:call)
1059
+ handler_class.instance_method(:call)
889
1060
  elsif handler_class.respond_to?(:call)
890
- handler_class.method(:call).source_location&.first
1061
+ handler_class.method(:call)
891
1062
  end
1063
+ return nil unless um
1064
+
1065
+ while um.owner != handler_class && um.super_method
1066
+ um = um.super_method
1067
+ end
1068
+
1069
+ um.source_location&.first
892
1070
  end
893
1071
 
894
1072
  # Check whether a string has balanced curly braces. Used to detect
@@ -118,13 +118,30 @@ module McpAuthorization
118
118
  end
119
119
 
120
120
  # Execute the tool by delegating to the handler.
121
+ #
122
+ # Inputs are filtered against the user's compiled input schema before
123
+ # being passed to the handler, and outputs are filtered against the
124
+ # user's compiled output schema before being returned. Fields and
125
+ # variants gated by +@requires+ that the user lacks permission for
126
+ # never reach the handler (in) or cross the wire (out).
121
127
  #: (?server_context: untyped?, **untyped) -> untyped
122
128
  def call(server_context: nil, **params)
123
129
  raise NotAuthorizedError unless server_context && permitted?(server_context)
124
- handler_instance(server_context).call(**params)
130
+ filtered = McpAuthorization::RbsSchemaCompiler.filter_input(
131
+ _contract_handler, params, server_context: server_context
132
+ )
133
+ result = handler_instance(server_context).call(**symbolize_keys(filtered))
134
+ McpAuthorization::RbsSchemaCompiler.filter_output(
135
+ _contract_handler, result, server_context: server_context
136
+ )
125
137
  end
126
138
 
127
139
  # Create an anonymous MCP::Tool subclass with this user's schemas baked in.
140
+ #
141
+ # The materialized +call+ enforces the compiled schema at runtime:
142
+ # input params are stripped of unknown or permission-gated fields
143
+ # before reaching the handler, and the handler's return value is
144
+ # projected onto the user's output schema before being serialized.
128
145
  #: (untyped) -> Class?
129
146
  def materialize_for(server_context)
130
147
  defn = to_mcp_definition(server_context: server_context)
@@ -132,6 +149,7 @@ module McpAuthorization
132
149
 
133
150
  handler = _contract_handler
134
151
  ctx = server_context
152
+ symbolize = method(:symbolize_keys)
135
153
 
136
154
  Class.new(MCP::Tool) do
137
155
  tool_name defn[:name]
@@ -142,12 +160,31 @@ module McpAuthorization
142
160
 
143
161
  define_singleton_method(:call) do |server_context: nil, **params|
144
162
  effective_ctx = server_context || ctx
145
- result = handler.new(server_context: effective_ctx).call(**params)
146
- MCP::Tool::Response.new([ { type: "text", text: result.to_json } ])
163
+ filtered_params = McpAuthorization::RbsSchemaCompiler.filter_input(
164
+ handler, params, server_context: effective_ctx
165
+ )
166
+ raw = handler.new(server_context: effective_ctx).call(**symbolize.call(filtered_params))
167
+ result = McpAuthorization::RbsSchemaCompiler.filter_output(
168
+ handler, raw, server_context: effective_ctx
169
+ )
170
+ response_args = [{ type: "text", text: result.to_json }]
171
+ if defn[:outputSchema]
172
+ MCP::Tool::Response.new(response_args, structured_content: result)
173
+ else
174
+ MCP::Tool::Response.new(response_args)
175
+ end
147
176
  end
148
177
  end
149
178
  end
150
179
 
180
+ # Normalize hash keys to symbols so projection output can be splatted
181
+ # into a handler's kwarg-only +#call+ signature.
182
+ #: (Hash[untyped, untyped]) -> Hash[Symbol, untyped]
183
+ def symbolize_keys(hash)
184
+ return {} unless hash.is_a?(Hash)
185
+ hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
186
+ end
187
+
151
188
  private
152
189
 
153
190
  #: (**untyped) -> void
@@ -1,3 +1,3 @@
1
1
  module McpAuthorization
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp_authorization
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - AndyGauge
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-02 00:00:00.000000000 Z
11
+ date: 2026-05-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -74,6 +74,7 @@ executables: []
74
74
  extensions: []
75
75
  extra_rdoc_files: []
76
76
  files:
77
+ - CHANGELOG.md
77
78
  - LICENSE
78
79
  - README.md
79
80
  - app/controllers/mcp_authorization/mcp_controller.rb