Introduce server sessions (#198)

Introduce a `ServerSession` to manage multiple connections to the mcp
server. `Protocall` logic of the `Server` was moved to `ServerSession`,
while MCP resources logic remained in `Server`

## Motivation and Context
1. Fix the multiple connection issues reported for SSE transport
2. Avoid server duplications during configurations: currently every connection
creates a session, but the server stays single

## How Has This Been Tested?

Added `SseIntegrationTest` and `WebSocketIntegrationTest` integration
tests to check connection with single and multiple clients

## Breaking Changes

This change caused several depreciations:
1. The `Server` -> `Protocol` inheritance (which caused single
connection issue) should be removed in future releases, now, all the
methods inherited from `Protocall` marked as `Deprecated` with a
`Warning`
2. Websocket Ktor server initialization changes in order not to create
server instance per connection

## 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
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [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

## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->

---------

Co-authored-by: devcrocod <devcrocod@gmail.com>
This commit is contained in:
Maria Tigina 2025-09-29 09:10:07 +02:00 • committed by GitHub
commit d86813636e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1191 additions and 602 deletions

View file

@ -157,9 +157,11 @@ public open class Client(private val clientInfo: Implementation, options: Client
notification(InitializedNotification())
} catch (error: Throwable) {
logger.error(error) { "Failed to initialize client: ${error.message}" }
close()
if (error !is CancellationException) {
throw IllegalStateException("Error connecting to transport: ${error.message}")
throw IllegalStateException("Error connecting to transport: ${error.message}", error)
}
throw error

View file

@ -1,5 +1,6 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.HttpClient
import io.ktor.client.plugins.sse.ClientSSESession
import io.ktor.client.plugins.sse.sseSession
@ -46,6 +47,8 @@ public class SseClientTransport(
private val reconnectionTime: Duration? = null,
private val requestBuilder: HttpRequestBuilder.() -> Unit = {},
) : AbstractTransport() {
private val logger = KotlinLogging.logger {}
private val initialized: AtomicBoolean = AtomicBoolean(false)
private val endpoint = CompletableDeferred<String>()
@ -111,6 +114,8 @@ public class SseClientTransport(
val text = response.bodyAsText()
error("Error POSTing to endpoint (HTTP ${response.status}): $text")
}
logger.debug { "Client successfully sent message via SSE $endpoint" }
} catch (e: Throwable) {
_onError(e)
throw e
@ -158,6 +163,7 @@ public class SseClientTransport(
val path = if (eventData.startsWith("/")) eventData.substring(1) else eventData
val endpointUrl = Url("$baseUrl/$path")
endpoint.complete(endpointUrl.toString())
logger.debug { "Client connected to endpoint: $endpointUrl" }
} catch (e: Throwable) {
_onError(e)
endpoint.completeExceptionally(e)

View file

@ -1,5 +1,6 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.HttpRequestBuilder
@ -10,6 +11,8 @@ import io.modelcontextprotocol.kotlin.sdk.shared.MCP_SUBPROTOCOL
import io.modelcontextprotocol.kotlin.sdk.shared.WebSocketMcpTransport
import kotlin.properties.Delegates
private val logger = KotlinLogging.logger {}
/**
* Client transport for WebSocket: this will connect to a server over the WebSocket protocol.
*/
@ -21,6 +24,8 @@ public class WebSocketClientTransport(
override var session: WebSocketSession by Delegates.notNull()
override suspend fun initializeSession() {
logger.debug { "Websocket session initialization started..." }
session = urlString?.let {
client.webSocketSession(it) {
requestBuilder()
@ -32,5 +37,7 @@ public class WebSocketClientTransport(
header(HttpHeaders.SecWebSocketProtocol, MCP_SUBPROTOCOL)
}
logger.debug { "Websocket session initialization finished" }
}
}

View file

@ -1,11 +1,14 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.HttpClient
import io.ktor.client.request.HttpRequestBuilder
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.LIB_VERSION
import io.modelcontextprotocol.kotlin.sdk.shared.IMPLEMENTATION_NAME
private val logger = KotlinLogging.logger {}
/**
* Returns a new WebSocket transport for the Model Context Protocol using the provided HttpClient.
*
@ -36,6 +39,8 @@ public suspend fun HttpClient.mcpWebSocket(
version = LIB_VERSION,
),
)
logger.debug { "Client started to connect to server" }
client.connect(transport)
logger.debug { "Client finished to connect to server" }
return client
}

View file

@ -41,7 +41,7 @@ import kotlin.reflect.typeOf
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
private val LOGGER = KotlinLogging.logger { }
private val logger = KotlinLogging.logger { }
public const val IMPLEMENTATION_NAME: String = "mcp-ktor"
@ -211,6 +211,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
}
}
logger.info { "Starting transport" }
return transport.start()
}
@ -228,29 +229,29 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
}
private suspend fun onNotification(notification: JSONRPCNotification) {
LOGGER.trace { "Received notification: ${notification.method}" }
logger.trace { "Received notification: ${notification.method}" }
val handler = notificationHandlers[notification.method] ?: fallbackNotificationHandler
if (handler == null) {
LOGGER.trace { "No handler found for notification: ${notification.method}" }
logger.trace { "No handler found for notification: ${notification.method}" }
return
}
try {
handler(notification)
} catch (cause: Throwable) {
LOGGER.error(cause) { "Error handling notification: ${notification.method}" }
logger.error(cause) { "Error handling notification: ${notification.method}" }
onError(cause)
}
}
private suspend fun onRequest(request: JSONRPCRequest) {
LOGGER.trace { "Received request: ${request.method} (id: ${request.id})" }
logger.trace { "Received request: ${request.method} (id: ${request.id})" }
val handler = requestHandlers[request.method] ?: fallbackRequestHandler
if (handler === null) {
LOGGER.trace { "No handler found for request: ${request.method}" }
logger.trace { "No handler found for request: ${request.method}" }
try {
transport?.send(
JSONRPCResponse(
@ -262,7 +263,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
),
)
} catch (cause: Throwable) {
LOGGER.error(cause) { "Error sending method not found response" }
logger.error(cause) { "Error sending method not found response" }
onError(cause)
}
return
@ -270,7 +271,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
try {
val result = handler(request, RequestHandlerExtra())
LOGGER.trace { "Request handled successfully: ${request.method} (id: ${request.id})" }
logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" }
transport?.send(
JSONRPCResponse(
@ -279,7 +280,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
),
)
} catch (cause: Throwable) {
LOGGER.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" }
logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" }
try {
transport?.send(
@ -292,7 +293,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
),
)
} catch (sendError: Throwable) {
LOGGER.error(sendError) {
logger.error(sendError) {
"Failed to send error response for request: ${request.method} (id: ${request.id})"
}
// Optionally implement fallback behavior here
@ -301,7 +302,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
}
private fun onProgress(notification: ProgressNotification) {
LOGGER.trace {
logger.trace {
"Received progress notification: token=${notification.params.progressToken}, progress=${notification.params.progress}/${notification.params.total}"
}
val progress = notification.params.progress
@ -314,7 +315,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
val error = Error(
"Received a progress notification for an unknown token: ${McpJson.encodeToString(notification)}",
)
LOGGER.error { error.message }
logger.error { error.message }
onError(error)
return
}
@ -389,9 +390,9 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
* Do not use this method to emit notifications! Use notification() instead.
*/
public suspend fun <T : RequestResult> request(request: Request, options: RequestOptions? = null): T {
LOGGER.trace { "Sending request: ${request.method}" }
logger.trace { "Sending request: ${request.method}" }
val result = CompletableDeferred<T>()
val transport = this@Protocol.transport ?: throw Error("Not connected")
val transport = transport ?: throw Error("Not connected")
if (this@Protocol.options?.enforceStrictCapabilities == true) {
assertCapabilityForMethod(request.method)
@ -401,7 +402,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
val messageId = message.id
if (options?.onProgress != null) {
LOGGER.trace { "Registering progress handler for request id: $messageId" }
logger.trace { "Registering progress handler for request id: $messageId" }
_progressHandlers.update { current ->
current.put(messageId, options.onProgress)
}
@ -451,12 +452,12 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
val timeout = options?.timeout ?: DEFAULT_REQUEST_TIMEOUT
try {
withTimeout(timeout) {
LOGGER.trace { "Sending request message with id: $messageId" }
logger.trace { "Sending request message with id: $messageId" }
this@Protocol.transport?.send(message)
}
return result.await()
} catch (cause: TimeoutCancellationException) {
LOGGER.error { "Request timed out after ${timeout.inWholeMilliseconds}ms: ${request.method}" }
logger.error { "Request timed out after ${timeout.inWholeMilliseconds}ms: ${request.method}" }
cancel(
McpError(
ErrorCode.Defined.RequestTimeout.code,
@ -473,7 +474,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
* Emits a notification, which is a one-way message that does not expect a response.
*/
public suspend fun notification(notification: Notification) {
LOGGER.trace { "Sending notification: ${notification.method}" }
logger.trace { "Sending notification: ${notification.method}" }
val transport = this.transport ?: error("Not connected")
assertNotificationCapability(notification.method)

View file

@ -1,5 +1,6 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.close
@ -17,6 +18,8 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi
public const val MCP_SUBPROTOCOL: String = "mcp"
private val logger = KotlinLogging.logger {}
/**
* Abstract class representing a WebSocket transport for the Model Context Protocol (MCP).
* Handles communication over a WebSocket session.
@ -40,6 +43,8 @@ public abstract class WebSocketMcpTransport : AbstractTransport() {
protected abstract suspend fun initializeSession()
override suspend fun start() {
logger.debug { "Starting websocket transport" }
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
error(
"WebSocketClientTransport already started! " +
@ -53,7 +58,8 @@ public abstract class WebSocketMcpTransport : AbstractTransport() {
while (true) {
val message = try {
session.incoming.receive()
} catch (_: ClosedReceiveChannelException) {
} catch (e: ClosedReceiveChannelException) {
logger.debug { "Closed receive channel, exiting" }
return@launch
}
@ -84,6 +90,7 @@ public abstract class WebSocketMcpTransport : AbstractTransport() {
}
override suspend fun send(message: JSONRPCMessage) {
logger.debug { "Sending message" }
if (!initialized.load()) {
error("Not connected")
}
@ -96,6 +103,7 @@ public abstract class WebSocketMcpTransport : AbstractTransport() {
error("Not connected")
}
logger.debug { "Closing websocket session" }
session.close()
session.coroutineContext.job.join()
}

View file

@ -44,7 +44,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredTool {
public fun toString ()Ljava/lang/String;
}
public class io/modelcontextprotocol/kotlin/sdk/server/Server : io/modelcontextprotocol/kotlin/sdk/shared/Protocol {
public class io/modelcontextprotocol/kotlin/sdk/server/Server {
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/Implementation;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;)V
public final fun addPrompt (Lio/modelcontextprotocol/kotlin/sdk/Prompt;Lkotlin/jvm/functions/Function2;)V
public final fun addPrompt (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/functions/Function2;)V
@ -57,35 +57,20 @@ public class io/modelcontextprotocol/kotlin/sdk/server/Server : io/modelcontextp
public final fun addTool (Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/Tool$Input;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/Tool$Output;Lio/modelcontextprotocol/kotlin/sdk/ToolAnnotations;Lkotlin/jvm/functions/Function2;)V
public static synthetic fun addTool$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/Tool$Input;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/Tool$Output;Lio/modelcontextprotocol/kotlin/sdk/ToolAnnotations;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
public final fun addTools (Ljava/util/List;)V
protected fun assertCapabilityForMethod (Lio/modelcontextprotocol/kotlin/sdk/Method;)V
protected fun assertNotificationCapability (Lio/modelcontextprotocol/kotlin/sdk/Method;)V
public fun assertRequestHandlerCapability (Lio/modelcontextprotocol/kotlin/sdk/Method;)V
public final fun createElicitation (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/CreateElicitationRequest$RequestedSchema;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun createElicitation$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/CreateElicitationRequest$RequestedSchema;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun createMessage (Lio/modelcontextprotocol/kotlin/sdk/CreateMessageRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun createMessage$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Lio/modelcontextprotocol/kotlin/sdk/CreateMessageRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun getClientCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/ClientCapabilities;
public final fun getClientVersion ()Lio/modelcontextprotocol/kotlin/sdk/Implementation;
public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun connect (Lio/modelcontextprotocol/kotlin/sdk/shared/Transport;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun getPrompts ()Ljava/util/Map;
public final fun getResources ()Ljava/util/Map;
public final fun getTools ()Ljava/util/Map;
public final fun listRoots (Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun listRoots$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public fun onClose ()V
public final fun onClose (Lkotlin/jvm/functions/Function0;)V
public final fun onConnect (Lkotlin/jvm/functions/Function0;)V
public final fun onInitialized (Lkotlin/jvm/functions/Function0;)V
public final fun ping (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun removePrompt (Ljava/lang/String;)Z
public final fun removePrompts (Ljava/util/List;)I
public final fun removeResource (Ljava/lang/String;)Z
public final fun removeResources (Ljava/util/List;)I
public final fun removeTool (Ljava/lang/String;)Z
public final fun removeTools (Ljava/util/List;)I
public final fun sendLoggingMessage (Lio/modelcontextprotocol/kotlin/sdk/LoggingMessageNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun sendPromptListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun sendResourceListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun sendResourceUpdated (Lio/modelcontextprotocol/kotlin/sdk/ResourceUpdatedNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun sendToolListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class io/modelcontextprotocol/kotlin/sdk/server/ServerOptions : io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions {
@ -94,6 +79,30 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/ServerOptions : io/
public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/ServerCapabilities;
}
public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelcontextprotocol/kotlin/sdk/shared/Protocol {
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/Implementation;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;)V
protected fun assertCapabilityForMethod (Lio/modelcontextprotocol/kotlin/sdk/Method;)V
protected fun assertNotificationCapability (Lio/modelcontextprotocol/kotlin/sdk/Method;)V
public fun assertRequestHandlerCapability (Lio/modelcontextprotocol/kotlin/sdk/Method;)V
public final fun createElicitation (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/CreateElicitationRequest$RequestedSchema;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun createElicitation$default (Lio/modelcontextprotocol/kotlin/sdk/server/ServerSession;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/CreateElicitationRequest$RequestedSchema;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun createMessage (Lio/modelcontextprotocol/kotlin/sdk/CreateMessageRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun createMessage$default (Lio/modelcontextprotocol/kotlin/sdk/server/ServerSession;Lio/modelcontextprotocol/kotlin/sdk/CreateMessageRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun getClientCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/ClientCapabilities;
public final fun getClientVersion ()Lio/modelcontextprotocol/kotlin/sdk/Implementation;
public final fun listRoots (Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun listRoots$default (Lio/modelcontextprotocol/kotlin/sdk/server/ServerSession;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public fun onClose ()V
public final fun onClose (Lkotlin/jvm/functions/Function0;)V
public final fun onInitialized (Lkotlin/jvm/functions/Function0;)V
public final fun ping (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun sendLoggingMessage (Lio/modelcontextprotocol/kotlin/sdk/LoggingMessageNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun sendPromptListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun sendResourceListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun sendResourceUpdated (Lio/modelcontextprotocol/kotlin/sdk/ResourceUpdatedNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun sendToolListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class io/modelcontextprotocol/kotlin/sdk/server/SseServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
public fun <init> (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;)V
public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
@ -112,8 +121,13 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/StdioServerTranspor
}
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
public static final fun mcpWebSocket (Lio/ktor/server/routing/Route;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function2;)V
public static final fun mcpWebSocket (Lio/ktor/server/routing/Route;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function2;)V
public static final fun mcpWebSocket (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function0;)V
public static final fun mcpWebSocket (Lio/ktor/server/routing/Routing;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V
public static final fun mcpWebSocket (Lio/ktor/server/routing/Routing;Lkotlin/jvm/functions/Function0;)V
public static synthetic fun mcpWebSocket$default (Lio/ktor/server/routing/Route;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
public static synthetic fun mcpWebSocket$default (Lio/ktor/server/routing/Route;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
public static final fun mcpWebSocketTransport (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V

View file

@ -13,11 +13,29 @@ import io.ktor.server.routing.routing
import io.ktor.server.sse.SSE
import io.ktor.server.sse.ServerSSESession
import io.ktor.server.sse.sse
import io.ktor.util.collections.ConcurrentMap
import io.ktor.utils.io.KtorDsl
import kotlinx.atomicfu.AtomicRef
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.toPersistentMap
private val logger = KotlinLogging.logger {}
internal class SseTransportManager(transports: Map<String, SseServerTransport> = emptyMap()) {
private val transports: AtomicRef<PersistentMap<String, SseServerTransport>> = atomic(transports.toPersistentMap())
fun getTransport(sessionId: String): SseServerTransport? = transports.value[sessionId]
fun addTransport(transport: SseServerTransport) {
transports.update { it.put(transport.sessionId, transport) }
}
fun removeTransport(sessionId: String) {
transports.update { it.remove(sessionId) }
}
}
@KtorDsl
public fun Routing.mcp(path: String, block: ServerSSESession.() -> Server) {
route(path) {
@ -25,85 +43,76 @@ public fun Routing.mcp(path: String, block: ServerSSESession.() -> Server) {
}
}
/**
* Configures the Ktor Application to handle Model Context Protocol (MCP) over Server-Sent Events (SSE).
*/
/*
* Configures the Ktor Application to handle Model Context Protocol (MCP) over Server-Sent Events (SSE).
*/
@KtorDsl
public fun Routing.mcp(block: ServerSSESession.() -> Server) {
val transports = ConcurrentMap<String, SseServerTransport>()
val sseTransportManager = SseTransportManager()
sse {
mcpSseEndpoint("", transports, block)
mcpSseEndpoint("", sseTransportManager, block)
}
post {
mcpPostEndpoint(transports)
mcpPostEndpoint(sseTransportManager)
}
}
@Suppress("FunctionName")
@Deprecated("Use mcp() instead", ReplaceWith("mcp(block)"), DeprecationLevel.WARNING)
@Deprecated("Use mcp() instead", ReplaceWith("mcp(block)"), DeprecationLevel.ERROR)
public fun Application.MCP(block: ServerSSESession.() -> Server) {
mcp(block)
}
@KtorDsl
public fun Application.mcp(block: ServerSSESession.() -> Server) {
val transports = ConcurrentMap<String, SseServerTransport>()
install(SSE)
routing {
sse("/sse") {
mcpSseEndpoint("/message", transports, block)
}
post("/message") {
mcpPostEndpoint(transports)
}
mcp(block)
}
}
private suspend fun ServerSSESession.mcpSseEndpoint(
internal suspend fun ServerSSESession.mcpSseEndpoint(
postEndpoint: String,
transports: ConcurrentMap<String, SseServerTransport>,
sseTransportManager: SseTransportManager,
block: ServerSSESession.() -> Server,
) {
val transport = mcpSseTransport(postEndpoint, transports)
val transport = mcpSseTransport(postEndpoint, sseTransportManager)
val server = block()
server.onClose {
logger.info { "Server connection closed for sessionId: ${transport.sessionId}" }
transports.remove(transport.sessionId)
sseTransportManager.removeTransport(transport.sessionId)
}
server.connect(transport)
logger.debug { "Server connected to transport for sessionId: ${transport.sessionId}" }
}
internal fun ServerSSESession.mcpSseTransport(
postEndpoint: String,
transports: ConcurrentMap<String, SseServerTransport>,
sseTransportManager: SseTransportManager,
): SseServerTransport {
val transport = SseServerTransport(postEndpoint, this)
transports[transport.sessionId] = transport
sseTransportManager.addTransport(transport)
logger.info { "New SSE connection established and stored with sessionId: ${transport.sessionId}" }
return transport
}
internal suspend fun RoutingContext.mcpPostEndpoint(transports: ConcurrentMap<String, SseServerTransport>) {
val sessionId: String = call.request.queryParameters["sessionId"]
?: run {
call.respond(HttpStatusCode.BadRequest, "sessionId query parameter is not provided")
return
}
internal suspend fun RoutingContext.mcpPostEndpoint(sseTransportManager: SseTransportManager) {
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 = transports[sessionId]
val transport = sseTransportManager.getTransport(sessionId)
if (transport == null) {
logger.warn { "Session not found for sessionId: $sessionId" }
call.respond(HttpStatusCode.NotFound, "Session not found")

View file

@ -3,58 +3,35 @@ package io.modelcontextprotocol.kotlin.sdk.server
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationRequest
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationRequest.RequestedSchema
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationResult
import io.modelcontextprotocol.kotlin.sdk.CreateMessageRequest
import io.modelcontextprotocol.kotlin.sdk.CreateMessageResult
import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject
import io.modelcontextprotocol.kotlin.sdk.EmptyRequestResult
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.InitializeRequest
import io.modelcontextprotocol.kotlin.sdk.InitializeResult
import io.modelcontextprotocol.kotlin.sdk.InitializedNotification
import io.modelcontextprotocol.kotlin.sdk.LATEST_PROTOCOL_VERSION
import io.modelcontextprotocol.kotlin.sdk.ListPromptsRequest
import io.modelcontextprotocol.kotlin.sdk.ListPromptsResult
import io.modelcontextprotocol.kotlin.sdk.ListResourceTemplatesRequest
import io.modelcontextprotocol.kotlin.sdk.ListResourceTemplatesResult
import io.modelcontextprotocol.kotlin.sdk.ListResourcesRequest
import io.modelcontextprotocol.kotlin.sdk.ListResourcesResult
import io.modelcontextprotocol.kotlin.sdk.ListRootsRequest
import io.modelcontextprotocol.kotlin.sdk.ListRootsResult
import io.modelcontextprotocol.kotlin.sdk.ListToolsRequest
import io.modelcontextprotocol.kotlin.sdk.ListToolsResult
import io.modelcontextprotocol.kotlin.sdk.LoggingMessageNotification
import io.modelcontextprotocol.kotlin.sdk.Method
import io.modelcontextprotocol.kotlin.sdk.PingRequest
import io.modelcontextprotocol.kotlin.sdk.Prompt
import io.modelcontextprotocol.kotlin.sdk.PromptArgument
import io.modelcontextprotocol.kotlin.sdk.PromptListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.Resource
import io.modelcontextprotocol.kotlin.sdk.ResourceListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.ResourceUpdatedNotification
import io.modelcontextprotocol.kotlin.sdk.SUPPORTED_PROTOCOL_VERSIONS
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.Tool
import io.modelcontextprotocol.kotlin.sdk.ToolAnnotations
import io.modelcontextprotocol.kotlin.sdk.ToolListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.shared.Protocol
import io.modelcontextprotocol.kotlin.sdk.shared.ProtocolOptions
import io.modelcontextprotocol.kotlin.sdk.shared.RequestOptions
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.getAndUpdate
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.minus
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CompletableDeferred
import kotlinx.serialization.json.JsonObject
private val logger = KotlinLogging.logger {}
@ -77,27 +54,18 @@ public class ServerOptions(public val capabilities: ServerCapabilities, enforceS
* @param serverInfo Information about this server implementation (name, version).
* @param options Configuration options for the server.
*/
public open class Server(private val serverInfo: Implementation, options: ServerOptions) : Protocol(options) {
public open class Server(private val serverInfo: Implementation, private val options: ServerOptions) {
private val sessions = atomic(persistentListOf<ServerSession>())
@Suppress("ktlint:standard:backing-property-naming")
private var _onInitialized: (() -> Unit) = {}
@Suppress("ktlint:standard:backing-property-naming")
private var _onConnect: (() -> Unit) = {}
@Suppress("ktlint:standard:backing-property-naming")
private var _onClose: () -> Unit = {}
/**
* The client's reported capabilities after initialization.
*/
public var clientCapabilities: ClientCapabilities? = null
private set
/**
* The client's version information after initialization.
*/
public var clientVersion: Implementation? = null
private set
private val capabilities: ServerCapabilities = options.capabilities
private val _tools = atomic(persistentMapOf<String, RegisteredTool>())
private val _prompts = atomic(persistentMapOf<String, RegisteredPrompt>())
private val _resources = atomic(persistentMapOf<String, RegisteredResource>())
@ -108,55 +76,83 @@ public open class Server(private val serverInfo: Implementation, options: Server
public val resources: Map<String, RegisteredResource>
get() = _resources.value
init {
logger.debug { "Initializing MCP server with capabilities: $capabilities" }
public suspend fun close() {
logger.debug { "Closing MCP server" }
sessions.value.forEach { it.close() }
_onClose()
}
// Core protocol handlers
setRequestHandler<InitializeRequest>(Method.Defined.Initialize) { request, _ ->
handleInitialize(request)
}
setNotificationHandler<InitializedNotification>(Method.Defined.NotificationsInitialized) {
_onInitialized()
CompletableDeferred(Unit)
}
/**
* Starts a new server session with the given transport and initializes
* internal request handlers based on the server's capabilities.
*
* @param transport The transport layer to connect the session with.
* @return The initialized and connected server session.
*/
public suspend fun connect(transport: Transport): ServerSession {
val session = ServerSession(serverInfo, options)
// Internal handlers for tools
if (capabilities.tools != null) {
setRequestHandler<ListToolsRequest>(Method.Defined.ToolsList) { _, _ ->
if (options.capabilities.tools != null) {
session.setRequestHandler<ListToolsRequest>(Method.Defined.ToolsList) { _, _ ->
handleListTools()
}
setRequestHandler<CallToolRequest>(Method.Defined.ToolsCall) { request, _ ->
session.setRequestHandler<CallToolRequest>(Method.Defined.ToolsCall) { request, _ ->
handleCallTool(request)
}
}
// Internal handlers for prompts
if (capabilities.prompts != null) {
setRequestHandler<ListPromptsRequest>(Method.Defined.PromptsList) { _, _ ->
if (options.capabilities.prompts != null) {
session.setRequestHandler<ListPromptsRequest>(Method.Defined.PromptsList) { _, _ ->
handleListPrompts()
}
setRequestHandler<GetPromptRequest>(Method.Defined.PromptsGet) { request, _ ->
session.setRequestHandler<GetPromptRequest>(Method.Defined.PromptsGet) { request, _ ->
handleGetPrompt(request)
}
}
// Internal handlers for resources
if (capabilities.resources != null) {
setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
if (options.capabilities.resources != null) {
session.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
handleListResources()
}
setRequestHandler<ReadResourceRequest>(Method.Defined.ResourcesRead) { request, _ ->
session.setRequestHandler<ReadResourceRequest>(Method.Defined.ResourcesRead) { request, _ ->
handleReadResource(request)
}
setRequestHandler<ListResourceTemplatesRequest>(Method.Defined.ResourcesTemplatesList) { _, _ ->
session.setRequestHandler<ListResourceTemplatesRequest>(Method.Defined.ResourcesTemplatesList) { _, _ ->
handleListResourceTemplates()
}
}
logger.debug { "Server session connecting to transport" }
session.connect(transport)
logger.debug { "Server session successfully connected to transport" }
sessions.update { it.add(session) }
_onConnect()
return session
}
/**
* Registers a callback to be invoked when the new server session connected.
*/
public fun onConnect(block: () -> Unit) {
val old = _onConnect
_onConnect = {
old()
block()
}
}
/**
* Registers a callback to be invoked when the server has completed initialization.
*/
@Deprecated(
"Initialization moved to ServerSession, use ServerSession.onInitialized instead.",
ReplaceWith("ServerSession.onInitialized"),
DeprecationLevel.WARNING,
)
public fun onInitialized(block: () -> Unit) {
val old = _onInitialized
_onInitialized = {
@ -176,14 +172,6 @@ public open class Server(private val serverInfo: Implementation, options: Server
}
}
/**
* Called when the server connection is closing.
*/
override fun onClose() {
logger.info { "Server connection closing" }
_onClose()
}
/**
* Registers a single tool. The client can then call this tool.
*
@ -192,7 +180,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
* @throws IllegalStateException If the server does not support tools.
*/
public fun addTool(tool: Tool, handler: suspend (CallToolRequest) -> CallToolResult) {
if (capabilities.tools == null) {
if (options.capabilities.tools == null) {
logger.error { "Failed to add tool '${tool.name}': Server does not support tools capability" }
throw IllegalStateException("Server does not support tools capability. Enable it in ServerOptions.")
}
@ -232,7 +220,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
* @throws IllegalStateException If the server does not support tools.
*/
public fun addTools(toolsToAdd: List<RegisteredTool>) {
if (capabilities.tools == null) {
if (options.capabilities.tools == null) {
logger.error { "Failed to add tools: Server does not support tools capability" }
throw IllegalStateException("Server does not support tools capability.")
}
@ -248,7 +236,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
* @throws IllegalStateException If the server does not support tools.
*/
public fun removeTool(name: String): Boolean {
if (capabilities.tools == null) {
if (options.capabilities.tools == null) {
logger.error { "Failed to remove tool '$name': Server does not support tools capability" }
throw IllegalStateException("Server does not support tools capability.")
}
@ -275,7 +263,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
* @throws IllegalStateException If the server does not support tools.
*/
public fun removeTools(toolNames: List<String>): Int {
if (capabilities.tools == null) {
if (options.capabilities.tools == null) {
logger.error { "Failed to remove tools: Server does not support tools capability" }
throw IllegalStateException("Server does not support tools capability.")
}
@ -302,7 +290,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
* @throws IllegalStateException If the server does not support prompts.
*/
public fun addPrompt(prompt: Prompt, promptProvider: suspend (GetPromptRequest) -> GetPromptResult) {
if (capabilities.prompts == null) {
if (options.capabilities.prompts == null) {
logger.error { "Failed to add prompt '${prompt.name}': Server does not support prompts capability" }
throw IllegalStateException("Server does not support prompts capability.")
}
@ -336,7 +324,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
* @throws IllegalStateException If the server does not support prompts.
*/
public fun addPrompts(promptsToAdd: List<RegisteredPrompt>) {
if (capabilities.prompts == null) {
if (options.capabilities.prompts == null) {
logger.error { "Failed to add prompts: Server does not support prompts capability" }
throw IllegalStateException("Server does not support prompts capability.")
}
@ -352,7 +340,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
* @throws IllegalStateException If the server does not support prompts.
*/
public fun removePrompt(name: String): Boolean {
if (capabilities.prompts == null) {
if (options.capabilities.prompts == null) {
logger.error { "Failed to remove prompt '$name': Server does not support prompts capability" }
throw IllegalStateException("Server does not support prompts capability.")
}
@ -379,7 +367,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
* @throws IllegalStateException If the server does not support prompts.
*/
public fun removePrompts(promptNames: List<String>): Int {
if (capabilities.prompts == null) {
if (options.capabilities.prompts == null) {
logger.error { "Failed to remove prompts: Server does not support prompts capability" }
throw IllegalStateException("Server does not support prompts capability.")
}
@ -416,7 +404,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
mimeType: String = "text/html",
readHandler: suspend (ReadResourceRequest) -> ReadResourceResult,
) {
if (capabilities.resources == null) {
if (options.capabilities.resources == null) {
logger.error { "Failed to add resource '$name': Server does not support resources capability" }
throw IllegalStateException("Server does not support resources capability.")
}
@ -436,7 +424,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
* @throws IllegalStateException If the server does not support resources.
*/
public fun addResources(resourcesToAdd: List<RegisteredResource>) {
if (capabilities.resources == null) {
if (options.capabilities.resources == null) {
logger.error { "Failed to add resources: Server does not support resources capability" }
throw IllegalStateException("Server does not support resources capability.")
}
@ -452,7 +440,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
* @throws IllegalStateException If the server does not support resources.
*/
public fun removeResource(uri: String): Boolean {
if (capabilities.resources == null) {
if (options.capabilities.resources == null) {
logger.error { "Failed to remove resource '$uri': Server does not support resources capability" }
throw IllegalStateException("Server does not support resources capability.")
}
@ -479,7 +467,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
* @throws IllegalStateException If the server does not support resources.
*/
public fun removeResources(uris: List<String>): Int {
if (capabilities.resources == null) {
if (options.capabilities.resources == null) {
logger.error { "Failed to remove resources: Server does not support resources capability" }
throw IllegalStateException("Server does not support resources capability.")
}
@ -499,123 +487,7 @@ public open class Server(private val serverInfo: Implementation, options: Server
return removedCount
}
/**
* Sends a ping request to the client to check connectivity.
*
* @return The result of the ping request.
* @throws IllegalStateException If for some reason the method is not supported or the connection is closed.
*/
public suspend fun ping(): EmptyRequestResult = request<EmptyRequestResult>(PingRequest())
/**
* Creates a message using the server's sampling capability.
*
* @param params The parameters for creating a message.
* @param options Optional request options.
* @return The created message result.
* @throws IllegalStateException If the server does not support sampling or if the request fails.
*/
public suspend fun createMessage(
params: CreateMessageRequest,
options: RequestOptions? = null,
): CreateMessageResult {
logger.debug { "Creating message with params: $params" }
return request<CreateMessageResult>(params, options)
}
/**
* Lists the available "roots" from the client's perspective (if supported).
*
* @param params JSON parameters for the request, usually empty.
* @param options Optional request options.
* @return The list of roots.
* @throws IllegalStateException If the server or client does not support roots.
*/
public suspend fun listRoots(
params: JsonObject = EmptyJsonObject,
options: RequestOptions? = null,
): ListRootsResult {
logger.debug { "Listing roots with params: $params" }
return request<ListRootsResult>(ListRootsRequest(params), options)
}
public suspend fun createElicitation(
message: String,
requestedSchema: RequestedSchema,
options: RequestOptions? = null,
): CreateElicitationResult {
logger.debug { "Creating elicitation with message: $message" }
return request(CreateElicitationRequest(message, requestedSchema), options)
}
/**
* Sends a logging message notification to the client.
*
* @param params The logging message notification parameters.
*/
public suspend fun sendLoggingMessage(params: LoggingMessageNotification) {
logger.trace { "Sending logging message: ${params.params.data}" }
notification(params)
}
/**
* Sends a resource-updated notification to the client, indicating that a specific resource has changed.
*
* @param params Details of the updated resource.
*/
public suspend fun sendResourceUpdated(params: ResourceUpdatedNotification) {
logger.debug { "Sending resource updated notification for: ${params.params.uri}" }
notification(params)
}
/**
* Sends a notification to the client indicating that the list of resources has changed.
*/
public suspend fun sendResourceListChanged() {
logger.debug { "Sending resource list changed notification" }
notification(ResourceListChangedNotification())
}
/**
* Sends a notification to the client indicating that the list of tools has changed.
*/
public suspend fun sendToolListChanged() {
logger.debug { "Sending tool list changed notification" }
notification(ToolListChangedNotification())
}
/**
* Sends a notification to the client indicating that the list of prompts has changed.
*/
public suspend fun sendPromptListChanged() {
logger.debug { "Sending prompt list changed notification" }
notification(PromptListChangedNotification())
}
// --- Internal Handlers ---
private suspend fun handleInitialize(request: InitializeRequest): InitializeResult {
logger.info { "Handling initialize request from client ${request.clientInfo}" }
clientCapabilities = request.capabilities
clientVersion = request.clientInfo
val requestedVersion = request.protocolVersion
val protocolVersion = if (SUPPORTED_PROTOCOL_VERSIONS.contains(requestedVersion)) {
requestedVersion
} else {
logger.warn {
"Client requested unsupported protocol version $requestedVersion, falling back to $LATEST_PROTOCOL_VERSION"
}
LATEST_PROTOCOL_VERSION
}
return InitializeResult(
protocolVersion = protocolVersion,
capabilities = capabilities,
serverInfo = serverInfo,
)
}
private suspend fun handleListTools(): ListToolsResult {
val toolList = tools.values.map { it.tool }
return ListToolsResult(tools = toolList, nextCursor = null)
@ -666,147 +538,6 @@ public open class Server(private val serverInfo: Implementation, options: Server
// If you have resource templates, return them here. For now, return empty.
return ListResourceTemplatesResult(listOf())
}
/**
* Asserts that the client supports the capability required for the given [method].
*
* This method is automatically called by the [Protocol] framework before handling requests.
* Throws [IllegalStateException] if the capability is not supported.
*
* @param method The method for which we are asserting capability.
*/
override fun assertCapabilityForMethod(method: Method) {
logger.trace { "Asserting capability for method: ${method.value}" }
when (method.value) {
"sampling/createMessage" -> {
if (clientCapabilities?.sampling == null) {
logger.error { "Client capability assertion failed: sampling not supported" }
throw IllegalStateException("Client does not support sampling (required for ${method.value})")
}
}
"roots/list" -> {
if (clientCapabilities?.roots == null) {
throw IllegalStateException("Client does not support listing roots (required for ${method.value})")
}
}
"elicitation/create" -> {
if (clientCapabilities?.elicitation == null) {
throw IllegalStateException("Client does not support elicitation (required for ${method.value})")
}
}
"ping" -> {
// No specific capability required
}
}
}
/**
* Asserts that the server can handle the specified notification method.
*
* Throws [IllegalStateException] if the server does not have the capabilities required to handle this notification.
*
* @param method The notification method.
*/
override fun assertNotificationCapability(method: Method) {
logger.trace { "Asserting notification capability for method: ${method.value}" }
when (method.value) {
"notifications/message" -> {
if (capabilities.logging == null) {
logger.error { "Server capability assertion failed: logging not supported" }
throw IllegalStateException("Server does not support logging (required for ${method.value})")
}
}
"notifications/resources/updated",
"notifications/resources/list_changed",
-> {
if (capabilities.resources == null) {
throw IllegalStateException(
"Server does not support notifying about resources (required for ${method.value})",
)
}
}
"notifications/tools/list_changed" -> {
if (capabilities.tools == null) {
throw IllegalStateException(
"Server does not support notifying of tool list changes (required for ${method.value})",
)
}
}
"notifications/prompts/list_changed" -> {
if (capabilities.prompts == null) {
throw IllegalStateException(
"Server does not support notifying of prompt list changes (required for ${method.value})",
)
}
}
"notifications/cancelled",
"notifications/progress",
-> {
// Always allowed
}
}
}
/**
* Asserts that the server can handle the specified request method.
*
* Throws [IllegalStateException] if the server does not have the capabilities required to handle this request.
*
* @param method The request method.
*/
override fun assertRequestHandlerCapability(method: Method) {
logger.trace { "Asserting request handler capability for method: ${method.value}" }
when (method.value) {
"sampling/createMessage" -> {
if (capabilities.sampling == null) {
logger.error { "Server capability assertion failed: sampling not supported" }
throw IllegalStateException("Server does not support sampling (required for $method)")
}
}
"logging/setLevel" -> {
if (capabilities.logging == null) {
throw IllegalStateException("Server does not support logging (required for $method)")
}
}
"prompts/get",
"prompts/list",
-> {
if (capabilities.prompts == null) {
throw IllegalStateException("Server does not support prompts (required for $method)")
}
}
"resources/list",
"resources/templates/list",
"resources/read",
-> {
if (capabilities.resources == null) {
throw IllegalStateException("Server does not support resources (required for $method)")
}
}
"tools/call",
"tools/list",
-> {
if (capabilities.tools == null) {
throw IllegalStateException("Server does not support tools (required for $method)")
}
}
"ping", "initialize" -> {
// No capability required
}
}
}
}
/**

View file

@ -0,0 +1,371 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationRequest
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationRequest.RequestedSchema
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationResult
import io.modelcontextprotocol.kotlin.sdk.CreateMessageRequest
import io.modelcontextprotocol.kotlin.sdk.CreateMessageResult
import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject
import io.modelcontextprotocol.kotlin.sdk.EmptyRequestResult
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.InitializeRequest
import io.modelcontextprotocol.kotlin.sdk.InitializeResult
import io.modelcontextprotocol.kotlin.sdk.InitializedNotification
import io.modelcontextprotocol.kotlin.sdk.LATEST_PROTOCOL_VERSION
import io.modelcontextprotocol.kotlin.sdk.ListRootsRequest
import io.modelcontextprotocol.kotlin.sdk.ListRootsResult
import io.modelcontextprotocol.kotlin.sdk.LoggingMessageNotification
import io.modelcontextprotocol.kotlin.sdk.Method
import io.modelcontextprotocol.kotlin.sdk.Method.Defined
import io.modelcontextprotocol.kotlin.sdk.PingRequest
import io.modelcontextprotocol.kotlin.sdk.PromptListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.ResourceListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.ResourceUpdatedNotification
import io.modelcontextprotocol.kotlin.sdk.SUPPORTED_PROTOCOL_VERSIONS
import io.modelcontextprotocol.kotlin.sdk.ToolListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.shared.Protocol
import io.modelcontextprotocol.kotlin.sdk.shared.RequestOptions
import kotlinx.coroutines.CompletableDeferred
import kotlinx.serialization.json.JsonObject
private val logger = KotlinLogging.logger {}
public open class ServerSession(private val serverInfo: Implementation, options: ServerOptions) : Protocol(options) {
@Suppress("ktlint:standard:backing-property-naming")
private var _onInitialized: (() -> Unit) = {}
@Suppress("ktlint:standard:backing-property-naming")
private var _onClose: () -> Unit = {}
init {
// Core protocol handlers
setRequestHandler<InitializeRequest>(Method.Defined.Initialize) { request, _ ->
handleInitialize(request)
}
setNotificationHandler<InitializedNotification>(Method.Defined.NotificationsInitialized) {
_onInitialized()
CompletableDeferred(Unit)
}
}
/**
* The capabilities supported by the server, related to the session.
*/
private val serverCapabilities = options.capabilities
/**
* The client's reported capabilities after initialization.
*/
public var clientCapabilities: ClientCapabilities? = null
private set
/**
* The client's version information after initialization.
*/
public var clientVersion: Implementation? = null
private set
/**
* Registers a callback to be invoked when the server has completed initialization.
*/
public fun onInitialized(block: () -> Unit) {
val old = _onInitialized
_onInitialized = {
old()
block()
}
}
/**
* Registers a callback to be invoked when the server session is closing.
*/
public fun onClose(block: () -> Unit) {
val old = _onClose
_onClose = {
old()
block()
}
}
/**
* Called when the server session is closing.
*/
override fun onClose() {
logger.debug { "Server connection closing" }
_onClose()
}
/**
* Sends a ping request to the client to check connectivity.
*
* @return The result of the ping request.
* @throws IllegalStateException If for some reason the method is not supported or the connection is closed.
*/
public suspend fun ping(): EmptyRequestResult = request<EmptyRequestResult>(PingRequest())
/**
* Creates a message using the server's sampling capability.
*
* @param params The parameters for creating a message.
* @param options Optional request options.
* @return The created message result.
* @throws IllegalStateException If the server does not support sampling or if the request fails.
*/
public suspend fun createMessage(
params: CreateMessageRequest,
options: RequestOptions? = null,
): CreateMessageResult {
logger.debug { "Creating message with params: $params" }
return request<CreateMessageResult>(params, options)
}
/**
* Lists the available "roots" from the client's perspective (if supported).
*
* @param params JSON parameters for the request, usually empty.
* @param options Optional request options.
* @return The list of roots.
* @throws IllegalStateException If the server or client does not support roots.
*/
public suspend fun listRoots(
params: JsonObject = EmptyJsonObject,
options: RequestOptions? = null,
): ListRootsResult {
logger.debug { "Listing roots with params: $params" }
return request<ListRootsResult>(ListRootsRequest(params), options)
}
public suspend fun createElicitation(
message: String,
requestedSchema: RequestedSchema,
options: RequestOptions? = null,
): CreateElicitationResult {
logger.debug { "Creating elicitation with message: $message" }
return request(CreateElicitationRequest(message, requestedSchema), options)
}
/**
* Sends a logging message notification to the client.
*
* @param notification The logging message notification.
*/
public suspend fun sendLoggingMessage(notification: LoggingMessageNotification) {
logger.trace { "Sending logging message: ${notification.params.data}" }
notification(notification)
}
/**
* Sends a resource-updated notification to the client, indicating that a specific resource has changed.
*
* @param notification Details of the updated resource.
*/
public suspend fun sendResourceUpdated(notification: ResourceUpdatedNotification) {
logger.debug { "Sending resource updated notification for: ${notification.params.uri}" }
notification(notification)
}
/**
* Sends a notification to the client indicating that the list of resources has changed.
*/
public suspend fun sendResourceListChanged() {
logger.debug { "Sending resource list changed notification" }
notification(ResourceListChangedNotification())
}
/**
* Sends a notification to the client indicating that the list of tools has changed.
*/
public suspend fun sendToolListChanged() {
logger.debug { "Sending tool list changed notification" }
notification(ToolListChangedNotification())
}
/**
* Sends a notification to the client indicating that the list of prompts has changed.
*/
public suspend fun sendPromptListChanged() {
logger.debug { "Sending prompt list changed notification" }
notification(PromptListChangedNotification())
}
/**
* Asserts that the client supports the capability required for the given [method].
*
* This method is automatically called by the [Protocol] framework before handling requests.
* Throws [IllegalStateException] if the capability is not supported.
*
* @param method The method for which we are asserting capability.
*/
override fun assertCapabilityForMethod(method: Method) {
logger.trace { "Asserting capability for method: ${method.value}" }
when (method) {
Defined.SamplingCreateMessage -> {
if (clientCapabilities?.sampling == null) {
logger.error { "Client capability assertion failed: sampling not supported" }
throw IllegalStateException("Client does not support sampling (required for ${method.value})")
}
}
Defined.RootsList -> {
if (clientCapabilities?.roots == null) {
logger.error { "Client capability assertion failed: listing roots not supported" }
throw IllegalStateException("Client does not support listing roots (required for ${method.value})")
}
}
Defined.ElicitationCreate -> {
if (clientCapabilities?.elicitation == null) {
logger.error { "Client capability assertion failed: elicitation not supported" }
throw IllegalStateException("Client does not support elicitation (required for ${method.value})")
}
}
Defined.Ping -> {
// No specific capability required
}
else -> {
// For notifications not specifically listed, no assertion by default
}
}
}
/**
* Asserts that the server can handle the specified notification method.
*
* Throws [IllegalStateException] if the server does not have the capabilities required to handle this notification.
*
* @param method The notification method.
*/
override fun assertNotificationCapability(method: Method) {
logger.trace { "Asserting notification capability for method: ${method.value}" }
when (method) {
Defined.NotificationsMessage -> {
if (serverCapabilities.logging == null) {
logger.error { "Server capability assertion failed: logging not supported" }
throw IllegalStateException("Server does not support logging (required for ${method.value})")
}
}
Defined.NotificationsResourcesUpdated,
Defined.NotificationsResourcesListChanged,
-> {
if (serverCapabilities.resources == null) {
throw IllegalStateException(
"Server does not support notifying about resources (required for ${method.value})",
)
}
}
Defined.NotificationsToolsListChanged -> {
if (serverCapabilities.tools == null) {
throw IllegalStateException(
"Server does not support notifying of tool list changes (required for ${method.value})",
)
}
}
Defined.NotificationsPromptsListChanged -> {
if (serverCapabilities.prompts == null) {
throw IllegalStateException(
"Server does not support notifying of prompt list changes (required for ${method.value})",
)
}
}
Defined.NotificationsCancelled,
Defined.NotificationsProgress,
-> {
// Always allowed
}
else -> {
// For notifications not specifically listed, no assertion by default
}
}
}
/**
* Asserts that the server can handle the specified request method.
*
* Throws [IllegalStateException] if the server does not have the capabilities required to handle this request.
*
* @param method The request method.
*/
override fun assertRequestHandlerCapability(method: Method) {
logger.trace { "Asserting request handler capability for method: ${method.value}" }
when (method) {
Defined.SamplingCreateMessage -> {
if (serverCapabilities.sampling == null) {
logger.error { "Server capability assertion failed: sampling not supported" }
throw IllegalStateException("Server does not support sampling (required for $method)")
}
}
Defined.LoggingSetLevel -> {
if (serverCapabilities.logging == null) {
throw IllegalStateException("Server does not support logging (required for $method)")
}
}
Defined.PromptsGet,
Defined.PromptsList,
-> {
if (serverCapabilities.prompts == null) {
throw IllegalStateException("Server does not support prompts (required for $method)")
}
}
Defined.ResourcesList,
Defined.ResourcesTemplatesList,
Defined.ResourcesRead,
Defined.ResourcesSubscribe,
Defined.ResourcesUnsubscribe,
-> {
if (serverCapabilities.resources == null) {
throw IllegalStateException("Server does not support resources (required for $method)")
}
}
Defined.ToolsCall,
Defined.ToolsList,
-> {
if (serverCapabilities.tools == null) {
throw IllegalStateException("Server does not support tools (required for $method)")
}
}
Defined.Ping, Defined.Initialize -> {
// No capability required
}
else -> {
// For notifications not specifically listed, no assertion by default
}
}
}
private suspend fun handleInitialize(request: InitializeRequest): InitializeResult {
logger.debug { "Handling initialization request from client" }
clientCapabilities = request.capabilities
clientVersion = request.clientInfo
val requestedVersion = request.protocolVersion
val protocolVersion = if (SUPPORTED_PROTOCOL_VERSIONS.contains(requestedVersion)) {
requestedVersion
} else {
logger.warn {
"Client requested unsupported protocol version $requestedVersion, falling back to $LATEST_PROTOCOL_VERSION"
}
LATEST_PROTOCOL_VERSION
}
return InitializeResult(
protocolVersion = protocolVersion,
capabilities = serverCapabilities,
serverInfo = serverInfo,
)
}
}

View file

@ -1,12 +1,83 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.routing.Route
import io.ktor.server.routing.Routing
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.utils.io.CancellationException
import io.ktor.utils.io.KtorDsl
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.LIB_VERSION
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.shared.IMPLEMENTATION_NAME
import kotlinx.coroutines.awaitCancellation
private val logger = KotlinLogging.logger {}
/**
* Configures the Ktor Application to handle Model Context Protocol (MCP) over WebSocket.
*/
@KtorDsl
public fun Routing.mcpWebSocket(block: () -> Server) {
webSocket {
mcpWebSocketEndpoint(block)
}
}
/**
* Configures the Ktor Application to handle Model Context Protocol (MCP) over WebSocket.
*/
@KtorDsl
public fun Routing.mcpWebSocket(path: String, block: () -> Server) {
webSocket(path) {
mcpWebSocketEndpoint(block)
}
}
/**
* Configures the Ktor Application to handle Model Context Protocol (MCP) over WebSocket.
*/
@KtorDsl
public fun Application.mcpWebSocket(block: () -> Server) {
install(WebSockets)
routing {
mcpWebSocket(block)
}
}
/**
* Configures the Ktor Application to handle Model Context Protocol (MCP) over WebSocket at the specified path.
*/
@KtorDsl
public fun Application.mcpWebSocket(path: String, block: () -> Server) {
install(WebSockets)
routing {
mcpWebSocket(path, block)
}
}
internal suspend fun WebSocketServerSession.mcpWebSocketEndpoint(block: () -> Server) {
logger.info { "Ktor Server establishing new connection" }
val transport = createMcpTransport(this)
val server = block()
var session: ServerSession? = null
try {
session = server.connect(transport)
awaitCancellation()
} catch (e: CancellationException) {
session?.close()
}
}
private fun createMcpTransport(webSocketSession: WebSocketServerSession): WebSocketMcpServerTransport =
WebSocketMcpServerTransport(webSocketSession)
/**
* Registers a WebSocket route that establishes an MCP (Model Context Protocol) server session.
@ -14,12 +85,28 @@ import io.modelcontextprotocol.kotlin.sdk.shared.IMPLEMENTATION_NAME
* @param options Optional server configuration settings for the MCP server.
* @param handler A suspend function that defines the server's behavior.
*/
@Deprecated(
"Use mcpWebSocket with a lambda that returns a Server instance instead",
ReplaceWith("Routing.mcpWebSocket"),
DeprecationLevel.WARNING,
)
public fun Route.mcpWebSocket(options: ServerOptions? = null, handler: suspend Server.() -> Unit = {}) {
webSocket {
createMcpServer(this, options, handler)
}
}
@Deprecated(
"Use mcpWebSocket with a lambda that returns a Server instance instead",
ReplaceWith("Routing.mcpWebSocket"),
DeprecationLevel.WARNING,
)
public fun Route.mcpWebSocket(block: () -> Server) {
webSocket {
block().connect(createMcpTransport(this))
}
}
/**
* Registers a WebSocket route at the specified [path] that establishes an MCP server session.
*
@ -27,6 +114,11 @@ public fun Route.mcpWebSocket(options: ServerOptions? = null, handler: suspend S
* @param options Optional server configuration settings for the MCP server.
* @param handler A suspend function that defines the server's behavior.
*/
@Deprecated(
"Use mcpWebSocket with a path and a lambda that returns a Server instance instead",
ReplaceWith("Routing.mcpWebSocket"),
DeprecationLevel.WARNING,
)
public fun Route.mcpWebSocket(path: String, options: ServerOptions? = null, handler: suspend Server.() -> Unit = {}) {
webSocket(path) {
createMcpServer(this, options, handler)
@ -38,6 +130,11 @@ public fun Route.mcpWebSocket(path: String, options: ServerOptions? = null, hand
*
* @param handler A suspend function that defines the behavior of the transport layer.
*/
@Deprecated(
"Use mcpWebSocket with a lambda that returns a Server instance instead",
ReplaceWith("Routing.mcpWebSocket"),
DeprecationLevel.WARNING,
)
public fun Route.mcpWebSocketTransport(handler: suspend WebSocketMcpServerTransport.() -> Unit = {}) {
webSocket {
val transport = createMcpTransport(this)
@ -53,6 +150,11 @@ public fun Route.mcpWebSocketTransport(handler: suspend WebSocketMcpServerTransp
* @param path The URL path at which to register the WebSocket route.
* @param handler A suspend function that defines the behavior of the transport layer.
*/
@Deprecated(
"Use mcpWebSocket with a path and a lambda that returns a Server instance instead",
ReplaceWith("Routing.mcpWebSocket"),
DeprecationLevel.WARNING,
)
public fun Route.mcpWebSocketTransport(path: String, handler: suspend WebSocketMcpServerTransport.() -> Unit = {}) {
webSocket(path) {
val transport = createMcpTransport(this)
@ -62,6 +164,11 @@ public fun Route.mcpWebSocketTransport(path: String, handler: suspend WebSocketM
}
}
@Deprecated(
"Use mcpWebSocket with a lambda that returns a Server instance instead",
ReplaceWith("mcpWebSocket"),
DeprecationLevel.WARNING,
)
private suspend fun Route.createMcpServer(
session: WebSocketServerSession,
options: ServerOptions?,
@ -87,6 +194,3 @@ private suspend fun Route.createMcpServer(
handler(server)
server.close()
}
private fun createMcpTransport(session: WebSocketServerSession): WebSocketMcpServerTransport =
WebSocketMcpServerTransport(session)

View file

@ -1,10 +1,13 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.http.HttpHeaders
import io.ktor.server.websocket.WebSocketServerSession
import io.modelcontextprotocol.kotlin.sdk.shared.MCP_SUBPROTOCOL
import io.modelcontextprotocol.kotlin.sdk.shared.WebSocketMcpTransport
private val logger = KotlinLogging.logger {}
/**
* Server-side implementation of the MCP (Model Context Protocol) transport over WebSocket.
*
@ -12,6 +15,7 @@ import io.modelcontextprotocol.kotlin.sdk.shared.WebSocketMcpTransport
*/
public class WebSocketMcpServerTransport(override val session: WebSocketServerSession) : WebSocketMcpTransport() {
override suspend fun initializeSession() {
logger.debug { "Checking session headers" }
val subprotocol = session.call.request.headers[HttpHeaders.SecWebSocketProtocol]
if (subprotocol != MCP_SUBPROTOCOL) {
error("Invalid subprotocol: $subprotocol, expected $MCP_SUBPROTOCOL")

View file

@ -30,6 +30,7 @@ import io.modelcontextprotocol.kotlin.sdk.TextContent
import io.modelcontextprotocol.kotlin.sdk.Tool
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.ServerSession
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport
import kotlinx.coroutines.CompletableDeferred
@ -241,7 +242,28 @@ class ClientTest {
serverOptions,
)
server.setRequestHandler<InitializeRequest>(Method.Defined.Initialize) { _, _ ->
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(
capabilities = ClientCapabilities(sampling = EmptyJsonObject),
),
)
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch {
client.connect(clientTransport)
},
launch {
serverSessionResult.complete(server.connect(serverTransport))
},
).joinAll()
val serverSession = serverSessionResult.await()
serverSession.setRequestHandler<InitializeRequest>(Method.Defined.Initialize) { _, _ ->
InitializeResult(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = ServerCapabilities(
@ -252,32 +274,13 @@ class ClientTest {
)
}
server.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
serverSession.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
ListResourcesResult(resources = emptyList(), nextCursor = null)
}
server.setRequestHandler<ListToolsRequest>(Method.Defined.ToolsList) { _, _ ->
serverSession.setRequestHandler<ListToolsRequest>(Method.Defined.ToolsList) { _, _ ->
ListToolsResult(tools = emptyList(), nextCursor = null)
}
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(
capabilities = ClientCapabilities(sampling = EmptyJsonObject),
),
)
listOf(
launch {
client.connect(clientTransport)
},
launch {
server.connect(serverTransport)
},
).joinAll()
// Server supports resources and tools, but not prompts
val caps = client.serverCapabilities
assertEquals(ServerCapabilities.Resources(null, null), caps?.resources)
@ -368,24 +371,27 @@ class ClientTest {
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
server.connect(serverTransport)
serverSessionResult.complete(server.connect(serverTransport))
println("Server connected")
},
).joinAll()
val serverSession = serverSessionResult.await()
// These should not throw
val jsonObject = buildJsonObject {
put("name", "John")
put("age", 30)
put("isStudent", false)
}
server.sendLoggingMessage(
serverSession.sendLoggingMessage(
LoggingMessageNotification(
params = LoggingMessageNotification.Params(
level = LoggingLevel.info,
@ -393,11 +399,11 @@ class ClientTest {
),
),
)
server.sendResourceListChanged()
serverSession.sendResourceListChanged()
// This should fail because the server doesn't have the tools capability
val ex = assertFailsWith<IllegalStateException> {
server.sendToolListChanged()
serverSession.sendToolListChanged()
}
assertTrue(ex.message?.contains("Server does not support notifying of tool list changes") == true)
}
@ -418,7 +424,29 @@ class ClientTest {
val def = CompletableDeferred<Unit>()
val defTimeOut = CompletableDeferred<Unit>()
server.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(capabilities = ClientCapabilities()),
)
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
serverSessionResult.complete(server.connect(serverTransport))
println("Server connected")
},
).joinAll()
val serverSession = serverSessionResult.await()
serverSession.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
// Simulate delay
def.complete(Unit)
try {
@ -431,24 +459,6 @@ class ClientTest {
fail("Shouldn't have been called")
}
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(capabilities = ClientCapabilities()),
)
listOf(
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
server.connect(serverTransport)
println("Server connected")
},
).joinAll()
val defCancel = CompletableDeferred<Unit>()
val job = launch {
try {
@ -478,7 +488,27 @@ class ClientTest {
),
)
server.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(capabilities = ClientCapabilities()),
)
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
serverSessionResult.complete(server.connect(serverTransport))
println("Server connected")
},
).joinAll()
val serverSession = serverSessionResult.await()
serverSession.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
// Simulate a delayed response
// Wait ~100ms unless canceled
try {
@ -492,23 +522,6 @@ class ClientTest {
ListResourcesResult(resources = emptyList())
}
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(capabilities = ClientCapabilities()),
)
listOf(
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
server.connect(serverTransport)
println("Server connected")
},
).joinAll()
// Request with 1 msec timeout should fail immediately
val ex = assertFailsWith<Exception> {
withTimeout(1) {
@ -559,34 +572,6 @@ class ClientTest {
serverOptions,
)
server.setRequestHandler<InitializeRequest>(Method.Defined.Initialize) { _, _ ->
InitializeResult(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = ServerCapabilities(
resources = ServerCapabilities.Resources(null, null),
tools = ServerCapabilities.Tools(null),
),
serverInfo = Implementation(name = "test", version = "1.0"),
)
}
val serverListToolsResult = ListToolsResult(
tools = listOf(
Tool(
name = "testTool",
title = "testTool title",
description = "testTool description",
annotations = null,
inputSchema = Tool.Input(),
outputSchema = null,
),
),
nextCursor = null,
)
server.setRequestHandler<ListToolsRequest>(Method.Defined.ToolsList) { _, _ ->
serverListToolsResult
}
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val client = Client(
@ -601,15 +586,50 @@ class ClientTest {
receivedMessage = msg
}
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
server.connect(serverTransport)
serverSessionResult.complete(server.connect(serverTransport))
println("Server connected")
},
).joinAll()
val serverSession = serverSessionResult.await()
serverSession.setRequestHandler<InitializeRequest>(Method.Defined.Initialize) { _, _ ->
InitializeResult(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = ServerCapabilities(
resources = ServerCapabilities.Resources(null, null),
tools = ServerCapabilities.Tools(null),
),
serverInfo = Implementation(name = "test", version = "1.0"),
)
}
val serverListToolsResult = ListToolsResult(
tools = listOf(
Tool(
name = "testTool",
title = "testTool title",
description = "testTool description",
annotations = null,
inputSchema = Tool.Input(),
outputSchema = null,
),
),
nextCursor = null,
)
serverSession.setRequestHandler<ListToolsRequest>(Method.Defined.ToolsList) { _, _ ->
serverListToolsResult
}
val serverCapabilities = client.serverCapabilities
assertEquals(ServerCapabilities.Tools(null), serverCapabilities?.tools)
@ -652,15 +672,25 @@ class ClientTest {
),
)
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch { client.connect(clientTransport) },
launch { server.connect(serverTransport) },
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
serverSessionResult.complete(server.connect(serverTransport))
println("Server connected")
},
).joinAll()
val clientCapabilities = server.clientCapabilities
val serverSession = serverSessionResult.await()
val clientCapabilities = serverSession.clientCapabilities
assertEquals(ClientCapabilities.Roots(null), clientCapabilities?.roots)
val listRootsResult = server.listRoots()
val listRootsResult = serverSession.listRoots()
assertEquals(listRootsResult.roots, clientRoots)
}
@ -773,16 +803,28 @@ class ClientTest {
// Track notifications
var rootListChangedNotificationReceived = false
server.setNotificationHandler<RootsListChangedNotification>(Method.Defined.NotificationsRootsListChanged) {
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
serverSessionResult.complete(server.connect(serverTransport))
println("Server connected")
},
).joinAll()
val serverSession = serverSessionResult.await()
serverSession.setNotificationHandler<RootsListChangedNotification>(
Method.Defined.NotificationsRootsListChanged,
) {
rootListChangedNotificationReceived = true
CompletableDeferred(Unit)
}
listOf(
launch { client.connect(clientTransport) },
launch { server.connect(serverTransport) },
).joinAll()
client.sendRootsListChanged()
assertTrue(
@ -809,14 +851,24 @@ class ClientTest {
),
)
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch { client.connect(clientTransport) },
launch { server.connect(serverTransport) },
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
serverSessionResult.complete(server.connect(serverTransport))
println("Server connected")
},
).joinAll()
val serverSession = serverSessionResult.await()
// Verify that creating an elicitation throws an exception
val exception = assertFailsWith<IllegalStateException> {
server.createElicitation(
serverSession.createElicitation(
message = "Please provide your GitHub username",
requestedSchema = CreateElicitationRequest.RequestedSchema(
properties = buildJsonObject {
@ -879,12 +931,22 @@ class ClientTest {
),
)
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch { client.connect(clientTransport) },
launch { server.connect(serverTransport) },
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
serverSessionResult.complete(server.connect(serverTransport))
println("Server connected")
},
).joinAll()
val result = server.createElicitation(
val serverSession = serverSessionResult.await()
val result = serverSession.createElicitation(
message = elicitationMessage,
requestedSchema = requestedSchema,
)

View file

@ -70,19 +70,6 @@ class SseTransportTest : BaseTransportTest() {
install(ServerSSE)
routing {
mcp { mcpServer }
// sse {
// mcpSseTransport("", transports).apply {
// onMessage {
// send(it)
// }
//
// start()
// }
// }
//
// post {
// mcpPostEndpoint(transports)
// }
}
}.startSuspend(wait = false)
@ -110,22 +97,7 @@ class SseTransportTest : BaseTransportTest() {
val server = embeddedServer(CIO, port = 0) {
install(ServerSSE)
routing {
mcp("/sse") { mcpServer }
// route("/sse") {
// sse {
// mcpSseTransport("", transports).apply {
// onMessage {
// send(it)
// }
//
// start()
// }
// }
//
// post {
// mcpPostEndpoint(transports)
// }
// }
mcp { mcpServer }
}
}.startSuspend(wait = false)

View file

@ -1,15 +1,22 @@
package io.modelcontextprotocol.kotlin.sdk.integration
import io.ktor.client.HttpClient
import io.ktor.client.plugins.sse.SSE
import io.ktor.server.application.install
import io.ktor.server.cio.CIOApplicationEngine
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.PromptArgument
import io.modelcontextprotocol.kotlin.sdk.PromptMessage
import io.modelcontextprotocol.kotlin.sdk.Role
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.TextContent
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.mcpSse
import io.modelcontextprotocol.kotlin.sdk.client.mcpSseTransport
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.mcp
@ -17,47 +24,172 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlin.test.Test
import kotlin.test.fail
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
import io.ktor.client.engine.cio.CIO as ClientCIO
import io.ktor.client.plugins.sse.SSE as ClientSSE
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.sse.SSE as ServerSSE
private const val URL = "127.0.0.1"
class SseIntegrationTest {
@Test
fun `client should be able to connect to sse server`() = runTest {
val serverEngine = initServer()
fun `client should be able to connect to sse server`() = runTest(timeout = 5.seconds) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var client: Client? = null
try {
withContext(Dispatchers.Default) {
val port = serverEngine.engine.resolvedConnectors().first().port
val client = initClient(port)
client.close()
server = initServer()
val port = server.engine.resolvedConnectors().first().port
client = initClient(serverPort = port)
}
} catch (e: Exception) {
fail("Failed to connect client: $e")
} finally {
// Make sure to stop the server
serverEngine.stopSuspend(1000, 2000)
client?.close()
server?.stopSuspend(1000, 2000)
}
}
private suspend fun initClient(port: Int): Client = HttpClient(ClientCIO) {
install(ClientSSE)
}.mcpSse("http://$URL:$port")
/**
* Test Case #1: One opened connection, a client gets a prompt
*
* 1. Open SSE from Client A.
* 2. Send a POST request from Client A to POST /prompts/get.
* 3. Observe that Client A receives a response related to it.
*/
@Test
fun `single sse connection`() = runTest(timeout = 5.seconds) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var client: Client? = null
try {
withContext(Dispatchers.Default) {
server = initServer()
val port = server.engine.resolvedConnectors().first().port
client = initClient("Client A", port)
val promptA = getPrompt(client, "Client A")
assertTrue { "Client A" in promptA }
}
} finally {
client?.close()
server?.stopSuspend(1000, 2000)
}
}
/**
* Test Case #1: Two open connections, each client gets a client-specific prompt
*
* 1. Open SSE connection #1 from Client A and note the sessionId=<sessionId#1> value.
* 2. Open SSE connection #2 from Client B and note the sessionId=<sessionId#2> value.
* 3. Send a POST request to POST /message with the corresponding sessionId#1.
* 4. Observe that Client B (connection #2) receives a response related to sessionId#1.
*/
@Test
fun `multiple sse connections`() = runTest(timeout = 5.seconds) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var clientA: Client? = null
var clientB: Client? = null
try {
withContext(Dispatchers.Default) {
server = initServer()
val port = server.engine.resolvedConnectors().first().port
clientA = initClient("Client A", port)
clientB = initClient("Client B", port)
// Step 3: Send a prompt request from Client A
val promptA = getPrompt(clientA, "Client A")
// Step 4: Send a prompt request from Client B
val promptB = getPrompt(clientB, "Client B")
assertTrue { "Client A" in promptA }
assertTrue { "Client B" in promptB }
}
} finally {
clientA?.close()
clientB?.close()
server?.stopSuspend(1000, 2000)
}
}
private suspend fun initClient(name: String = "", serverPort: Int): Client {
val client = Client(
Implementation(name = name, version = "1.0.0"),
)
val httpClient = HttpClient(ClientCIO) {
install(SSE)
}
// Create a transport wrapper that captures the session ID and received messages
val transport = httpClient.mcpSseTransport {
url {
host = URL
port = serverPort
}
}
client.connect(transport)
return client
}
private suspend fun initServer(): EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration> {
val server = Server(
Implementation(name = "sse-e2e-test", version = "1.0.0"),
ServerOptions(capabilities = ServerCapabilities()),
Implementation(name = "sse-server", version = "1.0.0"),
ServerOptions(
capabilities = ServerCapabilities(prompts = ServerCapabilities.Prompts(listChanged = true)),
),
)
return embeddedServer(ServerCIO, host = URL, port = 0) {
server.addPrompt(
name = "prompt",
description = "Prompt description",
arguments = listOf(
PromptArgument(
name = "client",
description = "Client name who requested a prompt",
required = true,
),
),
) { request ->
GetPromptResult(
"Prompt for ${request.name}",
messages = listOf(
PromptMessage(
role = Role.user,
content = TextContent("Prompt for client ${request.arguments?.get("client")}"),
),
),
)
}
val ktorServer = embeddedServer(ServerCIO, host = URL, port = PORT) {
install(ServerSSE)
routing {
mcp { server }
}
}.startSuspend(wait = false)
}
return ktorServer.startSuspend(wait = false)
}
/**
* Retrieves a prompt result using the provided client and client name.
*/
private suspend fun getPrompt(client: Client, clientName: String): String {
val response = client.getPrompt(
GetPromptRequest(
"prompt",
arguments = mapOf("client" to clientName),
),
)
return (response.messages.first().content as? TextContent)?.text
?: error("Failed to receive prompt for Client $clientName")
}
companion object {
private const val URL = "127.0.0.1"
private const val PORT = 0
}
}

View file

@ -0,0 +1,196 @@
package io.modelcontextprotocol.kotlin.sdk.integration
import io.ktor.client.HttpClient
import io.ktor.server.application.install
import io.ktor.server.cio.CIOApplicationEngine
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.PromptArgument
import io.modelcontextprotocol.kotlin.sdk.PromptMessage
import io.modelcontextprotocol.kotlin.sdk.Role
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.TextContent
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.mcpWebSocketTransport
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.mcpWebSocket
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
import io.ktor.client.engine.cio.CIO as ClientCIO
import io.ktor.client.plugins.websocket.WebSockets as ClientWebSocket
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.websocket.WebSockets as ServerWebSockets
class WebSocketIntegrationTest {
@Test
fun `client should be able to connect to websocket server 2`() = runTest(timeout = 5.seconds) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var client: Client? = null
try {
withContext(Dispatchers.Default) {
server = initServer()
val port = server.engine.resolvedConnectors().first().port
client = initClient(serverPort = port)
}
} finally {
client?.close()
server?.stopSuspend(1000, 2000)
}
}
/**
* Test Case #1: One opened connection, a client gets a prompt
*
* 1. Open WebSocket from Client A.
* 2. Send a POST request from Client A to POST /prompts/get.
* 3. Observe that Client A receives a response related to it.
*/
@Test
fun `single websocket connection`() = runTest(timeout = 5.seconds) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var client: Client? = null
try {
withContext(Dispatchers.Default) {
server = initServer()
val port = server.engine.resolvedConnectors().first().port
client = initClient("Client A", port)
val promptA = getPrompt(client, "Client A")
assertTrue { "Client A" in promptA }
}
} finally {
client?.close()
server?.stopSuspend(1000, 2000)
}
}
/**
* Test Case #1: Two open connections, each client gets a client-specific prompt
*
* 1. Open WebSocket connection #1 from Client A and note the sessionId=<sessionId#1> value.
* 2. Open WebSocket connection #2 from Client B and note the sessionId=<sessionId#2> value.
* 3. Send a POST request to POST /message with the corresponding sessionId#1.
* 4. Observe that Client B (connection #2) receives a response related to sessionId#1.
*/
@Test
fun `multiple websocket connections`() = runTest(timeout = 5.seconds) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var clientA: Client? = null
var clientB: Client? = null
try {
withContext(Dispatchers.Default) {
server = initServer()
val port = server.engine.resolvedConnectors().first().port
clientA = initClient("Client A", port)
clientB = initClient("Client B", port)
// Step 3: Send a prompt request from Client A
val promptA = getPrompt(clientA, "Client A")
// Step 4: Send a prompt request from Client B
val promptB = getPrompt(clientB, "Client B")
assertTrue { "Client A" in promptA }
assertTrue { "Client B" in promptB }
}
} finally {
clientA?.close()
clientB?.close()
server?.stopSuspend(1000, 2000)
}
}
private suspend fun initClient(name: String = "", serverPort: Int): Client {
val client = Client(
Implementation(name = name, version = "1.0.0"),
)
val httpClient = HttpClient(ClientCIO) {
install(ClientWebSocket)
}
// Create a transport wrapper that captures the session ID and received messages
val transport = httpClient.mcpWebSocketTransport {
url {
host = URL
port = serverPort
}
}
client.connect(transport)
return client
}
private suspend fun initServer(): EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration> {
val server = Server(
Implementation(name = "websocket-server", version = "1.0.0"),
ServerOptions(
capabilities = ServerCapabilities(prompts = ServerCapabilities.Prompts(listChanged = true)),
),
)
server.addPrompt(
name = "prompt",
description = "Prompt description",
arguments = listOf(
PromptArgument(
name = "client",
description = "Client name who requested a prompt",
required = true,
),
),
) { request ->
GetPromptResult(
"Prompt for ${request.name}",
messages = listOf(
PromptMessage(
role = Role.user,
content = TextContent("Prompt for client ${request.arguments?.get("client")}"),
),
),
)
}
val ktorServer = embeddedServer(ServerCIO, host = URL, port = PORT) {
install(ServerWebSockets)
routing {
mcpWebSocket(block = { server })
}
}
return ktorServer.startSuspend(wait = false)
}
/**
* Retrieves a prompt result using the provided client and client name.
*/
private suspend fun getPrompt(client: Client, clientName: String): String {
val response = client.getPrompt(
GetPromptRequest(
"prompt",
arguments = mapOf("client" to clientName),
),
)
return (response.messages.first().content as? TextContent)?.text
?: error("Failed to receive prompt for Client $clientName")
}
companion object {
private const val PORT = 0
private const val URL = "127.0.0.1"
}
}

View file

@ -1,8 +1,6 @@
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
import io.modelcontextprotocol.kotlin.sdk.BlobResourceContents
import io.modelcontextprotocol.kotlin.sdk.EmptyRequestResult
import io.modelcontextprotocol.kotlin.sdk.Method
import io.modelcontextprotocol.kotlin.sdk.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
@ -16,6 +14,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.Ignore
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@ -67,14 +66,6 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() {
)
}
server.setRequestHandler<SubscribeRequest>(Method.Defined.ResourcesSubscribe) { _, _ ->
EmptyRequestResult()
}
server.setRequestHandler<UnsubscribeRequest>(Method.Defined.ResourcesUnsubscribe) { _, _ ->
EmptyRequestResult()
}
server.addResource(
uri = testResourceUri,
name = testResourceName,
@ -169,6 +160,7 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() {
assertEquals(testResourceContent, content.text, "Resource content should match")
}
@Ignore("Blocked by https://github.com/modelcontextprotocol/kotlin-sdk/issues/249")
@Test
fun testSubscribeAndUnsubscribe() {
runBlocking(Dispatchers.IO) {

View file

@ -26,8 +26,6 @@ import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.JSONRPCNotification
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.LoggingLevel
import io.modelcontextprotocol.kotlin.sdk.LoggingMessageNotification
import io.modelcontextprotocol.kotlin.sdk.PromptArgument
import io.modelcontextprotocol.kotlin.sdk.PromptMessage
import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult
@ -254,32 +252,6 @@ class KotlinServerForTsClient {
) { request ->
val name = (request.arguments["name"] as? JsonPrimitive)?.content ?: "World"
server.sendToolListChanged()
server.sendLoggingMessage(
LoggingMessageNotification(
LoggingMessageNotification.Params(
level = LoggingLevel.info,
data = JsonPrimitive("Preparing greeting for $name"),
),
),
)
server.sendLoggingMessage(
LoggingMessageNotification(
LoggingMessageNotification.Params(
level = LoggingLevel.info,
data = JsonPrimitive("Halfway there for $name"),
),
),
)
server.sendLoggingMessage(
LoggingMessageNotification(
LoggingMessageNotification.Params(
level = LoggingLevel.info,
data = JsonPrimitive("Done sending greetings to $name"),
),
),
)
CallToolResult(
content = listOf(TextContent("Multiple greetings sent to $name!")),
structuredContent = buildJsonObject {

View file

@ -23,6 +23,7 @@ import io.modelcontextprotocol.kotlin.sdk.TextResourceContents
import io.modelcontextprotocol.kotlin.sdk.Tool
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.ServerSession
import io.modelcontextprotocol.kotlin.sdk.server.SseServerTransport
import io.modelcontextprotocol.kotlin.sdk.server.mcp
@ -94,33 +95,33 @@ fun configureServer(): Server {
}
suspend fun runSseMcpServerWithPlainConfiguration(port: Int) {
val servers = ConcurrentMap<String, Server>()
val serverSessions = ConcurrentMap<String, ServerSession>()
println("Starting sse server on port $port. ")
println("Use inspector to connect to the http://localhost:$port/sse")
val server = configureServer()
embeddedServer(CIO, host = "0.0.0.0", port = port) {
install(SSE)
routing {
sse("/sse") {
val transport = SseServerTransport("/message", this)
val server = configureServer()
// For SSE, you can also add prompts/tools/resources if needed:
// server.addTool(...), server.addPrompt(...), server.addResource(...)
servers[transport.sessionId] = server
val serverSession = server.connect(transport)
serverSessions[transport.sessionId] = server.connect(transport)
server.onClose {
serverSession.onClose {
println("Server closed")
servers.remove(transport.sessionId)
serverSessions.remove(transport.sessionId)
}
server.connect(transport)
}
post("/message") {
println("Received Message")
val sessionId: String = call.request.queryParameters["sessionId"]!!
val transport = servers[sessionId]?.transport as? SseServerTransport
val transport = serverSessions[sessionId]?.transport as? SseServerTransport
if (transport == null) {
call.respond(HttpStatusCode.NotFound, "Session not found")
return@post

View file

@ -114,9 +114,9 @@ fun `run mcp server`() {
)
runBlocking {
server.connect(transport)
val session = server.connect(transport)
val done = Job()
server.onClose {
session.onClose {
done.complete()
}
done.join()