fix(server): preserve Streamable HTTP response status and body (#911)

Streamable HTTP responses now keep their status when the client accepts
only `text/event-stream`.

## How Has This Been Tested?
New `StreamableHttpResponseDeliveryTest`

## Breaking Changes
None

## Types of changes
- [x] 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
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed
This commit is contained in:
Pavel Gorgulov 2026-07-27 13:39:49 +02:00 • committed by GitHub
commit 82fe56e1e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 191 additions and 21 deletions

View file

@ -1,6 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
@ -282,18 +283,10 @@ private fun Application.mcpStatelessStreamableHttp(
)
}
get {
call.reject(
HttpStatusCode.MethodNotAllowed,
RPCError.ErrorCode.CONNECTION_CLOSED,
"Method not allowed.",
)
call.rejectUnsupportedMethod()
}
delete {
call.reject(
HttpStatusCode.MethodNotAllowed,
RPCError.ErrorCode.CONNECTION_CLOSED,
"Method not allowed.",
)
call.rejectUnsupportedMethod()
}
}
}
@ -419,6 +412,16 @@ private suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportMa
logger.trace { "Message handled for sessionId: $sessionId" }
}
/** A stateless endpoint serves POST only, offering neither an SSE stream to open nor a session to delete. */
private suspend fun ApplicationCall.rejectUnsupportedMethod() {
response.header(HttpHeaders.Allow, HttpMethod.Post.value)
reject(
HttpStatusCode.MethodNotAllowed,
RPCError.ErrorCode.CONNECTION_CLOSED,
"Method not allowed.",
)
}
private fun ApplicationRequest.sessionId(): String? = header(MCP_SESSION_ID_HEADER)
private suspend fun existingStreamableTransport(

View file

@ -10,7 +10,7 @@ import io.ktor.server.request.header
import io.ktor.server.request.httpMethod
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondNullable
import io.ktor.server.response.respondText
import io.ktor.server.sse.ServerSSESession
import io.ktor.util.collections.ConcurrentMap
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
@ -474,12 +474,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
)
return
}
if (!validateProtocolVersion(call)) return
if (!validateProtocolVersion(call, initializationRequest.id)) return
if (messages.size > 1) {
call.reject(
HttpStatusCode.BadRequest,
RPCError.ErrorCode.INVALID_REQUEST,
"Invalid Request: Only one initialization request is allowed",
initializationRequest.id,
)
return
}
@ -491,12 +492,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
sessionId?.let { onSessionInitialized?.invoke(it) }
}
} else {
if (!validateSession(call) || !validateProtocolVersion(call)) return
val requestId = messages.filterIsInstance<JSONRPCRequest>().firstOrNull()?.id
if (!validateSession(call, requestId) || !validateProtocolVersion(call, requestId)) return
}
val hasRequest = messages.any { it is JSONRPCRequest }
if (!hasRequest) {
call.respondNullable(status = HttpStatusCode.Accepted, message = null)
call.respond(HttpStatusCode.Accepted)
messages.forEach { message -> _onMessage(message) }
// A cancellation may target a request still awaiting delivery on a JSON-mode POST;
// retire it so that POST completes instead of waiting for a response that never comes.
@ -552,7 +554,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
if (responses.isEmpty()) {
// Every request in this POST was cancelled before producing a response; there is
// nothing to return, so acknowledge with 202 rather than an empty JSON body.
call.respondNullable(status = HttpStatusCode.Accepted, message = null)
call.respond(HttpStatusCode.Accepted)
} else {
call.response.header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
val payload = if (responses.size == 1) responses.first() else responses
@ -645,7 +647,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
if (!validateSession(call) || !validateProtocolVersion(call)) return
sessionId?.let { onSessionClosed?.invoke(it) }
close()
call.respondNullable(status = HttpStatusCode.OK, message = null)
call.respond(HttpStatusCode.OK)
}
/**
@ -734,7 +736,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
}
}
private suspend fun validateSession(call: ApplicationCall): Boolean {
private suspend fun validateSession(call: ApplicationCall, id: RequestId? = null): Boolean {
if (sessionIdGenerator == null) return true
if (!initialized.load()) {
@ -742,6 +744,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
HttpStatusCode.BadRequest,
RPCError.ErrorCode.CONNECTION_CLOSED,
"Bad Request: Server not initialized",
id,
)
return false
}
@ -753,6 +756,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
HttpStatusCode.BadRequest,
RPCError.ErrorCode.CONNECTION_CLOSED,
"Bad Request: Mcp-Session-Id header is required",
id,
)
return false
}
@ -765,13 +769,14 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
HttpStatusCode.NotFound,
REQUEST_TIMEOUT,
"Session not found",
id,
)
false
}
}
}
private suspend fun validateProtocolVersion(call: ApplicationCall): Boolean {
private suspend fun validateProtocolVersion(call: ApplicationCall, id: RequestId? = null): Boolean {
val version = call.request.headers[MCP_PROTOCOL_VERSION_HEADER] ?: return true
return when (version) {
@ -784,6 +789,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
", ",
)
})",
id,
)
false
}
@ -914,12 +920,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
}
}
/** Writes an already serialized body so a client accepting only `text/event-stream` still receives [status]. */
internal suspend fun ApplicationCall.reject(
status: HttpStatusCode,
code: Int,
message: String,
id: RequestId? = null,
) {
this.response.status(status)
this.respond(JSONRPCError(id = id, error = RPCError(code = code, message = message)))
val error = JSONRPCError(id = id, error = RPCError(code = code, message = message))
respondText(McpJson.encodeToString(error), ContentType.Application.Json, status)
}

View file

@ -0,0 +1,99 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.matchers.shouldBe
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.server.testing.testApplication
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import kotlin.test.Test
/**
* A client opening the standalone stream sends `Accept: text/event-stream`, which matches no JSON
* converter. Responses rendered through ContentNegotiation used to reach such a client as an empty
* 406 with the intended status discarded.
*/
class StreamableHttpResponseDeliveryTest {
private val eventStream = ContentType.Text.EventStream.toString()
private fun testServer(): Server = Server(
Implementation("test-server", "1.0"),
ServerOptions(capabilities = ServerCapabilities()),
)
@Test
fun `stateless GET is rejected with 405 and advertises POST`() = testApplication {
application { mcpStatelessStreamableHttp { testServer() } }
val response = client.get("/mcp") {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Accept, eventStream)
}
response.assertMethodNotAllowed()
}
@Test
fun `stateless DELETE is rejected with 405 and advertises POST`() = testApplication {
application { mcpStatelessStreamableHttp { testServer() } }
val response = client.delete("/mcp") {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Accept, eventStream)
}
response.assertMethodNotAllowed()
}
@Test
fun `stateful DELETE for an unknown session returns 404`() = testApplication {
application { mcpStreamableHttp { testServer() } }
val response = client.delete("/mcp") {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Accept, eventStream)
header(MCP_SESSION_ID_HEADER, "unknown-session")
}
response.status shouldBe HttpStatusCode.NotFound
response.decodeError().error.message shouldBe "Session not found"
}
@Test
fun `POST carrying no request is acknowledged with a bodiless 202`() = testApplication {
application { mcpStatelessStreamableHttp { testServer() } }
val response = client.post("/mcp") {
header(HttpHeaders.Host, "localhost")
header(HttpHeaders.Accept, "${ContentType.Application.Json}, $eventStream")
contentType(ContentType.Application.Json)
setBody("""{"jsonrpc":"2.0","method":"notifications/initialized"}""")
}
response.status shouldBe HttpStatusCode.Accepted
response.bodyAsText() shouldBe ""
}
private suspend fun HttpResponse.assertMethodNotAllowed() {
status shouldBe HttpStatusCode.MethodNotAllowed
headers[HttpHeaders.Allow] shouldBe HttpMethod.Post.value
contentType()?.withoutParameters() shouldBe ContentType.Application.Json
decodeError().error.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED
}
private suspend fun HttpResponse.decodeError(): JSONRPCError = McpJson.decodeFromString(bodyAsText())
}

View file

@ -216,14 +216,16 @@ class StreamableHttpServerTransportTest {
configureTransportEndpoint(transport)
val payload = buildInitializeRequestPayload()
val initResponse = client.post(path) {
addStreamableHeaders()
header("mcp-protocol-version", "1900-01-01")
setBody(buildInitializeRequestPayload())
setBody(payload)
}
initResponse.status shouldBe HttpStatusCode.BadRequest
initResponse.headers[MCP_SESSION_ID_HEADER] shouldBe null
initResponse.body<JSONRPCError>().id shouldBe payload.id
}
@Test
@ -297,6 +299,65 @@ class StreamableHttpServerTransportTest {
}
response.status shouldBe HttpStatusCode.BadRequest
response.body<JSONRPCError>().id shouldBe RequestId("test-1")
}
@Test
fun `batch with an initialization request echoes the initialize id when rejected`() = testApplication {
configTestServer()
val client = createTestClient()
val transport = StreamableHttpServerTransport(enableJsonResponse = true)
transport.onMessage { }
configureTransportEndpoint(transport)
val initPayload = buildInitializeRequestPayload()
val response = client.post(path) {
addStreamableHeaders()
setBody(
encodeMessages(
listOf(
initPayload,
JSONRPCRequest(id = RequestId("extra"), method = Method.Defined.ToolsList.value),
),
),
)
}
response.status shouldBe HttpStatusCode.BadRequest
val error = response.body<JSONRPCError>()
error.error.message shouldBe "Invalid Request: Only one initialization request is allowed"
error.id shouldBe initPayload.id
}
@Test
fun `non-init request before initialization echoes the request id`() = testApplication {
configTestServer()
val client = createTestClient()
val transport = StreamableHttpServerTransport(enableJsonResponse = true)
transport.onMessage { }
configureTransportEndpoint(transport)
val response = client.post(path) {
addStreamableHeaders()
setBody(
encodeMessages(
listOf(
JSONRPCRequest(id = RequestId("before-init"), method = Method.Defined.ToolsList.value),
),
),
)
}
response.status shouldBe HttpStatusCode.BadRequest
val error = response.body<JSONRPCError>()
error.error.message shouldBe "Bad Request: Server not initialized"
error.id shouldBe RequestId("before-init")
}
@Test