fix: validate protocol version header on initialization requests (#697)

Validate `mcp-protocol-version` HTTP header during `InitializeRequest`

Fix `DEFAULT_NEGOTIATED_PROTOCOL_VERSION` to match the spec

fixes #547 

## How Has This Been Tested?
Updated existing test (removed TODO referencing #547) to verify init
request

## 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-04-15 14:12:05 +02:00 • committed by GitHub
commit bce6811b66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 49 additions and 24 deletions

View file

@ -12,7 +12,7 @@ import kotlinx.serialization.json.JsonObject
public const val LATEST_PROTOCOL_VERSION: String = "2025-11-25"
/** The default protocol version used when negotiation is not performed. */
public const val DEFAULT_NEGOTIATED_PROTOCOL_VERSION: String = "2025-06-18"
public const val DEFAULT_NEGOTIATED_PROTOCOL_VERSION: String = "2025-03-26"
/** All MCP protocol versions supported by this SDK. */
public val SUPPORTED_PROTOCOL_VERSIONS: List<String> = listOf(

View file

@ -367,6 +367,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
)
return
}
if (!validateProtocolVersion(call)) return
if (messages.size > 1) {
call.reject(
HttpStatusCode.BadRequest,
@ -393,11 +394,23 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
return
}
// Extract protocol version for priming event decision.
// For initialize requests, get from request params.
// For other requests, get from header (already validated).
val clientProtocolVersion = if (isInitializationRequest) {
val initRequest = messages.first() as JSONRPCRequest
(initRequest.params as? JsonObject)?.get("protocolVersion")
?.let { McpJson.decodeFromJsonElement<String>(it) }
?: DEFAULT_NEGOTIATED_PROTOCOL_VERSION
} else {
call.request.header(MCP_PROTOCOL_VERSION_HEADER) ?: DEFAULT_NEGOTIATED_PROTOCOL_VERSION
}
val streamId = Uuid.random().toString()
if (!configuration.enableJsonResponse) {
call.appendSseHeaders()
flushSse(session) // flush headers immediately
maybeSendPrimingEvent(streamId, session, call.request.header(MCP_PROTOCOL_VERSION_HEADER))
maybeSendPrimingEvent(streamId, session, clientProtocolVersion)
}
streamMutex.withLock {
@ -456,7 +469,9 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
// SSE headers (Content-Type, Cache-Control, Connection) are already set by the framework's SSE handler
flushSse(sseSession)
streamsMapping[STANDALONE_SSE_STREAM_ID] = SessionContext(sseSession, call)
maybeSendPrimingEvent(STANDALONE_SSE_STREAM_ID, sseSession, call.request.header(MCP_PROTOCOL_VERSION_HEADER))
val clientProtocolVersion =
call.request.header(MCP_PROTOCOL_VERSION_HEADER) ?: DEFAULT_NEGOTIATED_PROTOCOL_VERSION
maybeSendPrimingEvent(STANDALONE_SSE_STREAM_ID, sseSession, clientProtocolVersion)
sseSession.coroutineContext.job.invokeOnCompletion {
streamsMapping.remove(STANDALONE_SSE_STREAM_ID)
}
@ -568,9 +583,9 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
return false
}
val sessionHeaderValues = call.request.headers.getAll(MCP_SESSION_ID_HEADER)
val headerId = call.request.header(MCP_SESSION_ID_HEADER)
if (sessionHeaderValues.isNullOrEmpty()) {
if (headerId == null) {
call.reject(
HttpStatusCode.BadRequest,
RPCError.ErrorCode.CONNECTION_CLOSED,
@ -579,17 +594,6 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
return false
}
if (sessionHeaderValues.size > 1) {
call.reject(
HttpStatusCode.BadRequest,
RPCError.ErrorCode.CONNECTION_CLOSED,
"Bad Request: Mcp-Session-Id header must be a single value",
)
return false
}
val headerId = sessionHeaderValues.single()
return when (headerId) {
sessionId -> true
@ -605,8 +609,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
}
private suspend fun validateProtocolVersion(call: ApplicationCall): Boolean {
val protocolVersions = call.request.headers.getAll(MCP_PROTOCOL_VERSION_HEADER)
val version = protocolVersions?.lastOrNull() ?: DEFAULT_NEGOTIATED_PROTOCOL_VERSION
val version = call.request.headers[MCP_PROTOCOL_VERSION_HEADER] ?: return true
return when (version) {
!in SUPPORTED_PROTOCOL_VERSIONS -> {
@ -715,14 +718,14 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
private suspend fun maybeSendPrimingEvent(
streamId: String,
session: ServerSSESession?,
clientProtocolVersion: String? = null,
clientProtocolVersion: String,
) {
val store = configuration.eventStore
if (store == null || session == null) return
// Priming events have empty data which older clients cannot handle.
// Only send priming events to clients with protocol version >= 2025-11-25
// which includes the fix for handling empty SSE data.
if (clientProtocolVersion != null && clientProtocolVersion < MIN_PRIMING_EVENT_PROTOCOL_VERSION) return
if (clientProtocolVersion < MIN_PRIMING_EVENT_PROTOCOL_VERSION) return
try {
val primingEventId = store.storeEvent(streamId, JSONRPCEmptyMessage)
session.send(

View file

@ -176,6 +176,31 @@ class StreamableHttpServerTransportTest {
secondResponse.status shouldBe HttpStatusCode.BadRequest
}
@Test
fun `init request with unsupported protocol version returns an HTTP error`() = testApplication {
configTestServer()
val client = createTestClient()
val transport = StreamableHttpServerTransport(enableJsonResponse = true)
transport.onMessage { message ->
if (message is JSONRPCRequest) {
transport.send(JSONRPCResponse(message.id, EmptyResult()))
}
}
configureTransportEndpoint(transport)
val initResponse = client.post(path) {
addStreamableHeaders()
header("mcp-protocol-version", "1900-01-01")
setBody(buildInitializeRequestPayload())
}
initResponse.status shouldBe HttpStatusCode.BadRequest
initResponse.headers[MCP_SESSION_ID_HEADER] shouldBe null
}
@Test
fun `request with unsupported protocol version returns an HTTP error`() = testApplication {
configTestServer()
@ -191,18 +216,15 @@ class StreamableHttpServerTransportTest {
configureTransportEndpoint(transport)
val initPayload = buildInitializeRequestPayload()
val initResponse = client.post(path) {
addStreamableHeaders()
setBody(initPayload)
setBody(buildInitializeRequestPayload())
}
initResponse.status shouldBe HttpStatusCode.OK
val sessionId = initResponse.headers[MCP_SESSION_ID_HEADER]
assertNotNull(sessionId)
// TODO When https://github.com/modelcontextprotocol/kotlin-sdk/issues/547 is fixed,
// check the incompatible mcp-protocol-version in the InitializeRequest and delete the part below
val response = client.post(path) {
addStreamableHeaders()
header("mcp-session-id", sessionId)