Update package name across the project (#2)

* Change package name

* Update publication

* Update docs

* Update settings.gradle.kts, README and CONTRIBUTING

* Cleanup leftovers
This commit is contained in:
Leonid Stashevsky 2024-12-17 07:28:51 +01:00 • committed by GitHub
commit 215effb7d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 11094 additions and 13667 deletions

View file

@ -0,0 +1,462 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.modelcontextprotocol.kotlin.sdk.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.CallToolResultBase
import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.ClientNotification
import io.modelcontextprotocol.kotlin.sdk.ClientRequest
import io.modelcontextprotocol.kotlin.sdk.ClientResult
import io.modelcontextprotocol.kotlin.sdk.CompatibilityCallToolResult
import io.modelcontextprotocol.kotlin.sdk.CompleteRequest
import io.modelcontextprotocol.kotlin.sdk.CompleteResult
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.ListToolsRequest
import io.modelcontextprotocol.kotlin.sdk.ListToolsResult
import io.modelcontextprotocol.kotlin.sdk.LoggingLevel
import io.modelcontextprotocol.kotlin.sdk.LoggingMessageNotification.SetLevelRequest
import io.modelcontextprotocol.kotlin.sdk.Method
import io.modelcontextprotocol.kotlin.sdk.PingRequest
import io.modelcontextprotocol.kotlin.sdk.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.RootsListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.SUPPORTED_PROTOCOL_VERSIONS
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.SubscribeRequest
import io.modelcontextprotocol.kotlin.sdk.UnsubscribeRequest
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.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Options for configuring the MCP client.
*
* @property capabilities The capabilities this client supports.
* @property enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with the server.
*/
public class ClientOptions(
public val capabilities: ClientCapabilities = ClientCapabilities(),
enforceStrictCapabilities: Boolean = true,
) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities)
/**
* An MCP client on top of a pluggable transport.
*
* The client automatically performs the initialization handshake with the server when [connect] is called.
* After initialization, [getServerCapabilities] and [getServerVersion] provide details about the connected server.
*
* You can extend this class with custom request/notification/result types if needed.
*
* @param clientInfo Information about the client implementation (name, version).
* @param options Configuration options for this client.
*/
public open class Client(
private val clientInfo: Implementation,
options: ClientOptions = ClientOptions(),
) : Protocol<ClientRequest, ClientNotification, ClientResult>(options) {
private var serverCapabilities: ServerCapabilities? = null
private var serverVersion: Implementation? = null
private val capabilities: ClientCapabilities = options.capabilities
protected fun assertCapability(capability: String, method: String) {
val caps = serverCapabilities
val hasCapability = when (capability) {
"logging" -> caps?.logging != null
"prompts" -> caps?.prompts != null
"resources" -> caps?.resources != null
"tools" -> caps?.tools != null
else -> true
}
if (!hasCapability) {
throw IllegalStateException("Server does not support $capability (required for $method)")
}
}
/**
* Connects the client to the given [transport], performing the initialization handshake with the server.
*
* @param transport The transport to use for communication with the server.
* @throws IllegalStateException If the server's protocol version is not supported.
*/
override suspend fun connect(transport: Transport) {
super.connect(transport)
try {
val message = InitializeRequest(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = capabilities,
clientInfo = clientInfo
)
val result = request<InitializeResult>(message)
if (!SUPPORTED_PROTOCOL_VERSIONS.contains(result.protocolVersion)) {
throw IllegalStateException(
"Server's protocol version is not supported: ${result.protocolVersion}"
)
}
serverCapabilities = result.capabilities
serverVersion = result.serverInfo
notification(InitializedNotification())
} catch (error: Throwable) {
close()
throw error
}
}
/**
* Retrieves the server's reported capabilities after the initialization process completes.
*
* @return The server's capabilities, or `null` if initialization is not yet complete.
*/
public fun getServerCapabilities(): ServerCapabilities? {
return serverCapabilities
}
/**
* Retrieves the server's reported version information after initialization.
*
* @return Information about the server's implementation, or `null` if initialization is not yet complete.
*/
public fun getServerVersion(): Implementation? {
return serverVersion
}
override fun assertCapabilityForMethod(method: Method) {
when (method) {
Method.Defined.LoggingSetLevel -> {
if (serverCapabilities?.logging == null) {
throw IllegalStateException("Server does not support logging (required for $method)")
}
}
Method.Defined.PromptsGet,
Method.Defined.PromptsList,
Method.Defined.CompletionComplete,
-> {
if (serverCapabilities?.prompts == null) {
throw IllegalStateException("Server does not support prompts (required for $method)")
}
}
Method.Defined.ResourcesList,
Method.Defined.ResourcesTemplatesList,
Method.Defined.ResourcesRead,
Method.Defined.ResourcesSubscribe,
Method.Defined.ResourcesUnsubscribe,
-> {
val resCaps = serverCapabilities?.resources
if (resCaps == null) {
throw IllegalStateException("Server does not support resources (required for $method)")
}
if (method == Method.Defined.ResourcesSubscribe && resCaps.subscribe != true) {
throw IllegalStateException(
"Server does not support resource subscriptions (required for $method)"
)
}
}
Method.Defined.ToolsCall,
Method.Defined.ToolsList,
-> {
if (serverCapabilities?.tools == null) {
throw IllegalStateException("Server does not support tools (required for $method)")
}
}
Method.Defined.Initialize,
Method.Defined.Ping,
-> {
// No specific capability required
}
else -> {
// For unknown or future methods, no assertion by default
}
}
}
override fun assertNotificationCapability(method: Method) {
when (method) {
Method.Defined.NotificationsRootsListChanged -> {
if (capabilities.roots?.listChanged != true) {
throw IllegalStateException(
"Client does not support roots list changed notifications (required for $method)"
)
}
}
Method.Defined.NotificationsInitialized,
Method.Defined.NotificationsCancelled,
Method.Defined.NotificationsProgress,
-> {
// Always allowed
}
else -> {
// For notifications not specifically listed, no assertion by default
}
}
}
override fun assertRequestHandlerCapability(method: Method) {
when (method) {
Method.Defined.SamplingCreateMessage -> {
if (capabilities.sampling == null) {
throw IllegalStateException(
"Client does not support sampling capability (required for $method)"
)
}
}
Method.Defined.RootsList -> {
if (capabilities.roots == null) {
throw IllegalStateException(
"Client does not support roots capability (required for $method)"
)
}
}
Method.Defined.Ping -> {
// No capability required
}
else -> {}
}
}
/**
* Sends a ping request to the server to check connectivity.
*
* @param options Optional request options.
* @throws IllegalStateException If the server does not support the ping method (unlikely).
*/
public suspend fun ping(options: RequestOptions? = null): EmptyRequestResult {
return request<EmptyRequestResult>(PingRequest(), options)
}
/**
* Sends a completion request to the server, typically to generate or complete some content.
*
* @param params The completion request parameters.
* @param options Optional request options.
* @return The completion result returned by the server, or `null` if none.
* @throws IllegalStateException If the server does not support prompts or completion.
*/
public suspend fun complete(params: CompleteRequest, options: RequestOptions? = null): CompleteResult? {
return request<CompleteResult>(params, options)
}
/**
* Sets the logging level on the server.
*
* @param level The desired logging level.
* @param options Optional request options.
* @throws IllegalStateException If the server does not support logging.
*/
public suspend fun setLoggingLevel(level: LoggingLevel, options: RequestOptions? = null): EmptyRequestResult {
return request<EmptyRequestResult>(SetLevelRequest(level), options)
}
/**
* Retrieves a prompt by name from the server.
*
* @param request The prompt request containing the prompt name.
* @param options Optional request options.
* @return The requested prompt details, or `null` if not found.
* @throws IllegalStateException If the server does not support prompts.
*/
public suspend fun getPrompt(request: GetPromptRequest, options: RequestOptions? = null): GetPromptResult? {
return request<GetPromptResult>(request, options)
}
/**
* Lists all available prompts from the server.
*
* @param request A request object for listing prompts (usually empty).
* @param options Optional request options.
* @return The list of available prompts, or `null` if none.
* @throws IllegalStateException If the server does not support prompts.
*/
public suspend fun listPrompts(
request: ListPromptsRequest = ListPromptsRequest(),
options: RequestOptions? = null,
): ListPromptsResult? {
return request<ListPromptsResult>(request, options)
}
/**
* Lists all available resources from the server.
*
* @param request A request object for listing resources (usually empty).
* @param options Optional request options.
* @return The list of resources, or `null` if none.
* @throws IllegalStateException If the server does not support resources.
*/
public suspend fun listResources(
request: ListResourcesRequest = ListResourcesRequest(),
options: RequestOptions? = null,
): ListResourcesResult? {
return request<ListResourcesResult>(request, options)
}
/**
* Lists resource templates available on the server.
*
* @param request The request object for listing resource templates.
* @param options Optional request options.
* @return The list of resource templates, or `null` if none.
* @throws IllegalStateException If the server does not support resources.
*/
public suspend fun listResourceTemplates(
request: ListResourceTemplatesRequest,
options: RequestOptions? = null,
): ListResourceTemplatesResult? {
return request<ListResourceTemplatesResult>(request, options)
}
/**
* Reads a resource from the server by its URI.
*
* @param request The request object containing the resource URI.
* @param options Optional request options.
* @return The resource content, or `null` if the resource is not found.
* @throws IllegalStateException If the server does not support resources.
*/
public suspend fun readResource(
request: ReadResourceRequest,
options: RequestOptions? = null,
): ReadResourceResult? {
return request<ReadResourceResult>(request, options)
}
/**
* Subscribes to resource changes on the server.
*
* @param request The subscription request containing resource details.
* @param options Optional request options.
* @throws IllegalStateException If the server does not support resource subscriptions.
*/
public suspend fun subscribeResource(
request: SubscribeRequest,
options: RequestOptions? = null,
): EmptyRequestResult {
return request<EmptyRequestResult>(request, options)
}
/**
* Unsubscribes from resource changes on the server.
*
* @param request The unsubscribe request containing resource details.
* @param options Optional request options.
* @throws IllegalStateException If the server does not support resource subscriptions.
*/
public suspend fun unsubscribeResource(
request: UnsubscribeRequest,
options: RequestOptions? = null,
): EmptyRequestResult {
return request<EmptyRequestResult>(request, options)
}
/**
* Calls a tool on the server by name, passing the specified arguments.
*
* @param name The name of the tool to call.
* @param arguments A map of argument names to values for the tool.
* @param compatibility Whether to use compatibility mode for older protocol versions.
* @param options Optional request options.
* @return The result of the tool call, or `null` if none.
* @throws IllegalStateException If the server does not support tools.
*/
public suspend fun callTool(
name: String,
arguments: Map<String, Any?>,
compatibility: Boolean = false,
options: RequestOptions? = null,
): CallToolResultBase? {
val jsonArguments = arguments.mapValues { (_, value) ->
when (value) {
is String -> JsonPrimitive(value)
is Number -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is JsonElement -> value
null -> JsonNull
else -> JsonPrimitive(value.toString())
}
}
val request = CallToolRequest(
name = name,
arguments = JsonObject(jsonArguments)
)
return callTool(request, compatibility, options)
}
/**
* Calls a tool on the server using a [CallToolRequest] object.
*
* @param request The request object containing the tool name and arguments.
* @param compatibility Whether to use compatibility mode for older protocol versions.
* @param options Optional request options.
* @return The result of the tool call, or `null` if none.
* @throws IllegalStateException If the server does not support tools.
*/
public suspend fun callTool(
request: CallToolRequest,
compatibility: Boolean = false,
options: RequestOptions? = null,
): CallToolResultBase? {
return if (compatibility) {
request<CompatibilityCallToolResult>(request, options)
} else {
request<CallToolResult>(request, options)
}
}
/**
* Lists all available tools on the server.
*
* @param request A request object for listing tools (usually empty).
* @param options Optional request options.
* @return The list of available tools, or `null` if none.
* @throws IllegalStateException If the server does not support tools.
*/
public suspend fun listTools(
request: ListToolsRequest = ListToolsRequest(),
options: RequestOptions? = null,
): ListToolsResult? {
return request<ListToolsResult>(request, options)
}
/**
* Notifies the server that the list of roots has changed.
* Typically used if the client is managing some form of hierarchical structure.
*
* @throws IllegalStateException If the client or server does not support roots.
*/
public suspend fun sendRootsListChanged() {
notification(RootsListChangedNotification())
}
}

View file

@ -0,0 +1,138 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.ktor.client.*
import io.ktor.client.plugins.sse.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.properties.Delegates
import kotlin.time.Duration
/**
* Client transport for SSE: this will connect to a server using Server-Sent Events for receiving
* messages and make separate POST requests for sending messages.
*/
public class SSEClientTransport(
private val client: HttpClient,
private val urlString: String?,
private val reconnectionTime: Duration? = null,
private val requestBuilder: HttpRequestBuilder.() -> Unit = {},
) : Transport {
private val scope by lazy {
CoroutineScope(session.coroutineContext + SupervisorJob())
}
private val initialized = AtomicBoolean(false)
private var session: ClientSSESession by Delegates.notNull()
private val endpoint = CompletableDeferred<String>()
override var onClose: (() -> Unit)? = null
override var onError: ((Throwable) -> Unit)? = null
override var onMessage: (suspend ((JSONRPCMessage) -> Unit))? = null
private var job: Job? = null
private val baseUrl by lazy {
session.call.request.url.toString().removeSuffix("/")
}
override suspend fun start() {
if (!initialized.compareAndSet(false, true)) {
error(
"SSEClientTransport already started! " +
"If using Client class, note that connect() calls start() automatically.",
)
}
session = urlString?.let {
client.sseSession(
urlString = it,
reconnectionTime = reconnectionTime,
block = requestBuilder,
)
} ?: client.sseSession(
reconnectionTime = reconnectionTime,
block = requestBuilder,
)
job = scope.launch(CoroutineName("SseMcpClientTransport.collect#${hashCode()}")) {
session.incoming.collect { event ->
when (event.event) {
"error" -> {
val e = IllegalStateException("SSE error: ${event.data}")
onError?.invoke(e)
throw e
}
"open" -> {
// The connection is open, but we need to wait for the endpoint to be received.
}
"endpoint" -> {
try {
val eventData = event.data ?: ""
// check url correctness
val maybeEndpoint = Url("$baseUrl/$eventData")
endpoint.complete(maybeEndpoint.toString())
} catch (e: Exception) {
onError?.invoke(e)
close()
error(e)
}
}
else -> {
try {
val message = McpJson.decodeFromString<JSONRPCMessage>(event.data ?: "")
onMessage?.invoke(message)
} catch (e: Exception) {
onError?.invoke(e)
}
}
}
}
}
endpoint.await()
}
@OptIn(ExperimentalCoroutinesApi::class)
override suspend fun send(message: JSONRPCMessage) {
if (!endpoint.isCompleted) {
error("Not connected")
}
try {
val response = client.post(endpoint.getCompleted()) {
headers.append(HttpHeaders.ContentType, ContentType.Application.Json)
setBody(McpJson.encodeToString(message))
}
if (!response.status.isSuccess()) {
val text = response.bodyAsText()
error("Error POSTing to endpoint (HTTP ${response.status}): $text")
}
} catch (e: Exception) {
onError?.invoke(e)
throw e
}
}
override suspend fun close() {
if (!initialized.get()) {
error("SSEClientTransport is not initialized!")
}
session.cancel()
onClose?.invoke()
job?.cancelAndJoin()
}
}

View file

@ -0,0 +1,131 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.CoroutineContext
import kotlin.text.Charsets.UTF_8
/**
* A transport implementation for JSON-RPC communication that leverages standard input and output streams.
*
* This class reads from an input stream to process incoming JSON-RPC messages and writes JSON-RPC messages
* to an output stream.
*
* @param input The input stream where messages are received.
* @param output The output stream where messages are sent.
*/
public class StdioClientTransport(
private val input: InputStream,
private val output: OutputStream
) : Transport {
private val logger = KotlinLogging.logger {}
private val ioCoroutineContext: CoroutineContext = Dispatchers.IO
private val scope by lazy {
CoroutineScope(ioCoroutineContext + SupervisorJob())
}
private var job: Job? = null
private var initialized = AtomicBoolean(false)
private val sendChannel = Channel<JSONRPCMessage>(Channel.UNLIMITED)
private val readBuffer = ReadBuffer()
override var onClose: (() -> Unit)? = null
override var onError: ((Throwable) -> Unit)? = null
override var onMessage: (suspend ((JSONRPCMessage) -> Unit))? = null
override suspend fun start() {
if (!initialized.compareAndSet(false, true)) {
error("StdioClientTransport already started!")
}
logger.debug { "Starting StdioClientTransport..." }
val outputStream = output.bufferedWriter(UTF_8)
job = scope.launch(CoroutineName("StdioClientTransport.IO#${hashCode()}")) {
val readJob = launch {
logger.debug { "Read coroutine started." }
try {
val buffer = ByteArray(8192)
while (isActive) {
val bytesRead = input.read(buffer)
if (bytesRead == -1) break
if (bytesRead > 0) {
readBuffer.append(buffer.copyOf(bytesRead))
processReadBuffer()
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
onError?.invoke(e)
logger.error(e) { "Error reading from input stream" }
} finally {
input.close()
}
}
val writeJob = launch {
logger.debug { "Write coroutine started." }
try {
sendChannel.consumeEach { message ->
val json = serializeMessage(message)
outputStream.write(json)
outputStream.flush()
}
} catch (e: Throwable) {
if (isActive) {
onError?.invoke(e)
logger.error(e) { "Error writing to output stream" }
}
} finally {
output.close()
}
}
readJob.join()
writeJob.cancelAndJoin()
onClose?.invoke()
}
}
override suspend fun send(message: JSONRPCMessage) {
if (!initialized.get()) {
error("Transport not started")
}
sendChannel.send(message)
}
override suspend fun close() {
if (!initialized.compareAndSet(true, false)) {
error("Transport is already closed")
}
job?.cancelAndJoin()
input.close()
output.close()
readBuffer.clear()
sendChannel.close()
onClose?.invoke()
}
private suspend fun processReadBuffer() {
while (true) {
val msg = readBuffer.readMessage() ?: break
try {
onMessage?.invoke(msg)
} catch (e: Throwable) {
onError?.invoke(e)
logger.error(e) { "Error processing message." }
}
}
}
}

View file

@ -0,0 +1,35 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.ktor.client.*
import io.ktor.client.plugins.websocket.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.websocket.*
import io.modelcontextprotocol.kotlin.sdk.shared.MCP_SUBPROTOCOL
import io.modelcontextprotocol.kotlin.sdk.shared.WebSocketMcpTransport
import kotlin.properties.Delegates
/**
* Client transport for WebSocket: this will connect to a server over the WebSocket protocol.
*/
public class WebSocketClientTransport(
private val client: HttpClient,
private val urlString: String?,
private val requestBuilder: HttpRequestBuilder.() -> Unit = {},
) : WebSocketMcpTransport() {
override var session: WebSocketSession by Delegates.notNull()
override suspend fun initializeSession() {
session = urlString?.let {
client.webSocketSession(it) {
requestBuilder()
header(HttpHeaders.SecWebSocketProtocol, MCP_SUBPROTOCOL)
}
} ?: client.webSocketSession {
requestBuilder()
header(HttpHeaders.SecWebSocketProtocol, MCP_SUBPROTOCOL)
}
}
}

View file

@ -0,0 +1,41 @@
package io.modelcontextprotocol.kotlin.sdk.client
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
/**
* Returns a new WebSocket transport for the Model Context Protocol using the provided HttpClient.
*
* @param urlString Optional URL of the MCP server.
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A [WebSocketClientTransport] configured for MCP communication.
*/
public fun HttpClient.mcpWebSocketTransport(
urlString: String? = null,
requestBuilder: HttpRequestBuilder.() -> Unit = {},
): WebSocketClientTransport = WebSocketClientTransport(this, urlString, requestBuilder)
/**
* Creates and connects an MCP client over WebSocket using the provided HttpClient.
*
* @param urlString Optional URL of the MCP server.
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A connected [Client] ready for MCP communication.
*/
public suspend fun HttpClient.mcpWebSocket(
urlString: String? = null,
requestBuilder: HttpRequestBuilder.() -> Unit = {},
): Client {
val transport = mcpWebSocketTransport(urlString, requestBuilder)
val client = Client(
Implementation(
name = IMPLEMENTATION_NAME,
version = LIB_VERSION,
)
)
client.connect(transport)
return client
}

View file

@ -0,0 +1,46 @@
package io.modelcontextprotocol.kotlin.sdk.client
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
import kotlin.time.Duration
/**
* Returns a new SSE transport for the Model Context Protocol using the provided HttpClient.
*
* @param urlString Optional URL of the MCP server.
* @param reconnectionTime Optional duration to wait before attempting to reconnect.
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A [SSEClientTransport] configured for MCP communication.
*/
public fun HttpClient.mcpSseTransport(
urlString: String? = null,
reconnectionTime: Duration? = null,
requestBuilder: HttpRequestBuilder.() -> Unit = {},
): SSEClientTransport = SSEClientTransport(this, urlString, reconnectionTime, requestBuilder)
/**
* Creates and connects an MCP client over SSE using the provided HttpClient.
*
* @param urlString Optional URL of the MCP server.
* @param reconnectionTime Optional duration to wait before attempting to reconnect.
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A connected [Client] ready for MCP communication.
*/
public suspend fun HttpClient.mcpSse(
urlString: String? = null,
reconnectionTime: Duration? = null,
requestBuilder: HttpRequestBuilder.() -> Unit = {},
): Client {
val transport = mcpSseTransport(urlString, reconnectionTime, requestBuilder)
val client = Client(
Implementation(
name = IMPLEMENTATION_NAME,
version = LIB_VERSION,
)
)
client.connect(transport)
return client
}

View file

@ -0,0 +1,54 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sse.*
import io.ktor.util.collections.*
private val logger = KotlinLogging.logger {}
/**
* Configures the Ktor Application to handle Model Context Protocol (MCP) over Server-Sent Events (SSE).
*/
public fun Application.MCP(block: () -> Server) {
val servers = ConcurrentMap<String, Server>()
install(SSE)
routing {
sse("/sse") {
val transport = SSEServerTransport("/message", this)
logger.info { "New SSE connection established with sessionId: ${transport.sessionId}" }
val server = block()
servers[transport.sessionId] = server
logger.debug { "Server instance created and stored for sessionId: ${transport.sessionId}" }
server.onCloseCallback = {
logger.info { "Server connection closed for sessionId: ${transport.sessionId}" }
servers.remove(transport.sessionId)
}
server.connect(transport)
logger.debug { "Server connected to transport for sessionId: ${transport.sessionId}" }
}
post("/message") {
val sessionId: String = call.request.queryParameters["sessionId"]!!
logger.debug { "Received message for sessionId: $sessionId" }
val transport = servers[sessionId]?.transport as? SSEServerTransport
if (transport == null) {
logger.warn { "Session not found for sessionId: $sessionId" }
call.respond(HttpStatusCode.NotFound, "Session not found")
return@post
}
transport.handlePostMessage(call)
logger.trace { "Message handled for sessionId: $sessionId" }
}
}
}

View file

@ -0,0 +1,124 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.sse.*
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import kotlinx.coroutines.job
import kotlinx.serialization.encodeToString
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
internal const val SESSION_ID_PARAM = "sessionId"
/**
* Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests.
*
* Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`.
*/
public class SSEServerTransport(
private val endpoint: String,
private val session: ServerSSESession,
) : Transport {
private val initialized = AtomicBoolean(false)
@OptIn(ExperimentalUuidApi::class)
public val sessionId: String = Uuid.random().toString()
override var onClose: (() -> Unit)? = null
override var onError: ((Throwable) -> Unit)? = null
override var onMessage: (suspend ((JSONRPCMessage) -> Unit))? = null
/**
* Handles the initial SSE connection request.
*
* This should be called when a GET request is made to establish the SSE stream.
*/
override suspend fun start() {
if (!initialized.compareAndSet(false, true)) {
throw error("SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.")
}
// Send the endpoint event
session.send(
event = "endpoint",
data = "${endpoint.encodeURLPath()}?$SESSION_ID_PARAM=${sessionId}",
)
try {
session.coroutineContext.job.join()
} finally {
onClose?.invoke()
}
}
/**
* Handles incoming POST messages.
*
* This should be called when a POST request is made to send a message to the server.
*/
public suspend fun handlePostMessage(call: ApplicationCall) {
if (!initialized.get()) {
val message = "SSE connection not established"
call.respondText(message, status = HttpStatusCode.InternalServerError)
onError?.invoke(IllegalStateException(message))
}
val body = try {
val ct = call.request.contentType()
if (ct != ContentType.Application.Json) {
error("Unsupported content-type: $ct")
}
call.receiveText()
} catch (e: Exception) {
call.respondText("Invalid message: ${e.message}", status = HttpStatusCode.BadRequest)
onError?.invoke(e)
return
}
try {
handleMessage(body)
} catch (e: Exception) {
call.respondText("Error handling message $body: ${e.message}", status = HttpStatusCode.BadRequest)
return
}
call.respondText("Accepted", status = HttpStatusCode.Accepted)
}
/**
* Handle a client message, regardless of how it arrived.
* This can be used to inform the server of messages that arrive via a means different from HTTP POST.
*/
public suspend fun handleMessage(message: String) {
try {
val parsedMessage = McpJson.decodeFromString<JSONRPCMessage>(message)
onMessage?.invoke(parsedMessage)
} catch (e: Exception) {
onError?.invoke(e)
throw e
}
}
override suspend fun close() {
session.close()
onClose?.invoke()
}
override suspend fun send(message: JSONRPCMessage) {
if (!initialized.get()) {
throw error("Not connected")
}
session.send(
event = "message",
data = McpJson.encodeToString(message),
)
}
}

View file

@ -0,0 +1,572 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import kotlinx.coroutines.CompletableDeferred
import kotlinx.serialization.json.JsonObject
import io.modelcontextprotocol.kotlin.sdk.*
import io.modelcontextprotocol.kotlin.sdk.shared.Protocol
import io.modelcontextprotocol.kotlin.sdk.shared.ProtocolOptions
import io.modelcontextprotocol.kotlin.sdk.shared.RequestOptions
private val logger = KotlinLogging.logger {}
/**
* Configuration options for the MCP server.
*
* @property capabilities The capabilities this server supports.
* @property enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with clients.
*/
public class ServerOptions(
public val capabilities: ServerCapabilities,
enforceStrictCapabilities: Boolean = true,
) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities)
/**
* An MCP server on top of a pluggable transport.
*
* This server automatically responds to the initialization flow as initiated by the client.
* You can register tools, prompts, and resources using [addTool], [addPrompt], and [addResource].
* The server will then automatically handle listing and retrieval requests from the client.
*
* @param serverInfo Information about this server implementation (name, version).
* @param options Configuration options for the server.
* @param onCloseCallback A callback invoked when the server connection closes.
*/
public open class Server(
private val serverInfo: Implementation,
options: ServerOptions,
public var onCloseCallback: (() -> Unit)? = null
) : Protocol<ServerRequest, ServerNotification, ServerResult>(options) {
/**
* 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 = mutableMapOf<String, RegisteredTool>()
private val prompts = mutableMapOf<String, RegisteredPrompt>()
private val resources = mutableMapOf<String, RegisteredResource>()
/**
* A callback invoked when the server has completed the initialization sequence.
* After initialization, the server is ready to handle requests.
*/
public var onInitialized: (() -> Unit)? = null
init {
logger.debug { "Initializing MCP server with capabilities: $capabilities" }
// Core protocol handlers
setRequestHandler<InitializeRequest>(Method.Defined.Initialize) { request, _ ->
handleInitialize(request)
}
setNotificationHandler<InitializedNotification>(Method.Defined.NotificationsInitialized) {
onInitialized?.invoke()
CompletableDeferred(Unit)
}
// Internal handlers for tools
if (capabilities.tools != null) {
setRequestHandler<ListToolsRequest>(Method.Defined.ToolsList) { _, _ ->
handleListTools()
}
setRequestHandler<CallToolRequest>(Method.Defined.ToolsCall) { request, _ ->
handleCallTool(request)
}
}
// Internal handlers for prompts
if (capabilities.prompts != null) {
setRequestHandler<ListPromptsRequest>(Method.Defined.PromptsList) { _, _ ->
handleListPrompts()
}
setRequestHandler<GetPromptRequest>(Method.Defined.PromptsGet) { request, _ ->
handleGetPrompt(request)
}
}
// Internal handlers for resources
if (capabilities.resources != null) {
setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
handleListResources()
}
setRequestHandler<ReadResourceRequest>(Method.Defined.ResourcesRead) { request, _ ->
handleReadResource(request)
}
setRequestHandler<ListResourceTemplatesRequest>(Method.Defined.ResourcesTemplatesList) { _, _ ->
handleListResourceTemplates()
}
}
}
/**
* Called when the server connection is closing.
* Invokes [onCloseCallback] if set.
*/
override fun onclose() {
logger.info { "Server connection closing" }
onCloseCallback?.invoke()
}
/**
* Registers a single tool. This tool can then be called by the client.
*
* @param name The name of the tool.
* @param description A human-readable description of what the tool does.
* @param inputSchema The expected input schema for the tool.
* @param handler A suspend function that handles executing the tool when called by the client.
* @throws IllegalStateException If the server does not support tools.
*/
public fun addTool(
name: String,
description: String,
inputSchema: Tool.Input = Tool.Input(),
handler: suspend (CallToolRequest) -> CallToolResult
) {
if (capabilities.tools == null) {
logger.error { "Failed to add tool '$name': Server does not support tools capability" }
throw IllegalStateException("Server does not support tools capability. Enable it in ServerOptions.")
}
logger.info { "Registering tool: $name" }
tools[name] = RegisteredTool(Tool(name, description, inputSchema), handler)
}
/**
* Registers multiple tools at once.
*
* @param toolsToAdd A list of [RegisteredTool] objects representing the tools to register.
* @throws IllegalStateException If the server does not support tools.
*/
public fun addTools(toolsToAdd: List<RegisteredTool>) {
if (capabilities.tools == null) {
logger.error { "Failed to add tools: Server does not support tools capability" }
throw IllegalStateException("Server does not support tools capability.")
}
logger.info { "Registering ${toolsToAdd.size} tools" }
for (rt in toolsToAdd) {
logger.debug { "Registering tool: ${rt.tool.name}" }
tools[rt.tool.name] = rt
}
}
/**
* Registers a single prompt. The prompt can then be retrieved by the client.
*
* @param prompt A [Prompt] object describing the prompt.
* @param promptProvider A suspend function that returns the prompt content when requested by the client.
* @throws IllegalStateException If the server does not support prompts.
*/
public fun addPrompt(prompt: Prompt, promptProvider: suspend (GetPromptRequest) -> GetPromptResult) {
if (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.")
}
logger.info { "Registering prompt: ${prompt.name}" }
prompts[prompt.name] = RegisteredPrompt(prompt, promptProvider)
}
/**
* Registers a single prompt by constructing a [Prompt] from given parameters.
*
* @param name The name of the prompt.
* @param description An optional human-readable description of the prompt.
* @param arguments An optional list of [PromptArgument] that the prompt accepts.
* @param promptProvider A suspend function that returns the prompt content when requested.
* @throws IllegalStateException If the server does not support prompts.
*/
public fun addPrompt(
name: String,
description: String? = null,
arguments: List<PromptArgument>? = null,
promptProvider: suspend (GetPromptRequest) -> GetPromptResult
) {
val prompt = Prompt(name = name, description = description, arguments = arguments)
addPrompt(prompt, promptProvider)
}
/**
* Registers multiple prompts at once.
*
* @param promptsToAdd A list of [RegisteredPrompt] objects representing the prompts to register.
* @throws IllegalStateException If the server does not support prompts.
*/
public fun addPrompts(promptsToAdd: List<RegisteredPrompt>) {
if (capabilities.prompts == null) {
logger.error { "Failed to add prompts: Server does not support prompts capability" }
throw IllegalStateException("Server does not support prompts capability.")
}
logger.info { "Registering ${promptsToAdd.size} prompts" }
for (rp in promptsToAdd) {
logger.debug { "Registering prompt: ${rp.prompt.name}" }
prompts[rp.prompt.name] = rp
}
}
/**
* Registers a single resource. The resource content can then be read by the client.
*
* @param uri The URI of the resource.
* @param name A human-readable name for the resource.
* @param description A description of the resource's content.
* @param mimeType The MIME type of the resource content.
* @param readHandler A suspend function that returns the resource content when read by the client.
* @throws IllegalStateException If the server does not support resources.
*/
public fun addResource(
uri: String,
name: String,
description: String,
mimeType: String = "text/html",
readHandler: suspend (ReadResourceRequest) -> ReadResourceResult
) {
if (capabilities.resources == null) {
logger.error { "Failed to add resource '$name': Server does not support resources capability" }
throw IllegalStateException("Server does not support resources capability.")
}
logger.info { "Registering resource: $name ($uri)" }
resources[uri] = RegisteredResource(Resource(uri, name, description, mimeType), readHandler)
}
/**
* Registers multiple resources at once.
*
* @param resourcesToAdd A list of [RegisteredResource] objects representing the resources to register.
* @throws IllegalStateException If the server does not support resources.
*/
public fun addResources(resourcesToAdd: List<RegisteredResource>) {
if (capabilities.resources == null) {
logger.error { "Failed to add resources: Server does not support resources capability" }
throw IllegalStateException("Server does not support resources capability.")
}
logger.info { "Registering ${resourcesToAdd.size} resources" }
for (r in resourcesToAdd) {
logger.debug { "Registering resource: ${r.resource.name} (${r.resource.uri})" }
resources[r.resource.uri] = r
}
}
/**
* 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 {
return 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)
}
/**
* 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.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.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)
}
private suspend fun handleCallTool(request: CallToolRequest): CallToolResult {
logger.debug { "Handling tool call request for tool: ${request.name}" }
val tool = tools[request.name]
?: run {
logger.error { "Tool not found: ${request.name}" }
throw IllegalArgumentException("Tool not found: ${request.name}")
}
logger.trace { "Executing tool ${request.name} with input: ${request.arguments}" }
return tool.handler(request)
}
private suspend fun handleListPrompts(): ListPromptsResult {
logger.debug { "Handling list prompts request" }
return ListPromptsResult(prompts = prompts.values.map { it.prompt })
}
private suspend fun handleGetPrompt(request: GetPromptRequest): GetPromptResult {
logger.debug { "Handling get prompt request for: ${request.name}" }
val prompt = prompts[request.name]
?: run {
logger.error { "Prompt not found: ${request.name}" }
throw IllegalArgumentException("Prompt not found: ${request.name}")
}
return prompt.messageProvider(request)
}
private suspend fun handleListResources(): ListResourcesResult {
logger.debug { "Handling list resources request" }
return ListResourcesResult(resources = resources.values.map { it.resource })
}
private suspend fun handleReadResource(request: ReadResourceRequest): ReadResourceResult {
logger.debug { "Handling read resource request for: ${request.uri}" }
val resource = resources[request.uri]
?: run {
logger.error { "Resource not found: ${request.uri}" }
throw IllegalArgumentException("Resource not found: ${request.uri}")
}
return resource.readHandler(request)
}
private suspend fun handleListResourceTemplates(): ListResourceTemplatesResult {
// 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})")
}
}
"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
}
}
}
}
/**
* A wrapper class representing a registered tool on the server.
*
* @property tool The tool definition.
* @property handler A suspend function to handle the tool call requests.
*/
public data class RegisteredTool(
val tool: Tool,
val handler: suspend (CallToolRequest) -> CallToolResult
)
/**
* A wrapper class representing a registered prompt on the server.
*
* @property prompt The prompt definition.
* @property messageProvider A suspend function that returns the prompt content when requested by the client.
*/
public data class RegisteredPrompt(
val prompt: Prompt,
val messageProvider: suspend (GetPromptRequest) -> GetPromptResult
)
/**
* A wrapper class representing a registered resource on the server.
*
* @property resource The resource definition.
* @property readHandler A suspend function to handle read requests for this resource.
*/
public data class RegisteredResource(
val resource: Resource,
val readHandler: suspend (ReadResourceRequest) -> ReadResourceResult
)

View file

@ -0,0 +1,118 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import java.io.BufferedInputStream
import java.io.PrintStream
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.CoroutineContext
/**
* A server transport that communicates with a client via standard I/O.
*
* Reads from System.in and writes to System.out.
*/
public class StdioServerTransport(
private val inputStream: BufferedInputStream = BufferedInputStream(System.`in`),
outputStream: PrintStream = System.out
) : Transport {
private val logger = KotlinLogging.logger {}
override var onClose: (() -> Unit)? = null
override var onError: ((Throwable) -> Unit)? = null
override var onMessage: (suspend (JSONRPCMessage) -> Unit)? = null
private val readBuffer = ReadBuffer()
private var initialized = AtomicBoolean(false)
private var readingJob: Job? = null
private val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob()
private val scope = CoroutineScope(coroutineContext)
private val readChannel = Channel<ByteArray>(Channel.UNLIMITED)
private val outputWriter = outputStream.bufferedWriter()
override suspend fun start() {
if (!initialized.compareAndSet(false, true)) {
error("StdioServerTransport already started!")
}
// Launch a coroutine to read from stdin
readingJob = scope.launch {
val buf = ByteArray(8192)
try {
while (isActive) {
val bytesRead = inputStream.read(buf)
if (bytesRead == -1) {
// EOF reached
break
}
if (bytesRead > 0) {
val chunk = buf.copyOf(bytesRead)
readChannel.send(chunk)
}
}
} catch (e: Throwable) {
logger.error(e) { "Error reading from stdin" }
onError?.invoke(e)
} finally {
// Reached EOF or error, close connection
close()
}
}
// Launch a coroutine to process messages from readChannel
scope.launch {
try {
for (chunk in readChannel) {
readBuffer.append(chunk)
processReadBuffer()
}
} catch (e: Throwable) {
onError?.invoke(e)
}
}
}
private suspend fun processReadBuffer() {
while (true) {
val message = try {
readBuffer.readMessage()
} catch (e: Throwable) {
onError?.invoke(e)
null
}
if (message == null) break
// Async invocation broke delivery order
try {
onMessage?.invoke(message)
} catch (e: Throwable) {
onError?.invoke(e)
}
}
}
override suspend fun close() {
if (!initialized.compareAndSet(true, false)) return
// Cancel reading job and close channel
readingJob?.cancel() // ToDO("was cancel and join")
readChannel.close()
readBuffer.clear()
onClose?.invoke()
}
override suspend fun send(message: JSONRPCMessage) {
val json = serializeMessage(message)
synchronized(outputWriter) {
// You may need to add Content-Length headers before the message if using the LSP framing protocol
outputWriter.write(json)
outputWriter.flush()
}
}
}

View file

@ -0,0 +1,107 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
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
/**
* Registers a WebSocket route that establishes an MCP (Model Context Protocol) server session.
*
* @param options Optional server configuration settings for the MCP server.
* @param handler A suspend function that defines the server's behavior.
*/
public fun Route.mcpWebSocket(
options: ServerOptions? = null,
handler: suspend Server.() -> Unit = {},
) {
webSocket {
createMcpServer(this, options, handler)
}
}
/**
* Registers a WebSocket route at the specified [path] that establishes an MCP server session.
*
* @param path The URL path at which to register the WebSocket route.
* @param options Optional server configuration settings for the MCP server.
* @param handler A suspend function that defines the server's behavior.
*/
public fun Route.mcpWebSocket(
path: String,
options: ServerOptions? = null,
handler: suspend Server.() -> Unit = {},
) {
webSocket(path) {
createMcpServer(this, options, handler)
}
}
/**
* Registers a WebSocket route that creates an MCP server transport layer.
*
* @param handler A suspend function that defines the behavior of the transport layer.
*/
public fun Route.mcpWebSocketTransport(
handler: suspend WebSocketMcpServerTransport.() -> Unit = {},
) {
webSocket {
val transport = createMcpTransport(this)
transport.start()
handler(transport)
transport.close()
}
}
/**
* Registers a WebSocket route at the specified [path] that creates an MCP server transport layer.
*
* @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.
*/
public fun Route.mcpWebSocketTransport(
path: String,
handler: suspend WebSocketMcpServerTransport.() -> Unit = {},
) {
webSocket(path) {
val transport = createMcpTransport(this)
transport.start()
handler(transport)
transport.close()
}
}
private suspend fun Route.createMcpServer(
session: WebSocketServerSession,
options: ServerOptions?,
handler: suspend Server.() -> Unit,
) {
val transport = createMcpTransport(session)
val server = Server(
serverInfo = Implementation(
name = IMPLEMENTATION_NAME,
version = LIB_VERSION,
),
options = options ?: ServerOptions(
capabilities = ServerCapabilities(
prompts = ServerCapabilities.Prompts(listChanged = null),
resources = ServerCapabilities.Resources(subscribe = null, listChanged = null),
tools = ServerCapabilities.Tools(listChanged = null),
)
),
)
server.connect(transport)
handler(server)
server.close()
}
private fun createMcpTransport(
session: WebSocketServerSession,
): WebSocketMcpServerTransport {
return WebSocketMcpServerTransport(session)
}

View file

@ -0,0 +1,22 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.ktor.http.*
import io.ktor.server.websocket.*
import io.modelcontextprotocol.kotlin.sdk.shared.MCP_SUBPROTOCOL
import io.modelcontextprotocol.kotlin.sdk.shared.WebSocketMcpTransport
/**
* Server-side implementation of the MCP (Model Context Protocol) transport over WebSocket.
*
* @property session The WebSocket server session used for communication.
*/
public class WebSocketMcpServerTransport(
override val session: WebSocketServerSession,
) : WebSocketMcpTransport() {
override suspend fun initializeSession() {
val subprotocol = session.call.request.headers[HttpHeaders.SecWebSocketProtocol]
if (subprotocol != MCP_SUBPROTOCOL) {
error("Invalid subprotocol: $subprotocol, expected $MCP_SUBPROTOCOL")
}
}
}

View file

@ -0,0 +1,502 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.CancelledNotification
import io.modelcontextprotocol.kotlin.sdk.EmptyRequestResult
import io.modelcontextprotocol.kotlin.sdk.ErrorCode
import io.modelcontextprotocol.kotlin.sdk.JSONRPCError
import io.modelcontextprotocol.kotlin.sdk.JSONRPCNotification
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.McpError
import io.modelcontextprotocol.kotlin.sdk.Method
import io.modelcontextprotocol.kotlin.sdk.Notification
import io.modelcontextprotocol.kotlin.sdk.PingRequest
import io.modelcontextprotocol.kotlin.sdk.Progress
import io.modelcontextprotocol.kotlin.sdk.ProgressNotification
import io.modelcontextprotocol.kotlin.sdk.Request
import io.modelcontextprotocol.kotlin.sdk.RequestResult
import io.modelcontextprotocol.kotlin.sdk.fromJSON
import io.modelcontextprotocol.kotlin.sdk.toJSON
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.*
import kotlinx.serialization.serializer
import kotlin.reflect.KType
import kotlin.reflect.typeOf
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
private val LOGGER = KotlinLogging.logger { }
internal const val IMPLEMENTATION_NAME = "mcp-ktor"
/**
* Callback for progress notifications.
*/
public typealias ProgressCallback = (Progress) -> Unit
@OptIn(ExperimentalSerializationApi::class)
@PublishedApi
internal val McpJson: Json by lazy {
Json {
ignoreUnknownKeys = true
encodeDefaults = true
isLenient = true
classDiscriminatorMode = ClassDiscriminatorMode.NONE
explicitNulls = false
}
}
/**
* Additional initialization options.
*/
public open class ProtocolOptions(
/**
* Whether to restrict emitted requests to only those that the remote side has indicated
* that they can handle, through their advertised capabilities.
*
* Note that this DOES NOT affect checking of _local_ side capabilities, as it is
* considered a logic error to mis-specify those.
*
* Currently this defaults to false, for backwards compatibility with SDK versions
* that did not advertise capabilities correctly. In future, this will default to true.
*/
public var enforceStrictCapabilities: Boolean = false,
public var timeout: Duration = DEFAULT_REQUEST_TIMEOUT,
)
/**
* The default request timeout.
*/
public val DEFAULT_REQUEST_TIMEOUT: Duration = 60000.milliseconds
/**
* Options that can be given per request.
*/
public data class RequestOptions(
/**
* If set, requests progress notifications from the remote end (if supported).
* When progress notifications are received, this callback will be invoked.
*/
val onProgress: ProgressCallback? = null,
/**
* A timeout for this request. If exceeded, an McpError with code `RequestTimeout`
* will be raised from request().
*
* If not specified, `DEFAULT_REQUEST_TIMEOUT` will be used as the timeout.
*/
val timeout: Duration = DEFAULT_REQUEST_TIMEOUT,
)
/**
* Extra data given to request handlers.
*/
public class RequestHandlerExtra()
internal val COMPLETED = CompletableDeferred(Unit).also { it.complete(Unit) }
/**
* Implements MCP protocol framing on top of a pluggable transport, including
* features like request/response linking, notifications, and progress.
*/
public abstract class Protocol<SendRequestT : Request, SendNotificationT : Notification, SendResultT : RequestResult>(
@PublishedApi internal val options: ProtocolOptions?,
) {
public var transport: Transport? = null
private set
@PublishedApi
internal val requestHandlers: MutableMap<String, suspend (request: JSONRPCRequest, extra: RequestHandlerExtra) -> SendResultT?> =
mutableMapOf()
public val notificationHandlers: MutableMap<String, suspend (notification: JSONRPCNotification) -> Unit> =
mutableMapOf()
@PublishedApi
internal val responseHandlers: MutableMap<Long, (response: JSONRPCResponse?, error: Exception?) -> Unit> =
mutableMapOf()
@PublishedApi
internal val progressHandlers: MutableMap<Long, ProgressCallback> = mutableMapOf()
/**
* Callback for when the connection is closed for any reason.
*
* This is invoked when close() is called as well.
*/
public open fun onclose() {}
/**
* Callback for when an error occurs.
*
* Note that errors are not necessarily fatal they are used for reporting any kind of exceptional condition out of band.
*/
public open fun onerror(error: Throwable) {}
/**
* A handler to invoke for any request types that do not have their own handler installed.
*/
public var fallbackRequestHandler: (suspend (request: JSONRPCRequest, extra: RequestHandlerExtra) -> SendResultT?)? =
null
/**
* A handler to invoke for any notification types that do not have their own handler installed.
*/
public var fallbackNotificationHandler: (suspend (notification: JSONRPCNotification) -> Unit)? = null
init {
setNotificationHandler<ProgressNotification>(Method.Defined.NotificationsProgress) { notification ->
this.onProgress(notification)
COMPLETED
}
setRequestHandler<PingRequest>(Method.Defined.Ping) { request, _ ->
@Suppress("UNCHECKED_CAST")
EmptyRequestResult() as SendResultT
}
}
/**
* Attaches to the given transport, starts it, and starts listening for messages.
*
* The Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
*/
public open suspend fun connect(transport: Transport) {
this.transport = transport
transport.onClose = {
this.onClose()
}
transport.onError = {
this.onError(it)
}
transport.onMessage = { message ->
when (message) {
is JSONRPCResponse -> onResponse(message, null)
is JSONRPCRequest -> onRequest(message)
is JSONRPCNotification -> onNotification(message)
is JSONRPCError -> onResponse(null, message)
}
}
return transport.start()
}
private fun onClose() {
val error = McpError(ErrorCode.Defined.ConnectionClosed.code, "Connection closed")
for (handler in responseHandlers.values) {
handler(null, error)
}
responseHandlers.clear()
progressHandlers.clear()
transport = null
onclose()
}
private fun onError(error: Throwable) {
onerror(error)
}
private suspend fun onNotification(notification: JSONRPCNotification) {
LOGGER.trace { "Received notification: ${notification.method}" }
val function = notificationHandlers[notification.method]
val property = fallbackNotificationHandler
val handler = function ?: property
if (handler == null) {
LOGGER.trace { "No handler found for notification: ${notification.method}" }
return
}
try {
handler(notification)
} catch (cause: Throwable) {
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})" }
val handler = requestHandlers[request.method] ?: this.fallbackRequestHandler
if (handler === null) {
LOGGER.trace { "No handler found for request: ${request.method}" }
try {
transport!!.send(
JSONRPCResponse(
id = request.id,
error = JSONRPCError(
ErrorCode.Defined.MethodNotFound,
message = "Server does not support ${request.method}",
)
)
)
} catch (cause: Throwable) {
LOGGER.error(cause) { "Error sending method not found response" }
onError(cause)
}
return
}
try {
val result = handler(request, RequestHandlerExtra())
LOGGER.trace { "Request handled successfully: ${request.method} (id: ${request.id})" }
val response = JSONRPCResponse(
id = request.id,
result = result
)
transport!!.send(response)
} catch (cause: Throwable) {
LOGGER.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" }
transport!!.send(
JSONRPCResponse(
id = request.id,
error = JSONRPCError(
ErrorCode.Defined.InternalError,
message = cause.message ?: "Internal error",
)
)
)
}
}
private fun onProgress(notification: ProgressNotification) {
LOGGER.trace { "Received progress notification: token=${notification.progressToken}, progress=${notification.progress}/${notification.total}" }
val progress = notification.progress
val total = notification.total
val progressToken = notification.progressToken
val handler = this.progressHandlers[progressToken]
if (handler == null) {
val error = Error(
"Received a progress notification for an unknown token: ${McpJson.encodeToString(notification)}",
)
LOGGER.error { error.message }
this.onError(error)
return
}
handler.invoke(Progress(progress, total))
}
private fun onResponse(response: JSONRPCResponse?, error: JSONRPCError?) {
val messageId = response?.id
val handler = this.responseHandlers[messageId]
if (handler == null) {
this.onError(Error("Received a response for an unknown message ID: ${McpJson.encodeToString(response)}"))
return
}
this.responseHandlers.remove(messageId)
this.progressHandlers.remove(messageId)
if (response != null) {
handler(response, null)
} else {
check(error != null)
val error = McpError(
error.code.code,
error.message,
error.data,
)
handler(null, error)
}
}
/**
* Closes the connection.
*/
public suspend fun close() {
transport?.close()
}
/**
* A method to check if a capability is supported by the remote side, for the given method to be called.
*
* This should be implemented by subclasses.
*/
protected abstract fun assertCapabilityForMethod(method: Method)
/**
* A method to check if a notification is supported by the local side, for the given method to be sent.
*
* This should be implemented by subclasses.
*/
protected abstract fun assertNotificationCapability(method: Method)
/**
* A method to check if a request handler is supported by the local side, for the given method to be handled.
*
* This should be implemented by subclasses.
*/
public abstract fun assertRequestHandlerCapability(method: Method)
/**
* Sends a request and wait for a response.
*
* Do not use this method to emit notifications! Use notification() instead.
*/
public suspend fun <T : RequestResult> request(
request: SendRequestT,
options: RequestOptions? = null,
): T {
LOGGER.trace { "Sending request: ${request.method}" }
val result = CompletableDeferred<T>()
val transport = this@Protocol.transport ?: throw Error("Not connected")
if (this@Protocol.options?.enforceStrictCapabilities == true) {
assertCapabilityForMethod(request.method)
}
val message = request.toJSON()
val messageId = message.id
if (options?.onProgress != null) {
LOGGER.trace { "Registering progress handler for request id: $messageId" }
progressHandlers[messageId] = options.onProgress
}
responseHandlers[messageId] = set@{ response, error ->
if (error != null) {
result.completeExceptionally(error)
return@set
}
if (response?.error != null) {
result.completeExceptionally(IllegalStateException(response.error.toString()))
return@set
}
try {
@Suppress("UNCHECKED_CAST")
result.complete(response!!.result as T)
} catch (error: Throwable) {
result.completeExceptionally(error)
}
}
val cancel: suspend (Throwable) -> Unit = { reason: Throwable ->
responseHandlers.remove(messageId)
progressHandlers.remove(messageId)
val notification = CancelledNotification(requestId = messageId, reason = reason.message ?: "Unknown")
val serialized = JSONRPCNotification(
notification.method.value,
params = McpJson.encodeToJsonElement(notification)
)
transport.send(serialized)
result.completeExceptionally(reason)
Unit
}
val timeout = options?.timeout ?: DEFAULT_REQUEST_TIMEOUT
try {
withTimeout(timeout) {
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}" }
cancel(
McpError(
ErrorCode.Defined.RequestTimeout.code,
"Request timed out",
JsonObject(mutableMapOf("timeout" to JsonPrimitive(timeout.inWholeMilliseconds)))
),
)
result.cancel(cause)
throw cause
}
}
/**
* Emits a notification, which is a one-way message that does not expect a response.
*/
public suspend fun notification(notification: SendNotificationT) {
LOGGER.trace { "Sending notification: ${notification.method}" }
val transport = this.transport ?: error("Not connected")
assertNotificationCapability(notification.method)
val message = JSONRPCNotification(
notification.method.value,
params = McpJson.encodeToJsonElement<Notification>(notification) as JsonObject,
)
transport.send(message)
}
/**
* Registers a handler to invoke when this protocol object receives a request with the given method.
*
* Note that this will replace any previous request handler for the same method.
*/
public inline fun <reified T : Request> setRequestHandler(
method: Method,
noinline block: suspend (T, RequestHandlerExtra) -> SendResultT?,
) {
setRequestHandler(typeOf<T>(), method, block)
}
@PublishedApi
internal fun <T : Request> setRequestHandler(
requestType: KType,
method: Method,
block: suspend (T, RequestHandlerExtra) -> SendResultT?,
) {
assertRequestHandlerCapability(method)
val serializer = McpJson.serializersModule.serializer(requestType)
requestHandlers[method.value] = { request, extraHandler ->
val result = request.params?.let { McpJson.decodeFromJsonElement(serializer, it) }
val response = if (result != null) {
@Suppress("UNCHECKED_CAST")
block(result as T, extraHandler)
} else {
EmptyRequestResult()
}
@Suppress("UNCHECKED_CAST")
response as SendResultT
}
}
/**
* Removes the request handler for the given method.
*/
public fun removeRequestHandler(method: Method) {
requestHandlers.remove(method.value)
}
/**
* Registers a handler to invoke when this protocol object receives a notification with the given method.
*
* Note that this will replace any previous notification handler for the same method.
*/
public fun <T : Notification> setNotificationHandler(method: Method, handler: (notification: T) -> Deferred<Unit>) {
this.notificationHandlers[method.value] = {
@Suppress("UNCHECKED_CAST")
handler(it.fromJSON() as T)
}
}
/**
* Removes the notification handler for the given method.
*/
public fun removeNotificationHandler(method: Method) {
this.notificationHandlers.remove(method.value)
}
}

View file

@ -0,0 +1,56 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.ktor.utils.io.core.*
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import kotlinx.io.Buffer
import kotlinx.io.indexOf
import kotlinx.io.readString
import kotlinx.serialization.encodeToString
/**
* Buffers a continuous stdio stream into discrete JSON-RPC messages.
*/
public class ReadBuffer {
private val buffer: Buffer = Buffer()
public fun append(chunk: ByteArray) {
buffer.writeFully(chunk)
}
public fun readMessage(): JSONRPCMessage? {
if (buffer.exhausted()) return null
var lfIndex = buffer.indexOf('\n'.code.toByte())
val line = when (lfIndex) {
-1L -> return null
0L -> {
buffer.skip(1)
""
}
else -> {
var skipBytes = 1
if (buffer[lfIndex - 1] == '\r'.code.toByte()) {
lfIndex -= 1
skipBytes += 1
}
val string = buffer.readString(lfIndex)
buffer.skip(skipBytes.toLong())
string
}
}
return deserializeMessage(line)
}
public fun clear() {
buffer.clear()
}
}
internal fun deserializeMessage(line: String): JSONRPCMessage {
return McpJson.decodeFromString<JSONRPCMessage>(line)
}
internal fun serializeMessage(message: JSONRPCMessage): String {
return McpJson.encodeToString(message) + "\n"
}

View file

@ -0,0 +1,48 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
/**
* Describes the minimal contract for a MCP transport that a client or server can communicate over.
*/
public interface Transport {
/**
* Starts processing messages on the transport, including any connection steps that might need to be taken.
*
* This method should only be called after callbacks are installed, or else messages may be lost.
*
* NOTE: This method should not be called explicitly when using Client, Server, or Protocol classes,
* as they will implicitly call start().
*/
public suspend fun start()
/**
* Sends a JSON-RPC message (request or response).
*/
public suspend fun send(message: JSONRPCMessage)
/**
* Closes the connection.
*/
public suspend fun close()
/**
* Callback for when the connection is closed for any reason.
*
* This should be invoked when close() is called as well.
*/
public var onClose: (() -> Unit)?
/**
* Callback for when an error occurs.
*
* Note that errors are not necessarily fatal; they are used for reporting any kind of
* exceptional condition out of band.
*/
public var onError: ((Throwable) -> Unit)?
/**
* Callback for when a message (request or response) is received over the connection.
*/
public var onMessage: (suspend ((JSONRPCMessage) -> Unit))?
}

View file

@ -0,0 +1,104 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.close
import io.ktor.websocket.readText
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import java.util.concurrent.atomic.AtomicBoolean
internal const val MCP_SUBPROTOCOL = "mcp"
/**
* Abstract class representing a WebSocket transport for the Model Context Protocol (MCP).
* Handles communication over a WebSocket session.
*/
public abstract class WebSocketMcpTransport : Transport {
private val scope by lazy {
CoroutineScope(session.coroutineContext + SupervisorJob())
}
private val initialized = AtomicBoolean(false)
/**
* The WebSocket session used for communication.
*/
protected abstract val session: WebSocketSession
override var onClose: (() -> Unit)? = null
override var onError: ((Throwable) -> Unit)? = null
override var onMessage: (suspend ((JSONRPCMessage) -> Unit))? = null
/**
* Initializes the WebSocket session
*/
protected abstract suspend fun initializeSession()
override suspend fun start() {
if (!initialized.compareAndSet(false, true)) {
error(
"WebSocketClientTransport already started! " +
"If using Client class, note that connect() calls start() automatically.",
)
}
initializeSession()
scope.launch(CoroutineName("WebSocketMcpTransport.collect#${hashCode()}")) {
while (true) {
val message = try {
session.incoming.receive()
} catch (_: ClosedReceiveChannelException) {
return@launch
}
if (message !is Frame.Text) {
val e = IllegalArgumentException("Expected text frame, got ${message::class.simpleName}: $message")
onError?.invoke(e)
throw e
}
try {
val message = McpJson.decodeFromString<JSONRPCMessage>(message.readText())
onMessage?.invoke(message)
} catch (e: Exception) {
onError?.invoke(e)
throw e
}
}
}
@OptIn(InternalCoroutinesApi::class)
session.coroutineContext.job.invokeOnCompletion {
if (it != null) {
onError?.invoke(it)
} else {
onClose?.invoke()
}
}
}
override suspend fun send(message: JSONRPCMessage) {
if (!initialized.get()) {
error("Not connected")
}
session.outgoing.send(Frame.Text(McpJson.encodeToString(message)))
}
override suspend fun close() {
if (!initialized.get()) {
error("Not connected")
}
session.close()
session.coroutineContext.job.join()
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,272 @@
package io.modelcontextprotocol.kotlin.sdk
import io.modelcontextprotocol.kotlin.sdk.LoggingMessageNotification.SetLevelRequest
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
internal object ErrorCodeSerializer : KSerializer<ErrorCode> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("io.modelcontextprotocol.kotlin.sdk.ErrorCode", PrimitiveKind.STRING)
@OptIn(ExperimentalSerializationApi::class)
override fun serialize(encoder: Encoder, value: ErrorCode) {
encoder.encodeInt(value.code)
}
override fun deserialize(decoder: Decoder): ErrorCode {
val decodedString = decoder.decodeInt()
return ErrorCode.Defined.entries.firstOrNull { it.code == decodedString }
?: ErrorCode.Unknown(decodedString)
}
}
internal object RequestMethodSerializer : KSerializer<Method> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("io.modelcontextprotocol.kotlin.sdk.Method", PrimitiveKind.STRING)
@OptIn(ExperimentalSerializationApi::class)
override fun serialize(encoder: Encoder, value: Method) {
encoder.encodeString(value.value)
}
override fun deserialize(decoder: Decoder): Method {
val decodedString = decoder.decodeString()
return Method.Defined.entries.firstOrNull { it.value == decodedString }
?: Method.Custom(decodedString)
}
}
internal object StopReasonSerializer : KSerializer<StopReason> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("io.modelcontextprotocol.kotlin.sdk.StopReason", PrimitiveKind.STRING)
@OptIn(ExperimentalSerializationApi::class)
override fun serialize(encoder: Encoder, value: StopReason) {
encoder.encodeString(value.value)
}
override fun deserialize(decoder: Decoder): StopReason {
val decodedString = decoder.decodeString()
return when (decodedString) {
StopReason.StopSequence.value -> StopReason.StopSequence
StopReason.MaxTokens.value -> StopReason.MaxTokens
StopReason.EndTurn.value -> StopReason.EndTurn
else -> StopReason.Other(decodedString)
}
}
}
internal object ReferencePolymorphicSerializer : JsonContentPolymorphicSerializer<Reference>(Reference::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<Reference> {
return when (element.jsonObject.getValue("type").jsonPrimitive.content) {
ResourceReference.TYPE -> ResourceReference.serializer()
PromptReference.TYPE -> PromptReference.serializer()
else -> UnknownReference.serializer()
}
}
}
internal object PromptMessageContentPolymorphicSerializer :
JsonContentPolymorphicSerializer<PromptMessageContent>(PromptMessageContent::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<PromptMessageContent> {
return when (element.jsonObject.getValue("type").jsonPrimitive.content) {
ImageContent.TYPE -> ImageContent.serializer()
TextContent.TYPE -> TextContent.serializer()
EmbeddedResource.TYPE -> EmbeddedResource.serializer()
else -> UnknownContent.serializer()
}
}
}
internal object PromptMessageContentTextOrImagePolymorphicSerializer :
JsonContentPolymorphicSerializer<PromptMessageContentTextOrImage>(PromptMessageContentTextOrImage::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<PromptMessageContentTextOrImage> {
return when (element.jsonObject.getValue("type").jsonPrimitive.content) {
ImageContent.TYPE -> ImageContent.serializer()
TextContent.TYPE -> TextContent.serializer()
else -> UnknownContent.serializer()
}
}
}
internal object ResourceContentsPolymorphicSerializer :
JsonContentPolymorphicSerializer<ResourceContents>(ResourceContents::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<ResourceContents> {
val jsonObject = element.jsonObject
return when {
jsonObject.contains("text") -> TextResourceContents.serializer()
jsonObject.contains("blob") -> BlobResourceContents.serializer()
else -> UnknownResourceContents.serializer()
}
}
}
internal fun selectRequestDeserializer(method: String): DeserializationStrategy<Request> {
selectClientRequestDeserializer(method)?.let { return it }
selectServerRequestDeserializer(method)?.let { return it }
return CustomRequest.serializer()
}
internal fun selectClientRequestDeserializer(method: String): DeserializationStrategy<ClientRequest>? {
return when (method) {
Method.Defined.Ping.value -> PingRequest.serializer()
Method.Defined.Initialize.value -> InitializeRequest.serializer()
Method.Defined.CompletionComplete.value -> CompleteRequest.serializer()
Method.Defined.LoggingSetLevel.value -> SetLevelRequest.serializer()
Method.Defined.PromptsGet.value -> GetPromptRequest.serializer()
Method.Defined.PromptsList.value -> ListPromptsRequest.serializer()
Method.Defined.ResourcesList.value -> ListResourcesRequest.serializer()
Method.Defined.ResourcesTemplatesList.value -> ListResourceTemplatesRequest.serializer()
Method.Defined.ResourcesRead.value -> ReadResourceRequest.serializer()
Method.Defined.ResourcesSubscribe.value -> SubscribeRequest.serializer()
Method.Defined.ResourcesUnsubscribe.value -> UnsubscribeRequest.serializer()
Method.Defined.ToolsCall.value -> CallToolRequest.serializer()
Method.Defined.ToolsList.value -> ListToolsRequest.serializer()
else -> null
}
}
private fun selectClientNotificationDeserializer(element: JsonElement): DeserializationStrategy<ClientNotification>? {
return when (element.jsonObject.getValue("method").jsonPrimitive.content) {
Method.Defined.NotificationsCancelled.value -> CancelledNotification.serializer()
Method.Defined.NotificationsProgress.value -> ProgressNotification.serializer()
Method.Defined.NotificationsInitialized.value -> InitializedNotification.serializer()
Method.Defined.NotificationsRootsListChanged.value -> RootsListChangedNotification.serializer()
else -> null
}
}
internal object ClientNotificationPolymorphicSerializer :
JsonContentPolymorphicSerializer<ClientNotification>(ClientNotification::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<ClientNotification> {
return selectClientNotificationDeserializer(element)
?: UnknownMethodRequestOrNotification.serializer()
}
}
internal fun selectServerRequestDeserializer(method: String): DeserializationStrategy<ServerRequest>? {
return when (method) {
Method.Defined.Ping.value -> PingRequest.serializer()
Method.Defined.SamplingCreateMessage.value -> CreateMessageRequest.serializer()
Method.Defined.RootsList.value -> ListRootsRequest.serializer()
else -> null
}
}
internal fun selectServerNotificationDeserializer(element: JsonElement): DeserializationStrategy<ServerNotification>? {
return when (element.jsonObject.getValue("method").jsonPrimitive.content) {
Method.Defined.NotificationsCancelled.value -> CancelledNotification.serializer()
Method.Defined.NotificationsProgress.value -> ProgressNotification.serializer()
Method.Defined.NotificationsMessage.value -> LoggingMessageNotification.serializer()
Method.Defined.NotificationsResourcesUpdated.value -> ResourceUpdatedNotification.serializer()
Method.Defined.NotificationsResourcesListChanged.value -> ResourceListChangedNotification.serializer()
Method.Defined.ToolsList.value -> ToolListChangedNotification.serializer()
Method.Defined.PromptsList.value -> PromptListChangedNotification.serializer()
else -> null
}
}
internal object ServerNotificationPolymorphicSerializer :
JsonContentPolymorphicSerializer<ServerNotification>(ServerNotification::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<ServerNotification> {
return selectServerNotificationDeserializer(element)
?: UnknownMethodRequestOrNotification.serializer()
}
}
internal object NotificationPolymorphicSerializer :
JsonContentPolymorphicSerializer<Notification>(Notification::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<Notification> {
return selectClientNotificationDeserializer(element)
?: selectServerNotificationDeserializer(element)
?: UnknownMethodRequestOrNotification.serializer()
}
}
internal object RequestPolymorphicSerializer :
JsonContentPolymorphicSerializer<Request>(Request::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<Request> {
val method = element.jsonObject.getOrDefault("method", null)?.jsonPrimitive?.content ?: run {
System.err.println("No method in $element")
Throwable().printStackTrace(System.err)
error("No method in $element")
}
return selectClientRequestDeserializer(method)
?: selectServerRequestDeserializer(method)
?: UnknownMethodRequestOrNotification.serializer()
}
}
private fun selectServerResultDeserializer(element: JsonElement): DeserializationStrategy<ServerResult>? {
val jsonObject = element.jsonObject
return when {
jsonObject.contains("tools") -> ListToolsResult.serializer()
jsonObject.contains("resources") -> ListResourcesResult.serializer()
jsonObject.contains("resourceTemplates") -> ListResourceTemplatesResult.serializer()
jsonObject.contains("prompts") -> ListPromptsResult.serializer()
jsonObject.contains("capabilities") -> InitializeResult.serializer()
jsonObject.contains("description") -> GetPromptResult.serializer()
jsonObject.contains("completion") -> CompleteResult.serializer()
jsonObject.contains("toolResult") -> CompatibilityCallToolResult.serializer()
jsonObject.contains("contents") -> ReadResourceResult.serializer()
jsonObject.contains("content") -> CallToolResult.serializer()
else -> null
}
}
private fun selectClientResultDeserializer(element: JsonElement): DeserializationStrategy<ClientResult>? {
val jsonObject = element.jsonObject
return when {
jsonObject.contains("model") -> CreateMessageResult.serializer()
jsonObject.contains("roots") -> ListRootsResult.serializer()
else -> null
}
}
internal object ServerResultPolymorphicSerializer :
JsonContentPolymorphicSerializer<ServerResult>(ServerResult::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<ServerResult> {
return selectServerResultDeserializer(element)
?: EmptyRequestResult.serializer()
}
}
internal object ClientResultPolymorphicSerializer :
JsonContentPolymorphicSerializer<ClientResult>(ClientResult::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<ClientResult> {
return selectClientResultDeserializer(element)
?: EmptyRequestResult.serializer()
}
}
internal object RequestResultPolymorphicSerializer :
JsonContentPolymorphicSerializer<RequestResult>(RequestResult::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<RequestResult> {
return selectClientResultDeserializer(element)
?: selectServerResultDeserializer(element)
?: EmptyRequestResult.serializer()
}
}
internal object JSONRPCMessagePolymorphicSerializer :
JsonContentPolymorphicSerializer<JSONRPCMessage>(JSONRPCMessage::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<JSONRPCMessage> {
val jsonObject = element.jsonObject
return when {
jsonObject.contains("message") -> JSONRPCError.serializer()
!jsonObject.contains("method") -> JSONRPCResponse.serializer()
jsonObject.contains("id") -> JSONRPCRequest.serializer()
else -> JSONRPCNotification.serializer()
}
}
}
internal val EmptyJsonObject = JsonObject(emptyMap())

View file

@ -1,5 +1,5 @@
import org.jetbrains.kotlinx.mcp.JSONRPCMessage
import org.jetbrains.kotlinx.mcp.shared.Transport
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
/**
* In-memory transport for creating clients and servers that talk to each other within the same process.

View file

@ -1,11 +1,11 @@
package client
import org.jetbrains.kotlinx.mcp.InitializedNotification
import org.jetbrains.kotlinx.mcp.JSONRPCMessage
import org.jetbrains.kotlinx.mcp.PingRequest
import io.modelcontextprotocol.kotlin.sdk.InitializedNotification
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.PingRequest
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import io.modelcontextprotocol.kotlin.sdk.toJSON
import kotlinx.coroutines.CompletableDeferred
import org.jetbrains.kotlinx.mcp.shared.Transport
import org.jetbrains.kotlinx.mcp.toJSON
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@ -34,8 +34,7 @@ abstract class BaseTransportTest {
}
val messages = listOf<JSONRPCMessage>(
PingRequest().toJSON(),
InitializedNotification().toJSON()
PingRequest().toJSON(), InitializedNotification().toJSON()
)
val readMessages = mutableListOf<JSONRPCMessage>()

Some files were not shown because too many files have changed in this diff Show more