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,318 @@
1
+ require 'mcp'
2
+ require 'json'
3
+
4
+ require_relative '../server/instance_helpers'
5
+
6
+ module Cuboid
7
+ module MCP
8
+
9
+ # Framework-level MCP tools — instance management. Mounted by
10
+ # `Cuboid::MCP::Server::Dispatcher` at the top-level `/mcp` endpoint
11
+ # so an MCP-only client has a way to spawn / list / kill engine
12
+ # instances without going through the REST surface.
13
+ #
14
+ # Per-instance per-service tools (the application-gem-supplied ones
15
+ # registered via `mcp_service_for`) live at
16
+ # `/instances/:instance/<service>` and pick up where these leave off:
17
+ # the typical client lifecycle is
18
+ #
19
+ # spawn_instance → returns instance_id
20
+ # POST /instances/<instance_id>/<service> { tools/call ... }
21
+ # ...
22
+ # kill_instance(id: instance_id)
23
+ module CoreTools
24
+
25
+ # Direct access to the shared in-memory map populated by REST POST
26
+ # /instances + the scheduler-sync flow + spawn_instance below.
27
+ # Module-level so tools don't have to mix in the InstanceHelpers
28
+ # context (which carries Sinatra-helper assumptions like `session`).
29
+ def self.instances
30
+ ::Cuboid::Server::InstanceHelpers.instances
31
+ end
32
+
33
+ # Wraps a tool body. Returns an MCP::Tool::Response that always
34
+ # carries a JSON-encoded `text` content for clients that don't yet
35
+ # consume `structuredContent`, and — when the result is structured
36
+ # (Hash/Array) — also a `structuredContent` block matching the
37
+ # tool's `output_schema`. A raised exception is captured and
38
+ # returned as an MCP error response so the MCP server itself stays
39
+ # up for the next call.
40
+ def self.instrumented_call
41
+ result = yield
42
+
43
+ if result.is_a?( String )
44
+ ::MCP::Tool::Response.new(
45
+ [{ type: 'text', text: result }]
46
+ )
47
+ else
48
+ ::MCP::Tool::Response.new(
49
+ [{ type: 'text', text: JSON.pretty_generate( result ) }],
50
+ structured_content: result
51
+ )
52
+ end
53
+ rescue => e
54
+ ::MCP::Tool::Response.new(
55
+ [{ type: 'text', text: "error: #{e.class}: #{e.message}" }],
56
+ error: true
57
+ )
58
+ end
59
+
60
+ class ListInstances < ::MCP::Tool
61
+ tool_name 'list_instances'
62
+ description 'Returns the currently-registered application instances as a map of `instance_id` → metadata.'
63
+ input_schema(properties: {})
64
+ output_schema(
65
+ properties: {
66
+ instances: {
67
+ type: 'object',
68
+ description: 'Map of instance_id (string) → its metadata.',
69
+ additionalProperties: {
70
+ type: 'object',
71
+ properties: {
72
+ url: {
73
+ type: ['string', 'null'],
74
+ description: 'host:port the engine instance is bound to (nil for unreachable / scheduler-only entries).'
75
+ }
76
+ }
77
+ }
78
+ }
79
+ },
80
+ required: ['instances']
81
+ )
82
+
83
+ def self.call( ** )
84
+ CoreTools.instrumented_call do
85
+ instances = CoreTools.instances.each_with_object({}) do |(id, instance), h|
86
+ h[id] = { url: instance.respond_to?(:url) ? instance.url : nil }
87
+ end
88
+ { instances: instances }
89
+ end
90
+ end
91
+ end
92
+
93
+ class SpawnInstance < ::MCP::Tool
94
+ tool_name 'spawn_instance'
95
+ description 'Spawn a new application instance and (optionally) start it. Returns the `instance_id`; pass that to every per-service tool (`scan_progress`, `scan_entries`, etc.) as their `instance_id` argument. Pass `start: false` to spawn an idle instance with no run; an empty `options: {}` does NOT skip the run. Scan events stream live by default — listen for the `notifications/<brand>/live` JSON-RPC notification (the exact method is brand-derived; the spawn response\'s `live.notification_method` tells you what to subscribe to). Pass `live: false` to opt out and poll instead.'
96
+ input_schema(
97
+ properties: {
98
+ options: {
99
+ type: 'object',
100
+ description: 'Application-specific run-time options forwarded to `instance.run(...)`. Shape is defined by the running application — consult its docs for valid keys. Use `start: false` to spawn an idle instance with no run options at all.',
101
+ additionalProperties: true
102
+ },
103
+ start: {
104
+ type: 'boolean',
105
+ description: 'When true (default) the spawned instance is started immediately by calling `instance.run(options)`. When false the instance is registered without running anything (a "registered-but-not-started" handle); use this when you want to spawn now and supply options later via another channel.',
106
+ default: true
107
+ },
108
+ live: {
109
+ type: 'boolean',
110
+ description: 'Default true — scan events stream to the calling session as the brand-derived `notifications/<brand>/live` JSON-RPC notification. Set to false to opt out and poll instead.',
111
+ default: true
112
+ }
113
+ }
114
+ )
115
+
116
+ output_schema(
117
+ properties: {
118
+ instance_id: {
119
+ type: 'string',
120
+ description: 'Engine-instance handle. Pass this back as `instance_id` to every `scan_*` tool.'
121
+ },
122
+ url: {
123
+ type: ['string', 'null'],
124
+ description: 'host:port the engine instance is reachable at over RPC.'
125
+ },
126
+ live: {
127
+ type: 'object',
128
+ description: 'Present when live streaming is on. Tells the client which notification method to listen for.',
129
+ properties: {
130
+ notification_method: { type: 'string', description: 'JSON-RPC method to subscribe to — brand-derived (`notifications/<brand>/live`).' }
131
+ }
132
+ }
133
+ },
134
+ required: ['instance_id']
135
+ )
136
+
137
+ def self.call( options: {}, start: true, live: true, server_context: nil, ** )
138
+ CoreTools.instrumented_call do
139
+ # Goes through the shared spawner so a configured Agent
140
+ # provisions the instance over the grid; falls back to
141
+ # local `Processes::Instances.spawn` when no agent is set.
142
+ instance = ::Cuboid::Server::InstanceHelpers.spawn
143
+
144
+ live_attached = false
145
+ if live
146
+ options = CoreTools.inject_live_plugin(
147
+ options,
148
+ instance_id: instance.token,
149
+ server_context: server_context
150
+ )
151
+ live_attached = options != nil && options['plugins'] && options['plugins']['live']
152
+ end
153
+
154
+ if start
155
+ begin
156
+ instance.run( options )
157
+ rescue => e
158
+ # Roll back the spawn — leaking a half-initialised
159
+ # process is worse than leaking the error. Drop
160
+ # any live registration we made above.
161
+ (instance.shutdown rescue nil)
162
+ ::Cuboid::MCP::Live.unregister( instance.token ) if live_attached
163
+ raise e
164
+ end
165
+ end
166
+
167
+ CoreTools.instances[instance.token] = instance
168
+
169
+ result = { instance_id: instance.token, url: instance.url }
170
+ if live_attached
171
+ result[:live] = {
172
+ notification_method: ::Cuboid::MCP::Live.notification_method
173
+ }
174
+ end
175
+ result
176
+ end
177
+ end
178
+ end
179
+
180
+ # Mutate (a copy of) the user's `options` so the engine subprocess
181
+ # loads the `live` plugin pointed at this MCP server's
182
+ # `/mcp/live/<token>` route. Honors anything the user explicitly
183
+ # set under `plugins.live` (metadata, serializer); only the `url`
184
+ # is auto-injected. Skips the injection silently if the call
185
+ # didn't arrive over a session that can receive notifications —
186
+ # `live: true` over a stateless / non-MCP transport has nowhere
187
+ # to send events, so we let the spawn proceed without it.
188
+ def self.inject_live_plugin( options, instance_id:, server_context: )
189
+ session_id = extract_session_id( server_context )
190
+ return options if !session_id
191
+ return options if !::Cuboid::MCP::Live.configured?
192
+
193
+ token = ::Cuboid::MCP::Live.register(
194
+ session_id: session_id,
195
+ instance_id: instance_id
196
+ )
197
+
198
+ options = (options || {}).dup
199
+ # Normalise plugins to Hash{String => Hash} so the merge below
200
+ # is well-defined regardless of whether the caller supplied
201
+ # Hash, Array, or nothing.
202
+ plugins = case options['plugins']
203
+ when Hash then options['plugins'].dup
204
+ when Array then options['plugins'].each_with_object({}) { |n, h| h[n.to_s] = {} }
205
+ else {}
206
+ end
207
+
208
+ live_opts = (plugins['live'] || {}).dup
209
+ live_opts['url'] = ::Cuboid::MCP::Live.url_for( token )
210
+ plugins['live'] = live_opts
211
+
212
+ options['plugins'] = plugins
213
+ options
214
+ end
215
+
216
+ # `MCP::ServerContext` doesn't expose its `notification_target`
217
+ # publicly — the only readers are inside the gem. Reach through
218
+ # to the wrapped session for its session_id; if anything in this
219
+ # chain isn't there (stateless transport, bare invocation) we
220
+ # bail and the live injection is skipped.
221
+ def self.extract_session_id( server_context )
222
+ return nil if server_context.nil?
223
+ target = server_context.instance_variable_get( :@notification_target )
224
+ return nil if target.nil? || !target.respond_to?( :session_id )
225
+ target.session_id
226
+ end
227
+
228
+ class KillInstance < ::MCP::Tool
229
+ tool_name 'kill_instance'
230
+ description 'Shut down and unregister an application instance by instance_id.'
231
+ input_schema(
232
+ properties: {
233
+ instance_id: {
234
+ type: 'string',
235
+ description: 'instance_id returned by spawn_instance / present in list_instances.'
236
+ }
237
+ },
238
+ required: ['instance_id']
239
+ )
240
+ output_schema(
241
+ properties: {
242
+ killed: {
243
+ type: 'string',
244
+ description: 'instance_id of the instance that was shut down and removed.'
245
+ }
246
+ },
247
+ required: ['killed']
248
+ )
249
+
250
+ def self.call( instance_id:, ** )
251
+ CoreTools.instrumented_call do
252
+ instance = CoreTools.instances[instance_id]
253
+ raise "unknown instance: #{instance_id}" if !instance
254
+
255
+ # `instance.shutdown` is an RPC call asking the engine
256
+ # to clean up gracefully. The daemonised subprocess
257
+ # *should* exit on its own afterwards, but in practice
258
+ # it sometimes doesn't — leaking the ruby PID plus its
259
+ # whole chromedriver / browser pool subtree (each
260
+ # engine spawns ~7 chromes). Reap the PID directly
261
+ # too: TERM, brief grace, then KILL if still around.
262
+ pid = instance.pid rescue nil
263
+ (instance.shutdown rescue nil)
264
+ CoreTools.instances.delete( instance_id ).close
265
+ # Drop any live-event registration so future engine
266
+ # pushes 410 instead of being silently relayed.
267
+ ::Cuboid::MCP::Live.unregister( instance_id )
268
+
269
+ if pid && pid > 0
270
+ reap_engine_pid( pid )
271
+ end
272
+
273
+ { killed: instance_id }
274
+ end
275
+ end
276
+
277
+ # Send TERM, give the engine up to ~10s to drain its browser
278
+ # cluster + at_exit chain (which is what unlinks per-engine
279
+ # temp dirs), then SIGKILL if anything's still alive. The
280
+ # earlier 2s grace was generally too short for engines with
281
+ # an active browser pool — they'd get SIGKILL'd mid-shutdown
282
+ # and leak `/tmp/<App>_Engine_<…>/` directories. Daemonised
283
+ # processes have no parent to wait() on, so we can't reap;
284
+ # we just verify exit by ESRCH on `kill 0`. All branches are
285
+ # best-effort — a missing PID, ESRCH, or EPERM are all
286
+ # silently ignored.
287
+ REAP_GRACE_SECONDS = 10.0
288
+
289
+ def self.reap_engine_pid( pid )
290
+ Process.kill( 'TERM', pid ) rescue nil
291
+
292
+ deadline = Process.clock_gettime( Process::CLOCK_MONOTONIC ) +
293
+ REAP_GRACE_SECONDS
294
+ while Process.clock_gettime( Process::CLOCK_MONOTONIC ) < deadline
295
+ begin
296
+ Process.kill( 0, pid )
297
+ sleep 0.1
298
+ rescue Errno::ESRCH
299
+ return
300
+ rescue Errno::EPERM
301
+ return
302
+ end
303
+ end
304
+
305
+ Process.kill( 'KILL', pid ) rescue nil
306
+ end
307
+ end
308
+
309
+ TOOLS = [ ListInstances, SpawnInstance, KillInstance ].freeze
310
+
311
+ def self.tools
312
+ TOOLS
313
+ end
314
+
315
+ end
316
+
317
+ end
318
+ end
@@ -0,0 +1,166 @@
1
+ require 'json'
2
+ require 'msgpack'
3
+ require 'yaml'
4
+ require 'securerandom'
5
+
6
+ module Cuboid
7
+ module MCP
8
+
9
+ # In-process bridge between the engine's `live` plugin and the MCP
10
+ # session that asked for it. The plugin pushes JSON / msgpack / yaml
11
+ # envelopes to `/mcp/live/<token>` (loopback) and we relay each one
12
+ # back to the originating MCP session as a custom JSON-RPC
13
+ # notification (`notifications/<brand>/live` — the `<brand>` segment
14
+ # is derived from the running `Cuboid::Application`'s top-level
15
+ # namespace `shortname`, matching what `serverInfo` advertises). One
16
+ # token per spawned instance; dropped on `kill_instance` or when the
17
+ # session goes away.
18
+ #
19
+ # Singleton state because the dispatcher / SpawnInstance core tool /
20
+ # the live POST handler all need to reach the same registry without
21
+ # threading a reference through everything.
22
+ module Live
23
+
24
+ # Decoders keyed by Content-Type substring.
25
+ DECODERS = {
26
+ 'msgpack' => ->( raw ) { MessagePack.unpack( raw ) },
27
+ 'yaml' => ->( raw ) { ::YAML.safe_load( raw, permitted_classes: [Symbol, Time] ) || {} },
28
+ 'json' => ->( raw ) { ::JSON.parse( raw ) }
29
+ }.freeze
30
+
31
+ @mutex = Mutex.new
32
+ @bind = nil
33
+ @port = nil
34
+ @scheme = 'http'
35
+ @transport = nil
36
+ # token => { session_id:, instance_id: }
37
+ @by_token = {}
38
+ # instance_id => token (for cleanup on kill_instance)
39
+ @by_instance_id = {}
40
+
41
+ class <<self
42
+
43
+ # JSON-RPC notification method clients should subscribe to.
44
+ # Brand-derived from the running `Cuboid::Application` so
45
+ # different products built on cuboid get distinct
46
+ # namespaces — an application with `shortname == :foo`
47
+ # produces `notifications/foo/live`. Falls back to
48
+ # `notifications/cuboid/live` when no application is
49
+ # registered (bare framework / specs).
50
+ def notification_method
51
+ "notifications/#{brand_segment}/live"
52
+ end
53
+
54
+ def brand_segment
55
+ app = ::Cuboid::Application.application
56
+ return 'cuboid' if app.nil?
57
+ ns = app.name.to_s.split( '::' ).first
58
+ return 'cuboid' if ns.nil? || ns.empty?
59
+ mod = Object.const_get( ns )
60
+ (mod.respond_to?( :shortname ) ? mod.shortname : ns).to_s
61
+ rescue
62
+ 'cuboid'
63
+ end
64
+
65
+ # Called from `Server.run!` once the listener is bound so
66
+ # `url_for(token)` can synthesise a loopback URL the engine
67
+ # subprocess will POST to.
68
+ def configure( bind:, port:, tls: false )
69
+ @mutex.synchronize do
70
+ @bind = bind
71
+ @port = port
72
+ @scheme = tls ? 'https' : 'http'
73
+ end
74
+ end
75
+
76
+ def configured?
77
+ @mutex.synchronize { !!(@bind && @port) }
78
+ end
79
+
80
+ # Stored once the dispatcher builds the MCP::Server transport.
81
+ # We use it to dispatch `notifications/cuboid/live` to the
82
+ # session that owns each live token.
83
+ def transport=( t )
84
+ @mutex.synchronize { @transport = t }
85
+ end
86
+
87
+ # Register a fresh live token bound to this MCP session +
88
+ # engine instance pair. Returns the token. Idempotent per
89
+ # instance — re-registering replaces any prior token.
90
+ def register( session_id:, instance_id: )
91
+ token = SecureRandom.uuid
92
+ @mutex.synchronize do
93
+ # Drop any prior token for this instance — stale entries
94
+ # would leak the registry indefinitely under the rare
95
+ # case of double-spawn for the same instance_id.
96
+ if (prev = @by_instance_id[instance_id])
97
+ @by_token.delete( prev )
98
+ end
99
+ @by_token[token] = { session_id: session_id, instance_id: instance_id }
100
+ @by_instance_id[instance_id] = token
101
+ end
102
+ token
103
+ end
104
+
105
+ # Drop the registration for an instance (called from
106
+ # `kill_instance`). No-op if there's nothing registered.
107
+ def unregister( instance_id )
108
+ @mutex.synchronize do
109
+ token = @by_instance_id.delete( instance_id )
110
+ @by_token.delete( token ) if token
111
+ end
112
+ end
113
+
114
+ # Loopback URL the engine subprocess pushes to. The engine
115
+ # is forked on the same host so loopback is always reachable.
116
+ def url_for( token )
117
+ @mutex.synchronize do
118
+ fail 'Live#configure has not been called' if !@bind || !@port
119
+ "#{@scheme}://#{@bind}:#{@port}/mcp/live/#{token}"
120
+ end
121
+ end
122
+
123
+ # Forward a decoded envelope from the engine push to the
124
+ # originating MCP session as `notifications/cuboid/live`.
125
+ # Returns true on success, false when the token is unknown
126
+ # or the transport hasn't been wired yet (caller maps these
127
+ # to 404 / 410 / 503).
128
+ def deliver( token, envelope )
129
+ registration, transport = nil, nil
130
+
131
+ @mutex.synchronize do
132
+ registration = @by_token[token]
133
+ transport = @transport
134
+ end
135
+
136
+ return false if !registration || !transport
137
+
138
+ params = envelope.is_a?( Hash ) ? envelope : { 'envelope' => envelope }
139
+ params = params.merge( 'instance_id' => registration[:instance_id] )
140
+
141
+ transport.send_notification(
142
+ notification_method,
143
+ params,
144
+ session_id: registration[:session_id]
145
+ )
146
+ true
147
+ rescue => e
148
+ warn "[Cuboid::MCP::Live] deliver failed: #{e.class}: #{e.message}"
149
+ false
150
+ end
151
+
152
+ # Decode a Rack request body using its `Content-Type` header.
153
+ # Falls back to JSON; raises on undecipherable input so the
154
+ # caller can return 400.
155
+ def decode( content_type, body )
156
+ content_type = content_type.to_s
157
+ decoder = DECODERS.find { |fmt, _| content_type.include?( fmt ) }&.last
158
+ decoder ||= DECODERS['json']
159
+ decoder.call( body )
160
+ end
161
+
162
+ end
163
+
164
+ end
165
+ end
166
+ end