Class: Cuboid::MCP::Server::Dispatcher

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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