Class: Cuboid::MCP::Server::Dispatcher
- Includes:
- Server::InstanceHelpers
- Defined in:
- lib/cuboid/mcp/server.rb
Overview
Mounts a single MCP transport at ‘/mcp`. Tools are flattened into one server:
* Framework tools (`list_instances`, `spawn_instance`,
`kill_instance`) ship from `Cuboid::MCP::CoreTools`.
* Application service tools registered via `mcp_service_for` on
the `Cuboid::Application` subclass are wrapped to take an
`instance_id` argument and are exposed under
`<service>_<original_tool_name>` (e.g. `scan_progress`).
The wrapper resolves ‘instance_id` against the shared instance map at call time and forwards the looked-up RPC client to the original tool via `server_context` — so existing `MCPProxy.instrumented_call(server_context) { |instance| … }` code works unchanged.
Earlier revisions exposed a ‘/instances/:instance/<service>` second route; that’s gone. One endpoint, one session, no runtime URL handoff to the client.
Constant Summary collapse
- LIVE_PATH_RE =
‘/mcp/live/<token>` matches BEFORE `/mcp` would swallow it in the regex chain — order checks accordingly in `call`.
%r{\A/mcp/live/(?<token>[A-Za-z0-9_-]+)/?\z}- MCP_PATH_RE =
%r{\A/mcp(?<rest>/.*)?\z}
Class Method Summary collapse
-
.wrap_service_tool(service_name, tool_class) ⇒ Object
Wraps an application-supplied ‘MCP::Tool` subclass so it can live in the unified `/mcp` server.
Instance Method Summary collapse
- #call(env) ⇒ Object
-
#handle_live_push(env, token) ⇒ Object
Forward a single engine-side push (msgpack/json/yaml body) to the MCP session that registered the token.
-
#initialize(name: nil, version: nil, stateless: false) ⇒ Dispatcher
constructor
A new instance of Dispatcher.
Methods included from Server::InstanceHelpers
agent, #agent, #agents, connect_to_agent, #connect_to_agent, connect_to_instance, #connect_to_instance, #connect_to_scheduler, #exists?, instances, #instances, #scheduler, spawn, #spawn, #unplug_agent, #update_from_scheduler
Constructor Details
#initialize(name: nil, version: nil, stateless: false) ⇒ Dispatcher
Returns a new instance of Dispatcher.
148 149 150 151 152 153 |
# File 'lib/cuboid/mcp/server.rb', line 148 def initialize( name: nil, version: nil, stateless: false ) @name = name @version = version @stateless = stateless @mutex = Mutex.new end |
Class Method Details
.wrap_service_tool(service_name, tool_class) ⇒ Object
Wraps an application-supplied ‘MCP::Tool` subclass so it can live in the unified `/mcp` server. The wrapper:
* exposes `<service>_<original_tool_name>` as its name
* augments the input schema with a required `instance_id`
string (the only piece the client must supply that the
old per-instance routing carried in the URL)
* resolves `instance_id` to a registered RPC client at call
time and hands it to the wrapped tool via
`server_context[:instance]` — preserving the original
`instrumented_call(server_context) { |instance| … }`
contract.
Class method (not instance) so the registry that owns the wrapper class doesn’t carry hidden state across requests.
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 |
# File 'lib/cuboid/mcp/server.rb', line 315 def self.wrap_service_tool( service_name, tool_class ) base_schema = tool_class.input_schema.to_h base_props = base_schema[:properties] || {} base_required = (base_schema[:required] || []).map( &:to_s ) new_props = base_props.merge( instance_id: { type: 'string', description: 'Engine-instance handle returned by `spawn_instance` / present in `list_instances`.' } ) new_required = (base_required + ['instance_id']).uniq wrapped_name = "#{service_name}_#{tool_class.tool_name}" wrapped_desc = tool_class.description klass = Class.new( ::MCP::Tool ) klass.tool_name wrapped_name klass.description wrapped_desc klass.input_schema( properties: new_props, required: new_required ) # Pass the original tool's output_schema through to the # wrapper so a typed-output client sees the same contract # whether the tool ships from CoreTools or from a service. if (out = tool_class.output_schema_value) klass.output_schema( out.to_h ) end klass.define_singleton_method( :call ) do |server_context: nil, instance_id: nil, **kwargs| instance = ::Cuboid::Server::InstanceHelpers .instances[instance_id] if instance.nil? next ::MCP::Tool::Response.new( [{ type: 'text', text: "unknown instance: #{instance_id.inspect}" }], error: true ) end # MCPProxy reads `server_context[:instance]` etc. via # Hash-style access, which `MCP::ServerContext` forwards # to the underlying context Hash via method_missing — # so passing a plain Hash here keeps the proxy contract. ctx = { instance: instance, instance_id: instance_id, service: service_name } tool_class.call( server_context: ctx, **kwargs ) end klass end |
Instance Method Details
#call(env) ⇒ Object
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/cuboid/mcp/server.rb', line 155 def call( env ) update_from_scheduler path = env['PATH_INFO'].to_s if (m = LIVE_PATH_RE.match( path )) handle_live_push( env, m[:token] ) elsif (m = MCP_PATH_RE.match( path )) sub_env = env.dup sub_env['PATH_INFO'] = m[:rest].to_s sub_env['SCRIPT_NAME'] = "#{env['SCRIPT_NAME']}/mcp" transport.call( sub_env ) else not_found( 'route does not match /mcp or /mcp/live/<token>' ) end end |
#handle_live_push(env, token) ⇒ Object
Forward a single engine-side push (msgpack/json/yaml body) to the MCP session that registered the token. Loopback-only: the engine subprocess pushes from the same host, never from an external network. No auth — the token is the auth.
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/cuboid/mcp/server.rb', line 176 def handle_live_push( env, token ) remote = env['REMOTE_ADDR'].to_s unless %w(127.0.0.1 ::1 ::ffff:127.0.0.1).include?( remote ) return not_found( 'live push must come from loopback' ) end # Drain Rack's input. Rack 3 may have already consumed it # for known content types; rewind defensively. input = env['rack.input'] input.rewind if input.respond_to?( :rewind ) body = input.read envelope = begin Live.decode( env['CONTENT_TYPE'], body ) rescue => e return [ 400, { 'content-type' => 'application/json' }, [ { error: "could not decode #{env['CONTENT_TYPE']}: #{e.class}" }.to_json ] ] end ok = Live.deliver( token, envelope ) return [ 410, { 'content-type' => 'application/json' }, [ { error: 'live token unknown or session gone' }.to_json ] ] if !ok [ 204, {}, [] ] end |