Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions kotlin-sdk-core/api/kotlin-sdk-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -1071,16 +1071,18 @@ public final class io/modelcontextprotocol/kotlin/sdk/InitializedNotification$Pa

public final class io/modelcontextprotocol/kotlin/sdk/JSONRPCError : io/modelcontextprotocol/kotlin/sdk/JSONRPCMessage {
public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/JSONRPCError$Companion;
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/ErrorCode;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)V
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/ErrorCode;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/ErrorCode;
public final fun component2 ()Ljava/lang/String;
public final fun component3 ()Lkotlinx/serialization/json/JsonObject;
public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/ErrorCode;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/JSONRPCError;
public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/JSONRPCError;Lio/modelcontextprotocol/kotlin/sdk/ErrorCode;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/JSONRPCError;
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/RequestId;Lio/modelcontextprotocol/kotlin/sdk/ErrorCode;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)V
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/RequestId;Lio/modelcontextprotocol/kotlin/sdk/ErrorCode;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/RequestId;
public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/ErrorCode;
public final fun component3 ()Ljava/lang/String;
public final fun component4 ()Lkotlinx/serialization/json/JsonObject;
public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/RequestId;Lio/modelcontextprotocol/kotlin/sdk/ErrorCode;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/JSONRPCError;
public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/JSONRPCError;Lio/modelcontextprotocol/kotlin/sdk/RequestId;Lio/modelcontextprotocol/kotlin/sdk/ErrorCode;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/JSONRPCError;
public fun equals (Ljava/lang/Object;)Z
public final fun getCode ()Lio/modelcontextprotocol/kotlin/sdk/ErrorCode;
public final fun getData ()Lkotlinx/serialization/json/JsonObject;
public final fun getId ()Lio/modelcontextprotocol/kotlin/sdk/RequestId;
public final fun getMessage ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
JSONRPCResponse(
id = request.id,
error = JSONRPCError(
ErrorCode.Defined.MethodNotFound,
code = ErrorCode.Defined.MethodNotFound,
message = "Server does not support ${request.method}",
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,12 @@ public sealed interface ErrorCode {
* A response to a request that indicates an error occurred.
*/
@Serializable
public data class JSONRPCError(val code: ErrorCode, val message: String, val data: JsonObject = EmptyJsonObject) :
JSONRPCMessage
public data class JSONRPCError(
val id: RequestId? = null,
val code: ErrorCode,
val message: String,
val data: JsonObject = EmptyJsonObject,
) : JSONRPCMessage

/**
* Base interface for notification parameters with optional metadata.
Expand Down
27 changes: 27 additions & 0 deletions kotlin-sdk-server/api/kotlin-sdk-server.api
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
public abstract interface class io/modelcontextprotocol/kotlin/sdk/server/EventStore {
public abstract fun replayEventsAfter (Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun storeEvent (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/JSONRPCMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

public final class io/modelcontextprotocol/kotlin/sdk/server/KtorServerKt {
public static final fun MCP (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Routing;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Routing;Lkotlin/jvm/functions/Function1;)V
public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V
public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V
public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
}

public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt {
Expand Down Expand Up @@ -127,6 +136,24 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/StdioServerTranspor
public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
public static final field STANDALONE_SSE_STREAM_ID Ljava/lang/String;
public fun <init> ()V
public fun <init> (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;)V
public synthetic fun <init> (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun getSessionId ()Ljava/lang/String;
public final fun handleDeleteRequest (Lio/ktor/server/sse/ServerSSESession;Lio/ktor/server/application/ApplicationCall;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun handleGetRequest (Lio/ktor/server/sse/ServerSSESession;Lio/ktor/server/application/ApplicationCall;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun handlePostRequest (Lio/ktor/server/sse/ServerSSESession;Lio/ktor/server/application/ApplicationCall;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun handleRequest (Lio/ktor/server/sse/ServerSSESession;Lio/ktor/server/application/ApplicationCall;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun send (Lio/modelcontextprotocol/kotlin/sdk/JSONRPCMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun setOnSessionClosed (Lkotlin/jvm/functions/Function1;)V
public final fun setOnSessionInitialized (Lkotlin/jvm/functions/Function1;)V
public final fun setSessionIdGenerator (Lkotlin/jvm/functions/Function0;)V
public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

public final class io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensionsKt {
public static final fun mcpWebSocket (Lio/ktor/server/application/Application;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V
public static final fun mcpWebSocket (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function0;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.request.header
import io.ktor.server.response.respond
import io.ktor.server.routing.Routing
import io.ktor.server.routing.RoutingContext
Expand All @@ -19,16 +20,20 @@ import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.toPersistentMap
import io.modelcontextprotocol.kotlin.sdk.ErrorCode
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport

private val logger = KotlinLogging.logger {}

internal class SseTransportManager(transports: Map<String, SseServerTransport> = emptyMap()) {
private val transports: AtomicRef<PersistentMap<String, SseServerTransport>> = atomic(transports.toPersistentMap())
internal class TransportManager(transports: Map<String, AbstractTransport> = emptyMap()) {
private val transports: AtomicRef<PersistentMap<String, AbstractTransport>> = atomic(transports.toPersistentMap())

fun getTransport(sessionId: String): SseServerTransport? = transports.value[sessionId]
fun hasTransport(sessionId: String): Boolean = transports.value.containsKey(sessionId)

fun addTransport(transport: SseServerTransport) {
transports.update { it.put(transport.sessionId, transport) }
fun getTransport(sessionId: String): AbstractTransport? = transports.value[sessionId]

fun addTransport(sessionId: String, transport: AbstractTransport) {
transports.update { it.put(sessionId, transport) }
}

fun removeTransport(sessionId: String) {
Expand All @@ -48,14 +53,14 @@ public fun Routing.mcp(path: String, block: ServerSSESession.() -> Server) {
*/
@KtorDsl
public fun Routing.mcp(block: ServerSSESession.() -> Server) {
val sseTransportManager = SseTransportManager()
val transportManager = TransportManager()

sse {
mcpSseEndpoint("", sseTransportManager, block)
mcpSseEndpoint("", transportManager, block)
}

post {
mcpPostEndpoint(sseTransportManager)
mcpPostEndpoint(transportManager)
}
}

Expand All @@ -74,18 +79,71 @@ public fun Application.mcp(block: ServerSSESession.() -> Server) {
}
}

internal suspend fun ServerSSESession.mcpSseEndpoint(
/*
* Configures the Ktor Application to handle Model Context Protocol (MCP) over Streamable Http.
* It currently only works with JSON response.
*/
@KtorDsl
public fun Application.mcpStreamableHttp(
enableDnsRebindingProtection: Boolean = false,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
eventStore: EventStore? = null,
block: RoutingContext.() -> Server,
) {
val transportManager = TransportManager()

routing {
post("/mcp") {
mcpStreamableHttpEndpoint(
transportManager,
enableDnsRebindingProtection,
allowedHosts,
allowedOrigins,
eventStore,
block,
)
}
}
}

/*
* Configures the Ktor Application to handle Model Context Protocol (MCP) over stateless Streamable Http.
* It currently only works with JSON response.
*/
@KtorDsl
public fun Application.mcpStatelessStreamableHttp(
enableDnsRebindingProtection: Boolean = false,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
eventStore: EventStore? = null,
block: RoutingContext.() -> Server,
) {
routing {
post("/mcp") {
Copy link

Choose a reason for hiding this comment

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

get requests also need to be processed.

Copy link
Author

Choose a reason for hiding this comment

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

I didn't implement because it's not needed for json response. Once we add SSE back, we can do it then.

Copy link

@dvilker dvilker Oct 29, 2025

Choose a reason for hiding this comment

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

I didn't implement because it's not needed for json response. Once we add SSE back, we can do it then.

https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http

The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, or else return HTTP 405 Method Not Allowed, indicating that the server does not offer an SSE stream at this endpoint

Without processing (which responds with code 405), it seems the inspector was spamming errors.

I replaced post(... with route(".... Everything else has already been implemented by you.

mcpStatelessStreamableHttpEndpoint(
enableDnsRebindingProtection,
allowedHosts,
allowedOrigins,
eventStore,
block,
)
}
}
}

private suspend fun ServerSSESession.mcpSseEndpoint(
postEndpoint: String,
sseTransportManager: SseTransportManager,
transportManager: TransportManager,
block: ServerSSESession.() -> Server,
) {
val transport = mcpSseTransport(postEndpoint, sseTransportManager)
val transport = mcpSseTransport(postEndpoint, transportManager)

val server = block()

server.onClose {
logger.info { "Server connection closed for sessionId: ${transport.sessionId}" }
sseTransportManager.removeTransport(transport.sessionId)
transportManager.removeTransport(transport.sessionId)
}

server.connect(transport)
Expand All @@ -95,24 +153,106 @@ internal suspend fun ServerSSESession.mcpSseEndpoint(

internal fun ServerSSESession.mcpSseTransport(
postEndpoint: String,
sseTransportManager: SseTransportManager,
transportManager: TransportManager,
): SseServerTransport {
val transport = SseServerTransport(postEndpoint, this)
sseTransportManager.addTransport(transport)
transportManager.addTransport(transport.sessionId, transport)
logger.info { "New SSE connection established and stored with sessionId: ${transport.sessionId}" }

return transport
}

internal suspend fun RoutingContext.mcpPostEndpoint(sseTransportManager: SseTransportManager) {
internal suspend fun RoutingContext.mcpStreamableHttpEndpoint(
transportManager: TransportManager,
enableDnsRebindingProtection: Boolean = false,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
eventStore: EventStore? = null,
block: RoutingContext.() -> Server,
) {
val sessionId = this.call.request.header(MCP_SESSION_ID_HEADER)
val transport = if (sessionId != null && transportManager.hasTransport(sessionId)) {
transportManager.getTransport(sessionId)
} else if (sessionId == null) {
val transport = StreamableHttpServerTransport(
enableDnsRebindingProtection = enableDnsRebindingProtection,
allowedHosts = allowedHosts,
allowedOrigins = allowedOrigins,
eventStore = eventStore,
enableJsonResponse = true,
)

transport.setOnSessionInitialized { sessionId ->
transportManager.addTransport(sessionId, transport)

logger.info { "New StreamableHttp connection established and stored with sessionId: $sessionId" }
}

val server = block()
server.onClose {
logger.info { "Server connection closed for sessionId: ${transport.sessionId}" }
}

server.connect(transport)

transport
} else {
null
}

if (transport == null) {
this.call.reject(
HttpStatusCode.BadRequest,
ErrorCode.Unknown(-32000),
"Bad Request: No valid session ID provided",
)
return
}

(transport as StreamableHttpServerTransport).handleRequest(null, this.call)
logger.debug { "Server connected to transport for sessionId: ${transport.sessionId}" }
}

internal suspend fun RoutingContext.mcpStatelessStreamableHttpEndpoint(
enableDnsRebindingProtection: Boolean = false,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
eventStore: EventStore? = null,
block: RoutingContext.() -> Server,
) {
val transport = StreamableHttpServerTransport(
enableDnsRebindingProtection = enableDnsRebindingProtection,
allowedHosts = allowedHosts,
allowedOrigins = allowedOrigins,
eventStore = eventStore,
enableJsonResponse = true,
)
transport.setSessionIdGenerator(null)

logger.info { "New stateless StreamableHttp connection established without sessionId" }

val server = block()

server.onClose {
logger.info { "Server connection closed without sessionId" }
}

server.connect(transport)

transport.handleRequest(null, this.call)

logger.debug { "Server connected to transport without sessionId" }
}

internal suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportManager) {
val sessionId: String = call.request.queryParameters["sessionId"] ?: run {
call.respond(HttpStatusCode.BadRequest, "sessionId query parameter is not provided")
return
}

logger.debug { "Received message for sessionId: $sessionId" }

val transport = sseTransportManager.getTransport(sessionId)
val transport = transportManager.getTransport(sessionId) as SseServerTransport?
if (transport == null) {
logger.warn { "Session not found for sessionId: $sessionId" }
call.respond(HttpStatusCode.NotFound, "Session not found")
Expand Down
Loading