Initial commit

This commit is contained in:
leonid.stashevsky 2024-12-15 20:17:55 +01:00
commit 7b32c3e3da
8 changed files with 7267 additions and 5 deletions

View file

@ -0,0 +1,63 @@
package client
import org.jetbrains.kotlinx.mcp.InitializedNotification
import org.jetbrains.kotlinx.mcp.JSONRPCMessage
import org.jetbrains.kotlinx.mcp.PingRequest
import kotlinx.coroutines.CompletableDeferred
import org.jetbrains.kotlinx.mcp.shared.Transport
import org.jetbrains.kotlinx.mcp.toJSON
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

@ -0,0 +1,39 @@
package client
import org.jetbrains.kotlinx.mcp.Implementation
import org.jetbrains.kotlinx.mcp.ListToolsResult
import kotlinx.coroutines.test.runTest
import org.jetbrains.kotlinx.mcp.client.Client
import org.jetbrains.kotlinx.mcp.client.StdioClientTransport
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import java.net.Socket
class ClientIntegrationTest {
fun createTransport(): StdioClientTransport {
val socket = Socket("localhost", 3000)
return StdioClientTransport(socket.inputStream, socket.outputStream)
}
@Disabled("This test requires a running server")
@Test
fun testRequestTools() = runTest {
val client = Client(
Implementation("test", "1.0"),
)
val transport = createTransport()
try {
client.connect(transport)
val response: ListToolsResult? = client.listTools()
println(response?.tools)
} finally {
transport.close()
}
}
}

View file

@ -0,0 +1,510 @@
package client
import org.jetbrains.kotlinx.mcp.ClientCapabilities
import org.jetbrains.kotlinx.mcp.CreateMessageRequest
import org.jetbrains.kotlinx.mcp.CreateMessageResult
import org.jetbrains.kotlinx.mcp.EmptyJsonObject
import org.jetbrains.kotlinx.mcp.Implementation
import InMemoryTransport
import org.jetbrains.kotlinx.mcp.InitializeRequest
import org.jetbrains.kotlinx.mcp.InitializeResult
import org.jetbrains.kotlinx.mcp.JSONRPCMessage
import org.jetbrains.kotlinx.mcp.JSONRPCRequest
import org.jetbrains.kotlinx.mcp.JSONRPCResponse
import org.jetbrains.kotlinx.mcp.LATEST_PROTOCOL_VERSION
import org.jetbrains.kotlinx.mcp.ListResourcesRequest
import org.jetbrains.kotlinx.mcp.ListResourcesResult
import org.jetbrains.kotlinx.mcp.ListRootsRequest
import org.jetbrains.kotlinx.mcp.ListToolsRequest
import org.jetbrains.kotlinx.mcp.ListToolsResult
import org.jetbrains.kotlinx.mcp.LoggingLevel
import org.jetbrains.kotlinx.mcp.LoggingMessageNotification
import org.jetbrains.kotlinx.mcp.Method
import org.jetbrains.kotlinx.mcp.Role
import org.jetbrains.kotlinx.mcp.SUPPORTED_PROTOCOL_VERSIONS
import org.jetbrains.kotlinx.mcp.ServerCapabilities
import org.jetbrains.kotlinx.mcp.TextContent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.jetbrains.kotlinx.mcp.client.Client
import org.jetbrains.kotlinx.mcp.client.ClientOptions
import org.junit.jupiter.api.Test
import org.jetbrains.kotlinx.mcp.server.Server
import org.jetbrains.kotlinx.mcp.server.ServerOptions
import org.jetbrains.kotlinx.mcp.shared.Transport
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import kotlin.test.fail
class ClientTest {
@Test
fun `should initialize with matching protocol version`() = runTest {
var initialied = false
var clientTransport = object : Transport {
override suspend fun start() {}
override suspend fun send(message: JSONRPCMessage) {
if (message !is JSONRPCRequest) return
initialied = true
val result = InitializeResult(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = ServerCapabilities(),
serverInfo = Implementation(
name = "test",
version = "1.0"
)
)
val response = JSONRPCResponse(
id = message.id,
result = result
)
onMessage?.invoke(response)
}
override suspend fun close() {
}
override var onClose: (() -> Unit)? = null
override var onError: ((Throwable) -> Unit)? = null
override var onMessage: (suspend (JSONRPCMessage) -> Unit)? = null
}
val client = Client(
clientInfo = Implementation(
name = "test client",
version = "1.0"
),
options = ClientOptions(
capabilities = ClientCapabilities(
sampling = EmptyJsonObject
)
)
)
client.connect(clientTransport)
assertTrue(initialied)
}
@Test
fun `should initialize with supported older protocol version`() = runTest {
val OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]
var clientTransport = object : Transport {
override suspend fun start() {}
override suspend fun send(message: JSONRPCMessage) {
if (message !is JSONRPCRequest) return
check(message.method == Method.Defined.Initialize.value)
val result = InitializeResult(
protocolVersion = OLD_VERSION,
capabilities = ServerCapabilities(),
serverInfo = Implementation(
name = "test",
version = "1.0"
)
)
val response = JSONRPCResponse(
id = message.id,
result = result
)
onMessage?.invoke(response)
}
override suspend fun close() {
}
override var onClose: (() -> Unit)? = null
override var onError: ((Throwable) -> Unit)? = null
override var onMessage: (suspend (JSONRPCMessage) -> Unit)? = null
}
val client = Client(
clientInfo = Implementation(
name = "test client",
version = "1.0"
),
options = ClientOptions(
capabilities = ClientCapabilities(
sampling = EmptyJsonObject
)
)
)
client.connect(clientTransport)
assertEquals(
Implementation("test", "1.0"),
client.getServerVersion()
)
}
@Test
fun `should reject unsupported protocol version`() = runTest {
var closed = false
val clientTransport = object : Transport {
override suspend fun start() {}
override suspend fun send(message: JSONRPCMessage) {
if (message !is JSONRPCRequest) return
check(message.method == Method.Defined.Initialize.value)
val result = InitializeResult(
protocolVersion = "invalid-version",
capabilities = ServerCapabilities(),
serverInfo = Implementation(
name = "test",
version = "1.0"
)
)
val response = JSONRPCResponse(
id = message.id,
result = result
)
onMessage?.invoke(response)
}
override suspend fun close() {
closed = true
}
override var onClose: (() -> Unit)? = null
override var onError: ((Throwable) -> Unit)? = null
override var onMessage: (suspend (JSONRPCMessage) -> Unit)? = null
}
val client = Client(
clientInfo = Implementation(
name = "test client",
version = "1.0"
),
options = ClientOptions()
)
assertFailsWith<IllegalStateException>("Server's protocol version is not supported: invalid-version") {
client.connect(clientTransport)
}
assertTrue(closed)
}
@Test
fun `should respect server capabilities`() = runTest {
val serverOptions = ServerOptions(
capabilities = ServerCapabilities(
resources = ServerCapabilities.Resources(null, null),
tools = ServerCapabilities.Tools(null)
)
)
val server = Server(
Implementation(name = "test server", version = "1.0"),
serverOptions
)
server.setRequestHandler<InitializeRequest>(Method.Defined.Initialize) { request, _ ->
InitializeResult(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = ServerCapabilities(
resources = ServerCapabilities.Resources(null, null),
tools = ServerCapabilities.Tools(null)
),
serverInfo = Implementation(name = "test", version = "1.0")
)
}
server.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { request, _ ->
ListResourcesResult(resources = emptyList(), nextCursor = null)
}
server.setRequestHandler<ListToolsRequest>(Method.Defined.ToolsList) { request, _ ->
ListToolsResult(tools = emptyList(), nextCursor = null)
}
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(
capabilities = ClientCapabilities(sampling = EmptyJsonObject),
)
)
listOf(
launch {
client.connect(clientTransport)
},
launch {
server.connect(serverTransport)
}
).joinAll()
// Server supports resources and tools, but not prompts
val caps = client.getServerCapabilities()
assertEquals(ServerCapabilities.Resources(null, null), caps?.resources)
assertEquals(ServerCapabilities.Tools(null), caps?.tools)
assertTrue(caps?.prompts == null) // or check that prompts are absent
// These should not throw
client.listResources()
client.listTools()
// This should fail because prompts are not supported
val ex = assertFailsWith<IllegalStateException> {
client.listPrompts()
}
assertTrue(ex.message?.contains("Server does not support prompts") == true)
}
@Test
fun `should respect client notification capabilities`() = runTest {
val server = Server(
Implementation(name = "test server", version = "1.0"),
ServerOptions(capabilities = ServerCapabilities())
)
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(
capabilities = ClientCapabilities(
roots = ClientCapabilities.Roots(listChanged = true)
)
)
)
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
listOf(
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
server.connect(serverTransport)
println("Server connected")
}
).joinAll()
// This should not throw because the client supports roots.listChanged
client.sendRootsListChanged()
// Create a new client without the roots.listChanged capability
val clientWithoutCapability = Client(
clientInfo = Implementation(name = "test client without capability", version = "1.0"),
options = ClientOptions(
capabilities = ClientCapabilities(),
// enforceStrictCapabilities = true // TODO()
)
)
clientWithoutCapability.connect(clientTransport)
// Using the same transport pair might not be realistic - in a real scenario you'd create another pair.
// Adjust if necessary.
// This should fail
val ex = assertFailsWith<IllegalStateException> {
clientWithoutCapability.sendRootsListChanged()
}
assertTrue(ex.message?.startsWith("Client does not support") == true)
}
@Test
fun `should respect server notification capabilities`() = runTest {
val server = Server(
Implementation(name = "test server", version = "1.0"),
ServerOptions(
capabilities = ServerCapabilities(
logging = EmptyJsonObject,
resources = ServerCapabilities.Resources(listChanged = true, subscribe = null)
)
)
)
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(
capabilities = ClientCapabilities()
)
)
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
listOf(
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
server.connect(serverTransport)
println("Server connected")
}
).joinAll()
// These should not throw
val jsonObject = buildJsonObject {
put("name", "John")
put("age", 30)
put("isStudent", false)
}
server.sendLoggingMessage(
LoggingMessageNotification(
level = LoggingLevel.info,
data = jsonObject
)
)
server.sendResourceListChanged()
// This should fail because the server doesn't have the tools capability
val ex = assertFailsWith<IllegalStateException> {
server.sendToolListChanged()
}
assertTrue(ex.message?.contains("Server does not support notifying of tool list changes") == true)
}
@Test
fun `should handle client cancelling a request`() = runTest {
val server = Server(
Implementation(name = "test server", version = "1.0"),
ServerOptions(
capabilities = ServerCapabilities(resources = ServerCapabilities.Resources(listChanged = null, subscribe = null))
)
)
val def = CompletableDeferred<Unit>()
val defTimeOut = CompletableDeferred<Unit>()
server.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, extra ->
// Simulate delay
def.complete(Unit)
try {
kotlinx.coroutines.delay(1000)
} catch (e: CancellationException) {
defTimeOut.complete(Unit)
throw e
}
fail("Shouldn't have been called")
ListResourcesResult(resources = emptyList())
}
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(capabilities = ClientCapabilities())
)
listOf(
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
server.connect(serverTransport)
println("Server connected")
}
).joinAll()
val defCancel = CompletableDeferred<Unit>()
val job = launch {
try {
client.listResources()
} catch (e: CancellationException) {
defCancel.complete(Unit)
assertEquals("Cancelled by test", e.message)
}
}
def.await()
runCatching { job.cancel("Cancelled by test") }
defCancel.await()
defTimeOut.await()
}
@Test
fun `should handle request timeout`() = runTest {
val server = Server(
Implementation(name = "test server", version = "1.0"),
ServerOptions(
capabilities = ServerCapabilities(resources = ServerCapabilities.Resources(listChanged = null, subscribe = null))
)
)
server.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, extra ->
// Simulate a delayed response
// Wait ~100ms unless cancelled
try {
kotlinx.coroutines.withTimeout(100L) {
// Just delay here, if timeout is 0 on client side this won't return in time
kotlinx.coroutines.delay(100)
}
} catch (e: Exception) {
// If aborted, just rethrow or return early
}
ListResourcesResult(resources = emptyList())
}
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(capabilities = ClientCapabilities())
)
listOf(
launch {
client.connect(clientTransport)
println("Client connected")
},
launch {
server.connect(serverTransport)
println("Server connected")
}
).joinAll()
// Request with 1 msec timeout should fail immediately
val ex = assertFailsWith<Exception> {
kotlinx.coroutines.withTimeout(1) {
client.listResources()
}
}
assertTrue(ex is TimeoutCancellationException)
}
@Test
fun `should only allow setRequestHandler for declared capabilities`() = runTest {
val client = Client(
clientInfo = Implementation(
name = "test client",
version = "1.0"
),
options = ClientOptions(
capabilities = ClientCapabilities(
sampling = EmptyJsonObject
)
)
)
client.setRequestHandler<CreateMessageRequest>(Method.Defined.SamplingCreateMessage) { request, _ ->
CreateMessageResult(
model = "test-model",
role = Role.assistant,
content = TextContent(
text = "Test response"
)
)
}
assertFailsWith<IllegalStateException>("Client does not support roots capability (required for RootsList)") {
client.setRequestHandler<ListRootsRequest>(Method.Defined.RootsList) { _, _ -> null }
}
}
}

View file

@ -0,0 +1,124 @@
package client
import InMemoryTransport
import org.jetbrains.kotlinx.mcp.InitializedNotification
import org.jetbrains.kotlinx.mcp.JSONRPCMessage
import kotlinx.coroutines.runBlocking
import org.jetbrains.kotlinx.mcp.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> {
runBlocking {
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

@ -0,0 +1,69 @@
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 kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import mcpSse
import mcpSseTransport
import org.jetbrains.kotlinx.mcp.client.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)
routing {
mcpSse()
}
}.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 clientFinished = CompletableDeferred<Unit>()
val server = embeddedServer(CIO, port = PORT) {
install(io.ktor.server.sse.SSE)
routing {
mcpSseTransport {
onMessage = {
send(it)
}
}
}
}.start(wait = false)
val client = HttpClient {
install(SSE)
}.mcpSseTransport {
url {
host = "localhost"
port = PORT
}
}
testClientRead(client)
server.stop()
}
}

View file

@ -0,0 +1,45 @@
package client
import kotlinx.coroutines.test.runTest
import org.jetbrains.kotlinx.mcp.client.StdioClientTransport
import org.junit.jupiter.api.Test
class StdioClientTransportTest : BaseTransportTest() {
@Test
fun `should start then close cleanly`() = runTest {
// Run process "/usr/bin/tee"
val processBuilder = ProcessBuilder("/usr/bin/tee")
val process = processBuilder.start()
val input = process.inputStream
val output = process.outputStream
val client = StdioClientTransport(
input = input,
output = output
)
testClientOpenClose(client)
process.destroy()
}
@Test
fun `should read messages`() = runTest {
val processBuilder = ProcessBuilder("/usr/bin/tee")
val process = processBuilder.start()
val input = process.inputStream
val output = process.outputStream
val client = StdioClientTransport(
input = input,
output = output
)
testClientRead(client)
process.waitFor()
process.destroy()
}
}

View file

@ -0,0 +1,27 @@
package client
import org.jetbrains.kotlinx.mcp.JSONRPCMessage
import org.jetbrains.kotlinx.mcp.Request
import org.junit.jupiter.api.Test
import org.jetbrains.kotlinx.mcp.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)
}
}

View file

@ -0,0 +1,49 @@
package client
import io.ktor.server.testing.*
import io.ktor.server.websocket.*
import kotlinx.coroutines.CompletableDeferred
import org.jetbrains.kotlinx.mcp.client.mcpWebSocketTransport
import org.junit.jupiter.api.Test
import org.jetbrains.kotlinx.mcp.server.mcpWebSocket
import org.jetbrains.kotlinx.mcp.server.mcpWebSocketTransport
class WebSocketTransportTest : BaseTransportTest() {
@Test
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
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)
}
}