fix(server): GET SSE stream crash on Netty in Streamable HTTP#681
fix(server): GET SSE stream crash on Netty in Streamable HTTP#681
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes a Netty crash that occurred when setting SSE response headers after Ktor’s sse {} handler had already committed headers, which could terminate the SSE stream and cause client retry loops.
Changes:
- Stop calling
appendSseHeaders()inside Ktor’ssse {}blocks for GET SSE streams / replay paths. - Set
Mcp-Session-Idfor GET SSE responses earlier in the Ktor pipeline (before headers are committed). - Add a regression test ensuring the GET SSE stream returns
Mcp-Session-Idand remains open.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt |
Adds a test covering GET SSE header presence + connection staying open. |
kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt |
Removes header mutation inside sse {} and relies on early header setting + flush. |
kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt |
Adds a route interceptor to attach Mcp-Session-Id on GET before SSE commits headers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Set Mcp-Session-Id on GET responses before Ktor's sse {} commits headers. | ||
| intercept(ApplicationCallPipeline.Plugins) { | ||
| if (context.request.httpMethod == HttpMethod.Get) { | ||
| val sessionId = context.request.header(MCP_SESSION_ID_HEADER) |
There was a problem hiding this comment.
The GET interceptor reads the session ID via context.request.header(...) (single value), but the transport validation later rejects requests that contain multiple Mcp-Session-Id header values. In that case this interceptor can still echo a session ID onto a response that will be rejected. Consider using context.request.headers.getAll(MCP_SESSION_ID_HEADER) and only setting the response header when there is exactly one non-blank value (matching StreamableHttpServerTransport.validateSession).
| val sessionId = context.request.header(MCP_SESSION_ID_HEADER) | |
| val sessionIds = context.request.headers.getAll(MCP_SESSION_ID_HEADER) | |
| val sessionId = | |
| sessionIds | |
| ?.singleOrNull() | |
| ?.takeIf { it.isNotBlank() } |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
appendSseHeaders()was called inside Ktor'ssse {}block where response headers are already committed. On Netty this throwsUnsupportedOperationException, killing the SSE stream and causing the client to retry in a loopHow Has This Been Tested?
unit, simple-streamable-server sample
Breaking Changes
none
Types of changes
Checklist