Skip to content

Conversation

@devcrocod
Copy link
Contributor

@devcrocod devcrocod commented Dec 4, 2025

[WIP] StreamableHttpServerTransport implementation

  • Implement StreamableHttpServerTransport for MCP spec
  • Support both SSE streaming and direct HTTP JSON responses
  • Add event store interface for resumability

Important

The work is in progress. StreamableHttpServerTransport is not production-ready yet!!!

Based on #235

How Has This Been Tested?

Breaking Changes

None

Types of changes

  • 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

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@github-actions
Copy link

github-actions bot commented Dec 4, 2025

TestsPassed ✅Skipped ⚠️FailedTime ⏱
JUnit Test Report3417 ran3328 passed89 skipped4m 6s 708ms
TestResultTime ⏱
No test annotations available

Comment on lines +558 to +578
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

return when (version) {
!in SUPPORTED_PROTOCOL_VERSIONS -> {
call.reject(
HttpStatusCode.BadRequest,
RPCError.ErrorCode.CONNECTION_CLOSED,
"Bad Request: Unsupported protocol version (supported versions: ${
SUPPORTED_PROTOCOL_VERSIONS.joinToString(
", ",
)
})",
)
false
}

else -> true
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we handle version extraction and validation separately? Also, could we standardize the validation process for all transportation types? Having it in a central location would be great.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure
for stdio, for example, a version check is not required

Comment on lines +41 to +44
internal const val MCP_SESSION_ID_HEADER = "mcp-session-id"
private const val MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
private const val MCP_RESUMPTION_TOKEN_HEADER = "Last-Event-ID"
private const val MAXIMUM_MESSAGE_SIZE = 4 * 1024 * 1024 // 4 MB
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These constants could really help with all the different protocols. How about we make them a standard and reuse them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For sse, the session-id header is not sent and there is no resumption token. For now, this is needed exclusively for streamable

Comment on lines +121 to +124
public var sessionId: String? = null
private set

private var sessionIdGenerator: (() -> String)? = { Uuid.random().toString() }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's create AbstractServerTransport and move it there

*
* Set undefined to disable session management.
*/
public fun setSessionIdGenerator(block: (() -> String)?) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great if this were standard across all server transport methods. However, I’m not sure it should be publicly available.

Comment on lines +153 to +174
/**
* A callback for session initialization events
* This is called when the server initializes a new session.
* Useful in cases when you need to register multiple mcp sessions
* and need to keep track of them.
*/
public fun setOnSessionInitialized(block: ((String) -> Unit)?) {
onSessionInitialized = block
}

/**
* A callback for session close events
* This is called when the server closes a session due to a DELETE request.
* Useful in cases when you need to clean up resources associated with the session.
* Note that this is different from the transport closing, if you are handling
* HTTP requests from multiple nodes you might want to close each
* StreamableHTTPServerTransport after a request is completed while still keeping the
* session open/running.
*/
public fun setOnSessionClosed(block: ((String) -> Unit)?) {
onSessionClosed = block
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this kind of functionality is pretty standard across all transport types, or at least for server transports.

@devcrocod devcrocod marked this pull request as ready for review December 5, 2025 15:55
Copilot AI review requested due to automatic review settings December 5, 2025 15:55
Copilot finished reviewing on behalf of devcrocod December 5, 2025 15:57
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements the StreamableHttpServerTransport for the MCP (Model Context Protocol) specification, adding support for both Server-Sent Events (SSE) streaming and direct HTTP JSON responses with optional resumability support.

Key Changes:

  • New StreamableHttpServerTransport class supporting SSE and HTTP JSON response modes
  • EventStore interface for resumability via event storage and replay
  • JSONRPCEmptyMessage type for priming SSE events and empty message handling

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt Core implementation of the streamable HTTP transport with session management, SSE streaming, and HTTP JSON response support
kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/EventStore.kt Interface definition for event storage and replay to enable resumability
kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonRpc.kt Added JSONRPCEmptyMessage type and made JSONRPCError.id nullable
kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt Updated JSON-RPC message serializer to handle empty messages
kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/common.kt Added DEFAULT_NEGOTIATED_PROTOCOL_VERSION constant and removed unused imports
kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt Added handling for JSONRPCEmptyMessage in protocol message processing
kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt Comprehensive test suite covering initialization, batched requests, and notifications
kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonRpcTest.kt Test for JSONRPCEmptyMessage deserialization
kotlin-sdk-server/build.gradle.kts Added test dependencies for Ktor and Kotest
gradle/libs.versions.toml Added library definitions for Ktor content negotiation and serialization
kotlin-sdk-server/api/kotlin-sdk-server.api Public API surface additions for StreamableHttpServerTransport and EventStore
kotlin-sdk-core/api/kotlin-sdk-core.api Public API surface additions for new types and constants

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +29 to +30
* Returns the stream ID associated with [eventId], or null if the event is unknown.
* Default implementation is a no-op which disables extra validation during replay.
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing KDoc documentation for the getStreamIdForEventId method. The inline comment on line 30 describes it as a "no-op" default implementation, but there's no actual default implementation provided (it's an interface method). Consider either providing a default implementation or clarifying the documentation to explain that implementers may throw NotImplementedError or UnsupportedOperationException to disable this validation.

Suggested change
* Returns the stream ID associated with [eventId], or null if the event is unknown.
* Default implementation is a no-op which disables extra validation during replay.
* Returns the stream ID associated with the given [eventId], or null if the event is unknown.
*
* Implementers may throw [NotImplementedError] or [UnsupportedOperationException] to disable
* extra validation during replay, as there is no default implementation provided.
*
* @param eventId The event ID to look up.
* @return The stream ID associated with the event, or null if unknown.

Copilot uses AI. Check for mistakes.
private val streamMutex = Mutex()

private companion object {
const val STANDALONE_SSE_STREAM_ID = "_GET_stream"
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constant STANDALONE_SSE_STREAM_ID is marked as part of the companion object but is being exported in the public API (line 161 of the API file). If this is intended as an internal implementation detail, it should not be exposed in the public API. Consider making this internal or private if it's not meant for external consumption.

Suggested change
const val STANDALONE_SSE_STREAM_ID = "_GET_stream"
private const val STANDALONE_SSE_STREAM_ID = "_GET_stream"

Copilot uses AI. Check for mistakes.
call.reject(
HttpStatusCode.BadRequest,
RPCError.ErrorCode.CONNECTION_CLOSED,
"Bad Request: Mcp-Session-Id header is required",
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message references "Mcp-Session-Id" with inconsistent capitalization. The constant MCP_SESSION_ID_HEADER is defined as "mcp-session-id" (lowercase), but the error message uses "Mcp-Session-Id". For consistency and clarity, use the actual header name as defined in the constant.

Copilot uses AI. Check for mistakes.
call.reject(
HttpStatusCode.BadRequest,
RPCError.ErrorCode.CONNECTION_CLOSED,
"Bad Request: Mcp-Session-Id header must be a single value",
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message references "Mcp-Session-Id" with inconsistent capitalization. The constant MCP_SESSION_ID_HEADER is defined as "mcp-session-id" (lowercase), but the error message uses "Mcp-Session-Id". For consistency and clarity, use the actual header name as defined in the constant.

Copilot uses AI. Check for mistakes.
* Stores an event for later retrieval
* @param streamId ID of the stream the event belongs to
* @param message The JSON-RPC message to store
* @returns The generated event ID for the stored event
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment style uses @returns instead of the Kotlin standard @return. KDoc convention uses @return (singular) for documenting return values.

Suggested change
* @returns The generated event ID for the stored event
* @return The generated event ID for the stored event

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +213
// TODO(check order)
// assertEquals(listOf(firstRequest.id, secondRequest.id), responses.map { it.id })
// val firstMeta = (responses[0].result as EmptyResult).meta
// val secondMeta = (responses[1].result as EmptyResult).meta
// assertEquals("first", firstMeta?.get("label")?.jsonPrimitive?.content)
// assertEquals("second", secondMeta?.get("label")?.jsonPrimitive?.content)
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented-out test code should be removed before merging. If order verification is intended for future implementation, consider creating a separate TODO issue or ticket instead of leaving commented code in the codebase.

Suggested change
// TODO(check order)
// assertEquals(listOf(firstRequest.id, secondRequest.id), responses.map { it.id })
// val firstMeta = (responses[0].result as EmptyResult).meta
// val secondMeta = (responses[1].result as EmptyResult).meta
// assertEquals("first", firstMeta?.get("label")?.jsonPrimitive?.content)
// assertEquals("second", secondMeta?.get("label")?.jsonPrimitive?.content)

Copilot uses AI. Check for mistakes.
*/
@Serializable
public data class JSONRPCError(val id: RequestId, val error: RPCError) : JSONRPCMessage {
public data class JSONRPCError(val id: RequestId?, val error: RPCError) : JSONRPCMessage {
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The id field of JSONRPCError is now nullable, but this could be a breaking API change. According to the JSON-RPC 2.0 specification, the id member is required in error responses except when there was an error in detecting the id in the request object (e.g., parse error). Consider documenting when null is appropriate, or ensure this aligns with how the codebase handles error scenarios where the request ID cannot be determined.

Copilot uses AI. Check for mistakes.
streamsMapping.values.forEach {
try {
it.session?.close()
} catch (_: Exception) {
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty catch block silently swallows exceptions. While this may be intentional during cleanup, consider at least logging the exception or adding a comment explaining why it's safe to ignore.

Suggested change
} catch (_: Exception) {
} catch (e: Exception) {
println("Exception while closing session: ${e.message}")

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@kpavlov kpavlov Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logger should be used

val eventId = eventStore?.storeEvent(streamId, message)
try {
session?.send(event = "message", id = eventId, data = McpJson.encodeToString(message))
} catch (_: Exception) {
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty catch block silently swallows exceptions. While this may be intentional when a stream is already closed, consider at least logging the exception or adding a comment explaining why it's safe to ignore, especially since this is in the message emission path.

Suggested change
} catch (_: Exception) {
} catch (e: Exception) {
println("emitOnStream: Exception sending message on stream $streamId: ${e.message}")

Copilot uses AI. Check for mistakes.
@kpavlov kpavlov changed the title StreamableHttpServerTransport implementation [WIP] StreamableHttpServerTransport implementation Dec 5, 2025
Copy link
Contributor

@kpavlov kpavlov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@devcrocod 👍🏻
Let's merge this PR and continue in the follow-ups

@kpavlov kpavlov merged commit 10a0b67 into main Dec 5, 2025
5 of 6 checks passed
@kpavlov kpavlov deleted the devcrocod/streamable-server-transport branch December 5, 2025 17:51
@kpavlov kpavlov mentioned this pull request Dec 6, 2025
9 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants