diff --git a/build.gradle.kts b/build.gradle.kts index b512176..1f048a1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,61 +1,35 @@ +@file:OptIn(ExperimentalKotlinGradlePluginApi::class) + import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jreleaser.model.Active plugins { - alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.dokka) alias(libs.plugins.jreleaser) + alias(libs.plugins.atomicfu) `maven-publish` } group = "io.modelcontextprotocol" version = "0.2.0" -repositories { - mavenCentral() -} - -dependencies { - api(libs.kotlinx.serialization.json) - api(libs.ktor.client.cio) - api(libs.ktor.server.cio) - api(libs.ktor.server.sse) - api(libs.ktor.server.websockets) - - implementation(libs.kotlin.logging) - - testImplementation(libs.kotlin.test) - testImplementation(libs.mockk) - testImplementation(libs.ktor.server.test.host) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.kotlinx.coroutines.debug) -} - -val sources = tasks.create("sourcesJar") { - from(sourceSets["main"].allSource) - archiveClassifier.set("sources") +val mainSourcesJar = tasks.register("mainSourcesJar") { + archiveClassifier = "sources" + from(kotlin.sourceSets.getByName("commonMain").kotlin) } publishing { - publications { - create("maven") { - groupId = project.group.toString() - artifactId = project.name - version = project.version.toString() - - from(components["java"]) - } - } - val javadocJar = configureEmptyJavadocArtifact() publications.withType(MavenPublication::class).all { pom.configureMavenCentralMetadata() signPublicationIfKeyPresent() artifact(javadocJar) - artifact(sources) } repositories { @@ -164,17 +138,7 @@ infix fun Property.by(value: T) { set(value) } -tasks.create("localJar") { - dependsOn(tasks.jar) - - archiveFileName = "kotlin-sdk.jar" - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - from(configurations.runtimeClasspath.map { - it.map { if (it.isDirectory) it else zipTree(it) } - }) -} - -tasks.test { +tasks.withType().configureEach { useJUnitPlatform() } @@ -205,7 +169,7 @@ abstract class GenerateLibVersionTask @Inject constructor( dokka { moduleName.set("MCP Kotlin SDK") - dokkaSourceSets.main { + dokkaSourceSets.configureEach { sourceLink { localDirectory.set(file("src/main/kotlin")) remoteUrl("https://github.com/modelcontextprotocol/kotlin-sdk") @@ -224,13 +188,43 @@ val generateLibVersionTask = tasks.register("generateLibVersion", version.toString(), sourcesDir) kotlin { + jvm { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } + } + explicitApi = ExplicitApiMode.Strict jvmToolchain(21) sourceSets { - main { + commonMain { kotlin.srcDir(generateLibVersionTask.map { it.sourcesDir }) + dependencies { + api(libs.kotlinx.serialization.json) + api(libs.ktor.client.cio) + api(libs.ktor.server.cio) + api(libs.ktor.server.sse) + api(libs.ktor.server.websockets) + + implementation(libs.kotlin.logging) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.ktor.server.test.host) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.kotlinx.coroutines.debug) + } + } + + jvmTest { + dependencies { + implementation(libs.mockk) + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a16676c..9ee300e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,8 @@ [versions] # plugins version kotlin = "2.0.21" -dokka = "2.0.0-Beta" +dokka = "2.0.0" +atomicfu = "0.26.1" # libraries version serialization = "1.7.3" @@ -14,10 +15,9 @@ jreleaser = "1.15.0" [libraries] # Kotlinx libraries kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } -kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging-jvm", version.ref = "logging" } +kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging", version.ref = "logging" } # Ktor -ktor-client-apache = { group = "io.ktor", name = "ktor-client-apache", version.ref = "ktor" } ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" } ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse", version.ref = "ktor" } ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" } @@ -33,7 +33,8 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } [plugins] -kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } -jreleaser = { id = "org.jreleaser", version.ref = "jreleaser"} \ No newline at end of file +jreleaser = { id = "org.jreleaser", version.ref = "jreleaser"} +atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index c0de93e..5854415 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,19 @@ -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } + + plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" + } } + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + rootProject.name = "kotlin-sdk" diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt similarity index 100% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SSEClientTransport.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SSEClientTransport.kt similarity index 96% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SSEClientTransport.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SSEClientTransport.kt index 3d6b864..c4eba4a 100644 --- a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SSEClientTransport.kt +++ b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SSEClientTransport.kt @@ -8,9 +8,10 @@ import io.ktor.http.* import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.shared.McpJson import io.modelcontextprotocol.kotlin.sdk.shared.Transport +import kotlinx.atomicfu.AtomicBoolean +import kotlinx.atomicfu.atomic import kotlinx.coroutines.* import kotlinx.serialization.encodeToString -import java.util.concurrent.atomic.AtomicBoolean import kotlin.properties.Delegates import kotlin.time.Duration @@ -28,7 +29,7 @@ public class SSEClientTransport( CoroutineScope(session.coroutineContext + SupervisorJob()) } - private val initialized = AtomicBoolean(false) + private val initialized: AtomicBoolean = atomic(false) private var session: ClientSSESession by Delegates.notNull() private val endpoint = CompletableDeferred() @@ -127,7 +128,7 @@ public class SSEClientTransport( } override suspend fun close() { - if (!initialized.get()) { + if (!initialized.value) { error("SSEClientTransport is not initialized!") } diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt similarity index 79% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt index c15cd15..788f4e3 100644 --- a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt +++ b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt @@ -5,14 +5,18 @@ import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer import io.modelcontextprotocol.kotlin.sdk.shared.Transport import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage +import kotlinx.atomicfu.AtomicBoolean +import kotlinx.atomicfu.atomic import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.consumeEach -import java.io.InputStream -import java.io.OutputStream -import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.io.Buffer +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlinx.io.buffered +import kotlinx.io.readByteArray +import kotlinx.io.writeString import kotlin.coroutines.CoroutineContext -import kotlin.text.Charsets.UTF_8 /** * A transport implementation for JSON-RPC communication that leverages standard input and output streams. @@ -24,8 +28,8 @@ import kotlin.text.Charsets.UTF_8 * @param output The output stream where messages are sent. */ public class StdioClientTransport( - private val input: InputStream, - private val output: OutputStream + private val input: Source, + private val output: Sink ) : Transport { private val logger = KotlinLogging.logger {} private val ioCoroutineContext: CoroutineContext = Dispatchers.IO @@ -33,7 +37,7 @@ public class StdioClientTransport( CoroutineScope(ioCoroutineContext + SupervisorJob()) } private var job: Job? = null - private var initialized = AtomicBoolean(false) + private val initialized: AtomicBoolean = atomic(false) private val sendChannel = Channel(Channel.UNLIMITED) private val readBuffer = ReadBuffer() @@ -48,28 +52,26 @@ public class StdioClientTransport( logger.debug { "Starting StdioClientTransport..." } - val outputStream = output.bufferedWriter(UTF_8) + val outputStream = output.buffered() job = scope.launch(CoroutineName("StdioClientTransport.IO#${hashCode()}")) { val readJob = launch { logger.debug { "Read coroutine started." } try { - val buffer = ByteArray(8192) - while (isActive) { - val bytesRead = input.read(buffer) - if (bytesRead == -1) break - if (bytesRead > 0) { - readBuffer.append(buffer.copyOf(bytesRead)) - processReadBuffer() + input.use { + while (isActive) { + val buffer = Buffer() + val bytesRead = input.readAtMostTo(buffer, 8192) + if (bytesRead == -1L) break + if (bytesRead > 0L) { + readBuffer.append(buffer.readByteArray()) + processReadBuffer() + } } } - } catch (e: CancellationException) { - throw e } catch (e: Exception) { onError?.invoke(e) logger.error(e) { "Error reading from input stream" } - } finally { - input.close() } } @@ -78,7 +80,7 @@ public class StdioClientTransport( try { sendChannel.consumeEach { message -> val json = serializeMessage(message) - outputStream.write(json) + outputStream.writeString(json) outputStream.flush() } } catch (e: Throwable) { @@ -98,7 +100,7 @@ public class StdioClientTransport( } override suspend fun send(message: JSONRPCMessage) { - if (!initialized.get()) { + if (!initialized.value) { error("Transport not started") } diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketClientTransport.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketClientTransport.kt similarity index 100% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketClientTransport.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketClientTransport.kt diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketMcpKtorClientExtensions.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketMcpKtorClientExtensions.kt similarity index 97% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketMcpKtorClientExtensions.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketMcpKtorClientExtensions.kt index e1e8828..9d70d6c 100644 --- a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketMcpKtorClientExtensions.kt +++ b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketMcpKtorClientExtensions.kt @@ -33,7 +33,7 @@ public suspend fun HttpClient.mcpWebSocket( val client = Client( Implementation( name = IMPLEMENTATION_NAME, - version = LIB_VERSION, + version = LIB_VERSION ) ) client.connect(transport) diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse.ktor.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse.ktor.kt similarity index 98% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse.ktor.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse.ktor.kt index 107959b..4c36309 100644 --- a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse.ktor.kt +++ b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse.ktor.kt @@ -38,7 +38,7 @@ public suspend fun HttpClient.mcpSse( val client = Client( Implementation( name = IMPLEMENTATION_NAME, - version = LIB_VERSION, + version = LIB_VERSION ) ) client.connect(transport) diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/McpKtorServerPlugin.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/McpKtorServerPlugin.kt similarity index 100% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/McpKtorServerPlugin.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/McpKtorServerPlugin.kt diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt similarity index 95% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt index a742cf4..1902720 100644 --- a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt +++ b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt @@ -8,9 +8,10 @@ import io.ktor.server.sse.* import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.shared.McpJson import io.modelcontextprotocol.kotlin.sdk.shared.Transport +import kotlinx.atomicfu.AtomicBoolean +import kotlinx.atomicfu.atomic import kotlinx.coroutines.job import kotlinx.serialization.encodeToString -import java.util.concurrent.atomic.AtomicBoolean import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -25,7 +26,7 @@ public class SSEServerTransport( private val endpoint: String, private val session: ServerSSESession, ) : Transport { - private val initialized = AtomicBoolean(false) + private val initialized: AtomicBoolean = atomic(false) @OptIn(ExperimentalUuidApi::class) public val sessionId: String = Uuid.random().toString() @@ -63,7 +64,7 @@ public class SSEServerTransport( * This should be called when a POST request is made to send a message to the server. */ public suspend fun handlePostMessage(call: ApplicationCall) { - if (!initialized.get()) { + if (!initialized.value) { val message = "SSE connection not established" call.respondText(message, status = HttpStatusCode.InternalServerError) onError?.invoke(IllegalStateException(message)) @@ -112,7 +113,7 @@ public class SSEServerTransport( } override suspend fun send(message: JSONRPCMessage) { - if (!initialized.get()) { + if (!initialized.value) { throw error("Not connected") } diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt similarity index 100% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt similarity index 79% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt index 2a8f87d..b91b6cf 100644 --- a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt +++ b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt @@ -5,11 +5,18 @@ import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer import io.modelcontextprotocol.kotlin.sdk.shared.Transport import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage +import kotlinx.atomicfu.AtomicBoolean +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.locks.ReentrantLock +import kotlinx.atomicfu.locks.withLock import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel -import java.io.BufferedInputStream -import java.io.PrintStream -import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.io.Buffer +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlinx.io.buffered +import kotlinx.io.readByteArray +import kotlinx.io.writeString import kotlin.coroutines.CoroutineContext /** @@ -18,8 +25,8 @@ import kotlin.coroutines.CoroutineContext * Reads from System.in and writes to System.out. */ public class StdioServerTransport( - private val inputStream: BufferedInputStream = BufferedInputStream(System.`in`), - outputStream: PrintStream = System.out + private val inputStream: Source, //BufferedInputStream = BufferedInputStream(System.`in`), + outputStream: Sink //PrintStream = System.out ) : Transport { private val logger = KotlinLogging.logger {} override var onClose: (() -> Unit)? = null @@ -27,13 +34,14 @@ public class StdioServerTransport( override var onMessage: (suspend (JSONRPCMessage) -> Unit)? = null private val readBuffer = ReadBuffer() - private var initialized = AtomicBoolean(false) + private val initialized: AtomicBoolean = atomic(false) private var readingJob: Job? = null private val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() private val scope = CoroutineScope(coroutineContext) private val readChannel = Channel(Channel.UNLIMITED) - private val outputWriter = outputStream.bufferedWriter() + private val outputWriter = outputStream.buffered() + private val lock = ReentrantLock() override suspend fun start() { if (!initialized.compareAndSet(false, true)) { @@ -42,16 +50,16 @@ public class StdioServerTransport( // Launch a coroutine to read from stdin readingJob = scope.launch { - val buf = ByteArray(8192) + val buf = Buffer() try { while (isActive) { - val bytesRead = inputStream.read(buf) - if (bytesRead == -1) { + val bytesRead = inputStream.readAtMostTo(buf, 8192) + if (bytesRead == -1L) { // EOF reached break } if (bytesRead > 0) { - val chunk = buf.copyOf(bytesRead) + val chunk = buf.readByteArray() readChannel.send(chunk) } } @@ -109,9 +117,9 @@ public class StdioServerTransport( override suspend fun send(message: JSONRPCMessage) { val json = serializeMessage(message) - synchronized(outputWriter) { + lock.withLock { // You may need to add Content-Length headers before the message if using the LSP framing protocol - outputWriter.write(json) + outputWriter.writeString(json) outputWriter.flush() } } diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt similarity index 98% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt index c47b008..dcb491e 100644 --- a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt +++ b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt @@ -84,7 +84,7 @@ private suspend fun Route.createMcpServer( val server = Server( serverInfo = Implementation( name = IMPLEMENTATION_NAME, - version = LIB_VERSION, + version = LIB_VERSION ), options = options ?: ServerOptions( capabilities = ServerCapabilities( diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpServerTransport.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpServerTransport.kt similarity index 100% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpServerTransport.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpServerTransport.kt diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt similarity index 100% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt similarity index 100% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Transport.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Transport.kt similarity index 100% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Transport.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Transport.kt diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/WebSocketMcpTransport.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/WebSocketMcpTransport.kt similarity index 94% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/WebSocketMcpTransport.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/WebSocketMcpTransport.kt index 0f6be71..f1cac92 100644 --- a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/WebSocketMcpTransport.kt +++ b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/WebSocketMcpTransport.kt @@ -5,6 +5,8 @@ import io.ktor.websocket.WebSocketSession import io.ktor.websocket.close import io.ktor.websocket.readText import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import kotlinx.atomicfu.AtomicBoolean +import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.InternalCoroutinesApi @@ -13,7 +15,6 @@ import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString -import java.util.concurrent.atomic.AtomicBoolean internal const val MCP_SUBPROTOCOL = "mcp" @@ -26,7 +27,7 @@ public abstract class WebSocketMcpTransport : Transport { CoroutineScope(session.coroutineContext + SupervisorJob()) } - private val initialized = AtomicBoolean(false) + private val initialized: AtomicBoolean = atomic(false) /** * The WebSocket session used for communication. */ @@ -86,7 +87,7 @@ public abstract class WebSocketMcpTransport : Transport { } override suspend fun send(message: JSONRPCMessage) { - if (!initialized.get()) { + if (!initialized.value) { error("Not connected") } @@ -94,7 +95,7 @@ public abstract class WebSocketMcpTransport : Transport { } override suspend fun close() { - if (!initialized.get()) { + if (!initialized.value) { error("Not connected") } diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/types.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types.kt similarity index 99% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/types.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types.kt index 5e4a978..5343bef 100644 --- a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/types.kt +++ b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types.kt @@ -3,12 +3,14 @@ package io.modelcontextprotocol.kotlin.sdk import io.modelcontextprotocol.kotlin.sdk.shared.McpJson +import kotlinx.atomicfu.AtomicLong +import kotlinx.atomicfu.atomic import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement -import java.util.concurrent.atomic.AtomicLong +import kotlin.jvm.JvmInline public const val LATEST_PROTOCOL_VERSION: String = "2024-11-05" @@ -19,7 +21,7 @@ public val SUPPORTED_PROTOCOL_VERSIONS: Array = arrayOf( public const val JSONRPC_VERSION: String = "2.0" -private val REQUEST_MESSAGE_ID = AtomicLong(0L) +private val REQUEST_MESSAGE_ID: AtomicLong = atomic(0L) /** * A progress token, used to associate progress notifications with the original request. diff --git a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/types.util.kt b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types.util.kt similarity index 98% rename from src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/types.util.kt rename to src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types.util.kt index c1c1097..e990119 100644 --- a/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/types.util.kt +++ b/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types.util.kt @@ -1,5 +1,6 @@ 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.ExperimentalSerializationApi @@ -12,6 +13,8 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* +private val logger = KotlinLogging.logger {} + internal object ErrorCodeSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("io.modelcontextprotocol.kotlin.sdk.ErrorCode", PrimitiveKind.STRING) @@ -194,9 +197,8 @@ internal object NotificationPolymorphicSerializer : internal object RequestPolymorphicSerializer : JsonContentPolymorphicSerializer(Request::class) { override fun selectDeserializer(element: JsonElement): DeserializationStrategy { - val method = element.jsonObject.getOrDefault("method", null)?.jsonPrimitive?.content ?: run { - System.err.println("No method in $element") - Throwable().printStackTrace(System.err) + val method = element.jsonObject.getOrElse("method") { null }?.jsonPrimitive?.content ?: run { + logger.error { "No method in $element" } error("No method in $element") } diff --git a/src/test/kotlin/InMemoryTransport.kt b/src/jvmTest/kotlin/InMemoryTransport.kt similarity index 100% rename from src/test/kotlin/InMemoryTransport.kt rename to src/jvmTest/kotlin/InMemoryTransport.kt diff --git a/src/test/kotlin/client/BaseTransportTest.kt b/src/jvmTest/kotlin/client/BaseTransportTest.kt similarity index 100% rename from src/test/kotlin/client/BaseTransportTest.kt rename to src/jvmTest/kotlin/client/BaseTransportTest.kt diff --git a/src/test/kotlin/client/ClientIntegrationTest.kt b/src/jvmTest/kotlin/client/ClientIntegrationTest.kt similarity index 80% rename from src/test/kotlin/client/ClientIntegrationTest.kt rename to src/jvmTest/kotlin/client/ClientIntegrationTest.kt index 9661f71..5eee817 100644 --- a/src/test/kotlin/client/ClientIntegrationTest.kt +++ b/src/jvmTest/kotlin/client/ClientIntegrationTest.kt @@ -5,6 +5,9 @@ import io.modelcontextprotocol.kotlin.sdk.ListToolsResult import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport import kotlinx.coroutines.test.runTest +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import java.net.Socket @@ -14,7 +17,10 @@ class ClientIntegrationTest { fun createTransport(): StdioClientTransport { val socket = Socket("localhost", 3000) - return StdioClientTransport(socket.inputStream, socket.outputStream) + return StdioClientTransport( + socket.inputStream.asSource().buffered(), + socket.outputStream.asSink().buffered() + ) } @Disabled("This test requires a running server") diff --git a/src/test/kotlin/client/ClientTest.kt b/src/jvmTest/kotlin/client/ClientTest.kt similarity index 99% rename from src/test/kotlin/client/ClientTest.kt rename to src/jvmTest/kotlin/client/ClientTest.kt index 2fd14fb..38c52ca 100644 --- a/src/test/kotlin/client/ClientTest.kt +++ b/src/jvmTest/kotlin/client/ClientTest.kt @@ -48,7 +48,7 @@ class ClientTest { @Test fun `should initialize with matching protocol version`() = runTest { var initialied = false - var clientTransport = object : Transport { + val clientTransport = object : Transport { override suspend fun start() {} override suspend fun send(message: JSONRPCMessage) { @@ -98,7 +98,7 @@ class ClientTest { @Test fun `should initialize with supported older protocol version`() = runTest { val OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1] - var clientTransport = object : Transport { + val clientTransport = object : Transport { override suspend fun start() {} override suspend fun send(message: JSONRPCMessage) { @@ -445,7 +445,7 @@ class ClientTest { // Just delay here, if timeout is 0 on client side this won't return in time kotlinx.coroutines.delay(100) } - } catch (e: Exception) { + } catch (_: Exception) { // If aborted, just rethrow or return early } ListResourcesResult(resources = emptyList()) diff --git a/src/test/kotlin/client/InMemoryTransportTest.kt b/src/jvmTest/kotlin/client/InMemoryTransportTest.kt similarity index 100% rename from src/test/kotlin/client/InMemoryTransportTest.kt rename to src/jvmTest/kotlin/client/InMemoryTransportTest.kt diff --git a/src/test/kotlin/client/SseTransportTest.kt b/src/jvmTest/kotlin/client/SseTransportTest.kt similarity index 93% rename from src/test/kotlin/client/SseTransportTest.kt rename to src/jvmTest/kotlin/client/SseTransportTest.kt index 780e5e9..5099cbd 100644 --- a/src/test/kotlin/client/SseTransportTest.kt +++ b/src/jvmTest/kotlin/client/SseTransportTest.kt @@ -6,7 +6,6 @@ import io.ktor.server.application.* import io.ktor.server.cio.* import io.ktor.server.engine.* import io.ktor.server.routing.* -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest import mcpSse import mcpSseTransport @@ -41,8 +40,6 @@ class SseTransportTest : BaseTransportTest() { @Test fun `should read messages`() = runTest { - val clientFinished = CompletableDeferred() - val server = embeddedServer(CIO, port = PORT) { install(io.ktor.server.sse.SSE) routing { diff --git a/src/test/kotlin/client/StdioClientTransportTest.kt b/src/jvmTest/kotlin/client/StdioClientTransportTest.kt similarity index 74% rename from src/test/kotlin/client/StdioClientTransportTest.kt rename to src/jvmTest/kotlin/client/StdioClientTransportTest.kt index 4fb5d61..8663324 100644 --- a/src/test/kotlin/client/StdioClientTransportTest.kt +++ b/src/jvmTest/kotlin/client/StdioClientTransportTest.kt @@ -2,6 +2,9 @@ package client import kotlinx.coroutines.test.runTest import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered import org.junit.jupiter.api.Test class StdioClientTransportTest : BaseTransportTest() { @@ -11,8 +14,8 @@ class StdioClientTransportTest : BaseTransportTest() { val processBuilder = ProcessBuilder("/usr/bin/tee") val process = processBuilder.start() - val input = process.inputStream - val output = process.outputStream + val input = process.inputStream.asSource().buffered() + val output = process.outputStream.asSink().buffered() val client = StdioClientTransport( input = input, @@ -29,8 +32,8 @@ class StdioClientTransportTest : BaseTransportTest() { val processBuilder = ProcessBuilder("/usr/bin/tee") val process = processBuilder.start() - val input = process.inputStream - val output = process.outputStream + val input = process.inputStream.asSource().buffered() + val output = process.outputStream.asSink().buffered() val client = StdioClientTransport( input = input, diff --git a/src/test/kotlin/client/TypesTest.kt b/src/jvmTest/kotlin/client/TypesTest.kt similarity index 100% rename from src/test/kotlin/client/TypesTest.kt rename to src/jvmTest/kotlin/client/TypesTest.kt diff --git a/src/test/kotlin/client/WebSocketTransportTest.kt b/src/jvmTest/kotlin/client/WebSocketTransportTest.kt similarity index 100% rename from src/test/kotlin/client/WebSocketTransportTest.kt rename to src/jvmTest/kotlin/client/WebSocketTransportTest.kt diff --git a/src/test/kotlin/server/StdioServerTransportTest.kt b/src/jvmTest/kotlin/server/StdioServerTransportTest.kt similarity index 92% rename from src/test/kotlin/server/StdioServerTransportTest.kt rename to src/jvmTest/kotlin/server/StdioServerTransportTest.kt index 5bccc57..724965b 100644 --- a/src/test/kotlin/server/StdioServerTransportTest.kt +++ b/src/jvmTest/kotlin/server/StdioServerTransportTest.kt @@ -12,6 +12,11 @@ import org.junit.jupiter.api.Test import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage import io.modelcontextprotocol.kotlin.sdk.toJSON +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered import java.io.* class StdioServerTransportTest { @@ -21,8 +26,8 @@ class StdioServerTransportTest { private lateinit var output: ByteArrayOutputStream // We'll store the wrapped streams that meet the constructor requirements - private lateinit var bufferedInput: BufferedInputStream - private lateinit var printOutput: PrintStream + private lateinit var bufferedInput: Source + private lateinit var printOutput: Sink @BeforeEach fun setUp() { @@ -40,11 +45,9 @@ class StdioServerTransportTest { } } - // Wrap input in BufferedInputStream - bufferedInput = BufferedInputStream(input) + bufferedInput = input.asSource().buffered() - // Wrap output in PrintStream - printOutput = PrintStream(output, true) + printOutput = output.asSink().buffered() } @Test diff --git a/src/test/kotlin/shared/ReadBufferTest.kt b/src/jvmTest/kotlin/shared/ReadBufferTest.kt similarity index 100% rename from src/test/kotlin/shared/ReadBufferTest.kt rename to src/jvmTest/kotlin/shared/ReadBufferTest.kt diff --git a/src/test/kotlin/sse.ktor.kt b/src/jvmTest/kotlin/sse.ktor.kt similarity index 100% rename from src/test/kotlin/sse.ktor.kt rename to src/jvmTest/kotlin/sse.ktor.kt