Skip to content

Commit 5ed3b5d

Browse files
committed
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, the Ruby SDK only supported server-to-client messages via a separate GET SSE stream, and POST responses to JSON-RPC requests were sent through the GET stream with a 202 HTTP status. 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. This made the SDK non-compliant with the specification. 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[: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, falling back to the GET SSE stream for messages without a related request. 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 Updated existing tests to handle SSE response format where applicable. ## Breaking Changes This PR is a 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.
1 parent 0d700d7 commit 5ed3b5d

File tree

10 files changed

+617
-154
lines changed

10 files changed

+617
-154
lines changed

lib/json_rpc_handler.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def process_request(request, id_validation_pattern:, &method_finder)
9292
end
9393

9494
begin
95-
method = method_finder.call(method_name)
95+
method = method_finder.call(method_name, id)
9696

9797
if method.nil?
9898
return error_response(id: id, id_validation_pattern: id_validation_pattern, error: {

lib/mcp/progress.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
module MCP
44
class Progress
5-
def initialize(notification_target:, progress_token:)
5+
def initialize(notification_target:, progress_token:, related_request_id: nil)
66
@notification_target = notification_target
77
@progress_token = progress_token
8+
@related_request_id = related_request_id
89
end
910

1011
def report(progress, total: nil, message: nil)
@@ -16,6 +17,7 @@ def report(progress, total: nil, message: nil)
1617
progress: progress,
1718
total: total,
1819
message: message,
20+
related_request_id: @related_request_id,
1921
)
2022
end
2123
end

lib/mcp/server.rb

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ def initialize(
127127
# When `nil`, progress and logging notifications from tool handlers are silently skipped.
128128
# @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
129129
def handle(request, session: nil)
130-
JsonRpcHandler.handle(request) do |method|
131-
handle_request(request, method, session: session)
130+
JsonRpcHandler.handle(request) do |method, request_id|
131+
handle_request(request, method, session: session, related_request_id: request_id)
132132
end
133133
end
134134

@@ -140,8 +140,8 @@ def handle(request, session: nil)
140140
# When `nil`, progress and logging notifications from tool handlers are silently skipped.
141141
# @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
142142
def handle_json(request, session: nil)
143-
JsonRpcHandler.handle_json(request) do |method|
144-
handle_request(request, method, session: session)
143+
JsonRpcHandler.handle_json(request) do |method, request_id|
144+
handle_request(request, method, session: session, related_request_id: request_id)
145145
end
146146
end
147147

@@ -220,7 +220,8 @@ def create_sampling_message(
220220
stop_sequences: nil,
221221
metadata: nil,
222222
tools: nil,
223-
tool_choice: nil
223+
tool_choice: nil,
224+
related_request_id: nil
224225
)
225226
unless @transport
226227
raise "Cannot send sampling request without a transport."
@@ -371,7 +372,7 @@ def schema_contains_ref?(schema)
371372
end
372373
end
373374

374-
def handle_request(request, method, session: nil)
375+
def handle_request(request, method, session: nil, related_request_id: nil)
375376
handler = @handlers[method]
376377
unless handler
377378
instrument_call("unsupported_method") do
@@ -399,7 +400,7 @@ def handle_request(request, method, session: nil)
399400
when Methods::RESOURCES_TEMPLATES_LIST
400401
{ resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
401402
when Methods::TOOLS_CALL
402-
call_tool(params, session: session)
403+
call_tool(params, session: session, related_request_id: related_request_id)
403404
when Methods::COMPLETION_COMPLETE
404405
complete(params)
405406
when Methods::LOGGING_SET_LEVEL
@@ -499,7 +500,7 @@ def list_tools(request)
499500
@tools.values.map(&:to_h)
500501
end
501502

502-
def call_tool(request, session: nil)
503+
def call_tool(request, session: nil, related_request_id: nil)
503504
tool_name = request[:name]
504505

505506
tool = tools[tool_name]
@@ -531,7 +532,7 @@ def call_tool(request, session: nil)
531532

532533
progress_token = request.dig(:_meta, :progressToken)
533534

534-
call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session)
535+
call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id)
535536
rescue RequestHandlerError
536537
raise
537538
rescue => e
@@ -611,12 +612,12 @@ def accepts_server_context?(method_object)
611612
parameters.any? { |type, name| type == :keyrest || name == :server_context }
612613
end
613614

614-
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil)
615+
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil)
615616
args = arguments&.transform_keys(&:to_sym) || {}
616617

617618
if accepts_server_context?(tool.method(:call))
618-
progress = Progress.new(notification_target: session, progress_token: progress_token)
619-
server_context = ServerContext.new(context, progress: progress, notification_target: session)
619+
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
620+
server_context = ServerContext.new(context, progress: progress, notification_target: session, related_request_id: related_request_id)
620621
tool.call(**args, server_context: server_context).to_h
621622
else
622623
tool.call(**args).to_h

0 commit comments

Comments
 (0)