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.
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
-
.call(body:, agent:, logger: nil, progress_callback: nil, cancellation_token: nil) ⇒ Hash
Dispatch a JSON-RPC request body to the appropriate handler.
Class Method Details
.call(body:, agent:, logger: nil, progress_callback: nil, cancellation_token: nil) ⇒ Hash
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
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 |