fix(server): match Accept header as media ranges (#912)

The header is now parsed into media ranges (`parseHeaderValue` +
`ContentType.match`)

## 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-28 14:51:21 +02:00 • committed by GitHub
commit 24c3cd3a69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 127 additions and 2 deletions

View file

@ -1,9 +1,11 @@
package io.modelcontextprotocol.kotlin.sdk.server package io.modelcontextprotocol.kotlin.sdk.server
import io.ktor.http.BadContentTypeFormatException
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.parseHeaderValue
import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.contentType import io.ktor.server.request.contentType
import io.ktor.server.request.header import io.ktor.server.request.header
@ -864,8 +866,24 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
} }
} }
private fun String?.accepts(mime: ContentType): Boolean = /**
this?.lowercase()?.contains(mime.toString().lowercase()) == true * Reports whether the `Accept` header admits [mime].
*
* Comparison is over parsed media ranges rather than raw substrings, so wildcards are honored
* and a near miss such as `application/jsonp` no longer passes as `application/json`. An absent
* header states no preference and admits any type.
*/
private fun String?.accepts(mime: ContentType): Boolean {
if (this == null) return true
return parseHeaderValue(this).any { range ->
if (range.quality <= 0.0) return@any false
try {
mime.match(ContentType.parse(range.value))
} catch (_: BadContentTypeFormatException) {
false
}
}
}
private suspend fun emitOnStream(streamId: String, session: ServerSSESession?, message: JSONRPCMessage) { private suspend fun emitOnStream(streamId: String, session: ServerSSESession?, message: JSONRPCMessage) {
val eventId = configuration.eventStore?.storeEvent(streamId, message) val eventId = configuration.eventStore?.storeEvent(streamId, message)

View file

@ -9,6 +9,7 @@ import io.ktor.client.call.body
import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.prepareGet import io.ktor.client.request.prepareGet
@ -21,6 +22,7 @@ import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.routing.get
import io.ktor.server.routing.post import io.ktor.server.routing.post
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import io.ktor.server.sse.ServerSSESession import io.ktor.server.sse.ServerSSESession
@ -76,6 +78,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
@ -100,6 +103,26 @@ class StreamableHttpServerTransportTest {
Arguments.of(sizeTestPayload.length.toLong(), HttpStatusCode.BadRequest), Arguments.of(sizeTestPayload.length.toLong(), HttpStatusCode.BadRequest),
Arguments.of(sizeTestPayload.length.toLong() + 1, HttpStatusCode.BadRequest), Arguments.of(sizeTestPayload.length.toLong() + 1, HttpStatusCode.BadRequest),
) )
@JvmStatic
fun postAcceptHeaderCases(): List<Arguments> = listOf(
Arguments.of("application/json, text/event-stream", HttpStatusCode.OK),
Arguments.of("Application/JSON;q=0.9, TEXT/Event-Stream;charset=utf-8", HttpStatusCode.OK),
Arguments.of("*/*", HttpStatusCode.OK),
Arguments.of("application/*, text/*", HttpStatusCode.OK),
Arguments.of(null, HttpStatusCode.OK),
Arguments.of("application/json", HttpStatusCode.NotAcceptable),
Arguments.of("text/event-stream", HttpStatusCode.NotAcceptable),
Arguments.of("application/jsonp, text/event-stream-bogus", HttpStatusCode.NotAcceptable),
Arguments.of("application/json, text/event-stream;q=0", HttpStatusCode.NotAcceptable),
)
@JvmStatic
fun getAcceptHeaderCases() = listOf(
"application/json",
"text/event-stream-bogus",
"text/event-stream;q=0",
)
} }
private val path = "/transport" private val path = "/transport"
@ -130,6 +153,90 @@ class StreamableHttpServerTransportTest {
assertFalse(onMessageCalled.get(), "Transport should not deliver messages when headers are invalid") assertFalse(onMessageCalled.get(), "Transport should not deliver messages when headers are invalid")
} }
@ParameterizedTest
@MethodSource("postAcceptHeaderCases")
fun `POST Accept header is matched as media ranges`(acceptHeader: String?, expectedStatus: HttpStatusCode) =
testApplication {
configTestServer()
// Bare client: the ContentNegotiation plugin would supply an Accept header of its own,
// leaving the absent-header case untestable.
val client = createClient {}
val transport = StreamableHttpServerTransport(enableJsonResponse = true)
val onMessageCalled = AtomicBoolean(false)
transport.onMessage { message ->
onMessageCalled.set(true)
if (message is JSONRPCRequest) {
transport.send(JSONRPCResponse(message.id, EmptyResult()))
}
}
configureTransportEndpoint(transport)
val response = client.post(path) {
contentType(ContentType.Application.Json)
acceptHeader?.let { header(HttpHeaders.Accept, it) }
setBody(McpJson.encodeToString(JSONRPCMessage.serializer(), buildInitializeRequestPayload()))
}
assertEquals(expectedStatus, response.status)
assertEquals(
expectedStatus == HttpStatusCode.OK,
onMessageCalled.get(),
"Only a request that clears the Accept gate may reach the transport",
)
}
@Test
fun `POST with an unparsable Accept header is rejected without failing the request`() = testApplication {
configTestServer()
val client = createClient {}
val transport = StreamableHttpServerTransport(enableJsonResponse = true)
val onMessageCalled = AtomicBoolean(false)
transport.onMessage {
onMessageCalled.set(true)
}
configureTransportEndpoint(transport)
val response = client.post(path) {
contentType(ContentType.Application.Json)
header(HttpHeaders.Accept, "not-a-media-type")
setBody(McpJson.encodeToString(JSONRPCMessage.serializer(), buildInitializeRequestPayload()))
}
// The exact code is decided by content negotiation once the transport has rejected the request.
assertTrue(response.status.value in 400..499, "Expected a client error, got ${response.status}")
assertFalse(onMessageCalled.get(), "Transport should not deliver messages when headers are invalid")
}
@ParameterizedTest
@MethodSource("getAcceptHeaderCases")
fun `GET whose Accept header admits no event-stream range is rejected`(acceptHeader: String) = testApplication {
configTestServer()
val client = createClient {}
val transport = StreamableHttpServerTransport(enableJsonResponse = true)
application {
routing {
get(path) {
transport.handleGetRequest(FakeServerSSESession(call, call.coroutineContext), call)
}
}
}
val response = client.get(path) {
header(HttpHeaders.Accept, acceptHeader)
}
assertEquals(HttpStatusCode.NotAcceptable, response.status)
}
@Test @Test
fun `initialization request establishes session and returns json response`() = testApplication { fun `initialization request establishes session and returns json response`() = testApplication {
configTestServer() configTestServer()