Introduce server sessions (#198)

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

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

## How Has This Been Tested?

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

## Breaking Changes

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

## Types of changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed

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

---------

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

View file

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

View file

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

View file

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

View file

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

View file

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