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.
@@ -0,0 +1,426 @@
1
+ require 'puma'
2
+ require 'puma/minissl'
3
+ require 'rack'
4
+ require 'mcp'
5
+ require 'mcp/server/transports/streamable_http_transport'
6
+
7
+ # json-schema (a transitive dep of `mcp` for MCP::Tool input/output
8
+ # schema validation) emits a one-time deprecation notice at first use
9
+ # unless we opt out of its MultiJson backend. Stdlib JSON is faster
10
+ # and already loaded — no reason to keep MultiJson in the chain.
11
+ require 'json-schema'
12
+ JSON::Validator.use_multi_json = false
13
+
14
+ require_relative 'auth'
15
+ require_relative 'core_tools'
16
+ require_relative 'live'
17
+ require_relative '../server/instance_helpers'
18
+
19
+ module Cuboid
20
+ module MCP
21
+
22
+ # Cuboid's MCP-server framework. Mirrors `Cuboid::Rest::Server`:
23
+ # application gems register tool handlers via `mcp_service_for(name,
24
+ # handler)` on their `Cuboid::Application` subclass; this server
25
+ # mounts one MCP transport per (instance, service) pair under
26
+ # `/instances/:instance/<service>` and proxies tool calls to the
27
+ # resolved engine instance via RPC.
28
+ #
29
+ # Spawnable as `:mcp` via Cuboid::Processes::Manager (see
30
+ # `lib/cuboid/processes/executables/mcp.rb`).
31
+ class Server
32
+
33
+ class << self
34
+
35
+ # Boot the MCP server.
36
+ #
37
+ # @param [Hash] options
38
+ # @option options [String] :bind IP/host to bind
39
+ # @option options [Integer] :port Port to listen on
40
+ # @option options [String] :name MCP server name advertised to clients
41
+ # @option options [String] :version MCP server version advertised to clients
42
+ # @option options [Hash] :tls Optional TLS — same shape as Rest::Server
43
+ # @option options [Boolean] :stateless Streamable HTTP stateless mode
44
+ def run!( options )
45
+ puma = Puma::Server.new( rack_app( options ) )
46
+
47
+ ssl = configure_listener( puma, options )
48
+
49
+ # Configure the Live registry so its `url_for(token)` can
50
+ # synthesise the loopback URL the engine subprocess will
51
+ # POST live events to. Done after listener configuration so
52
+ # we know the resolved scheme.
53
+ Live.configure(
54
+ bind: options[:bind],
55
+ port: options[:port],
56
+ tls: ssl
57
+ )
58
+
59
+ puts "MCP server listening on " \
60
+ "http#{'s' if ssl}://#{options[:bind]}:#{options[:port]}/mcp"
61
+
62
+ begin
63
+ puma.run.join
64
+ rescue Interrupt
65
+ puma.stop( true )
66
+ end
67
+ end
68
+
69
+ # Build (without booting) the Rack app — exposed for tests
70
+ # (Rack::Test against `rack_app({})`) and for embedders that
71
+ # want to mount MCP under a larger Rack tree.
72
+ def rack_app( options = {} )
73
+ dispatcher = Dispatcher.new(
74
+ name: options[:name],
75
+ version: options[:version],
76
+ stateless: options.fetch( :stateless, false )
77
+ )
78
+
79
+ Rack::Builder.new do
80
+ use Cuboid::MCP::Auth
81
+ run dispatcher
82
+ end.to_app
83
+ end
84
+
85
+ private
86
+
87
+ def configure_listener( puma_server, options )
88
+ tls = options[:tls]
89
+
90
+ if tls && tls[:private_key] && tls[:certificate]
91
+ ctx = Puma::MiniSSL::Context.new
92
+ ctx.key = tls[:private_key]
93
+ ctx.cert = tls[:certificate]
94
+
95
+ if tls[:ca]
96
+ puts 'CA provided, peer verification enabled.'
97
+ ctx.ca = tls[:ca]
98
+ ctx.verify_mode = Puma::MiniSSL::VERIFY_PEER |
99
+ Puma::MiniSSL::VERIFY_FAIL_IF_NO_PEER_CERT
100
+ else
101
+ puts 'CA missing, peer verification disabled.'
102
+ end
103
+
104
+ puma_server.binder.add_ssl_listener(
105
+ options[:bind], options[:port], ctx
106
+ )
107
+ true
108
+ else
109
+ puma_server.add_tcp_listener( options[:bind], options[:port] )
110
+ false
111
+ end
112
+ end
113
+
114
+ end
115
+
116
+ # Mounts a single MCP transport at `/mcp`. Tools are flattened into
117
+ # one server:
118
+ #
119
+ # * Framework tools (`list_instances`, `spawn_instance`,
120
+ # `kill_instance`) ship from `Cuboid::MCP::CoreTools`.
121
+ # * Application service tools registered via `mcp_service_for` on
122
+ # the `Cuboid::Application` subclass are wrapped to take an
123
+ # `instance_id` argument and are exposed under
124
+ # `<service>_<original_tool_name>` (e.g. `scan_progress`).
125
+ #
126
+ # The wrapper resolves `instance_id` against the shared instance
127
+ # map at call time and forwards the looked-up RPC client to the
128
+ # original tool via `server_context[:instance]` — so existing
129
+ # `MCPProxy.instrumented_call(server_context) { |instance| … }` code
130
+ # works unchanged.
131
+ #
132
+ # Earlier revisions exposed a `/instances/:instance/<service>`
133
+ # second route; that's gone. One endpoint, one session, no
134
+ # runtime URL handoff to the client.
135
+ class Dispatcher
136
+ # InstanceHelpers' @@instances class variable is shared across
137
+ # all includers of the module — so the same map populated by
138
+ # Rest::Server / scheduler-sync / our own SpawnInstance core
139
+ # tool is visible here without any explicit cross-process
140
+ # plumbing.
141
+ include ::Cuboid::Server::InstanceHelpers
142
+
143
+ # `/mcp/live/<token>` matches BEFORE `/mcp` would swallow it
144
+ # in the regex chain — order checks accordingly in `call`.
145
+ LIVE_PATH_RE = %r{\A/mcp/live/(?<token>[A-Za-z0-9_-]+)/?\z}
146
+ MCP_PATH_RE = %r{\A/mcp(?<rest>/.*)?\z}
147
+
148
+ def initialize( name: nil, version: nil, stateless: false )
149
+ @name = name
150
+ @version = version
151
+ @stateless = stateless
152
+ @mutex = Mutex.new
153
+ end
154
+
155
+ def call( env )
156
+ update_from_scheduler
157
+
158
+ path = env['PATH_INFO'].to_s
159
+
160
+ if (m = LIVE_PATH_RE.match( path ))
161
+ handle_live_push( env, m[:token] )
162
+ elsif (m = MCP_PATH_RE.match( path ))
163
+ sub_env = env.dup
164
+ sub_env['PATH_INFO'] = m[:rest].to_s
165
+ sub_env['SCRIPT_NAME'] = "#{env['SCRIPT_NAME']}/mcp"
166
+ transport.call( sub_env )
167
+ else
168
+ not_found( 'route does not match /mcp or /mcp/live/<token>' )
169
+ end
170
+ end
171
+
172
+ # Forward a single engine-side push (msgpack/json/yaml body) to
173
+ # the MCP session that registered the token. Loopback-only:
174
+ # the engine subprocess pushes from the same host, never from
175
+ # an external network. No auth — the token is the auth.
176
+ def handle_live_push( env, token )
177
+ remote = env['REMOTE_ADDR'].to_s
178
+ unless %w(127.0.0.1 ::1 ::ffff:127.0.0.1).include?( remote )
179
+ return not_found( 'live push must come from loopback' )
180
+ end
181
+
182
+ # Drain Rack's input. Rack 3 may have already consumed it
183
+ # for known content types; rewind defensively.
184
+ input = env['rack.input']
185
+ input.rewind if input.respond_to?( :rewind )
186
+ body = input.read
187
+
188
+ envelope =
189
+ begin
190
+ Live.decode( env['CONTENT_TYPE'], body )
191
+ rescue => e
192
+ return [ 400, { 'content-type' => 'application/json' },
193
+ [ { error: "could not decode #{env['CONTENT_TYPE']}: #{e.class}" }.to_json ] ]
194
+ end
195
+
196
+ ok = Live.deliver( token, envelope )
197
+ return [ 410, { 'content-type' => 'application/json' },
198
+ [ { error: 'live token unknown or session gone' }.to_json ] ] if !ok
199
+
200
+ [ 204, {}, [] ]
201
+ end
202
+
203
+ private
204
+
205
+ def transport
206
+ @mutex.synchronize do
207
+ @transport ||= begin
208
+ mcp_server = ::MCP::Server.new(
209
+ name: @name || application_brand_name || 'cuboid',
210
+ version: @version || application_brand_version || ::Cuboid::VERSION,
211
+ tools: build_tools,
212
+ prompts: build_prompts,
213
+ resources: build_resources
214
+ )
215
+
216
+ if (read_handler = build_resources_read_handler)
217
+ mcp_server.resources_read_handler( &read_handler )
218
+ end
219
+
220
+ t = ::MCP::Server::Transports::StreamableHTTPTransport.new(
221
+ mcp_server,
222
+ stateless: @stateless
223
+ )
224
+
225
+ # Hand the transport to Live so `/mcp/live/<token>`
226
+ # pushes can be relayed to the right session as
227
+ # `notifications/cuboid/live` notifications.
228
+ Live.transport = t
229
+
230
+ t
231
+ end
232
+ end
233
+ end
234
+
235
+ def build_tools
236
+ tools = ::Cuboid::MCP::CoreTools.tools.dup
237
+
238
+ # App-level top-level tools registered via
239
+ # `Cuboid::Application.mcp_app_tool` — ride the same
240
+ # routing as CoreTools (no instance_id requirement).
241
+ app = ::Cuboid::Application.application
242
+ if app.respond_to?( :mcp_app_tools )
243
+ tools.concat( app.mcp_app_tools )
244
+ end
245
+
246
+ mcp_services.each do |service_name, handler|
247
+ Array( handler.tools ).each do |tool_class|
248
+ tools << Dispatcher.wrap_service_tool( service_name, tool_class )
249
+ end
250
+ end
251
+
252
+ tools
253
+ end
254
+
255
+ # Application MCP-service handlers may optionally expose
256
+ # `prompts` (canned conversation templates the client can
257
+ # surface to a user — e.g. "scan this URL with the quick-scan
258
+ # preset and summarise findings") and `resources` (read-only
259
+ # documents — glossary, option DSL reference, presets — that
260
+ # an LLM client pulls on demand instead of needing them
261
+ # bundled into every tool description).
262
+ #
263
+ # Both are additive: a handler that doesn't define `prompts` /
264
+ # `resources` is silently treated as exposing none.
265
+ def build_prompts
266
+ mcp_services.values.flat_map do |handler|
267
+ handler.respond_to?( :prompts ) ? Array( handler.prompts ) : []
268
+ end
269
+ end
270
+
271
+ def build_resources
272
+ mcp_services.values.flat_map do |handler|
273
+ handler.respond_to?( :resources ) ? Array( handler.resources ) : []
274
+ end
275
+ end
276
+
277
+ # Returns a Proc the MCP::Server uses for `resources/read`. The
278
+ # proc walks every handler that implements `read_resource(uri)`
279
+ # and returns the first non-nil match, normalised to the
280
+ # `Array<Resource::Contents-as-Hash>` shape the spec expects.
281
+ # Nil if no handler implements the protocol — letting the
282
+ # gem's default no-content responder do its thing.
283
+ def build_resources_read_handler
284
+ handlers = mcp_services.values.select { |h| h.respond_to?( :read_resource ) }
285
+ return nil if handlers.empty?
286
+
287
+ ->( params ) {
288
+ uri = params[:uri].to_s
289
+ handlers.each do |h|
290
+ content = h.read_resource( uri )
291
+ next if content.nil?
292
+ return Array( content ).map { |c|
293
+ c.respond_to?( :to_h ) ? c.to_h : c
294
+ }
295
+ end
296
+ []
297
+ }
298
+ end
299
+
300
+ # Wraps an application-supplied `MCP::Tool` subclass so it can
301
+ # live in the unified `/mcp` server. The wrapper:
302
+ #
303
+ # * exposes `<service>_<original_tool_name>` as its name
304
+ # * augments the input schema with a required `instance_id`
305
+ # string (the only piece the client must supply that the
306
+ # old per-instance routing carried in the URL)
307
+ # * resolves `instance_id` to a registered RPC client at call
308
+ # time and hands it to the wrapped tool via
309
+ # `server_context[:instance]` — preserving the original
310
+ # `instrumented_call(server_context) { |instance| … }`
311
+ # contract.
312
+ #
313
+ # Class method (not instance) so the registry that owns the
314
+ # wrapper class doesn't carry hidden state across requests.
315
+ def self.wrap_service_tool( service_name, tool_class )
316
+ base_schema = tool_class.input_schema.to_h
317
+ base_props = base_schema[:properties] || {}
318
+ base_required = (base_schema[:required] || []).map( &:to_s )
319
+
320
+ new_props = base_props.merge(
321
+ instance_id: {
322
+ type: 'string',
323
+ description: 'Engine-instance handle returned by `spawn_instance` / present in `list_instances`.'
324
+ }
325
+ )
326
+ new_required = (base_required + ['instance_id']).uniq
327
+
328
+ wrapped_name = "#{service_name}_#{tool_class.tool_name}"
329
+ wrapped_desc = tool_class.description
330
+
331
+ klass = Class.new( ::MCP::Tool )
332
+ klass.tool_name wrapped_name
333
+ klass.description wrapped_desc
334
+ klass.input_schema(
335
+ properties: new_props,
336
+ required: new_required
337
+ )
338
+
339
+ # Pass the original tool's output_schema through to the
340
+ # wrapper so a typed-output client sees the same contract
341
+ # whether the tool ships from CoreTools or from a service.
342
+ if (out = tool_class.output_schema_value)
343
+ klass.output_schema( out.to_h )
344
+ end
345
+
346
+ klass.define_singleton_method( :call ) do |server_context: nil, instance_id: nil, **kwargs|
347
+ instance = ::Cuboid::Server::InstanceHelpers
348
+ .instances[instance_id]
349
+ if instance.nil?
350
+ next ::MCP::Tool::Response.new(
351
+ [{ type: 'text', text: "unknown instance: #{instance_id.inspect}" }],
352
+ error: true
353
+ )
354
+ end
355
+
356
+ # MCPProxy reads `server_context[:instance]` etc. via
357
+ # Hash-style access, which `MCP::ServerContext` forwards
358
+ # to the underlying context Hash via method_missing —
359
+ # so passing a plain Hash here keeps the proxy contract.
360
+ ctx = {
361
+ instance: instance,
362
+ instance_id: instance_id,
363
+ service: service_name
364
+ }
365
+
366
+ tool_class.call( server_context: ctx, **kwargs )
367
+ end
368
+
369
+ klass
370
+ end
371
+
372
+ def mcp_services
373
+ app = ::Cuboid::Application.application
374
+ return {} if app.nil?
375
+ return {} if !app.respond_to?( :mcp_services )
376
+ app.mcp_services
377
+ end
378
+
379
+ # Identity advertised in MCP `serverInfo` defaults to the running
380
+ # Cuboid::Application's top-level namespace — preferring its
381
+ # branded `shortname` / `version` methods over the raw module
382
+ # name + VERSION constant. Falls back to 'cuboid' /
383
+ # Cuboid::VERSION when no application is registered (bare
384
+ # framework / specs). Explicit `name:` / `version:` passed to
385
+ # `run!` always win.
386
+ def application_brand
387
+ return @application_brand if defined?( @application_brand )
388
+
389
+ app = ::Cuboid::Application.application
390
+ @application_brand =
391
+ if app && (ns = app.name.to_s.split( '::' ).first) && !ns.empty?
392
+ mod = Object.const_get( ns )
393
+
394
+ name =
395
+ if mod.respond_to?( :shortname )
396
+ mod.shortname.to_s
397
+ else
398
+ ns
399
+ end
400
+
401
+ version =
402
+ if mod.respond_to?( :version )
403
+ mod.version.to_s
404
+ elsif mod.const_defined?( :VERSION )
405
+ mod::VERSION
406
+ end
407
+
408
+ { name: name, version: version }
409
+ else
410
+ { name: nil, version: nil }
411
+ end
412
+ end
413
+
414
+ def application_brand_name; application_brand[:name]; end
415
+ def application_brand_version; application_brand[:version]; end
416
+
417
+ def not_found( message )
418
+ body = { jsonrpc: '2.0', error: { code: -32601, message: message } }.to_json
419
+ [ 404, { 'content-type' => 'application/json' }, [body] ]
420
+ end
421
+ end
422
+
423
+ end
424
+
425
+ end
426
+ end
@@ -132,6 +132,13 @@ class Paths < Cuboid::OptionGroup
132
132
  def tmpdir
133
133
  return @tmpdir if @tmpdir
134
134
 
135
+ # Reap dirs left over from prior Cuboid runs that were
136
+ # SIGKILL'd / segfaulted / had their host crash — anything
137
+ # that bypassed the `at_exit` block below. Done at boot
138
+ # rather than on a timer so the user doesn't have to think
139
+ # about it.
140
+ sweep_orphaned_tmpdirs
141
+
135
142
  dir = tmp_dir_for( Process.pid )
136
143
 
137
144
  FileUtils.mkdir_p dir
@@ -146,6 +153,39 @@ class Paths < Cuboid::OptionGroup
146
153
  "#{os_tmpdir}/#{TMPDIR_SUFFIX}#{pid}"
147
154
  end
148
155
 
156
+ # Sweep `<os_tmpdir>/Cuboid_<pid>` dirs whose pid is no longer
157
+ # alive. Best-effort: a dir we can't probe / remove (permission,
158
+ # racing peer boot, etc.) is silently skipped — never block boot
159
+ # on cleanup. Ignores `Cuboid_Snapshot_<token>` and any other
160
+ # non-pid-suffixed siblings.
161
+ def sweep_orphaned_tmpdirs
162
+ Dir.glob( "#{os_tmpdir}/#{TMPDIR_SUFFIX}*" ).each do |path|
163
+ next if !File.directory?( path )
164
+
165
+ suffix = File.basename( path ).sub( TMPDIR_SUFFIX, '' )
166
+ next if suffix !~ /\A\d+\z/
167
+
168
+ pid = suffix.to_i
169
+ begin
170
+ Process.kill( 0, pid )
171
+ # Still alive — leave it.
172
+ next
173
+ rescue Errno::ESRCH
174
+ # No such process — orphan; fall through to cleanup.
175
+ rescue Errno::EPERM
176
+ # Alive but owned by another user — leave it.
177
+ next
178
+ rescue
179
+ next
180
+ end
181
+
182
+ FileUtils.rm_rf( path )
183
+ rescue
184
+ # Anything unexpected (filesystem race, transient I/O
185
+ # error) — skip this entry, keep going.
186
+ end
187
+ end
188
+
149
189
  def config
150
190
  self.class.config
151
191
  end
@@ -51,4 +51,41 @@ def puts_stderr( str )
51
51
  rescue
52
52
  end
53
53
 
54
+ # Parent-death watchdog. If the spawning process dies (rspec
55
+ # crashed / Ctrl-C'd before the after-hooks could run, MCP server
56
+ # SIGKILL'd, host shell exited) the engine subprocess gets
57
+ # reparented to init and would otherwise survive forever. Poll the
58
+ # **original** spawn-time parent pid (`ppid`) — not Process.ppid,
59
+ # which goes to 1 the moment we daemonise — and force a clean exit
60
+ # the moment ESRCH says the parent is gone.
61
+ #
62
+ # Polling cadence is 5 s: cheap, fires before tmpdirs accumulate.
63
+ # Skipped when no `ppid` was stamped on the options (manual
64
+ # invocations / tests that don't go through Manager.spawn).
65
+ PARENT_WATCHDOG_INTERVAL = 5.0
66
+
67
+ if ppid && ppid > 0
68
+ Thread.new do
69
+ loop do
70
+ sleep PARENT_WATCHDOG_INTERVAL
71
+ break if !parent_alive?
72
+ end
73
+
74
+ # Parent's gone. Try a graceful exit first so at_exit
75
+ # handlers fire (Cuboid_<pid> tmpdir cleanup, the live
76
+ # plugin's `exited` push, etc.); fall back to SIGKILL ourselves
77
+ # if a non-daemon Application thread refuses to release the
78
+ # runtime.
79
+ Thread.new do
80
+ sleep 5
81
+ Process.kill( 'KILL', Process.pid ) rescue nil
82
+ end
83
+
84
+ main = Thread.main
85
+ if main && main.alive? && main != Thread.current
86
+ main.raise( SystemExit.new( 0 ) ) rescue nil
87
+ end
88
+ end
89
+ end
90
+
54
91
  load ARGV.pop
@@ -0,0 +1,20 @@
1
+ # Spawnable as `:mcp` via Cuboid::Processes::Manager — mirror of
2
+ # `executables/rest_service.rb`. Boots Cuboid::MCP::Server on the
3
+ # RPC-port/address from Cuboid::Options so the operator can spawn an
4
+ # MCP server alongside REST/agent/scheduler with the same option
5
+ # plumbing.
6
+ require Options.paths.lib + 'mcp/server'
7
+
8
+ # Fully qualified — the `mcp` gem also exposes a top-level `MCP::Server`
9
+ # (different class entirely), and `include Cuboid` in executables/base.rb
10
+ # would otherwise hide that disambiguation.
11
+ Cuboid::MCP::Server.run!(
12
+ bind: Options.rpc.server_address,
13
+ port: Options.rpc.server_port,
14
+
15
+ tls: {
16
+ ca: Options.rpc.ssl_ca,
17
+ private_key: Options.rpc.server_ssl_private_key,
18
+ certificate: Options.rpc.server_ssl_certificate
19
+ }
20
+ )
@@ -66,6 +66,7 @@ class Instances
66
66
  fork = options.delete(:fork)
67
67
 
68
68
  daemonize = options.delete(:daemonize)
69
+ detached = options.delete(:detached)
69
70
  port_range = options.delete( :port_range )
70
71
 
71
72
  options[:ssl] ||= {
@@ -100,7 +101,14 @@ class Instances
100
101
  url = "#{options[:rpc][:server_address]}:#{options[:rpc][:server_port]}"
101
102
  end
102
103
 
103
- pid = Manager.spawn( :instance, options: options, token: token, fork: fork, daemonize: daemonize )
104
+ pid = Manager.spawn(
105
+ :instance,
106
+ options: options,
107
+ token: token,
108
+ fork: fork,
109
+ daemonize: daemonize,
110
+ detached: detached
111
+ )
104
112
 
105
113
  System.slots.use pid
106
114
 
@@ -58,6 +58,16 @@ class Manager
58
58
  end
59
59
  end
60
60
  rescue Timeout::Error
61
+ # SIGTERM didn't take in 10s — escalate. Ruby's default TERM
62
+ # handler only raises SignalException on the main thread; if
63
+ # the main thread already exited but a non-daemon Application
64
+ # thread is keeping the runtime up (audit workers, browser
65
+ # cluster manager, etc.) then no amount of TERMs will land.
66
+ # Without this escalation the spec suite leaks engine
67
+ # subprocesses every time a kill_instance / killall fails to
68
+ # land cleanly.
69
+ Process.kill( 'KILL', pid ) rescue nil
70
+ @pids.delete pid
61
71
  end
62
72
 
63
73
  def find( bin )
@@ -212,7 +222,18 @@ class Manager
212
222
  spawn_options[:out] = stdout if stdout
213
223
  spawn_options[:err] = stderr if stderr
214
224
 
215
- options[:ppid] = Process.pid
225
+ # `:detached` decouples "child outlives spawner" from
226
+ # `:daemonize`, which only controls whether THIS thread
227
+ # `waitpid`s. MCP and the spec helpers spawn with
228
+ # `daemonize: true` but want the child tethered (die with
229
+ # parent); only Agent-managed instances genuinely want to
230
+ # outlive their spawner. Default = tethered; Agent's
231
+ # `spawn_instance` opts out by passing `detached: true`.
232
+ # base.rb's parent-death watchdog reads `$options[:ppid]`;
233
+ # a missing ppid means "no tether" and the watchdog
234
+ # short-circuits.
235
+ detached = options.delete( :detached )
236
+ options[:ppid] = Process.pid if !detached
216
237
  options[:tmpdir] = Options.paths.tmpdir
217
238
 
218
239
  cuboid_options = Options.dup.update( options.delete(:options) || {} ).to_h