ruby-sdk/lib/json_rpc_handler.rb
Koichi ITO 8278ca6586 Support POST response SSE streams for server-to-client messages
## Motivation and Context

The MCP Streamable HTTP specification defines that servers can return POST responses
as SSE streams and send server-to-client JSON-RPC requests and notifications through them:

> If the input is a JSON-RPC request, the server MUST either return
> `Content-Type: text/event-stream`, to initiate an SSE stream, or
> `Content-Type: application/json`, to return one JSON object.

If the server initiates an SSE stream:

> The server MAY send JSON-RPC requests and notifications before
> sending the JSON-RPC response. These messages SHOULD relate to the
> originating client request.

See: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server

Previously, when a GET SSE stream was connected, the Ruby SDK sent POST response bodies through
the GET stream and returned a 202 HTTP status for the POST itself. When no GET SSE stream was
connected, the SDK correctly returned `application/json` responses. The 202 behavior with GET SSE
was non-compliant with the specification, which requires POST requests to return `text/event-stream`
or `application/json`. Additionally, since GET SSE is optional per the specification, clients that
did not establish a GET SSE connection could not receive server-to-client messages (e.g.,
`sampling/createMessage`, log notifications) during request processing.

With this change, `handle_regular_request` always returns the POST response as an SSE stream for stateful sessions.
Each POST response stream is stored in `session[:post_request_streams]` keyed by `related_request_id` (the JSON-RPC request ID),
enabling correct routing when multiple POST requests are processed concurrently on the same session.
Server-to-client messages with a `related_request_id` are routed to the originating POST response stream only;
when `related_request_id` is nil, the GET SSE stream is used.

The TypeScript and Python SDKs already support this pattern.

### Internal Changes

`JsonRpcHandler.handle` and `JsonRpcHandler.handle_json` now pass both `method_name` and `request_id` to the method finder block.
This allows `Server#handle_request` to receive `related_request_id` directly from the protocol layer.
Without this, `related_request_id` would need to be relayed as a keyword argument through `Server#handle_json`,
`ServerSession#handle_json`, and `dispatch_handle_json`, unnecessarily exposing it on public method signatures.
This follows the same design as the TypeScript and Python SDKs, where the protocol layer extracts the request ID
and propagates it to the handler context.

## How Has This Been Tested?

Added tests for POST response stream:

- `send_request` via POST response stream (sampling with and without GET SSE)
- `send_notification` via POST response stream (logging without GET SSE)
- `progress` notification via POST response stream (without GET SSE)
- POST request returns SSE response even with GET SSE connected
- Session-scoped notifications (log, progress) are sent to POST
  response stream, not GET SSE stream
- `active_stream` does not fall back to GET SSE when
  `related_request_id` is given but request stream is missing

Updated existing tests to handle SSE response format where applicable.

## Breaking Changes (spec compliance fix)

POST responses for JSON-RPC requests in stateful sessions now return `Content-Type: text/event-stream`
instead of being sent through the GET SSE stream with a 202 HTTP status. Clients that relied on receiving responses
via the GET SSE stream will need to read the POST response body instead. This is a spec compliance fix:
the MCP specification requires POST requests to return `text/event-stream` or `application/json`,
not 202 with the response on a separate GET stream.
2026-04-04 19:20:31 +09:00

171 lines
5.1 KiB
Ruby

# frozen_string_literal: true
require "json"
module JsonRpcHandler
class Version
V1_0 = "1.0"
V2_0 = "2.0"
end
class ErrorCode
INVALID_REQUEST = -32600
METHOD_NOT_FOUND = -32601
INVALID_PARAMS = -32602
INTERNAL_ERROR = -32603
PARSE_ERROR = -32700
end
DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/
extend self
def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
if request.is_a?(Array)
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INVALID_REQUEST,
message: "Invalid Request",
data: "Request is an empty array",
}) if request.empty?
# Handle batch requests
responses = request.map { |req| process_request(req, id_validation_pattern: id_validation_pattern, &method_finder) }.compact
# A single item is hoisted out of the array
return responses.first if responses.one?
# An empty array yields nil
responses if responses.any?
elsif request.is_a?(Hash)
# Handle single request
process_request(request, id_validation_pattern: id_validation_pattern, &method_finder)
else
error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INVALID_REQUEST,
message: "Invalid Request",
data: "Request must be an array or a hash",
})
end
end
def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
begin
request = JSON.parse(request_json, symbolize_names: true)
response = handle(request, id_validation_pattern: id_validation_pattern, &method_finder)
rescue JSON::ParserError
response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::PARSE_ERROR,
message: "Parse error",
data: "Invalid JSON",
})
end
response&.to_json
end
def process_request(request, id_validation_pattern:, &method_finder)
id = request[:id]
error = if !valid_version?(request[:jsonrpc])
"JSON-RPC version must be 2.0"
elsif !valid_id?(request[:id], id_validation_pattern)
"Request ID must match validation pattern, or be an integer or null"
elsif !valid_method_name?(request[:method])
'Method name must be a string and not start with "rpc."'
end
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INVALID_REQUEST,
message: "Invalid Request",
data: error,
}) if error
method_name = request[:method]
params = request[:params]
unless valid_params?(params)
return error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INVALID_PARAMS,
message: "Invalid params",
data: "Method parameters must be an array or an object or null",
})
end
begin
method = method_finder.call(method_name, id)
if method.nil?
return error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::METHOD_NOT_FOUND,
message: "Method not found",
data: method_name,
})
end
result = method.call(params)
success_response(id: id, result: result)
rescue MCP::Server::RequestHandlerError => e
handle_request_error(e, id, id_validation_pattern)
rescue StandardError => e
error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INTERNAL_ERROR,
message: "Internal error",
data: e.message,
})
end
end
def handle_request_error(error, id, id_validation_pattern)
error_type = error.respond_to?(:error_type) ? error.error_type : nil
code, message = case error_type
when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"]
when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"]
when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"]
when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"]
else [ErrorCode::INTERNAL_ERROR, "Internal error"]
end
error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
code: code,
message: message,
data: error.message,
})
end
def valid_version?(version)
version == Version::V2_0
end
def valid_id?(id, pattern = nil)
return true if id.nil? || id.is_a?(Integer)
return false unless id.is_a?(String)
pattern ? id.match?(pattern) : true
end
def valid_method_name?(method)
method.is_a?(String) && !method.start_with?("rpc.")
end
def valid_params?(params)
params.nil? || params.is_a?(Array) || params.is_a?(Hash)
end
def success_response(id:, result:)
{
jsonrpc: Version::V2_0,
id: id,
result: result,
} unless id.nil?
end
def error_response(id:, id_validation_pattern:, error:)
{
jsonrpc: Version::V2_0,
id: valid_id?(id, id_validation_pattern) ? id : nil,
error: error.compact,
} unless id.nil?
end
end