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
|
@@ -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
|