cuboid 0.3.5 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 640c2b600cf8fb162d629c1a213ae950796e5c2bedc0df8d91bb006b9eccfeba
4
- data.tar.gz: d0b42fac1eb06cf0a8e54fc3ca32e1e1346bba892eebe390ba42b75403a43ff1
3
+ metadata.gz: 51212d2d1adc9ffb33f4838212d9bbddcb88466a12e8e1b5522a2baff6b233f0
4
+ data.tar.gz: e4721af5a566266882fea5320129f58d01c667a03956d79300a3b3a3b652edc1
5
5
  SHA512:
6
- metadata.gz: 37d4a044e8113175387efdc1eef1dd72e134d08a722147a3befba34976159167345686e03612c6485300770b3b6d8366f98695901ef21d74b10c12399ffcd053
7
- data.tar.gz: 880059871670f4bc30beba7bbdaffde7cb33f79ab2618646ad5baa7ebe6b30b6335d4c54dba35b3bb88cbce5b37a006bfa3fa281f243a606fcffa5b21c72a3b5
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
@@ -46,11 +46,15 @@ Gem::Specification.new do |s|
46
46
  s.add_dependency 'base64'
47
47
 
48
48
  # DO NOT TOUCH THIS GROUP VERSION
49
- s.add_dependency 'rack', '~> 2.2.22'
49
+ s.add_dependency 'rack', '>= 3.0'
50
50
  s.add_dependency 'rack-test'
51
51
  # REST API
52
- s.add_dependency 'sinatra', '3.2.0'
53
- s.add_dependency 'sinatra-contrib', '3.2.0'
52
+ s.add_dependency 'sinatra', '>= 4.0'
53
+ s.add_dependency 'sinatra-contrib', '>= 4.0'
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'
54
58
 
55
59
  # RPC client/server implementation.
56
60
  s.add_dependency 'toq'
@@ -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 audit or
52
- # after canceling a running scan.
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` -- Scan runtime in seconds.
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