Skip to content

Support POST response SSE streams for server-to-client messages#294

Merged
koic merged 1 commit intomodelcontextprotocol:mainfrom
koic:post_response_sse_stream
Apr 4, 2026
Merged

Support POST response SSE streams for server-to-client messages#294
koic merged 1 commit intomodelcontextprotocol:mainfrom
koic:post_response_sse_stream

Conversation

@koic
Copy link
Copy Markdown
Member

@koic koic commented Apr 4, 2026

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

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.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@koic koic force-pushed the post_response_sse_stream branch 4 times, most recently from 46aa0fa to affbda4 Compare April 4, 2026 10:14
## 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.
@koic koic force-pushed the post_response_sse_stream branch from affbda4 to 8278ca6 Compare April 4, 2026 10:21
Copy link
Copy Markdown
Contributor

@atesgoral atesgoral left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

@koic koic merged commit 40b048b into modelcontextprotocol:main Apr 4, 2026
11 checks passed
@koic koic deleted the post_response_sse_stream branch April 4, 2026 17:56
koic added a commit that referenced this pull request Apr 5, 2026
Follow-up to #294.

The generic `:stream` key does not convey that it refers to the GET SSE stream,
making it harder to distinguish from `session[:post_request_streams]` introduced in #294.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants