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:
parent
eb24803547
commit
bce6811b66
3 changed files with 49 additions and 24 deletions
|
|
@ -12,7 +12,7 @@ import kotlinx.serialization.json.JsonObject
|
||||||
public const val LATEST_PROTOCOL_VERSION: String = "2025-11-25"
|
public const val LATEST_PROTOCOL_VERSION: String = "2025-11-25"
|
||||||
|
|
||||||
/** The default protocol version used when negotiation is not performed. */
|
/** 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. */
|
/** All MCP protocol versions supported by this SDK. */
|
||||||
public val SUPPORTED_PROTOCOL_VERSIONS: List<String> = listOf(
|
public val SUPPORTED_PROTOCOL_VERSIONS: List<String> = listOf(
|
||||||
|
|
|
||||||
|
|
@ -367,6 +367,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!validateProtocolVersion(call)) return
|
||||||
if (messages.size > 1) {
|
if (messages.size > 1) {
|
||||||
call.reject(
|
call.reject(
|
||||||
HttpStatusCode.BadRequest,
|
HttpStatusCode.BadRequest,
|
||||||
|
|
@ -393,11 +394,23 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
|
||||||
return
|
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()
|
val streamId = Uuid.random().toString()
|
||||||
if (!configuration.enableJsonResponse) {
|
if (!configuration.enableJsonResponse) {
|
||||||
call.appendSseHeaders()
|
call.appendSseHeaders()
|
||||||
flushSse(session) // flush headers immediately
|
flushSse(session) // flush headers immediately
|
||||||
maybeSendPrimingEvent(streamId, session, call.request.header(MCP_PROTOCOL_VERSION_HEADER))
|
maybeSendPrimingEvent(streamId, session, clientProtocolVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
streamMutex.withLock {
|
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
|
// SSE headers (Content-Type, Cache-Control, Connection) are already set by the framework's SSE handler
|
||||||
flushSse(sseSession)
|
flushSse(sseSession)
|
||||||
streamsMapping[STANDALONE_SSE_STREAM_ID] = SessionContext(sseSession, call)
|
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 {
|
sseSession.coroutineContext.job.invokeOnCompletion {
|
||||||
streamsMapping.remove(STANDALONE_SSE_STREAM_ID)
|
streamsMapping.remove(STANDALONE_SSE_STREAM_ID)
|
||||||
}
|
}
|
||||||
|
|
@ -568,9 +583,9 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
|
||||||
return false
|
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(
|
call.reject(
|
||||||
HttpStatusCode.BadRequest,
|
HttpStatusCode.BadRequest,
|
||||||
RPCError.ErrorCode.CONNECTION_CLOSED,
|
RPCError.ErrorCode.CONNECTION_CLOSED,
|
||||||
|
|
@ -579,17 +594,6 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
|
||||||
return false
|
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) {
|
return when (headerId) {
|
||||||
sessionId -> true
|
sessionId -> true
|
||||||
|
|
||||||
|
|
@ -605,8 +609,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun validateProtocolVersion(call: ApplicationCall): Boolean {
|
private suspend fun validateProtocolVersion(call: ApplicationCall): Boolean {
|
||||||
val protocolVersions = call.request.headers.getAll(MCP_PROTOCOL_VERSION_HEADER)
|
val version = call.request.headers[MCP_PROTOCOL_VERSION_HEADER] ?: return true
|
||||||
val version = protocolVersions?.lastOrNull() ?: DEFAULT_NEGOTIATED_PROTOCOL_VERSION
|
|
||||||
|
|
||||||
return when (version) {
|
return when (version) {
|
||||||
!in SUPPORTED_PROTOCOL_VERSIONS -> {
|
!in SUPPORTED_PROTOCOL_VERSIONS -> {
|
||||||
|
|
@ -715,14 +718,14 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
|
||||||
private suspend fun maybeSendPrimingEvent(
|
private suspend fun maybeSendPrimingEvent(
|
||||||
streamId: String,
|
streamId: String,
|
||||||
session: ServerSSESession?,
|
session: ServerSSESession?,
|
||||||
clientProtocolVersion: String? = null,
|
clientProtocolVersion: String,
|
||||||
) {
|
) {
|
||||||
val store = configuration.eventStore
|
val store = configuration.eventStore
|
||||||
if (store == null || session == null) return
|
if (store == null || session == null) return
|
||||||
// Priming events have empty data which older clients cannot handle.
|
// Priming events have empty data which older clients cannot handle.
|
||||||
// Only send priming events to clients with protocol version >= 2025-11-25
|
// Only send priming events to clients with protocol version >= 2025-11-25
|
||||||
// which includes the fix for handling empty SSE data.
|
// 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 {
|
try {
|
||||||
val primingEventId = store.storeEvent(streamId, JSONRPCEmptyMessage)
|
val primingEventId = store.storeEvent(streamId, JSONRPCEmptyMessage)
|
||||||
session.send(
|
session.send(
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,31 @@ class StreamableHttpServerTransportTest {
|
||||||
secondResponse.status shouldBe HttpStatusCode.BadRequest
|
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
|
@Test
|
||||||
fun `request with unsupported protocol version returns an HTTP error`() = testApplication {
|
fun `request with unsupported protocol version returns an HTTP error`() = testApplication {
|
||||||
configTestServer()
|
configTestServer()
|
||||||
|
|
@ -191,18 +216,15 @@ class StreamableHttpServerTransportTest {
|
||||||
|
|
||||||
configureTransportEndpoint(transport)
|
configureTransportEndpoint(transport)
|
||||||
|
|
||||||
val initPayload = buildInitializeRequestPayload()
|
|
||||||
val initResponse = client.post(path) {
|
val initResponse = client.post(path) {
|
||||||
addStreamableHeaders()
|
addStreamableHeaders()
|
||||||
setBody(initPayload)
|
setBody(buildInitializeRequestPayload())
|
||||||
}
|
}
|
||||||
|
|
||||||
initResponse.status shouldBe HttpStatusCode.OK
|
initResponse.status shouldBe HttpStatusCode.OK
|
||||||
val sessionId = initResponse.headers[MCP_SESSION_ID_HEADER]
|
val sessionId = initResponse.headers[MCP_SESSION_ID_HEADER]
|
||||||
assertNotNull(sessionId)
|
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) {
|
val response = client.post(path) {
|
||||||
addStreamableHeaders()
|
addStreamableHeaders()
|
||||||
header("mcp-session-id", sessionId)
|
header("mcp-session-id", sessionId)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue