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 +7 -0
- data/LICENSE +21 -0
- data/README.md +552 -0
- data/app/controllers/mcp_authorization/mcp_controller.rb +37 -0
- data/lib/mcp_authorization/configuration.rb +81 -0
- data/lib/mcp_authorization/dsl.rb +64 -0
- data/lib/mcp_authorization/engine.rb +68 -0
- data/lib/mcp_authorization/rbs_schema_compiler.rb +1015 -0
- data/lib/mcp_authorization/tool.rb +245 -0
- data/lib/mcp_authorization/tool_registry.rb +89 -0
- data/lib/mcp_authorization/version.rb +3 -0
- data/lib/mcp_authorization.rb +57 -0
- data/lib/tasks/mcp_authorization.rake +99 -0
- metadata +112 -0
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
|