add ios and wasm targets (#81)

* add wasm and ios targets

* add yarn.lock file to gitignore

* clean up code

* fix typo in method name

* add wasm server example

* update jrelease publishing for klibs

* update gradle-publish workflow to use macos runner

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

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

* update api dump

* Refactor `build.gradle.kts`

* clean up imports and remove unnecessary charset specification in tests

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

View file

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

View file

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

View file

@ -5,9 +5,9 @@ import io.modelcontextprotocol.kotlin.sdk.CreateMessageRequest
import io.modelcontextprotocol.kotlin.sdk.CreateMessageResult
import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject
import io.modelcontextprotocol.kotlin.sdk.Implementation
import InMemoryTransport
import io.mockk.coEvery
import io.mockk.spyk
import io.modelcontextprotocol.kotlin.sdk.InMemoryTransport
import io.modelcontextprotocol.kotlin.sdk.InitializeRequest
import io.modelcontextprotocol.kotlin.sdk.InitializeResult
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
@ -486,10 +486,10 @@ class ClientTest {
server.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, extra ->
// Simulate a delayed response
// Wait ~100ms unless cancelled
// Wait ~100ms unless canceled
try {
kotlinx.coroutines.withTimeout(100L) {
// Just delay here, if timeout is 0 on client side this won't return in time
// Just delay here, if timeout is 0 on the client side, this won't return in time
kotlinx.coroutines.delay(100)
}
} catch (_: Exception) {

View file

@ -1,122 +0,0 @@
package client
import InMemoryTransport
import io.modelcontextprotocol.kotlin.sdk.InitializedNotification
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import kotlinx.coroutines.runBlocking
import io.modelcontextprotocol.kotlin.sdk.toJSON
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class InMemoryTransportTest {
private lateinit var clientTransport: InMemoryTransport
private lateinit var serverTransport: InMemoryTransport
@BeforeEach
fun setUp() {
val (client, server) = InMemoryTransport.createLinkedPair()
clientTransport = client
serverTransport = server
}
@Test
fun `should create linked pair`() {
assertNotNull(clientTransport)
assertNotNull(serverTransport)
}
@Test
fun `should start without error`() {
runBlocking {
clientTransport.start()
serverTransport.start()
// If no exception is thrown, the test passes
}
}
@Test
fun `should send message from client to server`() {
runBlocking {
val message = InitializedNotification()
var receivedMessage: JSONRPCMessage? = null
serverTransport.onMessage { msg ->
receivedMessage = msg
}
val rpcNotification = message.toJSON()
clientTransport.send(rpcNotification)
assertEquals(rpcNotification, receivedMessage)
}
}
@Test
fun `should send message from server to client`() {
runBlocking {
val message = InitializedNotification()
.toJSON()
var receivedMessage: JSONRPCMessage? = null
clientTransport.onMessage { msg ->
receivedMessage = msg
}
serverTransport.send(message)
assertEquals(message, receivedMessage)
}
}
@Test
fun `should handle close`() {
runBlocking {
var clientClosed = false
var serverClosed = false
clientTransport.onClose {
clientClosed = true
}
serverTransport.onClose {
serverClosed = true
}
clientTransport.close()
assertTrue(clientClosed)
assertTrue(serverClosed)
}
}
@Test
fun `should throw error when sending after close`() {
runBlocking {
clientTransport.close()
assertThrows<IllegalStateException> {
clientTransport.send(
InitializedNotification().toJSON()
)
}
}
}
@Test
fun `should queue messages sent before start`() {
runBlocking {
val message = InitializedNotification()
.toJSON()
var receivedMessage: JSONRPCMessage? = null
serverTransport.onMessage { msg ->
receivedMessage = msg
}
clientTransport.send(message)
serverTransport.start()
assertEquals(message, receivedMessage)
}
}
}

View file

@ -1,85 +0,0 @@
package client
import io.ktor.client.*
import io.ktor.client.plugins.sse.*
import io.ktor.server.application.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import io.ktor.server.routing.*
import io.ktor.server.sse.sse
import io.ktor.util.collections.ConcurrentMap
import kotlinx.coroutines.test.runTest
import io.modelcontextprotocol.kotlin.sdk.client.mcpSseTransport
import io.modelcontextprotocol.kotlin.sdk.server.SseServerTransport
import io.modelcontextprotocol.kotlin.sdk.server.mcpPostEndpoint
import io.modelcontextprotocol.kotlin.sdk.server.mcpSseTransport
import org.junit.jupiter.api.Test
private const val PORT = 8080
class SseTransportTest : BaseTransportTest() {
@Test
fun `should start then close cleanly`() = runTest {
val server = embeddedServer(CIO, port = PORT) {
install(io.ktor.server.sse.SSE)
val transports = ConcurrentMap<String, SseServerTransport>()
routing {
sse {
mcpSseTransport("", transports).start()
}
post {
mcpPostEndpoint(transports)
}
}
}.start(wait = false)
val client = HttpClient {
install(SSE)
}.mcpSseTransport {
url {
host = "localhost"
port = PORT
}
}
testClientOpenClose(client)
server.stop()
}
@Test
fun `should read messages`() = runTest {
val server = embeddedServer(CIO, port = PORT) {
install(io.ktor.server.sse.SSE)
val transports = ConcurrentMap<String, SseServerTransport>()
routing {
sse {
mcpSseTransport("", transports).apply {
onMessage {
send(it)
}
start()
}
}
post {
mcpPostEndpoint(transports)
}
}
}.start(wait = false)
val client = HttpClient {
install(SSE)
}.mcpSseTransport {
url {
host = "localhost"
port = PORT
}
}
testClientRead(client)
server.stop()
}
}

View file

@ -1,5 +1,6 @@
package client
import io.modelcontextprotocol.kotlin.sdk.client.BaseTransportTest
import kotlinx.coroutines.test.runTest
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
import kotlinx.io.asSink

View file

@ -1,47 +0,0 @@
package client
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.Request
import org.junit.jupiter.api.Test
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
class TypesTest {
@Test
fun testRequestResult() {
val message = "{\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{\"tools\":{\"listChanged\":true},\"resources\":{}},\"serverInfo\":{\"name\":\"jetbrains/proxy\",\"version\":\"0.1.0\"}},\"jsonrpc\":\"2.0\",\"id\":1}"
McpJson.decodeFromString<JSONRPCMessage>(message)
}
@Test
fun testRequestError() {
val message = "{\"method\":\"initialize\", \"protocolVersion\":\"2024-11-05\",\"capabilities\":{\"experimental\":{},\"sampling\":{}},\"clientInfo\":{\"name\":\"test client\",\"version\":\"1.0\"},\"_meta\":{}}"
McpJson.decodeFromString<Request>(message)
}
@Test
fun testJSONRPCMessage() {
val line = "{\"result\":{\"content\":[{\"type\":\"text\"}],\"isError\":false},\"jsonrpc\":\"2.0\",\"id\":4}"
McpJson.decodeFromString<JSONRPCMessage>(line)
}
@Test
fun testJSONRPCMessageWithStringId() {
val line = """
{
"jsonrpc": "2.0",
"method": "initialize",
"id": "ebf9f64a-0",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "mcp-java-client",
"version": "0.2.0"
}
}
}
""".trimIndent()
McpJson.decodeFromString<JSONRPCMessage>(line)
}
}

View file

@ -1,52 +0,0 @@
package client
import io.ktor.server.testing.*
import io.ktor.server.websocket.*
import kotlinx.coroutines.CompletableDeferred
import io.modelcontextprotocol.kotlin.sdk.client.mcpWebSocketTransport
import org.junit.jupiter.api.Test
import io.modelcontextprotocol.kotlin.sdk.server.mcpWebSocket
import io.modelcontextprotocol.kotlin.sdk.server.mcpWebSocketTransport
import org.junit.jupiter.api.Disabled
class WebSocketTransportTest : BaseTransportTest() {
@Test
@Disabled("Test disabled for investigation #17")
fun `should start then close cleanly`() = testApplication {
install(WebSockets)
routing {
mcpWebSocket()
}
val client = createClient {
install(io.ktor.client.plugins.websocket.WebSockets)
}.mcpWebSocketTransport()
testClientOpenClose(client)
}
@Test
@Disabled("Test disabled for investigation #17")
fun `should read messages`() = testApplication {
val clientFinished = CompletableDeferred<Unit>()
install(WebSockets)
routing {
mcpWebSocketTransport {
onMessage {
send(it)
}
clientFinished.await()
}
}
val client = createClient {
install(io.ktor.client.plugins.websocket.WebSockets)
}.mcpWebSocketTransport()
testClientRead(client)
clientFinished.complete(Unit)
}
}

View file

@ -1,51 +0,0 @@
package integration
import io.ktor.client.*
import io.ktor.client.plugins.sse.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.mcpSse
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.mcp
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import io.ktor.client.engine.cio.CIO as ClientCIO
import io.ktor.server.cio.CIO as ServerCIO
class SseIntegrationTest {
@Test
fun `client should be able to connect to sse server`() { // runTest will cause network timeout issues
runBlocking {
val serverEngine = initServer()
try {
assertDoesNotThrow { initClient() }
} finally {
// Make sure to stop the server
serverEngine.stop(1000, 2000)
}
}
}
private suspend fun initClient(): Client {
return HttpClient(ClientCIO) { install(SSE) }.mcpSse("http://$URL:$PORT")
}
private fun initServer(): EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration> {
val server = Server(
Implementation(name = "sse-e2e-test", version = "1.0.0"),
ServerOptions(capabilities = ServerCapabilities()),
)
return embeddedServer(ServerCIO, host = URL, port = PORT) { mcp { server } }.start(wait = false)
}
companion object {
private const val PORT = 3001
private const val URL = "localhost"
}
}

View file

@ -1,35 +1,22 @@
package server
import io.modelcontextprotocol.kotlin.sdk.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.InMemoryTransport
import io.modelcontextprotocol.kotlin.sdk.Method
import io.modelcontextprotocol.kotlin.sdk.Prompt
import io.modelcontextprotocol.kotlin.sdk.PromptListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.Resource
import io.modelcontextprotocol.kotlin.sdk.ResourceListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.TextContent
import io.modelcontextprotocol.kotlin.sdk.TextResourceContents
import io.modelcontextprotocol.kotlin.sdk.Tool
import io.modelcontextprotocol.kotlin.sdk.ToolListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
import io.modelcontextprotocol.kotlin.sdk.server.RegisteredPrompt
import io.modelcontextprotocol.kotlin.sdk.server.RegisteredResource
import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import InMemoryTransport
import io.modelcontextprotocol.kotlin.sdk.PromptMessage
import io.modelcontextprotocol.kotlin.sdk.PromptMessageContent
import io.modelcontextprotocol.kotlin.sdk.Role
import io.modelcontextprotocol.kotlin.sdk.TextContent
import io.modelcontextprotocol.kotlin.sdk.TextResourceContents
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest

View file

@ -89,7 +89,7 @@ class StdioServerTransportTest {
val message = PingRequest().toJSON()
// Push message before the server started
// Push a message before the server started
val serialized = serializeMessage(message)
inputWriter.write(serialized)
inputWriter.flush()

View file

@ -1,65 +0,0 @@
package shared
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.JSONRPCNotification
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer
import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.nio.charset.StandardCharsets
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 newline
val messageBytes = json.encodeToString(testMessage)
.toByteArray(StandardCharsets.UTF_8)
readBuffer.append(messageBytes)
assertNull(readBuffer.readMessage())
// Append newline and verify message is now available
readBuffer.append("\n".toByteArray(StandardCharsets.UTF_8))
assertEquals(testMessage, readBuffer.readMessage())
assertNull(readBuffer.readMessage())
}
@Test
fun `skip empty line`() {
val readBuffer = ReadBuffer()
readBuffer.append("\n".toByteArray(StandardCharsets.UTF_8))
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)
}
}