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:
parent
0ed92f5b03
commit
d86813636e
20 changed files with 1191 additions and 602 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue