Module: Parse::Agent::MCPDispatcher

Defined in:
lib/parse/agent/mcp_dispatcher.rb

Overview

Pure JSON-RPC dispatch layer for the MCP protocol.

MCPDispatcher translates an already-parsed JSON-RPC request body into a JSON-RPC response envelope without touching any I/O, HTTP transport, or authentication. Callers are responsible for:

  • Parsing the raw request body into a Hash.
  • Authenticating the request and constructing a Parse::Agent instance.
  • Serializing the returned Hash back to JSON and writing it to the wire.

This design lets the same dispatch logic serve WEBrick (MCPServer), Rack (MCPRackApp), and in-process tests without duplication.

Examples:

Basic usage

body  = JSON.parse(raw_request_body)
agent = Parse::Agent.new(permissions: :readonly)
result = Parse::Agent::MCPDispatcher.call(body: body, agent: agent)
# => { status: 200, body: { "jsonrpc" => "2.0", "id" => 1, "result" => {...} } }

Defined Under Namespace

Classes: LogLevelRegistry

Constant Summary collapse

PROTOCOL_VERSION =

MCP protocol version advertised in the initialize handshake. Matches MCPServer::PROTOCOL_VERSION.

Bumped from 2024-11-05 to 2025-06-18 in v4.2 alongside tool-internal progress reporting. The changes from 2024-11-05 → 2025-06-18 that affect the surface this gem implements are all additive:

  • notifications/progress accepts an optional message field (2025-03-26).
  • Tool descriptors may carry annotations, outputSchema, and tool results may carry structuredContent / resource links (2025-06-18). The dispatcher does not emit these fields — they are forward-compatible no-ops.

Clients negotiating an older version (e.g. 2024-11-05-only) will still interpret the initialize capability shape and supported methods correctly; the wire-level differences only matter for the additive fields above.

Bumped to 2025-11-25 in 5.7.5. The changes that touch this server are additive: serverInfo may carry description, input validation failures are reported as tool results with isError: true rather than protocol errors, and elicitation gains URL mode and titled enums (the approval prompt uses neither). Tasks, icons, and tool-calling sampling are optional and not offered.

"2025-11-25"
SUPPORTED_PROTOCOL_VERSIONS =

Protocol versions the dispatcher is willing to negotiate. Per the MCP lifecycle spec the server MUST echo the client's requested version when supported, or fall back to a version it does support. This list reflects the versions whose wire shape and method set are compatible with the handlers below — additions from 2024-11-05 → 2025-06-18 are all additive and forward- compatible no-ops for older clients.

%w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
CAPABILITIES =

Server capability advertisement (mirrors MCPServer::CAPABILITIES).

tools.listChanged and prompts.listChanged are advertised as true in v4.2: Parse::Agent::MCPRackApp's SSEBody subscribes to Parse::Agent::Tools.subscribe and Parse::Agent::Prompts.subscribe and broadcasts notifications/tools/list_changed / notifications/prompts/list_changed onto every live SSE stream when an application calls Tools.register, Tools.reset_registry!, Prompts.register, or Prompts.reset_registry! at runtime. Standalone MCPServer callers (WEBrick, no streaming) cannot receive notifications; they still see the latest registry state on the next tools/list / prompts/list poll.

completions backs completion/complete (class names and field names for prompt arguments and resource-template variables). logging backs logging/setLevel. It is advertised only when the transport can deliver log messages (see capabilities_for), and messages flow only after a client sets a level.

{
  "tools" => { "listChanged" => true },
  "resources" => { "subscribe" => false, "listChanged" => false },
  "prompts" => { "listChanged" => true },
  "completions" => {},
  "logging" => {},
}.freeze
LOG_LEVELS =

RFC 5424 severities accepted by logging/setLevel, least to most severe. A session at a given level receives that level and above.

Parse::Agent::LOG_LEVELS
CLASS_COMPLETION_ARGUMENTS =

Prompt-argument and resource-template-variable names completed with the class names visible to the agent. classes is a comma-separated list, so only its last segment is completed.

%w[class_name parent_class child_class classes className].freeze
FIELD_COMPLETION_ARGUMENTS =

Argument names completed with field names of the class named by a sibling argument (class_name or child_class) in the request's context.arguments.

%w[group_by pointer_field].freeze
MAX_COMPLETION_VALUES =

The MCP spec caps a completion response at 100 values.

100
IDENTIFIER_RE =

Parse class-name identifier regex — used to validate resource URIs. Matches Parse's class-name convention: letter/underscore start, up to 128 chars, alphanumeric/underscore body.

/\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
MAX_TOOL_RESPONSE_BYTES =

Maximum serialized response body for a single tools/call. Prevents a wide-schema query with limit=1000 from producing tens of megabytes of JSON before the response is written. When exceeded, the dispatcher returns an isError tool result instructing the client to narrow the query, NOT a JSON-RPC transport error.

4_194_304

Class Method Summary collapse

Class Method Details

.call(body:, agent:, logger: nil, progress_callback: nil, cancellation_token: nil, subscription_manager: nil, approval_gate: nil, log_callback: nil, log_levels: nil) ⇒ Hash

Note:

Parse::Agent::Prompts contract observed from prompts.rb: Prompts.list returns an Array of prompt descriptor Hashes (builtins merged with any registered custom prompts). Prompts.render(name, args) returns the full MCP envelope Hash { "description" => String, "messages" => [...] } — already shaped. It raises Parse::Agent::ValidationError for unknown prompt names and for missing/invalid required arguments. The dispatcher passes the envelope through as-is and lets rescue handle ValidationError → -32602.

Dispatch a JSON-RPC request body to the appropriate handler.

Error codes used: -32700 Parse error (body is not a Hash or missing "method") -32601 Method not found (unknown method name) -32602 Invalid params (bad arguments, SecurityError, ValidationError) -32603 Internal error (unexpected StandardError — class name only, no message) -32001 Unauthorized (Parse::Agent::Unauthorized) → HTTP 401

Parameters:

  • body (Hash) —

    already-parsed JSON-RPC request body with string keys. Expected shape: { "jsonrpc" => "2.0", "method" => String, "params" => Hash, "id" => Any }

  • agent (Parse::Agent) —

    an authenticated agent instance.

  • logger (#warn, nil) (defaults to: nil) —

    optional logger for internal errors. When not provided, falls back to Kernel#warn → $stderr. Wire from the transport layer (MCPRackApp forwards its logger here automatically).

  • progress_callback (#call, nil) (defaults to: nil) —

    callback the dispatcher installs on the agent for the duration of the request, so tools can emit MCP notifications/progress events via agent.report_progress(...). Set by Parse::Agent::MCPRackApp on the SSE path; nil for the JSON path. The callback signature is call(progress:, total:, message:) (keyword args), and it is cleared from the agent in an ensure block before this method returns.

  • cancellation_token (Parse::Agent::CancellationToken, nil) (defaults to: nil) —

    cooperative cancellation token the dispatcher installs on the agent for the duration of the request. Tools check agent.cancelled? at safe checkpoints; cancelled tool results are translated into a JSON-RPC isError content envelope by #handle_tools_call. Cleared from the agent in an ensure block before this method returns.

  • subscription_manager (Parse::Agent::MCPSubscriptions::Manager, nil) (defaults to: nil) —

    the per-transport resource-subscription coordinator. When present and supported, the initialize handshake advertises the resources.subscribe capability and resources/subscribe / resources/unsubscribe are routed to it. nil (the default, and the only option on non-streaming transports like the WEBrick MCPServer) leaves the capability unadvertised and those methods returning a "not supported" error.

  • log_callback (#call, nil) (defaults to: nil) —

    installed on the agent for the duration of the request so tools (and the dispatcher) can emit MCP notifications/message events via agent.log(...). Set by Parse::Agent::MCPRackApp on the SSE path; the transport filters by the session's level. nil leaves agent.log a no-op.

  • log_levels (LogLevelRegistry, nil) (defaults to: nil) —

    where logging/setLevel records the session's level. nil (non-streaming transports, which cannot deliver log messages) still accepts and validates the call.

Returns:

  • (Hash) —

    always { status: Integer, body: Hash }. status is the HTTP status code (200 for all successful dispatches, including JSON-RPC error responses; 401 only for Unauthorized). body is the full JSON-RPC response envelope (string keys) containing "jsonrpc", "id", and either "result" or "error".

Raises:

  • nothing — all exceptions are caught and translated to error envelopes.



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/parse/agent/mcp_dispatcher.rb', line 220

def self.call(body:, agent:, logger: nil, progress_callback: nil, cancellation_token: nil,
              subscription_manager: nil, approval_gate: nil, log_callback: nil, log_levels: nil)
  # Snapshot any prior callback/token already on the agent (e.g. a
  # token a parent dispatcher installed before a tool handler
  # invoked us recursively, or values pre-set by the application).
  # We restore these in the ensure block so we never clobber state
  # we did not install. Without snapshot-restore, two interleaved
  # dispatches on the same shared agent would race: the second
  # request's ensure would null the first request's still-needed
  # token.
  prev_progress_callback = agent.progress_callback if agent.respond_to?(:progress_callback)
  prev_cancellation_token = agent.cancellation_token if agent.respond_to?(:cancellation_token)
  prev_approval_gate = agent.approval_gate if agent.respond_to?(:approval_gate)
  prev_log_callback = agent.log_callback if agent.respond_to?(:log_callback)

  # Install the progress callback and cancellation token on the
  # agent for the duration of the dispatch. Cleared in the ensure
  # block below so a per-request agent that is recycled (or
  # accidentally retained) never carries a stale callback or token
  # across requests.
  #
  # Note: a single Parse::Agent instance is NOT safe to drive from
  # two threads concurrently — the snapshot-restore pattern here
  # only handles sequential interleave. MCPRackApp's `agent_factory:`
  # is documented to return a fresh agent per request.
  agent.progress_callback = progress_callback if progress_callback && agent.respond_to?(:progress_callback=)
  agent.cancellation_token = cancellation_token if cancellation_token && agent.respond_to?(:cancellation_token=)
  # Install the per-session approval gate (MCP elicitation) so
  # agent.execute can request human approval for destructive tools.
  # Restored in the ensure block like the other per-request state.
  agent.approval_gate = approval_gate if approval_gate && agent.respond_to?(:approval_gate=)
  agent.log_callback = log_callback if log_callback && agent.respond_to?(:log_callback=)

  # Guard: body must be a Hash with a "method" key.
  unless body.is_a?(Hash) && body.key?("method")
    id = body.is_a?(Hash) ? body["id"] : nil
    return { status: 200, body: jsonrpc_error(id, -32700, "Invalid Request") }
  end

  method = body["method"]
  params = body["params"] || {}
  id = body["id"]

  # JSON-RPC notifications MUST NOT carry an `id` field. Reject
  # `notifications/*` methods that include one — silently treating
  # them as no-op notifications leaves a client expecting a
  # response hanging until its read timeout.
  if method.is_a?(String) && method.start_with?("notifications/") && body.key?("id") && !id.nil?
    return { status: 200, body: jsonrpc_error(id, -32600, "Invalid Request: notifications must not carry an id") }
  end

  result_hash = dispatch(method, params, agent, id, logger, subscription_manager, log_levels)
  { status: result_hash[:status], body: result_hash[:body] }
rescue Parse::Agent::Unauthorized
  { status: 401, body: jsonrpc_error(body.is_a?(Hash) ? body["id"] : nil, -32001, "Unauthorized") }
rescue StandardError => e
  # Do not leak the exception class name (gem fingerprinting). Server-
  # side log goes to the injected logger when set, otherwise $stderr.
  log_internal_error(logger, e)
  { status: 200, body: jsonrpc_error(body.is_a?(Hash) ? body["id"] : nil, -32603, "Internal error") }
ensure
  # Restore the prior callback/token state captured above. This
  # avoids clobbering a token installed by an outer scope when
  # this dispatch ran as a nested invocation, and avoids leaving
  # this request's token visible to a sibling dispatch on a
  # shared agent.
  if agent.respond_to?(:progress_callback=)
    agent.progress_callback = prev_progress_callback
  end
  if agent.respond_to?(:cancellation_token=)
    agent.cancellation_token = prev_cancellation_token
  end
  if agent.respond_to?(:approval_gate=)
    agent.approval_gate = prev_approval_gate
  end
  if agent.respond_to?(:log_callback=)
    agent.log_callback = prev_log_callback
  end
end