modularize sdk (#208)

* Modularize SDK: migrated client and server implementations to separate modules. Removed old configuration files and reorganized the source code structure

* add support native targets and update js/wasm configurations

* Refactor sample projects: add samples as composite builds, replace hardcoded dependencies with version catalog, remove obsolete gradle wrapper files

* Update .gitignore: exclude SWE agent directories (.claude/ and .junie/)

* Refine CodeQL workflow: update Kotlin build steps to target individual modules separately instead of building all at once
This commit is contained in:
Pavel Gorgulov 2025-08-04 11:53:38 +02:00 • committed by GitHub
commit 8698b96dd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 979 additions and 2003 deletions

View file

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

View file

@ -0,0 +1,540 @@
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.RequestId
import io.modelcontextprotocol.kotlin.sdk.RequestResult
import io.modelcontextprotocol.kotlin.sdk.fromJSON
import io.modelcontextprotocol.kotlin.sdk.toJSON
import kotlinx.atomicfu.AtomicRef
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.getAndUpdate
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.ExperimentalSerializationApi
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.collections.get
import kotlin.reflect.KType
import kotlin.reflect.typeOf
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
private val LOGGER = KotlinLogging.logger { }
public const val IMPLEMENTATION_NAME: String = "mcp-ktor"
/**
* Callback for progress notifications.
*/
public typealias ProgressCallback = (Progress) -> Unit
@OptIn(ExperimentalSerializationApi::class)
public 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 the 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(
@PublishedApi internal val options: ProtocolOptions?,
) {
public var transport: Transport? = null
private set
private val _requestHandlers: AtomicRef<PersistentMap<String, suspend (JSONRPCRequest, RequestHandlerExtra) -> RequestResult?>> =
atomic(persistentMapOf())
public val requestHandlers: Map<String, suspend (request: JSONRPCRequest, extra: RequestHandlerExtra) -> RequestResult?>
get() = _requestHandlers.value
private val _notificationHandlers =
atomic(persistentMapOf<String, suspend (notification: JSONRPCNotification) -> Unit>())
public val notificationHandlers: Map<String, suspend (notification: JSONRPCNotification) -> Unit>
get() = _notificationHandlers.value
private val _responseHandlers: AtomicRef<PersistentMap<RequestId, (response: JSONRPCResponse?, error: Exception?) -> Unit>> =
atomic(persistentMapOf())
public val responseHandlers: Map<RequestId, (response: JSONRPCResponse?, error: Exception?) -> Unit>
get() = _responseHandlers.value
private val _progressHandlers: AtomicRef<PersistentMap<RequestId, ProgressCallback>> =
atomic(persistentMapOf())
public val progressHandlers: Map<RequestId, ProgressCallback>
get() = _progressHandlers.value
/**
* 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 a 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) -> RequestResult?)? =
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 ->
onProgress(notification)
COMPLETED
}
setRequestHandler<PingRequest>(Method.Defined.Ping) { _, _ ->
EmptyRequestResult()
}
}
/**
* 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 {
doClose()
}
transport.onError {
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 doClose() {
val handlersToNotify = _responseHandlers.value.values.toList()
_responseHandlers.getAndSet(persistentMapOf())
_progressHandlers.getAndSet(persistentMapOf())
transport = null
onClose()
val error = McpError(ErrorCode.Defined.ConnectionClosed.code, "Connection closed")
for (handler in handlersToNotify) {
handler(null, error)
}
}
private suspend fun onNotification(notification: JSONRPCNotification) {
LOGGER.trace { "Received notification: ${notification.method}" }
val handler = notificationHandlers[notification.method] ?: fallbackNotificationHandler
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] ?: 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})" }
transport?.send(
JSONRPCResponse(
id = request.id,
result = result
)
)
} catch (cause: Throwable) {
LOGGER.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" }
try {
transport?.send(
JSONRPCResponse(
id = request.id,
error = JSONRPCError(
code = ErrorCode.Defined.InternalError,
message = cause.message ?: "Internal error"
)
)
)
} catch (sendError: Throwable) {
LOGGER.error(sendError) { "Failed to send error response for request: ${request.method} (id: ${request.id})" }
// Optionally implement fallback behavior here
}
}
}
private fun onProgress(notification: ProgressNotification) {
LOGGER.trace { "Received progress notification: token=${notification.params.progressToken}, progress=${notification.params.progress}/${notification.params.total}" }
val progress = notification.params.progress
val total = notification.params.total
val message = notification.params.message
val progressToken = notification.params.progressToken
val handler = _progressHandlers.value[progressToken]
if (handler == null) {
val error = Error(
"Received a progress notification for an unknown token: ${McpJson.encodeToString(notification)}",
)
LOGGER.error { error.message }
onError(error)
return
}
handler.invoke(Progress(progress, total, message))
}
private fun onResponse(response: JSONRPCResponse?, error: JSONRPCError?) {
val messageId = response?.id
val oldResponseHandlers = _responseHandlers.getAndUpdate { current ->
if (messageId != null && messageId in current) {
current.remove(messageId)
} else {
current
}
}
val handler = oldResponseHandlers[messageId]
if (handler != null) {
messageId?.let { msg -> _progressHandlers.update { it.remove(msg) } }
} else {
onError(Error("Received a response for an unknown message ID: ${McpJson.encodeToString(response)}"))
return
}
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 waits for a response.
*
* Do not use this method to emit notifications! Use notification() instead.
*/
public suspend fun <T : RequestResult> request(
request: Request,
options: RequestOptions? = null,
): T {
LOGGER.trace { "Sending request: ${request.method}" }
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.update { current ->
current.put(messageId, options.onProgress)
}
}
_responseHandlers.update { current ->
current.put(messageId) { response, error ->
if (error != null) {
result.completeExceptionally(error)
return@put
}
if (response?.error != null) {
result.completeExceptionally(IllegalStateException(response.error.toString()))
return@put
}
try {
@Suppress("UNCHECKED_CAST")
result.complete(response!!.result as T)
} catch (error: Throwable) {
result.completeExceptionally(error)
}
}
}
val cancel: suspend (Throwable) -> Unit = { reason: Throwable ->
_responseHandlers.update { current -> current.remove(messageId) }
_progressHandlers.update { current -> current.remove(messageId) }
val notification = CancelledNotification(
params = CancelledNotification.Params(
requestId = messageId,
reason = reason.message ?: "Unknown"
)
)
val serialized = JSONRPCNotification(
notification.method.value,
params = McpJson.encodeToJsonElement(notification)
)
transport.send(serialized)
result.completeExceptionally(reason)
}
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: Notification) {
LOGGER.trace { "Sending notification: ${notification.method}" }
val transport = this.transport ?: error("Not connected")
assertNotificationCapability(notification.method)
val message = notification.toJSON()
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) -> RequestResult?,
) {
setRequestHandler(typeOf<T>(), method, block)
}
@PublishedApi
internal fun <T : Request> setRequestHandler(
requestType: KType,
method: Method,
block: suspend (T, RequestHandlerExtra) -> RequestResult?,
) {
assertRequestHandlerCapability(method)
val serializer = McpJson.serializersModule.serializer(requestType)
_requestHandlers.update { current ->
current.put(method.value) { request, extraHandler ->
val result = McpJson.decodeFromJsonElement(serializer, request.params)
val response = if (result != null) {
@Suppress("UNCHECKED_CAST")
block(result as T, extraHandler)
} else {
EmptyRequestResult()
}
response
}
}
}
/**
* Removes the request handler for the given method.
*/
public fun removeRequestHandler(method: Method) {
_requestHandlers.update { current -> current.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>) {
_notificationHandlers.update { current ->
current.put(method.value) {
@Suppress("UNCHECKED_CAST")
handler(it.fromJSON() as T)
}
}
}
/**
* Removes the notification handler for the given method.
*/
public fun removeNotificationHandler(method: Method) {
_notificationHandlers.update { current -> current.remove(method.value) }
}
}

View file

@ -0,0 +1,54 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import kotlinx.io.Buffer
import kotlinx.io.indexOf
import kotlinx.io.readString
/**
* Buffers a continuous stdio stream into discrete JSON-RPC messages.
*/
public class ReadBuffer {
private val buffer: Buffer = Buffer()
public fun append(chunk: ByteArray) {
buffer.write(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)
return null
}
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)
}
public fun serializeMessage(message: JSONRPCMessage): String {
return McpJson.encodeToString(message) + "\n"
}

View file

@ -0,0 +1,99 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import kotlinx.coroutines.CompletableDeferred
/**
* Describes the minimal contract for 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 fun onClose(block: () -> 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 a band.
*/
public fun onError(block: (Throwable) -> Unit)
/**
* Callback for when a message (request or response) is received over the connection.
*/
public fun onMessage(block: suspend (JSONRPCMessage) -> Unit)
}
/**
* Implements [onClose], [onError] and [onMessage] functions of [Transport] providing
* corresponding [_onClose], [_onError] and [_onMessage] properties to use for an implementation.
*/
@Suppress("PropertyName")
public abstract class AbstractTransport : Transport {
protected var _onClose: (() -> Unit) = {}
private set
protected var _onError: ((Throwable) -> Unit) = {}
private set
// to not skip messages
private val _onMessageInitialized = CompletableDeferred<Unit>()
protected var _onMessage: (suspend ((JSONRPCMessage) -> Unit)) = {
_onMessageInitialized.await()
_onMessage.invoke(it)
}
private set
override fun onClose(block: () -> Unit) {
val old = _onClose
_onClose = {
old()
block()
}
}
override fun onError(block: (Throwable) -> Unit) {
val old = _onError
_onError = { e ->
old(e)
block(e)
}
}
override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {
val old: suspend (JSONRPCMessage) -> Unit = when (_onMessageInitialized.isCompleted) {
true -> _onMessage
false -> { _ -> }
}
_onMessage = { message ->
old(message)
block(message)
}
_onMessageInitialized.complete(Unit)
}
}

View file

@ -0,0 +1,102 @@
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 kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
public const val MCP_SUBPROTOCOL: String = "mcp"
/**
* Abstract class representing a WebSocket transport for the Model Context Protocol (MCP).
* Handles communication over a WebSocket session.
*/
@OptIn(ExperimentalAtomicApi::class)
public abstract class WebSocketMcpTransport : AbstractTransport() {
private val scope by lazy {
CoroutineScope(session.coroutineContext + SupervisorJob())
}
private val initialized: AtomicBoolean = AtomicBoolean(false)
/**
* The WebSocket session used for communication.
*/
protected abstract val session: WebSocketSession
/**
* Initializes the WebSocket session
*/
protected abstract suspend fun initializeSession()
override suspend fun start() {
if (!initialized.compareAndSet(expectedValue = false, newValue = 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.load()) {
error("Not connected")
}
session.outgoing.send(Frame.Text(McpJson.encodeToString(message)))
}
override suspend fun close() {
if (!initialized.load()) {
error("Not connected")
}
session.close()
session.coroutineContext.job.join()
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,329 @@
package io.modelcontextprotocol.kotlin.sdk
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.LoggingMessageNotification.SetLevelRequest
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
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 {}
internal object ErrorCodeSerializer : KSerializer<ErrorCode> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("io.modelcontextprotocol.kotlin.sdk.ErrorCode", PrimitiveKind.INT)
override fun serialize(encoder: Encoder, value: ErrorCode) {
encoder.encodeInt(value.code)
}
override fun deserialize(decoder: Decoder): ErrorCode {
val decodedInt = decoder.decodeInt()
return ErrorCode.Defined.entries.firstOrNull { it.code == decodedInt }
?: ErrorCode.Unknown(decodedInt)
}
}
internal object RequestMethodSerializer : KSerializer<Method> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("io.modelcontextprotocol.kotlin.sdk.Method", PrimitiveKind.STRING)
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)
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()
AudioContent.TYPE -> AudioContent.serializer()
else -> UnknownContent.serializer()
}
}
}
internal object PromptMessageContentMultimodalPolymorphicSerializer :
JsonContentPolymorphicSerializer<PromptMessageContentMultimodal>(PromptMessageContentMultimodal::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<PromptMessageContentMultimodal> {
return when (element.jsonObject.getValue("type").jsonPrimitive.content) {
ImageContent.TYPE -> ImageContent.serializer()
TextContent.TYPE -> TextContent.serializer()
AudioContent.TYPE -> AudioContent.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.getOrElse("method") { null }?.jsonPrimitive?.content ?: run {
logger.error { "No method in $element" }
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()
}
}
}
public val EmptyJsonObject: JsonObject = JsonObject(emptyMap())
public class RequestIdSerializer : KSerializer<RequestId> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("RequestId")
override fun deserialize(decoder: Decoder): RequestId {
val jsonDecoder = decoder as? JsonDecoder ?: error("Can only deserialize JSON")
val element = jsonDecoder.decodeJsonElement()
return when (element) {
is JsonPrimitive -> when {
element.isString -> RequestId.StringId(element.content)
element.longOrNull != null -> RequestId.NumberId(element.long)
else -> error("Invalid RequestId type")
}
else -> error("Invalid RequestId format")
}
}
override fun serialize(encoder: Encoder, value: RequestId) {
val jsonEncoder = encoder as? JsonEncoder ?: error("Can only serialize JSON")
when (value) {
is RequestId.StringId -> jsonEncoder.encodeString(value.value)
is RequestId.NumberId -> jsonEncoder.encodeLong(value.value)
}
}
}
/**
* Creates a [CallToolResult] with single [TextContent] and [meta].
*/
public fun CallToolResult.Companion.ok(content: String, meta: JsonObject = EmptyJsonObject): CallToolResult =
CallToolResult(
content = listOf(TextContent(content)),
isError = false,
_meta = meta
)
/**
* Creates a [CallToolResult] with single [TextContent] and [meta], with `isError` being true.
*/
public fun CallToolResult.Companion.error(content: String, meta: JsonObject = EmptyJsonObject): CallToolResult =
CallToolResult(
content = listOf(TextContent(content)),
isError = true,
_meta = meta
)

View file

@ -0,0 +1,33 @@
package io.modelcontextprotocol.kotlin.sdk
import io.kotest.assertions.json.shouldEqualJson
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
import kotlin.test.Test
import kotlin.test.assertEquals
class AudioContentSerializationTest {
private val audioContentJson = """
{
"data": "base64-encoded-audio-data",
"mimeType": "audio/wav",
"type": "audio"
}
""".trimIndent()
private val audioContent = AudioContent(
data = "base64-encoded-audio-data",
mimeType = "audio/wav"
)
@Test
fun `should serialize audio content`() {
McpJson.encodeToString(audioContent) shouldEqualJson audioContentJson
}
@Test
fun `should deserialize audio content`() {
val content = McpJson.decodeFromString<AudioContent>(audioContentJson)
assertEquals(expected = audioContent, actual = content)
}
}

View file

@ -0,0 +1,64 @@
package io.modelcontextprotocol.kotlin.sdk
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CallToolResultUtilsTest {
@Test
fun testOkWithOnlyText() {
val content = "TextMessage"
val result = CallToolResult.Companion.ok(content)
assertEquals(1, result.content.size)
assertEquals(content, (result.content[0] as TextContent).text)
assertFalse(result.isError == true)
assertEquals(EmptyJsonObject, result._meta)
}
@Test
fun testOkWithMeta() {
val content = "TextMessageWithMeta"
val meta = buildJsonObject {
put("key1", JsonPrimitive("value1"))
put("key2", JsonPrimitive(42))
}
val result = CallToolResult.Companion.ok(content, meta)
assertEquals(1, result.content.size)
assertEquals(content, (result.content[0] as TextContent).text)
assertFalse(result.isError == true)
assertEquals(meta, result._meta)
}
@Test
fun testErrorWithOnlyText() {
val content = "ErrorMessage"
val result = CallToolResult.Companion.error(content)
assertEquals(1, result.content.size)
assertEquals(content, (result.content[0] as TextContent).text)
assertTrue(result.isError == true)
assertEquals(EmptyJsonObject, result._meta)
}
@Test
fun testErrorWithMeta() {
val content = "ErrorMessageWithMeta"
val meta = buildJsonObject {
put("errorCode", JsonPrimitive(404))
put("errorDetail", JsonPrimitive("资源未找到"))
}
val result = CallToolResult.Companion.error(content, meta)
assertEquals(1, result.content.size)
assertEquals(content, (result.content[0] as TextContent).text)
assertTrue(result.isError == true)
assertEquals(meta, result._meta)
}
}

View file

@ -0,0 +1,427 @@
package io.modelcontextprotocol.kotlin.sdk
import io.kotest.assertions.json.shouldEqualJson
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
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",
"title": "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"]
},
"outputSchema": {
"type": "object",
"properties": {
"temperature": {
"type": "number",
"description": "Temperature in celsius"
},
"conditions": {
"type": "string",
"description": "Weather conditions description"
},
"humidity": {
"type": "number",
"description": "Humidity percentage"
}
},
"required": ["temperature", "conditions", "humidity"]
}
}
""".trimIndent()
val getWeatherTool = Tool(
name = "get_weather",
title = "Get weather",
description = "Get the current weather in a given location",
annotations = null,
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")
),
outputSchema = Tool.Output(
properties = buildJsonObject {
put("temperature", buildJsonObject {
put("type", JsonPrimitive("number"))
put("description", JsonPrimitive("Temperature in celsius"))
})
put("conditions", buildJsonObject {
put("type", JsonPrimitive("string"))
put("description", JsonPrimitive("Weather conditions description"))
})
put("humidity", buildJsonObject {
put("type", JsonPrimitive("number"))
put("description", JsonPrimitive("Humidity percentage"))
})
},
required = listOf("temperature", "conditions", "humidity")
)
)
//region Serialize
@Test
fun `should serialize get_weather tool`() {
McpJson.encodeToString(getWeatherTool) shouldEqualJson getWeatherToolJson
}
@Test
fun `should always serialize default value`() {
val json = Json(from = McpJson) {
encodeDefaults = false
}
json.encodeToString(getWeatherTool) shouldEqualJson getWeatherToolJson
}
@Test
fun `should serialize get_weather tool without optional properties`() {
val weatherTool = createWeatherTool(name = "get_weather")
val expectedJson = createWeatherToolJson(name = "get_weather")
val actualJson = McpJson.encodeToString(weatherTool)
actualJson shouldEqualJson expectedJson
}
@Test
fun `should serialize get_weather tool with title optional property specified`() {
val weatherTool = createWeatherTool(name = "get_weather", title = "Get weather")
val expectedJson = createWeatherToolJson(name = "get_weather", title = "Get weather")
val actualJson = McpJson.encodeToString(weatherTool)
actualJson shouldEqualJson expectedJson
}
@Test
fun `should serialize get_weather tool with outputSchema optional property specified`() {
val weatherTool = createWeatherTool(
name = "get_weather",
outputSchema = Tool.Output(
properties = buildJsonObject {
put("temperature", buildJsonObject {
put("type", JsonPrimitive("number"))
put("description", JsonPrimitive("Temperature in celsius"))
})
put("conditions", buildJsonObject {
put("type", JsonPrimitive("string"))
put("description", JsonPrimitive("Weather conditions description"))
})
put("humidity", buildJsonObject {
put("type", JsonPrimitive("number"))
put("description", JsonPrimitive("Humidity percentage"))
})
},
required = listOf("temperature", "conditions", "humidity")
)
)
val expectedJson = createWeatherToolJson(name = "get_weather", outputSchema = """
{
"type": "object",
"properties": {
"temperature": {
"type": "number",
"description": "Temperature in celsius"
},
"conditions": {
"type": "string",
"description": "Weather conditions description"
},
"humidity": {
"type": "number",
"description": "Humidity percentage"
}
},
"required": ["temperature", "conditions", "humidity"]
}
""".trimIndent())
val actualJson = McpJson.encodeToString(weatherTool)
actualJson shouldEqualJson expectedJson
}
@Test
fun `should serialize get_weather tool with all properties specified`() {
val weatherTool = createWeatherTool(
name = "get_weather",
title = "Get weather",
outputSchema = Tool.Output(
properties = buildJsonObject {
put("temperature", buildJsonObject {
put("type", JsonPrimitive("number"))
put("description", JsonPrimitive("Temperature in celsius"))
})
put("conditions", buildJsonObject {
put("type", JsonPrimitive("string"))
put("description", JsonPrimitive("Weather conditions description"))
})
put("humidity", buildJsonObject {
put("type", JsonPrimitive("number"))
put("description", JsonPrimitive("Humidity percentage"))
})
},
required = listOf("temperature", "conditions", "humidity")
)
)
val expectedJson = createWeatherToolJson(
name = "get_weather",
title = "Get weather",
outputSchema = """
{
"type": "object",
"properties": {
"temperature": {
"type": "number",
"description": "Temperature in celsius"
},
"conditions": {
"type": "string",
"description": "Weather conditions description"
},
"humidity": {
"type": "number",
"description": "Humidity percentage"
}
},
"required": ["temperature", "conditions", "humidity"]
}
""".trimIndent())
val actualJson = McpJson.encodeToString(weatherTool)
actualJson shouldEqualJson expectedJson
}
//endregion Serialize
//region Deserialize
@Test
fun `should deserialize get_weather tool`() {
val actualTool = McpJson.decodeFromString<Tool>(getWeatherToolJson)
assertEquals(expected = getWeatherTool, actual = actualTool)
}
@Test
fun `should deserialize get_weather tool without optional properties`() {
val toolJson = createWeatherToolJson(name = "get_weather")
val expectedTool = createWeatherTool(name = "get_weather")
val actualTool = McpJson.decodeFromString<Tool>(toolJson)
assertEquals(expected = expectedTool, actual = actualTool)
}
@Test
fun `should deserialize get_weather tool with title properties specified`() {
val toolJson = createWeatherToolJson(name = "get_weather", title = "Get weather")
val expectedTool = createWeatherTool(name = "get_weather", title = "Get weather")
val actualTool = McpJson.decodeFromString<Tool>(toolJson)
assertEquals(expected = expectedTool, actual = actualTool)
}
@Test
fun `should deserialize get_weather tool with outputSchema optional property specified`() {
val toolJson = createWeatherToolJson(name = "get_weather", outputSchema = """
{
"type": "object",
"properties": {
"temperature": {
"type": "number",
"description": "Temperature in celsius"
},
"conditions": {
"type": "string",
"description": "Weather conditions description"
},
"humidity": {
"type": "number",
"description": "Humidity percentage"
}
},
"required": ["temperature", "conditions", "humidity"]
}
""".trimIndent())
val expectedTool = createWeatherTool(
name = "get_weather",
outputSchema = Tool.Output(
properties = buildJsonObject {
put("temperature", buildJsonObject {
put("type", JsonPrimitive("number"))
put("description", JsonPrimitive("Temperature in celsius"))
})
put("conditions", buildJsonObject {
put("type", JsonPrimitive("string"))
put("description", JsonPrimitive("Weather conditions description"))
})
put("humidity", buildJsonObject {
put("type", JsonPrimitive("number"))
put("description", JsonPrimitive("Humidity percentage"))
})
},
required = listOf("temperature", "conditions", "humidity")
)
)
val actualTool = McpJson.decodeFromString<Tool>(toolJson)
assertEquals(expected = expectedTool, actual = actualTool)
}
@Test
fun `should deserialize get_weather tool with all properties specified`() {
val toolJson = createWeatherToolJson(
name = "get_weather",
title = "Get weather",
outputSchema = """
{
"type": "object",
"properties": {
"temperature": {
"type": "number",
"description": "Temperature in celsius"
},
"conditions": {
"type": "string",
"description": "Weather conditions description"
},
"humidity": {
"type": "number",
"description": "Humidity percentage"
}
},
"required": ["temperature", "conditions", "humidity"]
}
""".trimIndent())
val expectedTool = createWeatherTool(
name = "get_weather",
title = "Get weather",
outputSchema = Tool.Output(
properties = buildJsonObject {
put("temperature", buildJsonObject {
put("type", JsonPrimitive("number"))
put("description", JsonPrimitive("Temperature in celsius"))
})
put("conditions", buildJsonObject {
put("type", JsonPrimitive("string"))
put("description", JsonPrimitive("Weather conditions description"))
})
put("humidity", buildJsonObject {
put("type", JsonPrimitive("number"))
put("description", JsonPrimitive("Humidity percentage"))
})
},
required = listOf("temperature", "conditions", "humidity")
)
)
val actualTool = McpJson.decodeFromString<Tool>(toolJson)
assertEquals(expected = expectedTool, actual = actualTool)
}
//endregion Deserialize
//region Private Methods
private fun createWeatherToolJson(
name: String = "get_weather",
title: String? = null,
outputSchema: String? = null
): String {
val stringBuilder = StringBuilder()
stringBuilder
.appendLine("{")
.append(" \"name\": \"$name\"")
if (title != null) {
stringBuilder
.appendLine(",")
.append(" \"title\": \"$title\"")
}
stringBuilder
.appendLine(",")
.append(" \"description\": \"Get the current weather in a given location\"")
.appendLine(",")
.append("""
"inputSchema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA"
}
},
"required": ["location"]
}
""".trimIndent())
if (outputSchema != null) {
stringBuilder
.appendLine(",")
.append("""
"outputSchema": $outputSchema
""".trimIndent())
}
stringBuilder
.appendLine()
.appendLine("}")
return stringBuilder.toString().trimIndent()
}
private fun createWeatherTool(
name: String = "get_weather",
title: String? = null,
outputSchema: Tool.Output? = null
): Tool {
return Tool(
name = name,
title = title,
description = "Get the current weather in a given location",
annotations = null,
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")
),
outputSchema = outputSchema
)
}
//endregion Private Methods
}

View file

@ -0,0 +1,378 @@
package io.modelcontextprotocol.kotlin.sdk
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class TypesTest {
@Test
fun `should have correct latest protocol version`() {
assertNotEquals("", LATEST_PROTOCOL_VERSION)
assertEquals("2025-03-26", LATEST_PROTOCOL_VERSION)
}
@Test
fun `should have correct supported protocol versions`() {
assertIs<Array<String>>(SUPPORTED_PROTOCOL_VERSIONS)
assertTrue(SUPPORTED_PROTOCOL_VERSIONS.contains(LATEST_PROTOCOL_VERSION))
assertTrue(SUPPORTED_PROTOCOL_VERSIONS.contains("2024-11-05"))
assertEquals(2, SUPPORTED_PROTOCOL_VERSIONS.size)
}
@Test
fun `should validate JSONRPC version constant`() {
assertEquals("2.0", JSONRPC_VERSION)
}
// Reference Tests
@Test
fun `should validate ResourceReference`() {
val resourceRef = ResourceReference(uri = "file:///path/to/file.txt")
assertEquals("ref/resource", resourceRef.type)
assertEquals("file:///path/to/file.txt", resourceRef.uri)
}
@Test
fun `should serialize and deserialize ResourceReference correctly`() {
val resourceRef = ResourceReference(uri = "https://example.com/resource")
val json = McpJson.encodeToString<Reference>(resourceRef)
val decoded = McpJson.decodeFromString<Reference>(json)
assertIs<ResourceReference>(decoded)
assertEquals("ref/resource", decoded.type)
assertEquals("https://example.com/resource", decoded.uri)
}
@Test
fun `should validate PromptReference`() {
val promptRef = PromptReference(name = "greeting")
assertEquals("ref/prompt", promptRef.type)
assertEquals("greeting", promptRef.name)
}
@Test
fun `should serialize and deserialize PromptReference correctly`() {
val promptRef = PromptReference(name = "test-prompt")
val json = McpJson.encodeToString<Reference>(promptRef)
val decoded = McpJson.decodeFromString<Reference>(json)
assertIs<PromptReference>(decoded)
assertEquals("ref/prompt", decoded.type)
assertEquals("test-prompt", decoded.name)
}
@Test
fun `should handle UnknownReference for invalid type`() {
val invalidJson = """{"type": "invalid_type"}"""
val decoded = McpJson.decodeFromString<Reference>(invalidJson)
assertIs<UnknownReference>(decoded)
assertEquals("invalid_type", decoded.type)
}
// PromptMessageContent Tests
@Test
fun `should validate text content`() {
val textContent = TextContent(text = "Hello, world!")
assertEquals("text", textContent.type)
assertEquals("Hello, world!", textContent.text)
}
@Test
fun `should serialize and deserialize text content correctly`() {
val textContent = TextContent(text = "Test message")
val json = McpJson.encodeToString<PromptMessageContent>(textContent)
val decoded = McpJson.decodeFromString<PromptMessageContent>(json)
assertIs<TextContent>(decoded)
assertEquals("text", decoded.type)
assertEquals("Test message", decoded.text)
}
@Test
fun `should validate image content`() {
val imageContent = ImageContent(
data = "aGVsbG8=", // base64 encoded "hello"
mimeType = "image/png"
)
assertEquals("image", imageContent.type)
assertEquals("aGVsbG8=", imageContent.data)
assertEquals("image/png", imageContent.mimeType)
}
@Test
fun `should serialize and deserialize image content correctly`() {
val imageContent = ImageContent(
data = "dGVzdA==", // base64 encoded "test"
mimeType = "image/jpeg"
)
val json = McpJson.encodeToString<PromptMessageContent>(imageContent)
val decoded = McpJson.decodeFromString<PromptMessageContent>(json)
assertIs<ImageContent>(decoded)
assertEquals("image", decoded.type)
assertEquals("dGVzdA==", decoded.data)
assertEquals("image/jpeg", decoded.mimeType)
}
@Test
fun `should validate audio content`() {
val audioContent = AudioContent(
data = "aGVsbG8=", // base64 encoded "hello"
mimeType = "audio/mp3"
)
assertEquals("audio", audioContent.type)
assertEquals("aGVsbG8=", audioContent.data)
assertEquals("audio/mp3", audioContent.mimeType)
}
@Test
fun `should serialize and deserialize audio content correctly`() {
val audioContent = AudioContent(
data = "YXVkaW8=", // base64 encoded "audio"
mimeType = "audio/wav"
)
val json = McpJson.encodeToString<PromptMessageContent>(audioContent)
val decoded = McpJson.decodeFromString<PromptMessageContent>(json)
assertIs<AudioContent>(decoded)
assertEquals("audio", decoded.type)
assertEquals("YXVkaW8=", decoded.data)
assertEquals("audio/wav", decoded.mimeType)
}
@Test
fun `should validate embedded resource content`() {
val resource = TextResourceContents(
text = "File contents",
uri = "file:///path/to/file.txt",
mimeType = "text/plain"
)
val embeddedResource = EmbeddedResource(resource = resource)
assertEquals("resource", embeddedResource.type)
assertEquals(resource, embeddedResource.resource)
}
@Test
fun `should serialize and deserialize embedded resource content correctly`() {
val resource = BlobResourceContents(
blob = "YmluYXJ5ZGF0YQ==",
uri = "file:///path/to/binary.dat",
mimeType = "application/octet-stream"
)
val embeddedResource = EmbeddedResource(resource = resource)
val json = McpJson.encodeToString<PromptMessageContent>(embeddedResource)
val decoded = McpJson.decodeFromString<PromptMessageContent>(json)
assertIs<EmbeddedResource>(decoded)
assertEquals("resource", decoded.type)
assertIs<BlobResourceContents>(decoded.resource)
val decodedBlob = decoded.resource
assertEquals("YmluYXJ5ZGF0YQ==", decodedBlob.blob)
assertEquals("file:///path/to/binary.dat", decodedBlob.uri)
assertEquals("application/octet-stream", decodedBlob.mimeType)
}
@Test
fun `should handle unknown content type`() {
val unknownJson = """{"type": "unknown_type"}"""
val decoded = McpJson.decodeFromString<PromptMessageContent>(unknownJson)
assertIs<UnknownContent>(decoded)
assertEquals("unknown_type", decoded.type)
}
// PromptMessage Tests
@Test
fun `should validate prompt message with text content`() {
val textContent = TextContent(text = "Hello, assistant!")
val promptMessage = PromptMessage(
role = Role.user,
content = textContent
)
assertEquals(Role.user, promptMessage.role)
assertEquals(textContent, promptMessage.content)
assertEquals("text", promptMessage.content.type)
}
@Test
fun `should validate prompt message with embedded resource`() {
val resource = TextResourceContents(
text = "Primary application entry point",
uri = "file:///project/src/main.rs",
mimeType = "text/x-rust"
)
val embeddedResource = EmbeddedResource(resource = resource)
val promptMessage = PromptMessage(
role = Role.assistant,
content = embeddedResource
)
assertEquals(Role.assistant, promptMessage.role)
assertEquals("resource", promptMessage.content.type)
val content = promptMessage.content as EmbeddedResource
val textResource = content.resource as TextResourceContents
assertEquals("Primary application entry point", textResource.text)
assertEquals("file:///project/src/main.rs", textResource.uri)
assertEquals("text/x-rust", textResource.mimeType)
}
@Test
fun `should serialize and deserialize prompt message correctly`() {
val imageContent = ImageContent(
data = "aW1hZ2VkYXRh", // base64 encoded "imagedata"
mimeType = "image/png"
)
val promptMessage = PromptMessage(
role = Role.assistant,
content = imageContent
)
val json = McpJson.encodeToString(promptMessage)
val decoded = McpJson.decodeFromString<PromptMessage>(json)
assertEquals(Role.assistant, decoded.role)
assertIs<ImageContent>(decoded.content)
val decodedContent = decoded.content
assertEquals("aW1hZ2VkYXRh", decodedContent.data)
assertEquals("image/png", decodedContent.mimeType)
}
// CallToolResult Tests
@Test
fun `should validate tool result with multiple content types`() {
val toolResult = CallToolResult(
content = listOf(
TextContent(text = "Found the following files:"),
EmbeddedResource(
resource = TextResourceContents(
text = "fn main() {}",
uri = "file:///project/src/main.rs",
mimeType = "text/x-rust"
)
),
EmbeddedResource(
resource = TextResourceContents(
text = "pub mod lib;",
uri = "file:///project/src/lib.rs",
mimeType = "text/x-rust"
)
)
)
)
assertEquals(3, toolResult.content.size)
assertEquals("text", toolResult.content[0].type)
assertEquals("resource", toolResult.content[1].type)
assertEquals("resource", toolResult.content[2].type)
assertEquals(false, toolResult.isError)
}
@Test
fun `should validate empty content array with default`() {
val toolResult = CallToolResult(content = emptyList())
assertEquals(0, toolResult.content.size)
assertEquals(false, toolResult.isError)
}
@Test
fun `should serialize and deserialize CallToolResult correctly`() {
val toolResult = CallToolResult(
content = listOf(
TextContent(text = "Operation completed"),
ImageContent(data = "aW1hZ2U=", mimeType = "image/png")
),
isError = false
)
val json = McpJson.encodeToString(toolResult)
val decoded = McpJson.decodeFromString<CallToolResult>(json)
assertEquals(2, decoded.content.size)
assertIs<TextContent>(decoded.content[0])
assertIs<ImageContent>(decoded.content[1])
assertEquals(false, decoded.isError)
}
// CompleteRequest Tests
@Test
fun `should validate CompleteRequest with prompt reference`() {
val request = CompleteRequest(
ref = PromptReference(name = "greeting"),
argument = CompleteRequest.Argument(name = "name", value = "A")
)
assertEquals("completion/complete", request.method.value)
assertIs<PromptReference>(request.ref)
val promptRef = request.ref
assertEquals("greeting", promptRef.name)
assertEquals("name", request.argument.name)
assertEquals("A", request.argument.value)
}
@Test
fun `should validate CompleteRequest with resource reference`() {
val request = CompleteRequest(
ref = ResourceReference(uri = "github://repos/{owner}/{repo}"),
argument = CompleteRequest.Argument(name = "repo", value = "t")
)
assertEquals("completion/complete", request.method.value)
assertIs<ResourceReference>(request.ref)
val resourceRef = request.ref
assertEquals("github://repos/{owner}/{repo}", resourceRef.uri)
assertEquals("repo", request.argument.name)
assertEquals("t", request.argument.value)
}
@Test
fun `should serialize and deserialize CompleteRequest correctly`() {
val request = CompleteRequest(
ref = PromptReference(name = "test"),
argument = CompleteRequest.Argument(name = "arg", value = "")
)
val json = McpJson.encodeToString(request)
val decoded = McpJson.decodeFromString<CompleteRequest>(json)
assertEquals("completion/complete", decoded.method.value)
assertIs<PromptReference>(decoded.ref)
val promptRef = decoded.ref
assertEquals("test", promptRef.name)
assertEquals("arg", decoded.argument.name)
assertEquals("", decoded.argument.value)
}
@Test
fun `should validate CompleteRequest with complex URIs`() {
val request = CompleteRequest(
ref = ResourceReference(uri = "api://v1/{tenant}/{resource}/{id}"),
argument = CompleteRequest.Argument(name = "id", value = "123")
)
val resourceRef = request.ref as ResourceReference
assertEquals("api://v1/{tenant}/{resource}/{id}", resourceRef.uri)
assertEquals("id", request.argument.name)
assertEquals("123", request.argument.value)
}
}

View file

@ -0,0 +1,252 @@
package io.modelcontextprotocol.kotlin.sdk
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
class TypesUtilTest {
// ErrorCode Serializer Tests
@Test
fun `should serialize and deserialize ErrorCode Defined correctly`() {
val errorCode: ErrorCode = ErrorCode.Defined.InvalidRequest
val json = McpJson.encodeToString(errorCode)
val decoded = McpJson.decodeFromString<ErrorCode>(json)
assertEquals(ErrorCode.Defined.InvalidRequest, decoded)
assertEquals(-32600, decoded.code)
}
@Test
fun `should serialize and deserialize ErrorCode Unknown correctly`() {
val errorCode: ErrorCode = ErrorCode.Unknown(1001)
val json = McpJson.encodeToString(errorCode)
val decoded = McpJson.decodeFromString<ErrorCode>(json)
assertIs<ErrorCode.Unknown>(decoded)
assertEquals(1001, decoded.code)
}
// Method Serializer Tests
@Test
fun `should serialize and deserialize Method Defined correctly`() {
val method: Method = Method.Defined.Initialize
val json = McpJson.encodeToString(method)
val decoded = McpJson.decodeFromString<Method>(json)
assertEquals(Method.Defined.Initialize, decoded)
assertEquals("initialize", decoded.value)
}
@Test
fun `should serialize and deserialize Method Custom correctly`() {
val method: Method = Method.Custom("custom/method")
val json = McpJson.encodeToString(method)
val decoded = McpJson.decodeFromString<Method>(json)
assertIs<Method.Custom>(decoded)
assertEquals("custom/method", decoded.value)
}
// StopReason Serializer Tests
@Test
fun `should serialize and deserialize StopReason EndTurn correctly`() {
val stopReason: StopReason = StopReason.EndTurn
val json = McpJson.encodeToString(stopReason)
val decoded = McpJson.decodeFromString<StopReason>(json)
assertEquals(StopReason.EndTurn, decoded)
assertEquals("endTurn", decoded.value)
}
@Test
fun `should serialize and deserialize StopReason Other correctly`() {
val stopReason: StopReason = StopReason.Other("custom_reason")
val json = McpJson.encodeToString(stopReason)
val decoded = McpJson.decodeFromString<StopReason>(json)
assertIs<StopReason.Other>(decoded)
assertEquals("custom_reason", decoded.value)
}
// Reference Polymorphic Serializer Tests
@Test
fun `should deserialize ResourceReference polymorphically`() {
val json = """{"type": "ref/resource", "uri": "file:///test.txt"}"""
val decoded = McpJson.decodeFromString<Reference>(json)
assertIs<ResourceReference>(decoded)
assertEquals("ref/resource", decoded.type)
assertEquals("file:///test.txt", decoded.uri)
}
@Test
fun `should deserialize PromptReference polymorphically`() {
val json = """{"type": "ref/prompt", "name": "test-prompt"}"""
val decoded = McpJson.decodeFromString<Reference>(json)
assertIs<PromptReference>(decoded)
assertEquals("ref/prompt", decoded.type)
assertEquals("test-prompt", decoded.name)
}
@Test
fun `should deserialize UnknownReference for invalid type`() {
val json = """{"type": "unknown_ref", "data": "test"}"""
val decoded = McpJson.decodeFromString<Reference>(json)
assertIs<UnknownReference>(decoded)
assertEquals("unknown_ref", decoded.type)
}
// PromptMessageContent Polymorphic Serializer Tests
@Test
fun `should deserialize TextContent polymorphically`() {
val json = """{"type": "text", "text": "Hello world"}"""
val decoded = McpJson.decodeFromString<PromptMessageContent>(json)
assertIs<TextContent>(decoded)
assertEquals("text", decoded.type)
assertEquals("Hello world", decoded.text)
}
@Test
fun `should deserialize ImageContent polymorphically`() {
val json = """{"type": "image", "data": "aW1hZ2U=", "mimeType": "image/png"}"""
val decoded = McpJson.decodeFromString<PromptMessageContent>(json)
assertIs<ImageContent>(decoded)
assertEquals("image", decoded.type)
assertEquals("aW1hZ2U=", decoded.data)
assertEquals("image/png", decoded.mimeType)
}
@Test
fun `should deserialize AudioContent polymorphically`() {
val json = """{"type": "audio", "data": "YXVkaW8=", "mimeType": "audio/mp3"}"""
val decoded = McpJson.decodeFromString<PromptMessageContent>(json)
assertIs<AudioContent>(decoded)
assertEquals("audio", decoded.type)
assertEquals("YXVkaW8=", decoded.data)
assertEquals("audio/mp3", decoded.mimeType)
}
@Test
fun `should deserialize EmbeddedResource polymorphically`() {
val json =
"""{"type": "resource", "resource": {"uri": "file:///test.txt", "mimeType": "text/plain", "text": "content"}}"""
val decoded = McpJson.decodeFromString<PromptMessageContent>(json)
assertIs<EmbeddedResource>(decoded)
assertEquals("resource", decoded.type)
assertIs<TextResourceContents>(decoded.resource)
val textResource = decoded.resource
assertEquals("file:///test.txt", textResource.uri)
assertEquals("content", textResource.text)
}
// ResourceContents Polymorphic Serializer Tests
@Test
fun `should deserialize TextResourceContents polymorphically`() {
val json = """{"uri": "file:///test.txt", "mimeType": "text/plain", "text": "file content"}"""
val decoded = McpJson.decodeFromString<ResourceContents>(json)
assertIs<TextResourceContents>(decoded)
assertEquals("file:///test.txt", decoded.uri)
assertEquals("file content", decoded.text)
assertEquals("text/plain", decoded.mimeType)
}
@Test
fun `should deserialize BlobResourceContents polymorphically`() {
val json = """{"uri": "file:///binary.dat", "mimeType": "application/octet-stream", "blob": "YmluYXJ5"}"""
val decoded = McpJson.decodeFromString<ResourceContents>(json)
assertIs<BlobResourceContents>(decoded)
assertEquals("file:///binary.dat", decoded.uri)
assertEquals("YmluYXJ5", decoded.blob)
assertEquals("application/octet-stream", decoded.mimeType)
}
@Test
fun `should deserialize UnknownResourceContents for missing fields`() {
val json = """{"uri": "file:///unknown.dat", "mimeType": "unknown/type"}"""
val decoded = McpJson.decodeFromString<ResourceContents>(json)
assertIs<UnknownResourceContents>(decoded)
assertEquals("file:///unknown.dat", decoded.uri)
assertEquals("unknown/type", decoded.mimeType)
}
// RequestId Serializer Tests
@Test
fun `should serialize and deserialize RequestId StringId correctly`() {
val requestId: RequestId = RequestId.StringId("test-id")
val json = McpJson.encodeToString(requestId)
val decoded = McpJson.decodeFromString<RequestId>(json)
assertIs<RequestId.StringId>(decoded)
assertEquals("test-id", decoded.value)
}
@Test
fun `should serialize and deserialize RequestId NumberId correctly`() {
val requestId: RequestId = RequestId.NumberId(42L)
val json = McpJson.encodeToString(requestId)
val decoded = McpJson.decodeFromString<RequestId>(json)
assertIs<RequestId.NumberId>(decoded)
assertEquals(42L, decoded.value)
}
// Utility Functions Tests
@Test
fun `should create CallToolResult ok correctly`() {
val result = CallToolResult.ok("Success message")
assertEquals(listOf(TextContent("Success message")), result.content)
assertEquals(false, result.isError)
assertEquals(EmptyJsonObject, result._meta)
}
@Test
fun `should create CallToolResult error correctly`() {
val result = CallToolResult.error("Error message")
assertEquals(listOf(TextContent("Error message")), result.content)
assertEquals(true, result.isError)
assertEquals(EmptyJsonObject, result._meta)
}
@Test
fun `should create CallToolResult with custom meta`() {
val meta = buildJsonObject { put("custom", "value") }
val result = CallToolResult.ok("Success", meta)
assertEquals(listOf(TextContent("Success")), result.content)
assertEquals(false, result.isError)
assertEquals(meta, result._meta)
}
}

View file

@ -0,0 +1,62 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.ktor.utils.io.charsets.Charsets
import io.ktor.utils.io.core.toByteArray
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.JSONRPCNotification
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ReadBufferTest {
private val testMessage: JSONRPCMessage = JSONRPCNotification(method = "foobar")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
@Test
fun `should have no messages after initialization`() {
val readBuffer = ReadBuffer()
assertNull(readBuffer.readMessage())
}
@Test
fun `should only yield a message after a newline`() {
val readBuffer = ReadBuffer()
// Append message without a newline
val messageBytes = json.encodeToString(testMessage).encodeToByteArray()
readBuffer.append(messageBytes)
assertNull(readBuffer.readMessage())
// Append a newline and verify message is now available
readBuffer.append("\n".encodeToByteArray())
assertEquals(testMessage, readBuffer.readMessage())
assertNull(readBuffer.readMessage())
}
@Test
fun `skip empty line`() {
val readBuffer = ReadBuffer()
readBuffer.append("\n".toByteArray())
assertNull(readBuffer.readMessage())
}
@Test
fun `should be reusable after clearing`() {
val readBuffer = ReadBuffer()
readBuffer.append("foobar".toByteArray(Charsets.UTF_8))
readBuffer.clear()
assertNull(readBuffer.readMessage())
val messageJson = serializeMessage(testMessage)
readBuffer.append(messageJson.toByteArray(Charsets.UTF_8))
readBuffer.append("\n".toByteArray(Charsets.UTF_8))
val message = readBuffer.readMessage()
assertEquals(testMessage, message)
}
}

View file

@ -0,0 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.internal
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
public actual val IODispatcher: CoroutineDispatcher
get() = Dispatchers.Default

View file

@ -0,0 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.internal
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
public actual val IODispatcher: CoroutineDispatcher
get() = Dispatchers.IO

View file

@ -0,0 +1,8 @@
package io.modelcontextprotocol.kotlin.sdk.internal
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
public actual val IODispatcher: CoroutineDispatcher
get() = Dispatchers.IO

View file

@ -0,0 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.internal
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
public actual val IODispatcher: CoroutineDispatcher
get() = Dispatchers.Default