Skip to content

Commit 10a0b67

Browse files
authored
[WIP] StreamableHttpServerTransport implementation (#449)
# [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? <!-- Have you tested this in a real application? Which scenarios were tested? --> ## Breaking Changes None ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [x] 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
1 parent 2dd4688 commit 10a0b67

File tree

12 files changed

+1024
-3
lines changed

12 files changed

+1024
-3
lines changed

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
4444
ktor-client-apache5 = { group = "io.ktor", name = "ktor-client-apache5", version.ref = "ktor" }
4545
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
4646
ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
47+
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
48+
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
49+
ktor-serialization = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
4750
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
4851
ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse", version.ref = "ktor" }
4952
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }

kotlin-sdk-core/api/kotlin-sdk-core.api

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ClientResult$Default
988988
}
989989

990990
public final class io/modelcontextprotocol/kotlin/sdk/types/CommonKt {
991+
public static final field DEFAULT_NEGOTIATED_PROTOCOL_VERSION Ljava/lang/String;
991992
public static final field LATEST_PROTOCOL_VERSION Ljava/lang/String;
992993
public static final fun ProgressToken (J)Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;
993994
public static final fun ProgressToken (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;
@@ -2046,6 +2047,15 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/InitializedNotificat
20462047
public final fun serializer ()Lkotlinx/serialization/KSerializer;
20472048
}
20482049

2050+
public final class io/modelcontextprotocol/kotlin/sdk/types/JSONRPCEmptyMessage : io/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage {
2051+
public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCEmptyMessage;
2052+
public fun equals (Ljava/lang/Object;)Z
2053+
public fun getJsonrpc ()Ljava/lang/String;
2054+
public fun hashCode ()I
2055+
public final fun serializer ()Lkotlinx/serialization/KSerializer;
2056+
public fun toString ()Ljava/lang/String;
2057+
}
2058+
20492059
public final class io/modelcontextprotocol/kotlin/sdk/types/JSONRPCError : io/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage {
20502060
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCError$Companion;
20512061
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;Lio/modelcontextprotocol/kotlin/sdk/types/RPCError;)V

kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
44
import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotification
55
import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams
66
import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult
7+
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCEmptyMessage
78
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError
89
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification
910
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
@@ -249,6 +250,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
249250
is JSONRPCRequest -> onRequest(message)
250251
is JSONRPCNotification -> onNotification(message)
251252
is JSONRPCError -> onResponse(null, message)
253+
is JSONRPCEmptyMessage -> Unit
252254
}
253255
}
254256

kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/common.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package io.modelcontextprotocol.kotlin.sdk.types
22

3-
import io.modelcontextprotocol.kotlin.sdk.types.Icon.Theme.Dark
4-
import io.modelcontextprotocol.kotlin.sdk.types.Icon.Theme.Light
53
import kotlinx.serialization.SerialName
64
import kotlinx.serialization.Serializable
75
import kotlinx.serialization.json.JsonObject
@@ -12,6 +10,8 @@ import kotlinx.serialization.json.JsonObject
1210

1311
public const val LATEST_PROTOCOL_VERSION: String = "2025-06-18"
1412

13+
public const val DEFAULT_NEGOTIATED_PROTOCOL_VERSION: String = "2025-03-26"
14+
1515
public val SUPPORTED_PROTOCOL_VERSIONS: List<String> = listOf(
1616
LATEST_PROTOCOL_VERSION,
1717
"2025-03-26",

kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonRpc.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ public sealed interface JSONRPCMessage {
8585
public val jsonrpc: String
8686
}
8787

88+
@Serializable
89+
public data object JSONRPCEmptyMessage : JSONRPCMessage {
90+
override val jsonrpc: String = JSONRPC_VERSION
91+
}
92+
8893
// ============================================================================
8994
// JSONRPCRequest
9095
// ============================================================================
@@ -197,7 +202,7 @@ public data class JSONRPCResponse(val id: RequestId, val result: RequestResult =
197202
* @property error Details about the error that occurred, including error code and message.
198203
*/
199204
@Serializable
200-
public data class JSONRPCError(val id: RequestId, val error: RPCError) : JSONRPCMessage {
205+
public data class JSONRPCError(val id: RequestId?, val error: RPCError) : JSONRPCMessage {
201206
@EncodeDefault
202207
override val jsonrpc: String = JSONRPC_VERSION
203208
}

kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ internal object JSONRPCMessagePolymorphicSerializer :
384384
"result" in jsonObject -> JSONRPCResponse.serializer()
385385
"method" in jsonObject && "id" in jsonObject -> JSONRPCRequest.serializer()
386386
"method" in jsonObject -> JSONRPCNotification.serializer()
387+
jsonObject.isEmpty() || jsonObject.keys == setOf("jsonrpc") -> JSONRPCEmptyMessage.serializer()
387388
else -> throw SerializationException("Invalid JSONRPCMessage type: ${jsonObject.keys}")
388389
}
389390
}

kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonRpcTest.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,4 +430,16 @@ class JsonRpcTest {
430430
request.method shouldBe "notifications/log"
431431
request.params shouldBeSameInstanceAs params
432432
}
433+
434+
@Test
435+
fun `should deserialize JSONRPCEmptyMessage`() {
436+
val json = """
437+
{
438+
"jsonrpc": "2.0"
439+
}
440+
""".trimIndent()
441+
442+
val message = McpJson.decodeFromString<JSONRPCMessage>(json)
443+
message shouldBeSameInstanceAs JSONRPCEmptyMessage
444+
}
433445
}

kotlin-sdk-server/api/kotlin-sdk-server.api

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
public abstract interface class io/modelcontextprotocol/kotlin/sdk/server/EventStore {
2+
public abstract fun getStreamIdForEventId (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
3+
public abstract fun replayEventsAfter (Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
4+
public abstract fun storeEvent (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
5+
}
6+
17
public final class io/modelcontextprotocol/kotlin/sdk/server/KtorServerKt {
28
public static final fun mcp (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)V
39
public static final fun mcp (Lio/ktor/server/routing/Routing;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
@@ -151,6 +157,25 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/StdioServerTranspor
151157
public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
152158
}
153159

160+
public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
161+
public static final field STANDALONE_SSE_STREAM_ID Ljava/lang/String;
162+
public fun <init> ()V
163+
public fun <init> (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Ljava/lang/Long;)V
164+
public synthetic fun <init> (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
165+
public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
166+
public final fun closeSseStream (Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
167+
public final fun getSessionId ()Ljava/lang/String;
168+
public final fun handleDeleteRequest (Lio/ktor/server/application/ApplicationCall;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
169+
public final fun handleGetRequest (Lio/ktor/server/sse/ServerSSESession;Lio/ktor/server/application/ApplicationCall;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
170+
public final fun handlePostRequest (Lio/ktor/server/sse/ServerSSESession;Lio/ktor/server/application/ApplicationCall;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
171+
public final fun handleRequest (Lio/ktor/server/sse/ServerSSESession;Lio/ktor/server/application/ApplicationCall;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
172+
public fun send (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lio/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
173+
public final fun setOnSessionClosed (Lkotlin/jvm/functions/Function1;)V
174+
public final fun setOnSessionInitialized (Lkotlin/jvm/functions/Function1;)V
175+
public final fun setSessionIdGenerator (Lkotlin/jvm/functions/Function0;)V
176+
public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
177+
}
178+
154179
public final class io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensionsKt {
155180
public static final fun mcpWebSocket (Lio/ktor/server/application/Application;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V
156181
public static final fun mcpWebSocket (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function0;)V

kotlin-sdk-server/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ kotlin {
2626

2727
jvmTest {
2828
dependencies {
29+
implementation(libs.ktor.client.logging)
30+
implementation(libs.ktor.server.content.negotiation)
31+
implementation(libs.ktor.client.content.negotiation)
32+
implementation(libs.ktor.serialization)
33+
implementation(libs.ktor.server.test.host)
34+
implementation(libs.kotest.assertions.core)
35+
implementation(libs.kotest.assertions.json)
2936
runtimeOnly(libs.slf4j.simple)
3037
}
3138
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package io.modelcontextprotocol.kotlin.sdk.server
2+
3+
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
4+
5+
/**
6+
* Interface for resumability support via event storage
7+
*/
8+
public interface EventStore {
9+
/**
10+
* Stores an event for later retrieval
11+
* @param streamId ID of the stream the event belongs to
12+
* @param message The JSON-RPC message to store
13+
* @returns The generated event ID for the stored event
14+
*/
15+
public suspend fun storeEvent(streamId: String, message: JSONRPCMessage): String
16+
17+
/**
18+
* Replays events after the specified event ID
19+
* @param lastEventId The last event ID that was received
20+
* @param sender Function to send events
21+
* @return The stream ID for the replayed events
22+
*/
23+
public suspend fun replayEventsAfter(
24+
lastEventId: String,
25+
sender: suspend (eventId: String, message: JSONRPCMessage) -> Unit,
26+
): String
27+
28+
/**
29+
* Returns the stream ID associated with [eventId], or null if the event is unknown.
30+
* Default implementation is a no-op which disables extra validation during replay.
31+
*/
32+
public suspend fun getStreamIdForEventId(eventId: String): String?
33+
}

0 commit comments

Comments
 (0)