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" => {...} } }

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.

"2025-06-18"
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-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.

{
  "tools"     => { "listChanged" => true  },
  "resources" => { "subscribe"   => false, "listChanged" => false },
  "prompts"   => { "listChanged" => true  },
}.freeze
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) ⇒ 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.

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.



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
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
# File 'lib/parse/agent/mcp_dispatcher.rb', line 135

def self.call(body:, agent:, logger: nil, progress_callback: nil, cancellation_token: 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)

  # 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=)

  # 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)
  { status: result_hash[:status], body: result_hash[:body] }

rescue Parse::Agent::Unauthorized => e
  { 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
end