You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Support application/json responses in Streamable HTTP transport (opt-in JSON response mode) #844
When enabled, doPost() for JSONRPCRequest would buffer the response from session.responseStream() and return it as application/json instead of opening an SSE stream. Server-initiated notifications would go through the standalone SSE stream (GET on the same /mcp endpoint, as defined in the Streamable HTTP spec).
All other official MCP SDKs already support this as an opt-in option:
In HttpServletStreamableServerTransportProvider.doPost(), session-bound JSONRPCRequest messages always get text/event-stream:
elseif (messageinstanceofMcpSchema.JSONRPCRequestjsonrpcRequest) {
// For streaming responses, we need to return SSEresponse.setContentType(TEXT_EVENT_STREAM); // ← always SSE// ...
}
There is no way to get application/json for session-bound requests. The initialize request (line 461) and HttpServletStatelessServerTransport (line 173) already return application/json - so the pattern exists in the codebase.
"If the input is a JSON-RPC request, the server MUST either return Content-Type: text/event-stream [...] or Content-Type: application/json, to return one JSON object."
This means session management does not require SSE for every response. There are scenarios where a server benefits from sessions (capability negotiation, logging, server-initiated notifications via GET) but its tool handlers produce simple single-message responses that don't need SSE framing.
The existing STATELESS mode (HttpServletStatelessServerTransport) does return application/json, but it removes session management entirely - no server-to-client notifications, no sampling, no capability tracking. This feature request is about supporting application/json responses within the Streamable transport, with sessions, as the spec allows and as the other SDKs already implement.
In local benchmarks comparing MCP server implementations across different SDKs, we observed that switching from SSE-only to JSON responses for simple tool calls (single request-response, no intermediate notifications) resulted in significant performance improvements, primarily due to eliminating chunked transfer encoding, AsyncContext lifecycle overhead, and SSE framing/parsing on both server and client sides.
Would it make sense to add this? Happy to contribute a PR if the maintainers agree.
Hey, thanks. Absolutely that would be wonderful to give that option as well. The design question to resolve is - do you have an idea how to evolve the API around Mcp(Sync|Async)ServerExchange to handle that gracefully without branching logic at every handler? It would also be reasonable to address this together with a design idea for the stateless transport to allow a symmetrical option to upgrade to SSE. Let's discuss this before you open a PR. Thanks!
Hey, thanks. Absolutely that would be wonderful to give that option as well. The design question to resolve is - do you have an idea how to evolve the API around Mcp(Sync|Async)ServerExchange to handle that gracefully without branching logic at every handler? It would also be reasonable to address this together with a design idea for the stateless transport to allow a symmetrical option to upgrade to SSE. Let's discuss this before you open a PR. Thanks!
Great questions @chemicL ! I took a closer look at the codebase to think through the design.
1. Exchange/Handler API: No changes needed
The McpRequestHandler is already transport-agnostic, returning Mono<T> with no awareness of SSE or JSON. The JSON/SSE decision can be resolved below the handler layer, so no branching is needed in handler code.
In McpStreamableServerSession.responseStream() (line 198), the flow is:
requestHandler.handle(exchange, params) // returns Mono<response> - handler doesn't know about transport
.map(result -> JSONRPCResponse) // wraps result
.flatMap(transport::sendMessage) // transport decides format
.then(transport.closeGracefully())
For JSON mode, the transport provider could adapt how responseStream() is used, collecting the JSONRPCResponse and writing it as application/json rather than streaming via SSE. A similar pattern already exists in HttpServletStatelessServerTransport.doPost() (line 137-180).
One subtlety I noticed: if a handler calls exchange.progressNotification() or exchange.loggingNotification() during execution, these currently flow through McpStreamableServerSessionStream.sendNotification() (line 409), which sends to the per-request transport. In JSON mode, there's no SSE stream on that request to receive these.
"Notifications or requests made within the context of a server request will be sent to the standalone SSE stream, if any."
The Go SDK redirects intermediate messages to the GET SSE stream and buffers only the response in pendingJSONMessages (L775-791).
Python SDK (streamable_http.py L534-541) takes a simpler approach: in JSON mode, it iterates through the message stream, keeps only the JSONRPCResponse, and logs/skips intermediate notifications:
TypeScript SDK (streamableHttp.ts L972-981) follows the same pattern as Python: when enableJsonResponse is true, the SSE write path is guarded by if (!this._enableJsonResponse), meaning intermediate notifications are simply not delivered. Only the final JSONRPCResponse is collected and resolved as JSON (L994-1009).
Both are valid approaches depending on the architecture. In the Java SDK context, the Go-style approach (redirect to listeningStreamRef) might be achievable by constructing the exchange differently in responseStream() when JSON mode is enabled, but you all know the architecture much better than I do.
2. Symmetrical transport capabilities
I really like the idea of a stateless SSE upgrade. Currently the two modes are asymmetric:
Streamable (stateful)
Stateless
SSE responses
always
never
JSON responses
never
always
With both opt-in options:
Streamable (stateful)
Stateless
SSE responses
default (can opt into JSON)
opt-in
JSON responses
opt-in
default (can opt into SSE)
Both transport variants could share the same internal pattern: a ResponseMode (or a boolean flag in the builder) that determines the serialization format.
For you who know the implementation deeply: does this rationale make sense, or am I overlooking something?
@thiagomendes thank you for the summary. Can you paraphrase more succinctly what is your API proposal in pseudocode/public API signatures?
Sure! Here's the proposal distilled to public API and internal behavior:
Public API (no changes to handler/exchange)
// Streamable transport - add jsonResponse(boolean) to existing builderHttpServletStreamableServerTransportProvider.builder()
.jsonMapper(mapper)
.mcpEndpoint("/mcp")
.jsonResponse(true) // NEW - opt-in, default false (preserves current SSE behavior)
.build();
Handler code: zero changes. The handler API stays exactly as-is:
// McpRequestHandler<T> - UNCHANGEDMono<T> handle(McpAsyncServerExchangeexchange, Objectparams);
// McpAsyncServerExchange - UNCHANGED (no new methods, no branching)// McpSyncServerExchange - UNCHANGED
Internal behavior (in transport provider, transparent to handlers)
When jsonResponse is enabled, doPost() for JSONRPCRequest would reuse the existing reactive pipeline but collect the result instead of streaming SSE events:
For intermediate notifications (progress/logging) during handler execution, I'd suggest starting with the simpler Python/TypeScript approach: skip them in JSON mode, since it's opt-in and the developer knowingly trades streaming capabilities for simplicity. The Go approach (redirect to standalone SSE stream) could be added later as an evolution if needed.
This could also symmetrically apply to the stateless transport in the future (opt-in SSE upgrade), but keeping the scope focused on the streamable transport for now.
Adding a few sequence diagrams to help visualize the current vs proposed flow:
Current flow: SSE response (text/event-stream)
sequenceDiagram
participant C as Client
participant TP as TransportProvider<br/>(doPost)
participant AC as AsyncContext
participant ST as SessionTransport<br/>(SSE Writer)
participant S as Session<br/>(responseStream)
participant SS as SessionStream<br/>(per-request)
participant E as Exchange
participant H as Handler
C->>TP: POST /mcp (JSONRPCRequest)
TP->>TP: check jsonResponse flag
Note over TP: jsonResponse = false (default)
TP->>TP: setContentType(text/event-stream)
TP->>AC: request.startAsync()
TP->>ST: new SessionTransport(asyncContext, writer)
TP->>S: responseStream(request, transport)
S->>SS: new SessionStream(transport)
S->>E: new Exchange(sessionId, stream, capabilities)
S->>H: handler.handle(exchange, params)
H-->>S: Mono<Result>
S->>S: map(result → JSONRPCResponse)
S->>ST: transport.sendMessage(response)
ST->>ST: writeEvent("event: message\ndata: {json}\n\n")
ST-->>C: SSE event (chunked transfer)
S->>ST: transport.closeGracefully()
ST->>AC: asyncContext.complete()
Loading
Proposed flow: JSON response (application/json)
sequenceDiagram
participant C as Client
participant TP as TransportProvider<br/>(doPost)
participant S as Session<br/>(responseAsJson)
participant E as Exchange
participant H as Handler
C->>TP: POST /mcp (JSONRPCRequest)
TP->>TP: check jsonResponse flag
Note over TP: jsonResponse = true (opt-in)
TP->>TP: setContentType(application/json)
rect rgba(128, 128, 128, 0.15)
Note over TP,H: NEW: JSON response path (no AsyncContext, no SSE transport)
TP->>S: responseAsJson(request)
S->>S: wrap session with no-op notification handler
S->>E: new Exchange(sessionId, noOpWrapper, capabilities)
S->>H: handler.handle(exchange, params)
H-->>S: Mono<Result>
S->>S: map(result → JSONRPCResponse)
S-->>TP: Mono<JSONRPCResponse>
end
TP->>TP: .block()
TP->>TP: writer.write(jsonMapper.writeValueAsString(result))
TP-->>C: HTTP 200, Content-Type: application/json<br/>{"jsonrpc":"2.0","id":"1","result":{...}}
Loading
Edge case: handler emits notifications in JSON mode
sequenceDiagram
participant C as Client
participant TP as TransportProvider<br/>(doPost)
participant S as Session<br/>(responseAsJson)
participant E as Exchange
participant H as Handler
C->>TP: POST /mcp (JSONRPCRequest)
TP->>TP: check jsonResponse flag (true)
TP->>TP: setContentType(application/json)
TP->>S: responseAsJson(request)
S->>S: wrap session with no-op notification handler
S->>E: new Exchange(sessionId, noOpWrapper, capabilities)
S->>H: handler.handle(exchange, params)
rect rgba(128, 128, 128, 0.15)
Note over H,E: Handler emits intermediate notification
H->>E: exchange.progressNotification(...)
E->>E: noOpWrapper.sendNotification(...)
Note over E: Returns Mono.empty() - silently skipped<br/>(Python/TypeScript approach)
end
H-->>S: Mono<Result>
S-->>TP: Mono<JSONRPCResponse>
TP->>TP: .block()
TP-->>C: HTTP 200, application/json
the out-of-band requests or notifications going to the generic listening stream are undesired; interactions related with a client request should be sent on the dedicated SSE stream
ignoring notifications/requests is poor UX
your follow-up uses public API that you did not document - responseAsJson
There might be more but you provide a lot of text, pretty certain that out of an LLM interaction and the investment on our part is best on actually implementing this with adherence to the design philosophy outlined in the .md files of this repo and the existing patterns - especially type safety, backwards-compatibility, etc. Please don't open a PR for this.
I didn't mean to close the issue, the request is absolutely valid. Just the actual solution must be one that is not surprising and uses APIs that clearly indicate to the user what is and what is not possible.
Hi @chemicL, I'm interested in picking this up. Building on the earlier discussion, here's a design direction I'd like to propose — let me know if it aligns with your expectations.
Core problem: intermediate messages in JSON mode
You pointed out two unacceptable approaches: redirecting to the listening stream (semantically wrong) and silently dropping (poor UX). I noticed that MissingMcpTransportSession already establishes a pattern for exactly this kind of scenario — fail explicitly, returning Mono.error(IllegalStateException("Stream unavailable for session ...")).
JSON response mode can reuse this pattern: when a handler in JSON mode attempts to send intermediate notifications (progressNotification, loggingNotification) or server-initiated requests (createMessage, createElicitation, listRoots), a dedicated session wrapper returns a clear error, e.g.:
"Server-initiated messages are not supported in JSON response mode.
Use SSE response mode or remove intermediate notifications from this handler."
Benefits:
No misleading behavior — messages are neither silently swallowed nor sent to the wrong stream
Consistent with existing patterns — aligns with MissingMcpTransportSession's design philosophy
Zero handler API changes — McpRequestHandler, McpAsyncServerExchange, McpSyncServerExchange signatures remain untouched
Backwards-compatible — jsonResponseEnabled defaults to false, existing behavior is unaffected
Implementation outline
McpStreamableServerSession — add a handleRequest(JSONRPCRequest) method returning Mono<JSONRPCResponse>. Unlike responseStream(), it does not create an SSE transport/stream. The exchange is constructed with a "JSON-mode session wrapper" whose sendRequest/sendNotification return explicit errors.
HttpServletStreamableServerTransportProvider (and the WebMvc/WebFlux counterparts) — Builder gets jsonResponseEnabled(boolean). In doPost(), the JSONRPCRequest branch routes to either JSON or SSE based on the flag.
Accept header validation — in JSON mode, only application/json is required, not text/event-stream.
On symmetry with stateless SSE upgrade
You previously mentioned considering a symmetrical opt-in for stateless transport to upgrade to SSE. I see the value in that direction, but would it be acceptable to focus this PR on the streamable transport's JSON response support, and handle the stateless SSE upgrade as a separate issue/PR? That would keep each change scoped and reviewable.
Happy to prepare a PR if this direction works. Looking forward to your feedback.
Related context:#616 and PR #521 also discussed a similar topic. At the time, @chemicL pointed out that the MCP spec requires the client Accept header to include both application/json and text/event-stream, so the approach of differentiating response format based on the client's Accept header was not spec-compliant.
The approach proposed here is different — it's a server-side opt-in configuration that doesn't change client behavior or rely on the Accept header to determine response format, so it remains compatible with the spec's client requirements.
Hi @chemicL, gentle ping — would appreciate your feedback on the design direction I outlined above when you have a moment. Happy to iterate on any part of it before moving to a PR. Thanks!
Activity
chemicL commentedon Mar 4, 2026
Hey, thanks. Absolutely that would be wonderful to give that option as well. The design question to resolve is - do you have an idea how to evolve the API around
Mcp(Sync|Async)ServerExchangeto handle that gracefully without branching logic at every handler? It would also be reasonable to address this together with a design idea for the stateless transport to allow a symmetrical option to upgrade to SSE. Let's discuss this before you open a PR. Thanks!thiagomendes commentedon Mar 4, 2026
Great questions @chemicL ! I took a closer look at the codebase to think through the design.
1. Exchange/Handler API: No changes needed
The
McpRequestHandleris already transport-agnostic, returningMono<T>with no awareness of SSE or JSON. The JSON/SSE decision can be resolved below the handler layer, so no branching is needed in handler code.In
McpStreamableServerSession.responseStream()(line 198), the flow is:For JSON mode, the transport provider could adapt how
responseStream()is used, collecting theJSONRPCResponseand writing it asapplication/jsonrather than streaming via SSE. A similar pattern already exists inHttpServletStatelessServerTransport.doPost()(line 137-180).One subtlety I noticed: if a handler calls
exchange.progressNotification()orexchange.loggingNotification()during execution, these currently flow throughMcpStreamableServerSessionStream.sendNotification()(line 409), which sends to the per-request transport. In JSON mode, there's no SSE stream on that request to receive these.Looking at how other SDKs handle this:
Go SDK (
streamable.goL562-568) explicitly documents the approach:The Go SDK redirects intermediate messages to the GET SSE stream and buffers only the response in
pendingJSONMessages(L775-791).Python SDK (
streamable_http.pyL534-541) takes a simpler approach: in JSON mode, it iterates through the message stream, keeps only theJSONRPCResponse, and logs/skips intermediate notifications:TypeScript SDK (
streamableHttp.tsL972-981) follows the same pattern as Python: whenenableJsonResponseis true, the SSE write path is guarded byif (!this._enableJsonResponse), meaning intermediate notifications are simply not delivered. Only the finalJSONRPCResponseis collected and resolved as JSON (L994-1009).Both are valid approaches depending on the architecture. In the Java SDK context, the Go-style approach (redirect to
listeningStreamRef) might be achievable by constructing the exchange differently inresponseStream()when JSON mode is enabled, but you all know the architecture much better than I do.2. Symmetrical transport capabilities
I really like the idea of a stateless SSE upgrade. Currently the two modes are asymmetric:
With both opt-in options:
Both transport variants could share the same internal pattern: a
ResponseMode(or a boolean flag in the builder) that determines the serialization format.For you who know the implementation deeply: does this rationale make sense, or am I overlooking something?
chemicL commentedon Mar 5, 2026
@thiagomendes thank you for the summary. Can you paraphrase more succinctly what is your API proposal in pseudocode/public API signatures?
thiagomendes commentedon Mar 5, 2026
Sure! Here's the proposal distilled to public API and internal behavior:
Public API (no changes to handler/exchange)
Handler code: zero changes. The handler API stays exactly as-is:
Internal behavior (in transport provider, transparent to handlers)
When
jsonResponseis enabled,doPost()forJSONRPCRequestwould reuse the existing reactive pipeline but collect the result instead of streaming SSE events:For intermediate notifications (progress/logging) during handler execution, I'd suggest starting with the simpler Python/TypeScript approach: skip them in JSON mode, since it's opt-in and the developer knowingly trades streaming capabilities for simplicity. The Go approach (redirect to standalone SSE stream) could be added later as an evolution if needed.
This could also symmetrically apply to the stateless transport in the future (opt-in SSE upgrade), but keeping the scope focused on the streamable transport for now.
thiagomendes commentedon Mar 5, 2026
Adding a few sequence diagrams to help visualize the current vs proposed flow:
Current flow: SSE response (text/event-stream)
sequenceDiagram participant C as Client participant TP as TransportProvider<br/>(doPost) participant AC as AsyncContext participant ST as SessionTransport<br/>(SSE Writer) participant S as Session<br/>(responseStream) participant SS as SessionStream<br/>(per-request) participant E as Exchange participant H as Handler C->>TP: POST /mcp (JSONRPCRequest) TP->>TP: check jsonResponse flag Note over TP: jsonResponse = false (default) TP->>TP: setContentType(text/event-stream) TP->>AC: request.startAsync() TP->>ST: new SessionTransport(asyncContext, writer) TP->>S: responseStream(request, transport) S->>SS: new SessionStream(transport) S->>E: new Exchange(sessionId, stream, capabilities) S->>H: handler.handle(exchange, params) H-->>S: Mono<Result> S->>S: map(result → JSONRPCResponse) S->>ST: transport.sendMessage(response) ST->>ST: writeEvent("event: message\ndata: {json}\n\n") ST-->>C: SSE event (chunked transfer) S->>ST: transport.closeGracefully() ST->>AC: asyncContext.complete()Proposed flow: JSON response (application/json)
sequenceDiagram participant C as Client participant TP as TransportProvider<br/>(doPost) participant S as Session<br/>(responseAsJson) participant E as Exchange participant H as Handler C->>TP: POST /mcp (JSONRPCRequest) TP->>TP: check jsonResponse flag Note over TP: jsonResponse = true (opt-in) TP->>TP: setContentType(application/json) rect rgba(128, 128, 128, 0.15) Note over TP,H: NEW: JSON response path (no AsyncContext, no SSE transport) TP->>S: responseAsJson(request) S->>S: wrap session with no-op notification handler S->>E: new Exchange(sessionId, noOpWrapper, capabilities) S->>H: handler.handle(exchange, params) H-->>S: Mono<Result> S->>S: map(result → JSONRPCResponse) S-->>TP: Mono<JSONRPCResponse> end TP->>TP: .block() TP->>TP: writer.write(jsonMapper.writeValueAsString(result)) TP-->>C: HTTP 200, Content-Type: application/json<br/>{"jsonrpc":"2.0","id":"1","result":{...}}Edge case: handler emits notifications in JSON mode
sequenceDiagram participant C as Client participant TP as TransportProvider<br/>(doPost) participant S as Session<br/>(responseAsJson) participant E as Exchange participant H as Handler C->>TP: POST /mcp (JSONRPCRequest) TP->>TP: check jsonResponse flag (true) TP->>TP: setContentType(application/json) TP->>S: responseAsJson(request) S->>S: wrap session with no-op notification handler S->>E: new Exchange(sessionId, noOpWrapper, capabilities) S->>H: handler.handle(exchange, params) rect rgba(128, 128, 128, 0.15) Note over H,E: Handler emits intermediate notification H->>E: exchange.progressNotification(...) E->>E: noOpWrapper.sendNotification(...) Note over E: Returns Mono.empty() - silently skipped<br/>(Python/TypeScript approach) end H-->>S: Mono<Result> S-->>TP: Mono<JSONRPCResponse> TP->>TP: .block() TP-->>C: HTTP 200, application/jsonKey differences
text/event-streamapplication/jsonevent: message\ndata: ...\n\nchemicL commentedon Mar 9, 2026
Thanks for clarifying.
My main concerns are:
responseAsJsonThere might be more but you provide a lot of text, pretty certain that out of an LLM interaction and the investment on our part is best on actually implementing this with adherence to the design philosophy outlined in the .md files of this repo and the existing patterns - especially type safety, backwards-compatibility, etc. Please don't open a PR for this.
thiagomendes commentedon Mar 9, 2026
@chemicL Thanks for the feedback.
chemicL commentedon Mar 9, 2026
I didn't mean to close the issue, the request is absolutely valid. Just the actual solution must be one that is not surprising and uses APIs that clearly indicate to the user what is and what is not possible.
SkyeBeFreeman commentedon Apr 14, 2026
Hi @chemicL, I'm interested in picking this up. Building on the earlier discussion, here's a design direction I'd like to propose — let me know if it aligns with your expectations.
Core problem: intermediate messages in JSON mode
You pointed out two unacceptable approaches: redirecting to the listening stream (semantically wrong) and silently dropping (poor UX). I noticed that
MissingMcpTransportSessionalready establishes a pattern for exactly this kind of scenario — fail explicitly, returningMono.error(IllegalStateException("Stream unavailable for session ...")).JSON response mode can reuse this pattern: when a handler in JSON mode attempts to send intermediate notifications (
progressNotification,loggingNotification) or server-initiated requests (createMessage,createElicitation,listRoots), a dedicated session wrapper returns a clear error, e.g.:Benefits:
MissingMcpTransportSession's design philosophyMcpRequestHandler,McpAsyncServerExchange,McpSyncServerExchangesignatures remain untouchedjsonResponseEnableddefaults tofalse, existing behavior is unaffectedImplementation outline
McpStreamableServerSession— add ahandleRequest(JSONRPCRequest)method returningMono<JSONRPCResponse>. UnlikeresponseStream(), it does not create an SSE transport/stream. The exchange is constructed with a "JSON-mode session wrapper" whosesendRequest/sendNotificationreturn explicit errors.HttpServletStreamableServerTransportProvider(and the WebMvc/WebFlux counterparts) — Builder getsjsonResponseEnabled(boolean). IndoPost(), theJSONRPCRequestbranch routes to either JSON or SSE based on the flag.application/jsonis required, nottext/event-stream.On symmetry with stateless SSE upgrade
You previously mentioned considering a symmetrical opt-in for stateless transport to upgrade to SSE. I see the value in that direction, but would it be acceptable to focus this PR on the streamable transport's JSON response support, and handle the stateless SSE upgrade as a separate issue/PR? That would keep each change scoped and reviewable.
Happy to prepare a PR if this direction works. Looking forward to your feedback.
SkyeBeFreeman commentedon Apr 16, 2026
Related context: #616 and PR #521 also discussed a similar topic. At the time, @chemicL pointed out that the MCP spec requires the client Accept header to include both
application/jsonandtext/event-stream, so the approach of differentiating response format based on the client's Accept header was not spec-compliant.The approach proposed here is different — it's a server-side opt-in configuration that doesn't change client behavior or rely on the Accept header to determine response format, so it remains compatible with the spec's client requirements.
SkyeBeFreeman commentedon Apr 28, 2026
Hi @chemicL, gentle ping — would appreciate your feedback on the design direction I outlined above when you have a moment. Happy to iterate on any part of it before moving to a PR. Thanks!
Add a stateless server for MCP 2026-07-28
Add a stateless server for MCP 2026-07-28
Modern stateless server for MCP 2026-07-28 (#1170)