add ios and wasm targets (#81)

* add wasm and ios targets

* add yarn.lock file to gitignore

* clean up code

* fix typo in method name

* add wasm server example

* update jrelease publishing for klibs

* update gradle-publish workflow to use macos runner

* update README to reflect multiplatform support and clarify server setup for JVM and WASM

* remove the old main file from the kotlin-mcp-server example

* update api dump

* Refactor `build.gradle.kts`

* clean up imports and remove unnecessary charset specification in tests

* add JS target configuration and update API dump
This commit is contained in:
Pavel Gorgulov 2025-05-29 07:01:50 +02:00 • committed by GitHub
commit 0cff2ca718
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 705 additions and 478 deletions

View file

@ -1,7 +1,39 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.modelcontextprotocol.kotlin.sdk.*
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.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
@ -27,7 +59,7 @@ public class ClientOptions(
* 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, [severCapabilities] and [serverVersion] provide details about the connected server.
* After initialization, [serverCapabilities] and [serverVersion] provide details about the connected server.
*
* You can extend this class with custom request/notification/result types if needed.
*
@ -135,9 +167,7 @@ public open class Client(
Method.Defined.ResourcesUnsubscribe,
-> {
val resCaps = serverCapabilities?.resources
if (resCaps == null) {
throw IllegalStateException("Server does not support resources (required for $method)")
}
?: error("Server does not support resources (required for $method)")
if (method == Method.Defined.ResourcesSubscribe && resCaps.subscribe != true) {
throw IllegalStateException(

View file

@ -1,14 +1,29 @@
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.ktor.client.HttpClient
import io.ktor.client.plugins.sse.ClientSSESession
import io.ktor.client.plugins.sse.sseSession
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.Url
import io.ktor.http.append
import io.ktor.http.isSuccess
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
import kotlinx.coroutines.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
@ -44,7 +59,7 @@ public class SseClientTransport(
}
override suspend fun start() {
if (!initialized.compareAndSet(false, true)) {
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
error(
"SSEClientTransport already started! " +
"If using Client class, note that connect() calls start() automatically.",

View file

@ -2,13 +2,25 @@ package io.modelcontextprotocol.kotlin.sdk.client
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.internal.IODispatcher
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer
import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.io.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.io.Buffer
import kotlinx.io.Sink
import kotlinx.io.Source
import kotlinx.io.buffered
import kotlinx.io.readByteArray
import kotlinx.io.writeString
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.coroutines.CoroutineContext
@ -28,7 +40,7 @@ public class StdioClientTransport(
private val output: Sink
) : AbstractTransport() {
private val logger = KotlinLogging.logger {}
private val ioCoroutineContext: CoroutineContext = Dispatchers.IO
private val ioCoroutineContext: CoroutineContext = IODispatcher
private val scope by lazy {
CoroutineScope(ioCoroutineContext + SupervisorJob())
}
@ -38,7 +50,7 @@ public class StdioClientTransport(
private val readBuffer = ReadBuffer()
override suspend fun start() {
if (!initialized.compareAndSet(false, true)) {
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
error("StdioClientTransport already started!")
}
@ -100,7 +112,7 @@ public class StdioClientTransport(
}
override suspend fun close() {
if (!initialized.compareAndSet(true, false)) {
if (!initialized.compareAndSet(expectedValue = true, newValue = false)) {
error("Transport is already closed")
}
job?.cancelAndJoin()

View file

@ -1,10 +1,11 @@
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.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.websocket.WebSocketSession
import io.modelcontextprotocol.kotlin.sdk.shared.MCP_SUBPROTOCOL
import io.modelcontextprotocol.kotlin.sdk.shared.WebSocketMcpTransport
import kotlin.properties.Delegates

View file

@ -0,0 +1,5 @@
package io.modelcontextprotocol.kotlin.sdk.internal
import kotlinx.coroutines.CoroutineDispatcher
internal expect val IODispatcher: CoroutineDispatcher

View file

@ -1,12 +1,19 @@
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.*
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.response.respond
import io.ktor.server.routing.Routing
import io.ktor.server.routing.RoutingContext
import io.ktor.server.routing.post
import io.ktor.server.routing.route
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
private val logger = KotlinLogging.logger {}

View file

@ -1,10 +1,13 @@
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.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.encodeURLPath
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.contentType
import io.ktor.server.request.receiveText
import io.ktor.server.response.respondText
import io.ktor.server.sse.ServerSSESession
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
@ -41,8 +44,8 @@ public class SseServerTransport(
* 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.")
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
error("SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.")
}
// Send the endpoint event
@ -114,7 +117,7 @@ public class SseServerTransport(
override suspend fun send(message: JSONRPCMessage) {
if (!initialized.load()) {
throw error("Not connected")
error("Not connected")
}
session.send(

View file

@ -1,7 +1,45 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.*
import io.modelcontextprotocol.kotlin.sdk.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities
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.ToolListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.shared.Protocol
import io.modelcontextprotocol.kotlin.sdk.shared.ProtocolOptions
import io.modelcontextprotocol.kotlin.sdk.shared.RequestOptions
@ -105,7 +143,7 @@ public open class Server(
/**
* Registers a callback to be invoked when the server has completed initialization.
*/
public fun onInitalized(block: () -> Unit) {
public fun onInitialized(block: () -> Unit) {
val old = _onInitialized
_onInitialized = {
old()
@ -133,7 +171,7 @@ public open class Server(
}
/**
* Registers a single tool. This tool can then be called by the client.
* Registers a single tool. The client can then call this tool.
*
* @param name The name of the tool.
* @param description A human-readable description of what the tool does.
@ -228,7 +266,7 @@ public open class Server(
}
/**
* Registers a single prompt. The prompt can then be retrieved by the client.
* Registers a single prompt. The client can then retrieve the prompt.
*
* @param prompt A [Prompt] object describing the prompt.
* @param promptProvider A suspend function that returns the prompt content when requested by the client.
@ -335,7 +373,7 @@ public open class Server(
}
/**
* Registers a single resource. The resource content can then be read by the client.
* Registers a single resource. The client can then read the resource content.
*
* @param uri The URI of the resource.
* @param name A human-readable name for the resource.

View file

@ -2,12 +2,25 @@ package io.modelcontextprotocol.kotlin.sdk.server
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.internal.IODispatcher
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer
import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.io.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.io.Buffer
import kotlinx.io.Sink
import kotlinx.io.Source
import kotlinx.io.buffered
import kotlinx.io.readByteArray
import kotlinx.io.writeString
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.coroutines.CoroutineContext
@ -29,7 +42,7 @@ public class StdioServerTransport(
private var readingJob: Job? = null
private var sendingJob: Job? = null
private val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob()
private val coroutineContext: CoroutineContext = IODispatcher + SupervisorJob()
private val scope = CoroutineScope(coroutineContext)
private val readChannel = Channel<ByteArray>(Channel.UNLIMITED)
private val writeChannel = Channel<JSONRPCMessage>(Channel.UNLIMITED)
@ -113,15 +126,26 @@ public class StdioServerTransport(
override suspend fun close() {
if (!initialized.compareAndSet(expectedValue = true, newValue = false)) return
// Cancel reading job and close channel
readingJob?.cancel() // ToDO("was cancel and join")
sendingJob?.cancel()
withContext(NonCancellable) {
writeChannel.close()
sendingJob?.cancelAndJoin()
readChannel.close()
writeChannel.close()
readBuffer.clear()
runCatching {
inputStream.close()
}.onFailure { logger.warn(it) { "Failed to close stdin" } }
_onClose.invoke()
readingJob?.cancel()
readChannel.close()
readBuffer.clear()
runCatching {
outputWriter.flush()
outputWriter.close()
}.onFailure { logger.warn(it) { "Failed to close stdout" } }
_onClose.invoke()
}
}
override suspend fun send(message: JSONRPCMessage) {

View file

@ -1,7 +1,8 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.server.routing.Route
import io.ktor.server.websocket.WebSocketServerSession
import io.ktor.server.websocket.webSocket
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.LIB_VERSION
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities

View file

@ -1,7 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.ktor.http.*
import io.ktor.server.websocket.*
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

View file

@ -1,14 +1,35 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.*
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.RequestId
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.json.ClassDiscriminatorMode
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.serializer
import kotlin.reflect.KType
import kotlin.reflect.typeOf
@ -47,7 +68,8 @@ public open class ProtocolOptions(
* 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.
* that did not advertise capabilities correctly.
* In the future, this will default to true.
*/
public var enforceStrictCapabilities: Boolean = false,
@ -118,7 +140,8 @@ public abstract class Protocol(
/**
* 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.
* Note that errors are not necessarily fatal they are used
* for reporting any kind of exceptional condition out of a band.
*/
public open fun onError(error: Throwable) {}

View file

@ -1,6 +1,6 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.ktor.utils.io.core.*
import io.ktor.utils.io.core.writeFully
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import kotlinx.io.Buffer
import kotlinx.io.indexOf

View file

@ -4,7 +4,7 @@ import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import kotlinx.coroutines.CompletableDeferred
/**
* Describes the minimal contract for a MCP transport that a client or server can communicate over.
* Describes the minimal contract for MCP transport that a client or server can communicate over.
*/
public interface Transport {
/**
@ -38,7 +38,7 @@ public interface Transport {
* 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.
* exceptional condition out of a band.
*/
public fun onError(block: (Throwable) -> Unit)

View file

@ -1,9 +1,17 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.ktor.websocket.*
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.*
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 kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
@ -21,6 +29,7 @@ public abstract class WebSocketMcpTransport : AbstractTransport() {
}
private val initialized: AtomicBoolean = AtomicBoolean(false)
/**
* The WebSocket session used for communication.
*/
@ -32,7 +41,7 @@ public abstract class WebSocketMcpTransport : AbstractTransport() {
protected abstract suspend fun initializeSession()
override suspend fun start() {
if (!initialized.compareAndSet(false, true)) {
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
error(
"WebSocketClientTransport already started! " +
"If using Client class, note that connect() calls start() automatically.",

View file

@ -13,6 +13,7 @@ import kotlinx.serialization.json.encodeToJsonElement
import kotlin.concurrent.atomics.AtomicLong
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.incrementAndFetch
import kotlin.jvm.JvmInline
public const val LATEST_PROTOCOL_VERSION: String = "2024-11-05"
@ -184,7 +185,7 @@ internal fun JSONRPCNotification.fromJSON(): Notification {
public sealed interface RequestResult : WithMeta
/**
* An empty result for a request, containing optional metadata.
* An empty result for a request containing optional metadata.
*
* @param _meta Additional metadata for the response. Defaults to an empty JSON object.
*/
@ -493,7 +494,8 @@ public class InitializedNotification : ClientNotification {
/* Ping */
/**
* A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected.
* A ping, issued by either the server or the client, to check that the other party is still alive.
* The receiver must promptly respond, or else it may be disconnected.
*/
@Serializable
public class PingRequest : ServerRequest, ClientRequest {
@ -1492,6 +1494,7 @@ public class RootsListChangedNotification : ClientNotification {
* @property message The error message.
* @property data Additional error data as a JSON object.
*/
public class McpError(public val code: Int, message: String, public val data: JsonObject = EmptyJsonObject) : Exception() {
public class McpError(public val code: Int, message: String, public val data: JsonObject = EmptyJsonObject) :
Exception() {
override val message: String = "MCP error ${code}: $message"
}

View file

@ -10,7 +10,16 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.longOrNull
private val logger = KotlinLogging.logger {}
@ -285,6 +294,7 @@ public class RequestIdSerializer : KSerializer<RequestId> {
element.longOrNull != null -> RequestId.NumberId(element.long)
else -> error("Invalid RequestId type")
}
else -> error("Invalid RequestId format")
}
}

View file

@ -0,0 +1,47 @@
package io.modelcontextprotocol.kotlin.sdk
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
/**
* In-memory transport for creating clients and servers that talk to each other within the same process.
*/
class InMemoryTransport : AbstractTransport() {
private var otherTransport: InMemoryTransport? = null
private val messageQueue: MutableList<JSONRPCMessage> = mutableListOf()
/**
* Creates a pair of linked in-memory transports that can communicate with each other.
* One should be passed to a Client and one to a Server.
*/
companion object {
fun createLinkedPair(): Pair<InMemoryTransport, InMemoryTransport> {
val clientTransport = InMemoryTransport()
val serverTransport = InMemoryTransport()
clientTransport.otherTransport = serverTransport
serverTransport.otherTransport = clientTransport
return Pair(clientTransport, serverTransport)
}
}
override suspend fun start() {
// Process any messages that were queued before start was called
while (messageQueue.isNotEmpty()) {
messageQueue.removeFirstOrNull()?.let { message ->
_onMessage.invoke(message) // todo?
}
}
}
override suspend fun close() {
val other = otherTransport
otherTransport = null
other?.close()
_onClose.invoke()
}
override suspend fun send(message: JSONRPCMessage) {
val other = otherTransport ?: throw IllegalStateException("Not connected")
other._onMessage.invoke(message)
}
}

View file

@ -0,0 +1,65 @@
package io.modelcontextprotocol.kotlin.sdk
import io.kotest.assertions.json.shouldEqualJson
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlin.test.Test
import kotlin.test.assertEquals
class ToolSerializationTest {
// see https://docs.anthropic.com/en/docs/build-with-claude/tool-use
/* language=json */
private val getWeatherToolJson = """
{
"name": "get_weather",
"description": "Get the current weather in a given location",
"inputSchema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA"
}
},
"required": ["location"]
}
}
""".trimIndent()
val getWeatherTool = Tool(
name = "get_weather",
description = "Get the current weather in a given location",
inputSchema = Tool.Input(
properties = buildJsonObject {
put("location", buildJsonObject {
put("type", JsonPrimitive("string"))
put("description", JsonPrimitive("The city and state, e.g. San Francisco, CA"))
})
},
required = listOf("location")
)
)
@Test
fun `should serialize get_weather tool`() {
McpJson.encodeToString(getWeatherTool) shouldEqualJson getWeatherToolJson
}
@Test
fun `should deserialize get_weather tool`() {
val tool = McpJson.decodeFromString<Tool>(getWeatherToolJson)
assertEquals(expected = getWeatherTool, actual = tool)
}
@Test
fun `should always serialize default value`() {
val json = Json(from = McpJson) {
encodeDefaults = false
}
json.encodeToString(getWeatherTool) shouldEqualJson getWeatherToolJson
}
}

View file

@ -0,0 +1,62 @@
package io.modelcontextprotocol.kotlin.sdk.client
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 kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.test.fail
abstract class BaseTransportTest {
protected suspend fun testClientOpenClose(client: Transport) {
client.onError { error ->
fail("Unexpected error: $error")
}
var didClose = false
client.onClose { didClose = true }
client.start()
assertFalse(didClose, "Transport should not be closed immediately after start")
client.close()
assertTrue(didClose, "Transport should be closed after close() call")
}
protected suspend fun testClientRead(client: Transport) {
client.onError { error ->
error.printStackTrace()
fail("Unexpected error: $error")
}
val messages = listOf<JSONRPCMessage>(
PingRequest().toJSON(), InitializedNotification().toJSON()
)
val readMessages = mutableListOf<JSONRPCMessage>()
val finished = CompletableDeferred<Unit>()
client.onMessage { message ->
readMessages.add(message)
if (message == messages.last()) {
finished.complete(Unit)
}
}
client.start()
for (message in messages) {
client.send(message)
}
finished.await()
assertEquals(messages, readMessages, "Assert messages received")
client.close()
}
}

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