Skip to content

Commit 26a87ed

Browse files
committed
Support JSON response mode for StreamableHTTPTransport
## Motivation and Context The MCP Streamable HTTP specification allows servers to return POST responses as either `text/event-stream` (SSE) or `application/json`: > 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. See: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server The TypeScript and Python SDKs support a configurable JSON response mode via `enableJsonResponse` / `is_json_response_enabled`. JSON response mode is suitable for simple tool servers that do not need server-initiated requests. It avoids SSE framing overhead and returns a single JSON object for the POST response. ## Behavior - POST responses use `Content-Type: application/json` and return a single JSON object. - The POST `Accept` header requirement is relaxed to `application/json` only (matching the Python SDK's lenient behavior). - Request-scoped notifications (`progress`, `log`) cannot ride along with the single-object response and are silently dropped. - Session-scoped standalone notifications (`resources/updated`, `elicitation/complete`) and broadcast notifications (`tools/list_changed`, etc.) continue to flow to clients connected to the GET SSE stream. - All server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error in JSON response mode. - Combines with `stateless: true` for simple single-response servers without session tracking. ## How Has This Been Tested? Added tests for JSON response mode: - POST request returns `application/json` response - Accept header validation requires only `application/json` - Returns 406 when Accept header is missing - Accepts wildcard `*/*` in Accept header - Request-scoped notifications (progress, log) during tool execution are silently dropped - Request-scoped notifications do not leak to GET SSE even when connected - Session-scoped standalone notifications are delivered via GET SSE - Broadcast notifications are delivered via GET SSE - `send_request` raises for `sampling/createMessage`, `roots/list`, `elicitation/create` - Combined with `stateless: true`: POST returns JSON without a session ID, GET returns 405 ## Breaking Changes None. JSON response mode is opt-in via `enable_json_response: true`. The default behavior (SSE responses) is unchanged.
1 parent 0770e27 commit 26a87ed

3 files changed

Lines changed: 449 additions & 4 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,6 +1425,23 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`
14251425
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
14261426
```
14271427

1428+
You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`.
1429+
Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`:
1430+
1431+
```ruby
1432+
# JSON response mode
1433+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, enable_json_response: true)
1434+
```
1435+
1436+
In JSON response mode, the POST response is a single JSON object, so server-to-client
1437+
messages that need to arrive during request processing are not supported:
1438+
request-scoped notifications (`progress`, `log`) are silently dropped, and all
1439+
server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`)
1440+
raise an error. Session-scoped standalone notifications (`resources/updated`,
1441+
`elicitation/complete`) and broadcast notifications (`tools/list_changed`, etc.) still
1442+
flow to clients connected to the GET SSE stream. This mode is suitable for simple tool
1443+
servers that do not need server-initiated requests.
1444+
14281445
By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds).
14291446
When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up:
14301447

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,14 @@ class StreamableHTTPTransport < Transport
2222
"Connection" => "keep-alive",
2323
}.freeze
2424

25-
def initialize(server, stateless: false, session_idle_timeout: nil)
25+
def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
2626
super(server)
2727
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
2828
@sessions = {}
2929
@mutex = Mutex.new
3030

3131
@stateless = stateless
32+
@enable_json_response = enable_json_response
3233
@session_idle_timeout = session_idle_timeout
3334
@pending_responses = {}
3435

@@ -43,7 +44,8 @@ def initialize(server, stateless: false, session_idle_timeout: nil)
4344
start_reaper_thread if @session_idle_timeout
4445
end
4546

46-
REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
47+
REQUIRED_POST_ACCEPT_TYPES_SSE = ["application/json", "text/event-stream"].freeze
48+
REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze
4749
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
4850
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
4951
SESSION_REAP_INTERVAL = 60
@@ -94,6 +96,12 @@ def send_notification(method, params = nil, session_id: nil, related_request_id:
9496

9597
result = @mutex.synchronize do
9698
if session_id
99+
# JSON response mode returns a single JSON object as the POST response,
100+
# so request-scoped notifications (e.g. progress, log) cannot be delivered
101+
# alongside it. Session-scoped standalone notifications
102+
# (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
103+
next false if @enable_json_response && related_request_id
104+
97105
# Send to specific session
98106
if (session = @sessions[session_id])
99107
stream = active_stream(session, related_request_id: related_request_id)
@@ -172,6 +180,10 @@ def send_request(method, params = nil, session_id: nil, related_request_id: nil)
172180
raise "Stateless mode does not support server-to-client requests."
173181
end
174182

183+
if @enable_json_response
184+
raise "JSON response mode does not support server-to-client requests."
185+
end
186+
175187
unless session_id
176188
raise "session_id is required for server-to-client requests."
177189
end
@@ -278,7 +290,8 @@ def send_ping_to_stream(stream)
278290
end
279291

280292
def handle_post(request)
281-
accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
293+
required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
294+
accept_error = validate_accept_header(request, required_types)
282295
return accept_error if accept_error
283296

284297
content_type_error = validate_content_type(request)
@@ -519,7 +532,7 @@ def handle_regular_request(body_string, session_id, related_request_id: nil)
519532
end
520533
end
521534

522-
if session_id && !@stateless
535+
if session_id && !@stateless && !@enable_json_response
523536
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
524537
else
525538
response = dispatch_handle_json(body_string, server_session)

0 commit comments

Comments
 (0)