cuboid 0.3.6 → 0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +195 -0
- data/cuboid.gemspec +4 -0
- data/lib/cuboid/application.rb +84 -3
- data/lib/cuboid/mcp/auth.rb +99 -0
- data/lib/cuboid/mcp/core_tools.rb +318 -0
- data/lib/cuboid/mcp/live.rb +166 -0
- data/lib/cuboid/mcp/server.rb +426 -0
- data/lib/cuboid/option_groups/paths.rb +40 -0
- data/lib/cuboid/processes/executables/base.rb +37 -0
- data/lib/cuboid/processes/executables/mcp.rb +20 -0
- data/lib/cuboid/processes/instances.rb +9 -1
- data/lib/cuboid/processes/manager.rb +22 -1
- data/lib/cuboid/rest/server/instance_helpers.rb +21 -70
- data/lib/cuboid/rest/server/routes/instances.rb +1 -3
- data/lib/cuboid/rest/server.rb +1 -1
- data/lib/cuboid/rpc/server/agent.rb +6 -1
- data/lib/cuboid/rpc/server/instance.rb +32 -0
- data/lib/cuboid/server/instance_helpers.rb +131 -0
- data/lib/version +1 -1
- data/spec/cuboid/mcp/auth_spec.rb +179 -0
- data/spec/cuboid/mcp/server_spec.rb +346 -0
- data/spec/cuboid/rest/server_spec.rb +3 -4
- data/spec/support/shared/option_group.rb +11 -1
- metadata +26 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 51212d2d1adc9ffb33f4838212d9bbddcb88466a12e8e1b5522a2baff6b233f0
|
|
4
|
+
data.tar.gz: e4721af5a566266882fea5320129f58d01c667a03956d79300a3b3a3b652edc1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c2cec6fd1a808c0fc1473a61a7f93a0e3ed7a974562df6c44b55e4e450461cef29320d82a252de344eb1d21ceec94f6e4e8bd7bfd7f7301ccb05f15561c3255e
|
|
7
|
+
data.tar.gz: c7f15a7207fa170a0874c9931a75533bb6ba37dda9a0b2a0b2946a019ea90f16258b5051ab523fbc5ca59795c0f948ff18fac1434c55ca7ae7b860198576b94e
|
data/README.md
CHANGED
|
@@ -62,6 +62,17 @@ framework:
|
|
|
62
62
|
* `instance_service_for( Symbol, Class )` -- Adds a custom _**Instance**_ RPC API.
|
|
63
63
|
* `rest_service_for( Symbol, Module )` -- Hooks-up to the _**REST**_ service to provide a custom REST API.
|
|
64
64
|
* `agent_service_for( Symbol, Class )` -- Hooks-up to the _**Agent**_ to provide a custom RPC API.
|
|
65
|
+
* `mcp_service_for( Symbol, Module )` -- Registers an _**MCP**_ service module
|
|
66
|
+
(its `tools` / `prompts` / `resources` / `read_resource` are exposed at
|
|
67
|
+
`/mcp` and routed per-instance via `instance_id`).
|
|
68
|
+
* `mcp_app_tool( MCP::Tool )` -- Ships an app-level _**MCP**_ tool at the
|
|
69
|
+
top-level `/mcp` endpoint, alongside `list_instances` / `spawn_instance`
|
|
70
|
+
/ `kill_instance` -- no `instance_id` required (catalogue / metadata
|
|
71
|
+
tools the client may want to consult before spawning anything).
|
|
72
|
+
* `mcp_authenticate_with { |bearer_token| ... }` -- Bearer-token
|
|
73
|
+
validator for the _**MCP**_ transport; block returns a truthy
|
|
74
|
+
principal on success, `nil` to reject. Without one the server
|
|
75
|
+
accepts unauthenticated traffic.
|
|
65
76
|
* `serialize_with( Module )` -- A serializer to be used for:
|
|
66
77
|
* `#options`
|
|
67
78
|
* `Report#data`
|
|
@@ -163,6 +174,76 @@ management for the rest of the entities.
|
|
|
163
174
|
Each _**Application**_ can extend upon this and expose an API via its _**REST**_
|
|
164
175
|
service's interface.
|
|
165
176
|
|
|
177
|
+
### MCP
|
|
178
|
+
|
|
179
|
+
An [Model Context Protocol][mcp] server is also available, allowing AI clients
|
|
180
|
+
(Claude Desktop / Code, Cursor, Continue, anything that speaks MCP) to drive
|
|
181
|
+
_**Applications**_ end-to-end over a single Streamable-HTTP endpoint
|
|
182
|
+
(`POST /mcp` for request/response, `GET /mcp` for the server-initiated SSE
|
|
183
|
+
notifications channel).
|
|
184
|
+
|
|
185
|
+
Spin up the MCP server with:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
MyApp.spawn(:mcp, ssl: { ... })
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
[mcp]: https://modelcontextprotocol.io/
|
|
192
|
+
|
|
193
|
+
#### Tools
|
|
194
|
+
|
|
195
|
+
Three tools ship at the top-level endpoint by default
|
|
196
|
+
(`Cuboid::MCP::CoreTools`):
|
|
197
|
+
|
|
198
|
+
| Tool | Required | Returns |
|
|
199
|
+
|------------------|-----------------|-------------------------------|
|
|
200
|
+
| `list_instances` | -- | `{ instances: { <id>: { url } } }` |
|
|
201
|
+
| `spawn_instance` | -- | `{ instance_id, url, live: { notification_method } }` |
|
|
202
|
+
| `kill_instance` | `instance_id` | `{ killed: <id> }` |
|
|
203
|
+
|
|
204
|
+
Per-service tools (registered via `mcp_service_for`) take an `instance_id`
|
|
205
|
+
argument and are dispatched to the matching engine instance. App-level
|
|
206
|
+
catalogue tools (registered via `mcp_app_tool`) live at the top-level
|
|
207
|
+
endpoint without an `instance_id` requirement.
|
|
208
|
+
|
|
209
|
+
#### Live channel
|
|
210
|
+
|
|
211
|
+
Every `spawn_instance` attaches the calling MCP session to a live
|
|
212
|
+
notification stream — every interesting state change inside the engine
|
|
213
|
+
arrives as a JSON-RPC `notifications/<brand>/live` notification on the SSE
|
|
214
|
+
half of the transport, where `<brand>` is derived from the umbrella's
|
|
215
|
+
`shortname` (falling back to `cuboid` for bare-cuboid builds). The spawn
|
|
216
|
+
response carries `live.notification_method` so clients don't have to
|
|
217
|
+
hard-code the brand.
|
|
218
|
+
|
|
219
|
+
Status payloads emitted by the live plugin: `started` (synthetic, on plugin
|
|
220
|
+
attach) → `preparing` → `scanning` → `auditing` → `cleanup` → `done` (or
|
|
221
|
+
`aborted`) → `exited` (synthetic, fired from the live plugin's `at_exit`
|
|
222
|
+
when the engine subprocess actually exits — only after `kill_instance` and
|
|
223
|
+
only on a graceful unwind; SIGKILL skips it).
|
|
224
|
+
|
|
225
|
+
Pass `live: false` to `spawn_instance` to opt out (poll `scan_progress`
|
|
226
|
+
instead) -- recommended for non-MCP integrations and any application that
|
|
227
|
+
disables the engine-side `live` plugin via `validate_options`.
|
|
228
|
+
|
|
229
|
+
#### Auth
|
|
230
|
+
|
|
231
|
+
Authentication is opt-in via `mcp_authenticate_with`. With a registered
|
|
232
|
+
validator the server requires `Authorization: Bearer <token>` on every
|
|
233
|
+
request and returns `401 Unauthorized` otherwise (RFC 6750 --
|
|
234
|
+
`WWW-Authenticate: Bearer realm="MCP", error=…`). The resolved principal is
|
|
235
|
+
stashed at `env['cuboid.mcp.auth']` for downstream middleware.
|
|
236
|
+
|
|
237
|
+
#### Resources & prompts
|
|
238
|
+
|
|
239
|
+
`Cuboid::MCP` also wires the standard `resources/list` / `resources/read`
|
|
240
|
+
and `prompts/list` / `prompts/get` MCP plumbing. Service modules can ship
|
|
241
|
+
markdown / JSON resources (glossaries, options references, presets) and
|
|
242
|
+
prompts (canned operator workflows) via `tools` / `prompts` / `resources`
|
|
243
|
+
/ `read_resource` class methods on the registered handler. The Spectre /
|
|
244
|
+
Apex umbrellas use this to ground AI clients without out-of-band knowledge
|
|
245
|
+
-- the tool / prompt / resource descriptions ARE the docs.
|
|
246
|
+
|
|
166
247
|
## Examples
|
|
167
248
|
|
|
168
249
|
### MyApp
|
|
@@ -263,11 +344,125 @@ sleep 0.1 while sleepers.map(&:busy?).include?( true )
|
|
|
263
344
|
|
|
264
345
|
_You can replace `host1` with `localhost` and run all examples on the same machine._
|
|
265
346
|
|
|
347
|
+
### Driving an Application over MCP
|
|
348
|
+
|
|
349
|
+
`mcp_app_tool` ships a top-level catalogue tool (no `instance_id`);
|
|
350
|
+
`mcp_service_for` ships per-instance tools whose first arg is `instance_id`.
|
|
351
|
+
|
|
352
|
+
`sleeper_mcp.rb`:
|
|
353
|
+
```ruby
|
|
354
|
+
require 'cuboid'
|
|
355
|
+
require 'mcp'
|
|
356
|
+
|
|
357
|
+
# A top-level catalogue tool — no `instance_id` required.
|
|
358
|
+
class Ping < MCP::Tool
|
|
359
|
+
tool_name 'ping'
|
|
360
|
+
description 'Connectivity check; returns "pong".'
|
|
361
|
+
input_schema(properties: {}, type: 'object')
|
|
362
|
+
def self.call( ** )
|
|
363
|
+
MCP::Tool::Response.new([{ type: 'text', text: 'pong' }])
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# A per-instance tool routed to the engine identified by `instance_id`.
|
|
368
|
+
module SleeperMCP
|
|
369
|
+
class HowLong < MCP::Tool
|
|
370
|
+
tool_name 'how_long'
|
|
371
|
+
description "Reports the sleeper's progress."
|
|
372
|
+
input_schema(
|
|
373
|
+
properties: { instance_id: { type: 'string' } },
|
|
374
|
+
required: ['instance_id'],
|
|
375
|
+
type: 'object'
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def self.call( instance_id:, server_context: nil, ** )
|
|
379
|
+
instance = server_context[:instance]
|
|
380
|
+
MCP::Tool::Response.new([{
|
|
381
|
+
type: 'text',
|
|
382
|
+
text: instance.progress.to_json
|
|
383
|
+
}])
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
TOOLS = [HowLong].freeze
|
|
388
|
+
def self.tools; TOOLS; end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
class Sleeper < Cuboid::Application
|
|
392
|
+
mcp_app_tool Ping
|
|
393
|
+
mcp_service_for :sleeper, SleeperMCP
|
|
394
|
+
|
|
395
|
+
def run; sleep options['time']; end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Spawn the MCP server (Streamable HTTP at /mcp on the default RPC port).
|
|
399
|
+
Sleeper.spawn(:mcp)
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
bundle exec ruby sleeper_mcp.rb
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
In another shell, the standard MCP handshake:
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
SID=$(curl -sS -i -X POST http://127.0.0.1:7331/mcp \
|
|
410
|
+
-H 'Content-Type: application/json' \
|
|
411
|
+
-H 'Accept: application/json, text/event-stream' \
|
|
412
|
+
--data '{"jsonrpc":"2.0","id":1,"method":"initialize",
|
|
413
|
+
"params":{"protocolVersion":"2025-06-18",
|
|
414
|
+
"capabilities":{},
|
|
415
|
+
"clientInfo":{"name":"curl","version":"0"}}}' \
|
|
416
|
+
| awk -F': ' '/[Mm]cp-[Ss]ession-[Ii]d/ {gsub(/\r/,"",$2); print $2}')
|
|
417
|
+
|
|
418
|
+
curl -sS -X POST http://127.0.0.1:7331/mcp \
|
|
419
|
+
-H "Mcp-Session-Id: $SID" \
|
|
420
|
+
-H 'Content-Type: application/json' \
|
|
421
|
+
-H 'Accept: application/json, text/event-stream' \
|
|
422
|
+
--data '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
|
423
|
+
|
|
424
|
+
# 1. The catalogue tool — no instance_id needed.
|
|
425
|
+
curl -sS -X POST http://127.0.0.1:7331/mcp \
|
|
426
|
+
-H "Mcp-Session-Id: $SID" -H 'Content-Type: application/json' \
|
|
427
|
+
-H 'Accept: application/json, text/event-stream' \
|
|
428
|
+
--data '{"jsonrpc":"2.0","id":2,"method":"tools/call",
|
|
429
|
+
"params":{"name":"ping","arguments":{}}}'
|
|
430
|
+
# → "pong"
|
|
431
|
+
|
|
432
|
+
# 2. Spawn an instance, then call the per-instance tool.
|
|
433
|
+
SPAWN=$(curl -sS -X POST http://127.0.0.1:7331/mcp \
|
|
434
|
+
-H "Mcp-Session-Id: $SID" -H 'Content-Type: application/json' \
|
|
435
|
+
-H 'Accept: application/json, text/event-stream' \
|
|
436
|
+
--data '{"jsonrpc":"2.0","id":3,"method":"tools/call",
|
|
437
|
+
"params":{"name":"spawn_instance",
|
|
438
|
+
"arguments":{"options":{"time":5},"start":true}}}')
|
|
439
|
+
IID=$(echo "$SPAWN" | sed -n 's/^data: //p' | jq -r '.result.structuredContent.instance_id')
|
|
440
|
+
|
|
441
|
+
curl -sS -X POST http://127.0.0.1:7331/mcp \
|
|
442
|
+
-H "Mcp-Session-Id: $SID" -H 'Content-Type: application/json' \
|
|
443
|
+
-H 'Accept: application/json, text/event-stream' \
|
|
444
|
+
--data "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",
|
|
445
|
+
\"params\":{\"name\":\"how_long\",\"arguments\":{\"instance_id\":\"$IID\"}}}"
|
|
446
|
+
# → progress JSON
|
|
447
|
+
|
|
448
|
+
# 3. Tear down.
|
|
449
|
+
curl -sS -X POST http://127.0.0.1:7331/mcp \
|
|
450
|
+
-H "Mcp-Session-Id: $SID" -H 'Content-Type: application/json' \
|
|
451
|
+
-H 'Accept: application/json, text/event-stream' \
|
|
452
|
+
--data "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",
|
|
453
|
+
\"params\":{\"name\":\"kill_instance\",\"arguments\":{\"instance_id\":\"$IID\"}}}"
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
The `live` channel (SSE on `GET /mcp`) is attached automatically;
|
|
457
|
+
omit it with `live: false` on `spawn_instance`.
|
|
458
|
+
|
|
266
459
|
## Users
|
|
267
460
|
|
|
268
461
|
* [QMap](https://github.com/qadron/qmap) -- A distributed network mapper/security scanner powered by [nmap](http://nmap.org/).
|
|
269
462
|
* [Peplum](https://github.com/peplum/peplum) -- A distributed parallel processing solution -- allows you to build Beowulf
|
|
270
463
|
(or otherwise) clusters and even super-computers.
|
|
464
|
+
* [Spectre Scan](https://try.spectre-scan.sh)
|
|
465
|
+
* [Apex Recon](https://try.apex-recon.sh)
|
|
271
466
|
|
|
272
467
|
## License
|
|
273
468
|
|
data/cuboid.gemspec
CHANGED
|
@@ -52,6 +52,10 @@ Gem::Specification.new do |s|
|
|
|
52
52
|
s.add_dependency 'sinatra', '>= 4.0'
|
|
53
53
|
s.add_dependency 'sinatra-contrib', '>= 4.0'
|
|
54
54
|
|
|
55
|
+
# MCP (Model Context Protocol) server framework — Cuboid::MCP::Server
|
|
56
|
+
# mounts MCP::Server::Transports::StreamableHTTPTransport as a Rack app.
|
|
57
|
+
s.add_dependency 'mcp', '>= 0.15'
|
|
58
|
+
|
|
55
59
|
# RPC client/server implementation.
|
|
56
60
|
s.add_dependency 'toq'
|
|
57
61
|
|
data/lib/cuboid/application.rb
CHANGED
|
@@ -48,8 +48,8 @@ class Application
|
|
|
48
48
|
true
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
# Cleans up the framework; should be called after running the
|
|
52
|
-
# after canceling a running
|
|
51
|
+
# Cleans up the framework; should be called after running the application
|
|
52
|
+
# or after canceling a running instance.
|
|
53
53
|
def clean_up
|
|
54
54
|
return if @cleaned_up
|
|
55
55
|
@cleaned_up = true
|
|
@@ -132,6 +132,81 @@ class Application
|
|
|
132
132
|
@rest_services ||= {}
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# Register an MCP service handler. Mirrors `rest_service_for`:
|
|
136
|
+
# the application gem provides a module/class that exposes a
|
|
137
|
+
# set of tools and Cuboid::MCP::Server mounts them per-instance
|
|
138
|
+
# at `/instances/:instance/<name>` (just like REST mounts
|
|
139
|
+
# rest_service handlers at `/instances/:instance/<name>`).
|
|
140
|
+
#
|
|
141
|
+
# The handler must respond to `.tools`, returning an Array of
|
|
142
|
+
# `MCP::Tool` subclasses. Each tool's `call` receives a
|
|
143
|
+
# `server_context:` Hash containing at least `:instance` —
|
|
144
|
+
# the resolved RPC client for the engine instance the request
|
|
145
|
+
# is targeting.
|
|
146
|
+
#
|
|
147
|
+
# Example:
|
|
148
|
+
#
|
|
149
|
+
# module MyApp::MCPHandler
|
|
150
|
+
# class Ping < ::MCP::Tool
|
|
151
|
+
# tool_name 'ping'
|
|
152
|
+
# description 'Ping the application instance.'
|
|
153
|
+
# def self.call(server_context:, **)
|
|
154
|
+
# server_context[:instance].some_application_method
|
|
155
|
+
# ::MCP::Tool::Response.new([{ type: 'text', text: 'pong' }])
|
|
156
|
+
# end
|
|
157
|
+
# end
|
|
158
|
+
# TOOLS = [Ping].freeze
|
|
159
|
+
# def self.tools; TOOLS; end
|
|
160
|
+
# end
|
|
161
|
+
#
|
|
162
|
+
# class MyApp < Cuboid::Application
|
|
163
|
+
# mcp_service_for :my_service, MCPHandler
|
|
164
|
+
# end
|
|
165
|
+
def mcp_service_for( name, handler )
|
|
166
|
+
mcp_services[name] = handler
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def mcp_services
|
|
170
|
+
@mcp_services ||= {}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Register an `MCP::Tool` subclass to ship at the top-level
|
|
174
|
+
# `/mcp` endpoint, alongside `CoreTools` (`list_instances`,
|
|
175
|
+
# `spawn_instance`, `kill_instance`) — i.e. NOT routed through
|
|
176
|
+
# the per-instance dispatcher and not requiring an
|
|
177
|
+
# `instance_id` argument. Use this for app-level catalog /
|
|
178
|
+
# metadata tools the client may want to consult before
|
|
179
|
+
# spawning anything.
|
|
180
|
+
#
|
|
181
|
+
# Example:
|
|
182
|
+
#
|
|
183
|
+
# class MyApp < Cuboid::Application
|
|
184
|
+
# mcp_app_tool ListChecks
|
|
185
|
+
# end
|
|
186
|
+
def mcp_app_tool( tool_class )
|
|
187
|
+
mcp_app_tools << tool_class
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def mcp_app_tools
|
|
191
|
+
@mcp_app_tools ||= []
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Register a bearer-token validator for the MCP transport. The
|
|
195
|
+
# block receives the token string and should return a truthy
|
|
196
|
+
# principal (typically a User record) on success or nil/false
|
|
197
|
+
# on failure. See Cuboid::MCP::Auth for the request flow.
|
|
198
|
+
#
|
|
199
|
+
# Without a registered validator the auth middleware passes
|
|
200
|
+
# every request through — keeps smoke tests / pre-auth-layer
|
|
201
|
+
# deployments simple.
|
|
202
|
+
def mcp_authenticate_with( &block )
|
|
203
|
+
@mcp_auth_validator = block
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def mcp_auth_validator
|
|
207
|
+
@mcp_auth_validator
|
|
208
|
+
end
|
|
209
|
+
|
|
135
210
|
def agent_service_for( name, service )
|
|
136
211
|
agent_services[name] = service
|
|
137
212
|
end
|
|
@@ -200,6 +275,12 @@ class Application
|
|
|
200
275
|
options.merge( options: { paths: { application: source_location } } ),
|
|
201
276
|
&block
|
|
202
277
|
)
|
|
278
|
+
when :mcp
|
|
279
|
+
return Processes::Manager.spawn(
|
|
280
|
+
:mcp,
|
|
281
|
+
options.merge( options: { paths: { application: source_location } } ),
|
|
282
|
+
&block
|
|
283
|
+
)
|
|
203
284
|
end
|
|
204
285
|
|
|
205
286
|
Processes.const_get( const ).spawn(
|
|
@@ -282,7 +363,7 @@ class Application
|
|
|
282
363
|
#
|
|
283
364
|
# Framework statistics:
|
|
284
365
|
#
|
|
285
|
-
# * `:runtime` --
|
|
366
|
+
# * `:runtime` -- Application runtime in seconds.
|
|
286
367
|
def statistics
|
|
287
368
|
{
|
|
288
369
|
runtime: @start_datetime ? (@finish_datetime || Time.now) - @start_datetime : 0,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module Cuboid
|
|
4
|
+
module MCP
|
|
5
|
+
|
|
6
|
+
# Bearer-token authentication middleware for the MCP transport.
|
|
7
|
+
#
|
|
8
|
+
# Application gems opt in by registering a validator block on their
|
|
9
|
+
# Cuboid::Application subclass:
|
|
10
|
+
#
|
|
11
|
+
# class MyApplication < Cuboid::Application
|
|
12
|
+
# mcp_authenticate_with do |token|
|
|
13
|
+
# # Return truthy (typically the User record) on success,
|
|
14
|
+
# # nil/false on failure.
|
|
15
|
+
# User.find_by( api_token: token )
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# When no validator is registered the middleware passes every request
|
|
20
|
+
# through — useful for smoke tests and for transports terminated
|
|
21
|
+
# behind another auth layer (e.g. a reverse proxy).
|
|
22
|
+
#
|
|
23
|
+
# On success the resolved validator return value is stashed in
|
|
24
|
+
# `env['cuboid.mcp.auth']` so downstream middleware / tooling can
|
|
25
|
+
# look up the authenticated principal.
|
|
26
|
+
#
|
|
27
|
+
# Failure modes follow RFC 6750 — Bearer Token Usage:
|
|
28
|
+
# * Missing / malformed Authorization header → 401 + WWW-Authenticate
|
|
29
|
+
# * Token rejected by the validator → 401 + WWW-Authenticate
|
|
30
|
+
class Auth
|
|
31
|
+
|
|
32
|
+
REALM = 'MCP'.freeze
|
|
33
|
+
|
|
34
|
+
# Standard Bearer-prefix per RFC 6750 §2.1, case-insensitive.
|
|
35
|
+
BEARER_PREFIX = /\ABearer\s+/i
|
|
36
|
+
|
|
37
|
+
def initialize( app )
|
|
38
|
+
@app = app
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call( env )
|
|
42
|
+
validator = current_validator
|
|
43
|
+
return @app.call( env ) if validator.nil?
|
|
44
|
+
|
|
45
|
+
token = extract_token( env )
|
|
46
|
+
return unauthorized( 'invalid_request' ) if token.nil?
|
|
47
|
+
|
|
48
|
+
principal = safe_validate( validator, token )
|
|
49
|
+
return unauthorized( 'invalid_token' ) if !principal
|
|
50
|
+
|
|
51
|
+
env['cuboid.mcp.auth'] = principal
|
|
52
|
+
@app.call( env )
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Look up the validator at request time (not boot time) so
|
|
58
|
+
# applications can register / replace it after the server has
|
|
59
|
+
# already booted.
|
|
60
|
+
def current_validator
|
|
61
|
+
app = ::Cuboid::Application.application
|
|
62
|
+
return nil if app.nil?
|
|
63
|
+
return nil if !app.respond_to?( :mcp_auth_validator )
|
|
64
|
+
app.mcp_auth_validator
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract_token( env )
|
|
68
|
+
header = env['HTTP_AUTHORIZATION'].to_s
|
|
69
|
+
return nil if header.empty?
|
|
70
|
+
return nil if header !~ BEARER_PREFIX
|
|
71
|
+
header.sub( BEARER_PREFIX, '' ).strip
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Wrap the validator call so an exception in user code becomes a
|
|
75
|
+
# generic 401 rather than a 500 — leaking validator internals to
|
|
76
|
+
# an unauthenticated caller would be a footgun.
|
|
77
|
+
def safe_validate( validator, token )
|
|
78
|
+
validator.call( token )
|
|
79
|
+
rescue => e
|
|
80
|
+
warn "[Cuboid::MCP::Auth] validator raised: #{e.class}: #{e.message}"
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def unauthorized( error )
|
|
85
|
+
body = { jsonrpc: '2.0', error: { code: -32001, message: error } }.to_json
|
|
86
|
+
[
|
|
87
|
+
401,
|
|
88
|
+
{
|
|
89
|
+
'content-type' => 'application/json',
|
|
90
|
+
'www-authenticate' => %(Bearer realm="#{REALM}", error="#{error}")
|
|
91
|
+
},
|
|
92
|
+
[body]
|
|
93
|
+
]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
end
|
|
99
|
+
end
|