mcp_authorization 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 20c6e18176da825d9b0e7bcf23a46962bfd56fe35feb9a50153eaf2596abf350
4
+ data.tar.gz: 61aff70044f8363aff6f36978653f9df161c99fc762e31640e739576048863ff
5
+ SHA512:
6
+ metadata.gz: e3777b677db7e5bb447e7a7a574d7328e180007eb179165ff74ede8a80a7f93390989a9bae0869314d86754ad24d4c15cb89a082a6323de33f6327d6696c897f
7
+ data.tar.gz: bfa8da4b876faa556e8e51aeb3c9dd4fa948295eeab6d1ddb3017fa9ddea6c3dbbe20d13056fa5bc8ac08f18f983b2ea1b437e76efc622853e8333bdfac13bf1
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fountain (onboardiq)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,552 @@
1
+ # mcp_authorization
2
+
3
+ Rails engine for serving MCP tools with per-request schema discrimination compiled from RBS type annotations.
4
+
5
+ Add it to your Gemfile and your Rails app speaks [MCP](https://modelcontextprotocol.io). Write `@rbs type` comments in plain Ruby service classes, tag fields and variants with `@requires(:flag)`, and the gem compiles tailored JSON Schema per request. The type definitions are the authorization policy.
6
+
7
+ ## Three layers of authorization
8
+
9
+ The gem gives you three independent controls over what each user sees:
10
+
11
+ | Layer | Mechanism | Effect |
12
+ |---|---|---|
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` |
16
+
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
+
19
+ ## Install
20
+
21
+ ```ruby
22
+ # Gemfile
23
+ gem "mcp_authorization"
24
+ ```
25
+
26
+ ```sh
27
+ bundle install
28
+ ```
29
+
30
+ Routes install automatically at `/mcp`. No `mount` needed.
31
+
32
+ ## Configuration
33
+
34
+ ```ruby
35
+ # config/initializers/mcp_authorization.rb
36
+ McpAuthorization.configure do |config|
37
+ config.server_name = "my-app"
38
+ config.server_version = "1.0.0"
39
+
40
+ # Build a context from each MCP request.
41
+ # Return anything that responds to .current_user.can?(symbol).
42
+ config.context_builder = ->(request) {
43
+ user = User.authenticate(request.headers["Authorization"])
44
+ OpenStruct.new(current_user: user)
45
+ }
46
+ end
47
+ ```
48
+
49
+ ### Options
50
+
51
+ | Option | Default | Description |
52
+ |---|---|---|
53
+ | `server_name` | `"mcp-authorization"` | Name in MCP handshake |
54
+ | `server_version` | `"1.0.0"` | Version in MCP handshake |
55
+ | `mount_path` | `"/mcp"` | URL prefix for MCP endpoints |
56
+ | `default_domain` | `"default"` | Domain when no `:domain` segment in path |
57
+ | `tool_paths` | `["app/mcp"]` | Directories where tool classes live (relative to Rails.root) |
58
+ | `shared_type_paths` | `["sig/shared"]` | Directories where shared `.rbs` type files live |
59
+ | `context_builder` | *required* | `(request) -> context` |
60
+ | `cli_context_builder` | `nil` | `(domain:, role:) -> context` for rake tasks |
61
+
62
+ ## The contract
63
+
64
+ The gem has two opinions about your app:
65
+
66
+ ```ruby
67
+ context.current_user.can?(:symbol) # => true/false (required)
68
+ context.current_user.default_for(:symbol) # => value | nil (optional)
69
+ ```
70
+
71
+ `can?` gates visibility -- fields, variants, and entire tools. `default_for` populates JSON Schema `default` values from the current user's context. The symbols can mean anything:
72
+
73
+ ```ruby
74
+ current_user.can?(:manage_workflows) # permission
75
+ current_user.can?(:backward_routing) # feature flag
76
+ current_user.can?(:enterprise_plan) # plan tier
77
+ current_user.can?(:experiment_v2) # A/B test
78
+
79
+ current_user.default_for(:timezone) # => "America/Chicago"
80
+ current_user.default_for(:locale) # => "en-US"
81
+ ```
82
+
83
+ `default_for` is optional. If you don't use `@default_for` tags, you don't need it. When present, it's a simple case statement -- no metaprogramming:
84
+
85
+ ```ruby
86
+ def default_for(key)
87
+ case key
88
+ when :timezone then timezone
89
+ when :locale then locale
90
+ end
91
+ end
92
+ ```
93
+
94
+ ## Quick example
95
+
96
+ ### 1. Define shared types
97
+
98
+ Define reusable types as `.rbs` files. These are plain RBS -- no comment markers.
99
+
100
+ ```rbs
101
+ # sig/shared/error.rbs
102
+ type error_code = "not_found"
103
+ | "invalid_transition"
104
+ | "already_at_stage"
105
+
106
+ type error = {
107
+ success: false,
108
+ error: { code: error_code, message: String, hint: String }
109
+ }
110
+ ```
111
+
112
+ ```rbs
113
+ # sig/shared/applicant.rbs
114
+ type applicant = {
115
+ id: String,
116
+ name: String,
117
+ current_stage: String,
118
+ applied_at: String
119
+ }
120
+ ```
121
+
122
+ ### 2. Define a handler
123
+
124
+ A handler includes `McpAuthorization::DSL`, imports shared types, and defines its own types. The `#:` annotation on `def call` is the input schema -- tag params with `@requires` to control who sees them.
125
+
126
+ ```ruby
127
+ # app/service/workflows/advance_step.rb
128
+ module Workflows
129
+ class AdvanceStep
130
+ # @rbs import error
131
+
132
+ include McpAuthorization::DSL
133
+
134
+ # @rbs type success = {
135
+ # success: true,
136
+ # applicant_id: String,
137
+ # current_stage: String
138
+ # }
139
+
140
+ # @rbs type rerouted_success = {
141
+ # success: true,
142
+ # applicant_id: String,
143
+ # previous_stage: String,
144
+ # current_stage: String,
145
+ # audit_trail: Array[String]
146
+ # }
147
+
148
+ # @rbs type output = success
149
+ # | rerouted_success @requires(:backward_routing)
150
+ # | error
151
+
152
+ def description
153
+ if can?(:backward_routing)
154
+ "Advance an applicant to any stage, or reroute them backward."
155
+ else
156
+ "Advance an applicant to the next stage."
157
+ end
158
+ end
159
+
160
+ #: (
161
+ #: applicant_id: String,
162
+ #: workflow_id: String,
163
+ #: ?stage_id: String? @requires(:backward_routing),
164
+ #: ?reason: String? @requires(:backward_routing)
165
+ #: ) -> Hash[Symbol, untyped]
166
+ def call(applicant_id:, workflow_id:, stage_id: nil, reason: nil)
167
+ # your logic here
168
+ end
169
+ end
170
+ end
171
+ ```
172
+
173
+ ### 3. Declare a tool
174
+
175
+ ```ruby
176
+ # app/mcp/workflows/advance_step_tool.rb
177
+ module Workflows
178
+ class AdvanceStepTool < McpAuthorization::Tool
179
+ tool_name "advance_step"
180
+ authorization :manage_workflows
181
+ not_destructive!
182
+ tags "operator"
183
+ dynamic_contract Workflows::AdvanceStep
184
+ end
185
+ end
186
+ ```
187
+
188
+ ### 4. See the difference
189
+
190
+ A user **without** `:backward_routing`:
191
+
192
+ ```
193
+ advance_step — "Advance an applicant to the next stage."
194
+ input: applicant_id, workflow_id
195
+ output: success | error
196
+ ```
197
+
198
+ A user **with** `:backward_routing`:
199
+
200
+ ```
201
+ advance_step — "Advance an applicant to any stage, or reroute them backward."
202
+ input: applicant_id, workflow_id, stage_id, reason
203
+ output: success | rerouted_success | error
204
+ ```
205
+
206
+ Same tool, same endpoint. The feature flag shapes the schema.
207
+
208
+ ## Handler interface
209
+
210
+ A handler includes `McpAuthorization::DSL` and implements two methods:
211
+
212
+ | Method | Purpose |
213
+ |---|---|
214
+ | `description` | Tool description shown to the MCP client |
215
+ | `call(**params)` | Execute the tool and return a result |
216
+
217
+ The DSL mixin provides `initialize(server_context:)`, `server_context`, and `can?(:flag)`.
218
+
219
+ The input schema is inferred from the `#:` annotation on `def call`. The output schema comes from `@rbs type output`. No separate schema definition needed.
220
+
221
+ ## `@requires` rules
222
+
223
+ **On input params** -- the param is excluded from the input schema when `can?` returns false. Tag them in the `#:` annotation above `def call`:
224
+
225
+ ```ruby
226
+ #: (
227
+ #: query: String,
228
+ #: ?force: bool @requires(:admin),
229
+ #: ?include_deleted: bool @requires(:admin)
230
+ #: ) -> Hash[Symbol, untyped]
231
+ def call(query:, force: false, include_deleted: false)
232
+ ```
233
+
234
+ **On output variants** -- the variant is excluded from the `oneOf`:
235
+
236
+ ```ruby
237
+ # @rbs type output = public_result
238
+ # | admin_result @requires(:admin)
239
+ # | error
240
+ ```
241
+
242
+ Untagged params and variants are always included.
243
+
244
+ ## Shared types
245
+
246
+ Define reusable types as `.rbs` files in `sig/shared/` (configurable via `shared_type_paths`):
247
+
248
+ ```rbs
249
+ # sig/shared/pagination.rbs
250
+ type pagination = {
251
+ page: Integer,
252
+ per_page: Integer,
253
+ total: Integer
254
+ }
255
+ ```
256
+
257
+ Import them in any handler:
258
+
259
+ ```ruby
260
+ # @rbs import pagination
261
+ # @rbs import error
262
+
263
+ # @rbs type success = {
264
+ # success: true,
265
+ # items: Array[String],
266
+ # pagination: pagination
267
+ # }
268
+
269
+ # @rbs type output = success | error
270
+ ```
271
+
272
+ The compiler loads `sig/shared/pagination.rbs` and `sig/shared/error.rbs`, parses their type definitions, and merges them into the handler's type map. The handler's own `@rbs type` definitions override on conflict.
273
+
274
+ Shared types define **shapes**. Authorization (`@requires`) stays on the handler -- it's a local policy decision, not a property of the type itself.
275
+
276
+ ## Tool DSL
277
+
278
+ ```ruby
279
+ class MyTool < McpAuthorization::Tool
280
+ tool_name "my_tool"
281
+ authorization :some_flag # tool hidden when can?(:some_flag) is false
282
+ tags "recruiting", "operations" # which domains this tool appears in
283
+ read_only! # MCP annotation hints
284
+ dynamic_contract MyService # handler class
285
+ end
286
+ ```
287
+
288
+ | Method | Purpose |
289
+ |---|---|
290
+ | `tool_name "name"` | MCP tool name |
291
+ | `authorization :sym` | Tool-level visibility gate. Omit for public tools. |
292
+ | `tags "domain1", ...` | Domain(s) this tool appears under. Defaults to `["default"]`. |
293
+ | `dynamic_contract HandlerClass` | Handler providing description, schemas, and execution |
294
+ | `read_only!` | Annotation: tool only reads data |
295
+ | `not_destructive!` | Annotation: tool does not destroy data |
296
+ | `destructive!` | Annotation: tool may destroy data |
297
+ | `idempotent!` | Annotation: multiple calls have same effect |
298
+ | `open_world!` | Annotation: tool may access external services |
299
+ | `closed_world!` | Annotation: tool stays within the system |
300
+
301
+ Tools self-register when loaded. Put them anywhere under `tool_paths` (default: `app/mcp/`).
302
+
303
+ ## Contract validation
304
+
305
+ If a handler is missing required methods or schema definitions, the gem raises an `ArgumentError` on first request with a full diagnostic:
306
+
307
+ ```
308
+ MyHandler does not satisfy the McpAuthorization handler contract.
309
+
310
+ Problems:
311
+ - missing instance method #call
312
+ - missing instance method #description
313
+ - missing output schema (define # @rbs type output = variant1 | variant2 | ...)
314
+
315
+ A handler class should look like:
316
+
317
+ class MyHandler
318
+ include McpAuthorization::DSL
319
+
320
+ # @rbs type output = success | error
321
+
322
+ def description
323
+ "What this tool does"
324
+ end
325
+
326
+ #: (name: String, ?force: bool @requires(:admin)) -> Hash[Symbol, untyped]
327
+ def call(name:, force: false)
328
+ # ...
329
+ end
330
+ end
331
+ ```
332
+
333
+ ## Multi-domain routing
334
+
335
+ ```
336
+ POST /mcp/operator -> tools tagged "operator"
337
+ POST /mcp/recruiting -> tools tagged "recruiting"
338
+ POST /mcp -> tools tagged with default_domain
339
+ ```
340
+
341
+ Tag a tool with multiple domains to make it available in each:
342
+
343
+ ```ruby
344
+ tags "operator", "recruiting"
345
+ ```
346
+
347
+ ## RBS type syntax
348
+
349
+ The `@rbs type` comments compile to JSON Schema:
350
+
351
+ ```ruby
352
+ # Primitives
353
+ # @rbs type x = String -> { "type": "string" }
354
+ # @rbs type x = Integer -> { "type": "integer" }
355
+ # @rbs type x = Float -> { "type": "number" }
356
+ # @rbs type x = bool -> { "type": "boolean" }
357
+ # @rbs type x = true -> { "type": "boolean", "const": true }
358
+ # @rbs type x = false -> { "type": "boolean", "const": false }
359
+
360
+ # String enums
361
+ # @rbs type status = "pending"
362
+ # | "active"
363
+ # | "closed"
364
+
365
+ # Records
366
+ # @rbs type result = {
367
+ # success: bool,
368
+ # message: String,
369
+ # count?: Integer
370
+ # }
371
+ # (count? is optional -- excluded from "required")
372
+
373
+ # Arrays
374
+ # @rbs type items = Array[String]
375
+
376
+ # Type references (resolved from local types and imports)
377
+ # @rbs type input = { id: String, status: status }
378
+ ```
379
+
380
+ ### Constraint and annotation tags
381
+
382
+ Tag any field in a `#:` annotation or `@rbs type` record to add JSON Schema constraints. Tags are written as `@tag(value)` after the type:
383
+
384
+ ```ruby
385
+ #: (
386
+ #: name: String @min(1) @max(100),
387
+ #: email: String @format(email),
388
+ #: age: Integer @min(0) @max(150),
389
+ #: score: Float @exclusive_min(0) @exclusive_max(1.0),
390
+ #: tags: Array[String] @min(1) @max(10) @unique(),
391
+ #: quantity: Integer @multiple_of(5),
392
+ #: ?timezone: String @default_for(:timezone),
393
+ #: ?stage_id: String? @requires(:backward_routing) @depends_on(:workflow_id)
394
+ #: ) -> Hash[Symbol, untyped]
395
+ ```
396
+
397
+ **Value constraints:**
398
+
399
+ | Tag | Applies to | JSON Schema |
400
+ |---|---|---|
401
+ | `@min(n)` | String, Integer, Float, Array | `minLength`, `minimum`, or `minItems` |
402
+ | `@max(n)` | String, Integer, Float, Array | `maxLength`, `maximum`, or `maxItems` |
403
+ | `@exclusive_min(n)` | Integer, Float | `exclusiveMinimum` |
404
+ | `@exclusive_max(n)` | Integer, Float | `exclusiveMaximum` |
405
+ | `@multiple_of(n)` | Integer, Float | `multipleOf` |
406
+ | `@pattern(regex)` | String | `pattern` |
407
+ | `@format(name)` | String | `format` (e.g. `email`, `uri`, `date-time`) |
408
+ | `@unique()` | Array | `uniqueItems: true` |
409
+
410
+ **Metadata:**
411
+
412
+ | Tag | JSON Schema | Purpose |
413
+ |---|---|---|
414
+ | `@desc(text)` | `description` | Field description — also used as tool-chaining hints for MCP clients |
415
+ | `@title(text)` | `title` | Human-readable title |
416
+ | `@default(value)` | `default` | Default value (`true`, `false`, `nil`, numbers, strings) |
417
+ | `@default_for(:key)` | `default` | Dynamic default resolved via `current_user.default_for(:key)` |
418
+ | `@example(value)` | `examples` | Example value (repeat for multiple: `@example(foo) @example(bar)`) |
419
+ | `@deprecated()` | `deprecated: true` | Mark as deprecated |
420
+ | `@read_only()` | `readOnly: true` | Read-only field |
421
+ | `@write_only()` | `writeOnly: true` | Write-only field |
422
+
423
+ **Authorization:**
424
+
425
+ | Tag | Purpose |
426
+ |---|---|
427
+ | `@requires(:flag)` | Field/variant excluded when `can?(:flag)` is false |
428
+ | `@depends_on(:field)` | Emits `dependentRequired` — field only required when parent field is present |
429
+
430
+ **Niche:**
431
+
432
+ | Tag | JSON Schema |
433
+ |---|---|
434
+ | `@closed()` / `@strict()` | `additionalProperties: false` |
435
+ | `@media_type(type)` | `contentMediaType` (e.g. `application/json`) |
436
+ | `@encoding(enc)` | `contentEncoding` (e.g. `base64`) |
437
+
438
+ The `@min` / `@max` tags are type-aware: on strings they emit `minLength`/`maxLength`, on numbers `minimum`/`maximum`, and on arrays `minItems`/`maxItems`.
439
+
440
+ ### Multiline `#:` annotations
441
+
442
+ The `#:` annotation above `def call` supports multiple lines. Each line starts with `#:`:
443
+
444
+ ```ruby
445
+ #: (
446
+ #: applicant_id: String @desc(Use fetch_latest_applicant to find this),
447
+ #: workflow_id: String,
448
+ #: ?stage_id: String? @requires(:backward_routing) @depends_on(:workflow_id),
449
+ #: ?reason: String? @requires(:backward_routing)
450
+ #: ) -> Hash[Symbol, untyped]
451
+ def call(applicant_id:, workflow_id:, stage_id: nil, reason: nil)
452
+ ```
453
+
454
+ 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.
455
+
456
+ ### `@depends_on` for conditional required fields
457
+
458
+ Use `@depends_on(:parent_field)` to express that a field is only required when another field is present. This emits JSON Schema `dependentRequired`:
459
+
460
+ ```ruby
461
+ #: (
462
+ #: workflow_id: String,
463
+ #: ?stage_id: String? @requires(:backward_routing) @depends_on(:workflow_id),
464
+ #: ?reason: String? @requires(:backward_routing) @depends_on(:stage_id)
465
+ #: ) -> Hash[Symbol, untyped]
466
+ ```
467
+
468
+ When `:backward_routing` is enabled, the schema includes:
469
+ ```json
470
+ {
471
+ "dependentRequired": {
472
+ "workflow_id": ["stage_id"],
473
+ "stage_id": ["reason"]
474
+ }
475
+ }
476
+ ```
477
+
478
+ ### Discriminated unions
479
+
480
+ Literal `true` / `false` types become `"const"` values in JSON Schema:
481
+
482
+ ```ruby
483
+ # @rbs type success = { success: true, data: String }
484
+ # @rbs type error = { success: false, code: String }
485
+ # @rbs type output = success | error
486
+ ```
487
+
488
+ MCP clients can narrow on `success: const true` vs `success: const false` -- the same pattern as TypeScript discriminated unions.
489
+
490
+ ## Performance
491
+
492
+ Source files are parsed once at boot and cached in memory. Only `@requires` filtering runs per request (hash lookups and `can?` calls). In development, caches are cleared automatically on file change via the Rails reloader.
493
+
494
+ ## Development
495
+
496
+ ### Live reload
497
+
498
+ In development mode, the gem wires into the Rails reloader. Edit an `@rbs type` annotation, save, and the next MCP request returns the updated schema. No server restart needed.
499
+
500
+ ### Rake tasks
501
+
502
+ ```sh
503
+ # List tools visible to a given role
504
+ bundle exec rake "mcp:tools[operator,manager]"
505
+
506
+ # Print Claude Code / Claude Desktop config JSON
507
+ bundle exec rake "mcp:claude[operator,manager]"
508
+
509
+ # Launch MCP Inspector (requires npx)
510
+ bundle exec rake "mcp:inspect[operator,manager]"
511
+ ```
512
+
513
+ Rake tasks require `cli_context_builder`:
514
+
515
+ ```ruby
516
+ config.cli_context_builder = ->(domain:, role:) {
517
+ user = User.new(role: role, permissions: ROLE_PERMISSIONS[role])
518
+ OpenStruct.new(current_user: user)
519
+ }
520
+ ```
521
+
522
+ ## How it works
523
+
524
+ 1. MCP client sends a request to `/mcp/:domain`
525
+ 2. Engine calls your `context_builder` with the request
526
+ 3. `ToolRegistry` filters tools by domain tag and `authorization` gate (`can?` check)
527
+ 4. `RbsSchemaCompiler` loads shared types from `# @rbs import` declarations
528
+ 5. Input schema is compiled from the `#:` annotation on `def call`, filtering `@requires` params
529
+ 6. Output schema is compiled from `@rbs type output`, filtering `@requires` variants
530
+ 7. MCP client receives tool definitions with schemas tailored to the current user
531
+
532
+ Different users hitting the same endpoint can see different tools, different descriptions, different input fields, and different output shapes.
533
+
534
+ ## Stateless transport and schema lifetime
535
+
536
+ The gem uses the MCP SDK's Streamable HTTP transport in **stateless mode**. Each HTTP request creates a fresh `MCP::Server`, materialized with tools filtered and shaped for the current user. There is no persistent session or SSE stream between requests.
537
+
538
+ This is a deliberate choice. The gem's value is per-request schema discrimination -- the same endpoint returns different JSON Schema depending on who's asking. A stateful session would bake the tool list at connection time, meaning permission changes during a session would serve stale schemas until reconnect.
539
+
540
+ In practice this doesn't matter because MCP clients call `tools/list` once -- at the start of a conversation or when manually refreshed. The schema returned at that point is what the client (and the LLM behind it) uses for the entire conversation. Tool calls made later in the conversation still go through `context_builder` and the `authorization` gate, so a revoked permission results in a rejected call, not a leaked capability.
541
+
542
+ The tradeoff: stateless mode cannot send `notifications/tools/list_changed` or use `report_progress` during long-running tool calls, since both require an open SSE stream. For most use cases this is the right default -- schemas that reflect the current user's permissions at conversation start, enforced again at call time.
543
+
544
+ ## Requirements
545
+
546
+ - Ruby >= 3.1
547
+ - Rails >= 7.0
548
+ - [mcp](https://rubygems.org/gems/mcp) ~> 0.10
549
+
550
+ ## License
551
+
552
+ MIT
@@ -0,0 +1,37 @@
1
+ module McpAuthorization
2
+ class McpController < ActionController::Base
3
+ skip_forgery_protection
4
+
5
+ # POST/GET/DELETE /mcp/:domain
6
+ #: () -> void
7
+ def handle
8
+ server_context = build_server_context
9
+ tools = McpAuthorization::ToolRegistry.tool_classes_for(
10
+ domain: params[:domain],
11
+ server_context: server_context
12
+ )
13
+
14
+ server = MCP::Server.new(
15
+ name: McpAuthorization.config.server_name,
16
+ version: McpAuthorization.config.server_version,
17
+ tools: tools,
18
+ server_context: server_context
19
+ )
20
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
21
+ server.transport = transport
22
+
23
+ status, headers, body = transport.handle_request(request)
24
+ headers.each { |k, v| response.set_header(k, v) }
25
+ render json: body.first, status: status
26
+ end
27
+
28
+ private
29
+
30
+ #: () -> untyped
31
+ def build_server_context
32
+ builder = McpAuthorization.config.context_builder
33
+ raise "McpAuthorization.config.context_builder must be configured" unless builder
34
+ builder.call(request)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,81 @@
1
+ module McpAuthorization
2
+ # Holds gem-wide settings. A single global instance is created lazily by
3
+ # McpAuthorization.configuration and configured in a Rails initializer:
4
+ #
5
+ # McpAuthorization.configure do |c|
6
+ # c.server_name = "my-app"
7
+ # c.server_version = MyApp::VERSION
8
+ # c.tool_paths = %w[app/mcp]
9
+ # c.context_builder = ->(request) { ... }
10
+ # end
11
+ #
12
+ # == Required settings
13
+ #
14
+ # +context_builder+ must be set before the first MCP request. Everything
15
+ # else has sensible defaults.
16
+ #
17
+ # == The context contract
18
+ #
19
+ # Both +context_builder+ and +cli_context_builder+ must return an object
20
+ # whose +current_user+ responds to:
21
+ #
22
+ # current_user.can?(:symbol) # required — gates field/tool visibility
23
+ # current_user.default_for(:symbol) # optional — populates @default_for tags
24
+ #
25
+ class Configuration
26
+ # Server name reported in the MCP +initialize+ handshake.
27
+ #: String
28
+ attr_accessor :server_name
29
+
30
+ # Server version reported in the MCP +initialize+ handshake.
31
+ #: String
32
+ attr_accessor :server_version
33
+
34
+ # Directories (relative to +Rails.root+) that contain tool classes.
35
+ # Added to +autoload_paths+ and +eager_load_paths+ by the Engine.
36
+ #: Array[String]
37
+ attr_accessor :tool_paths
38
+
39
+ # Directories (relative to +Rails.root+) where shared +.rbs+ type
40
+ # files live. Used by RbsSchemaCompiler to resolve +# @rbs import+.
41
+ #: Array[String]
42
+ attr_accessor :shared_type_paths
43
+
44
+ # Domain name used when the request URL has no +:domain+ segment.
45
+ #: String
46
+ attr_accessor :default_domain
47
+
48
+ # URL prefix where the Engine mounts its routes.
49
+ #: String
50
+ attr_accessor :mount_path
51
+
52
+ # Lambda that builds a server context from a Rack request.
53
+ # The returned object must satisfy the context contract above.
54
+ #: (^(untyped) -> untyped)?
55
+ attr_accessor :context_builder
56
+
57
+ # Lambda that builds a server context for CLI/rake usage.
58
+ # Same duck-type contract as +context_builder+.
59
+ #: (^(domain: String, role: String) -> untyped)?
60
+ attr_accessor :cli_context_builder
61
+
62
+ # When true, strips JSON Schema keywords that cause 400 errors in
63
+ # Anthropic's strict tool use mode (minLength, maximum, maxItems, etc.)
64
+ # and adds additionalProperties: false to all objects.
65
+ #: bool
66
+ attr_accessor :strict_schema
67
+
68
+ #: () -> void
69
+ def initialize
70
+ @server_name = "mcp-authorization"
71
+ @server_version = "1.0.0"
72
+ @tool_paths = %w[app/mcp]
73
+ @shared_type_paths = %w[sig/shared]
74
+ @default_domain = "default"
75
+ @mount_path = "/mcp"
76
+ @context_builder = nil
77
+ @cli_context_builder = nil
78
+ @strict_schema = false
79
+ end
80
+ end
81
+ end