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:
parent
641df74f5d
commit
8698b96dd2
17 changed files with 979 additions and 2003 deletions
|
|
@ -0,0 +1,5 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.internal
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
||||
public expect val IODispatcher: CoroutineDispatcher
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue