test: Refactor test utilities, fix conformance (#476)

# Refactor test utilities, fix conformance

- Add `test-utils` module to streamline test cases
- Rename module `kotlin-sdk-test` -> `integration-test`
- Move test utilities for managing processes, ports, and TypeScript
execution logic
- Make conformance tests work by specifying conformance package version
(0.1.8)

## Motivation and Context
Reuse test infrastructure code, resurrect conformance tests

## How Has This Been Tested?
CI, locally

## Breaking Changes
No

## Types of changes
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] Tests update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [ ] I have added appropriate error handling
- [ ] I have added or updated documentation as needed
This commit is contained in:
Konstantin Pavlov 2026-01-10 16:51:15 +02:00 • committed by GitHub
commit 29d34b8e48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 243 additions and 39 deletions

View file

@ -0,0 +1,32 @@
plugins {
id("mcp.multiplatform")
}
kotlin {
jvm {
tasks.withType<Test> {
useJUnitPlatform()
}
}
sourceSets {
commonTest {
dependencies {
implementation(project(":kotlin-sdk"))
implementation(kotlin("test"))
implementation(libs.kotest.assertions.core)
implementation(libs.kotest.assertions.json)
implementation(libs.kotlin.logging)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.websockets)
implementation(libs.ktor.server.test.host)
}
}
jvmTest {
dependencies {
implementation(project(":test-utils"))
}
}
}
}

View file

@ -0,0 +1,123 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.ktor.client.HttpClient
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.mcp
import io.modelcontextprotocol.kotlin.sdk.shared.BaseTransportTest
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
import kotlin.test.Ignore
import kotlin.test.Test
import io.ktor.client.plugins.sse.SSE as ClientSSE
import io.ktor.server.sse.SSE as ServerSSE
class OldSchemaSseTransportTest : BaseTransportTest() {
private suspend fun EmbeddedServer<*, *>.actualPort() = engine.resolvedConnectors().first().port
private lateinit var mcpServer: Server
@BeforeTest
fun setUp() {
mcpServer = Server(
serverInfo = Implementation(
name = "test-server",
version = "1.0",
),
options = ServerOptions(ServerCapabilities()),
)
}
@Test
@Ignore // Ignored because it doesn’t work with wasm/js in Ktor 3.2.3
fun `should start then close cleanly`() = runTest {
val server = embeddedServer(CIO, port = 0) {
install(ServerSSE)
routing {
mcp { mcpServer }
}
}.startSuspend(wait = false)
val actualPort = server.actualPort()
val transport = HttpClient {
install(ClientSSE)
}.mcpSseTransport {
url {
host = "localhost"
this.port = actualPort
}
}
try {
testTransportRead(transport)
} finally {
server.stopSuspend()
}
}
@Ignore
@Test
fun `should read messages`() = runTest {
val server = embeddedServer(CIO, port = 0) {
install(ServerSSE)
routing {
mcp { mcpServer }
}
}.startSuspend(wait = false)
val actualPort = server.actualPort()
val transport = HttpClient {
install(ClientSSE)
}.mcpSseTransport {
url {
host = "localhost"
this.port = actualPort
}
}
try {
testTransportRead(transport)
} finally {
server.stopSuspend()
}
}
@Ignore
@Test
fun `test sse path not root path`() = runTest {
val server = embeddedServer(CIO, port = 0) {
install(ServerSSE)
routing {
mcp { mcpServer }
}
}.startSuspend(wait = false)
val actualPort = server.actualPort()
val transport = HttpClient {
install(ClientSSE)
}.mcpSseTransport {
url {
host = "localhost"
this.port = actualPort
pathSegments = listOf("sse")
}
}
try {
testTransportRead(transport)
} finally {
server.stopSuspend()
}
}
}

View file

@ -0,0 +1,123 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.ktor.client.HttpClient
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.mcp
import io.modelcontextprotocol.kotlin.sdk.shared.BaseTransportTest
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
import kotlin.test.Ignore
import kotlin.test.Test
import io.ktor.client.plugins.sse.SSE as ClientSSE
import io.ktor.server.sse.SSE as ServerSSE
class SseTransportTest : BaseTransportTest() {
private suspend fun EmbeddedServer<*, *>.actualPort() = engine.resolvedConnectors().first().port
private lateinit var mcpServer: Server
@BeforeTest
fun setUp() {
mcpServer = Server(
serverInfo = Implementation(
name = "test-server",
version = "1.0",
),
options = ServerOptions(ServerCapabilities()),
)
}
@Test
@Ignore // Ignored because it doesn’t work with wasm/js in Ktor 3.2.3
fun `should start then close cleanly`() = runTest {
val server = embeddedServer(CIO, port = 0) {
install(ServerSSE)
routing {
mcp { mcpServer }
}
}.startSuspend(wait = false)
val actualPort = server.actualPort()
val transport = HttpClient {
install(ClientSSE)
}.mcpSseTransport {
url {
host = "localhost"
this.port = actualPort
}
}
try {
testTransportRead(transport)
} finally {
server.stopSuspend()
}
}
@Ignore
@Test
fun `should read messages`() = runTest {
val server = embeddedServer(CIO, port = 0) {
install(ServerSSE)
routing {
mcp { mcpServer }
}
}.startSuspend(wait = false)
val actualPort = server.actualPort()
val transport = HttpClient {
install(ClientSSE)
}.mcpSseTransport {
url {
host = "localhost"
this.port = actualPort
}
}
try {
testTransportRead(transport)
} finally {
server.stopSuspend()
}
}
@Ignore
@Test
fun `test sse path not root path`() = runTest {
val server = embeddedServer(CIO, port = 0) {
install(ServerSSE)
routing {
mcp { mcpServer }
}
}.startSuspend(wait = false)
val actualPort = server.actualPort()
val transport = HttpClient {
install(ClientSSE)
}.mcpSseTransport {
url {
host = "localhost"
this.port = actualPort
pathSegments = listOf("sse")
}
}
try {
testTransportRead(transport)
} finally {
server.stopSuspend()
}
}
}

View file

@ -0,0 +1,52 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.ktor.server.testing.testApplication
import io.ktor.server.websocket.WebSockets
import io.modelcontextprotocol.kotlin.sdk.server.mcpWebSocket
import io.modelcontextprotocol.kotlin.sdk.server.mcpWebSocketTransport
import io.modelcontextprotocol.kotlin.sdk.shared.BaseTransportTest
import kotlinx.coroutines.CompletableDeferred
import kotlin.test.Ignore
import kotlin.test.Test
class WebSocketTransportTest : BaseTransportTest() {
@Test
@Ignore // "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()
testTransportOpenClose(client)
}
@Test
@Ignore // "Test disabled for investigation #17"
fun `should read messages`() = testApplication {
val clientFinished = CompletableDeferred<Unit>()
install(WebSockets)
routing {
mcpWebSocketTransport {
onMessage {
send(it)
}
clientFinished.await()
}
}
val transport = createClient {
install(io.ktor.client.plugins.websocket.WebSockets)
}.mcpWebSocketTransport()
testTransportRead(transport)
clientFinished.complete(Unit)
}
}

View file

@ -0,0 +1,206 @@
package io.modelcontextprotocol.kotlin.sdk.integration
import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport
import io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams
import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotificationParams
import io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class InMemoryTransportTest {
private lateinit var clientTransport: InMemoryTransport
private lateinit var serverTransport: InMemoryTransport
@BeforeTest
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`() = runTest {
clientTransport.start()
serverTransport.start()
// If no exception is thrown, the test passes
}
@Test
fun `should send message from client to server`() = runTest {
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`() = runTest {
val message = InitializedNotification()
.toJSON()
var receivedMessage: JSONRPCMessage? = null
clientTransport.onMessage { msg ->
receivedMessage = msg
}
serverTransport.send(message)
assertEquals(message, receivedMessage)
}
@Test
fun `should handle close`() = runTest {
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`() = runTest {
clientTransport.close()
assertFailsWith<IllegalStateException> {
clientTransport.send(
InitializedNotification().toJSON(),
)
}
}
@Test
fun `should queue messages sent before start`() = runTest {
val message = InitializedNotification()
.toJSON()
var receivedMessage: JSONRPCMessage? = null
serverTransport.onMessage { msg ->
receivedMessage = msg
}
clientTransport.send(message)
serverTransport.start()
assertEquals(message, receivedMessage)
}
@Test
fun `should send ToolListChangedNotification from server to client`() = runTest {
val notification = ToolListChangedNotification(
BaseNotificationParams(),
)
var receivedMessage: JSONRPCMessage? = null
clientTransport.onMessage { msg ->
receivedMessage = msg
}
val rpcMessage = notification.toJSON()
serverTransport.send(rpcMessage)
assertEquals(rpcMessage, receivedMessage)
}
@Test
fun `should send PromptListChangedNotification from server to client`() = runTest {
val notification = PromptListChangedNotification(
BaseNotificationParams(),
)
var receivedMessage: JSONRPCMessage? = null
clientTransport.onMessage { msg ->
receivedMessage = msg
}
val rpcMessage = notification.toJSON()
serverTransport.send(rpcMessage)
assertEquals(rpcMessage, receivedMessage)
}
@Test
fun `should send ResourceListChangedNotification from server to client`() = runTest {
val notification = ResourceListChangedNotification(
BaseNotificationParams(),
)
var receivedMessage: JSONRPCMessage? = null
clientTransport.onMessage { msg ->
receivedMessage = msg
}
val rpcMessage = notification.toJSON()
serverTransport.send(rpcMessage)
assertEquals(rpcMessage, receivedMessage)
}
@Test
fun `should send ResourceUpdatedNotification from server to client`() = runTest {
val notification = ResourceUpdatedNotification(
ResourceUpdatedNotificationParams(
uri = "file:///workspace/data.json",
),
)
var receivedMessage: JSONRPCMessage? = null
clientTransport.onMessage { msg ->
receivedMessage = msg
}
val rpcMessage = notification.toJSON()
serverTransport.send(rpcMessage)
assertEquals(rpcMessage, receivedMessage)
}
@Test
fun `should handle multiple notifications in sequence`() = runTest {
val notifications = listOf(
ToolListChangedNotification(),
PromptListChangedNotification(),
ResourceListChangedNotification(),
ResourceUpdatedNotification(ResourceUpdatedNotificationParams(uri = "file:///workspace/data.json")),
)
val receivedMessages = mutableListOf<JSONRPCMessage>()
clientTransport.onMessage { msg ->
receivedMessages.add(msg)
}
notifications.forEach { notification ->
serverTransport.send(notification.toJSON())
}
assertEquals(notifications.size, receivedMessages.size)
notifications.forEachIndexed { index, notification ->
assertEquals(notification.toJSON(), receivedMessages[index])
}
}
}

View file

@ -0,0 +1,110 @@
package io.modelcontextprotocol.kotlin.sdk.integration
import io.modelcontextprotocol.kotlin.sdk.InitializedNotification
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport
import io.modelcontextprotocol.kotlin.sdk.toJSON
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class OldSchemaInMemoryTransportTest {
private lateinit var clientTransport: InMemoryTransport
private lateinit var serverTransport: InMemoryTransport
@BeforeTest
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`() = runTest {
clientTransport.start()
serverTransport.start()
// If no exception is thrown, the test passes
}
@Test
fun `should send message from client to server`() = runTest {
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`() = runTest {
val message = InitializedNotification()
.toJSON()
var receivedMessage: JSONRPCMessage? = null
clientTransport.onMessage { msg ->
receivedMessage = msg
}
serverTransport.send(message)
assertEquals(message, receivedMessage)
}
@Test
fun `should handle close`() = runTest {
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`() = runTest {
clientTransport.close()
assertFailsWith<IllegalStateException> {
clientTransport.send(
InitializedNotification().toJSON(),
)
}
}
@Test
fun `should queue messages sent before start`() = runTest {
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,67 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlin.time.Duration.Companion.seconds
abstract class BaseTransportTest {
protected suspend fun testTransportOpenClose(transport: Transport) {
transport.onError { error ->
fail("Unexpected error: $error")
}
var didClose = false
transport.onClose { didClose = true }
transport.start()
delay(1.seconds)
assertFalse(didClose, "Transport should not be closed immediately after start")
transport.close()
assertTrue(didClose, "Transport should be closed after close() call")
}
protected suspend fun testTransportRead(transport: Transport) {
transport.onError { error ->
error.printStackTrace()
fail("Unexpected error: $error")
}
val messages = listOf(
PingRequest().toJSON(),
InitializedNotification().toJSON(),
)
val readMessages = mutableListOf<JSONRPCMessage>()
val finished = CompletableDeferred<Unit>()
transport.onMessage { message ->
readMessages.add(message)
if (message == messages.last()) {
finished.complete(Unit)
}
}
transport.start()
for (message in messages) {
transport.send(message)
}
finished.await()
assertEquals(messages, readMessages, "Assert messages received")
transport.close()
}
}

View file

@ -0,0 +1,47 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
/**
* 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, options: TransportSendOptions?) {
val other = otherTransport ?: throw IllegalStateException("Not connected")
other._onMessage.invoke(message)
}
}

View file

@ -0,0 +1,63 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.modelcontextprotocol.kotlin.sdk.InitializedNotification
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.PingRequest
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 OldSchemaBaseTransportTest {
protected suspend fun testTransportOpenClose(transport: Transport) {
transport.onError { error ->
fail("Unexpected error: $error")
}
var didClose = false
transport.onClose { didClose = true }
transport.start()
assertFalse(didClose, "Transport should not be closed immediately after start")
transport.close()
assertTrue(didClose, "Transport should be closed after close() call")
}
protected suspend fun testTransportRead(transport: Transport) {
transport.onError { error ->
error.printStackTrace()
fail("Unexpected error: $error")
}
val messages = listOf<JSONRPCMessage>(
PingRequest().toJSON(),
InitializedNotification().toJSON(),
)
val readMessages = mutableListOf<JSONRPCMessage>()
val finished = CompletableDeferred<Unit>()
transport.onMessage { message ->
readMessages.add(message)
if (message == messages.last()) {
finished.complete(Unit)
}
}
transport.start()
for (message in messages) {
transport.send(message)
}
finished.await()
assertEquals(messages, readMessages, "Assert messages received")
transport.close()
}
}

View file

@ -0,0 +1,47 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
/**
* In-memory transport for creating clients and servers that talk to each other within the same process.
*/
class OldSchemaInMemoryTransport : AbstractTransport() {
private var otherTransport: OldSchemaInMemoryTransport? = 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<OldSchemaInMemoryTransport, OldSchemaInMemoryTransport> {
val clientTransport = OldSchemaInMemoryTransport()
val serverTransport = OldSchemaInMemoryTransport()
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, options: TransportSendOptions?) {
val other = otherTransport ?: throw IllegalStateException("Not connected")
other._onMessage.invoke(message)
}
}

View file

@ -0,0 +1,142 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.modelcontextprotocol.kotlin.sdk.shared.BaseTransportTest
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.test.utils.createSleepyProcessBuilder
import io.modelcontextprotocol.kotlin.test.utils.createTeeProcessBuilder
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Timeout
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.condition.DisabledOnOs
import org.junit.jupiter.api.condition.OS
import java.util.concurrent.TimeUnit
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Timeout(30, unit = TimeUnit.SECONDS)
@DisabledOnOs(OS.WINDOWS) // TODO: fix running on windows
class StdioClientTransportTest : BaseTransportTest() {
@Test
fun `handle stdio error`(): Unit = runBlocking {
val processBuilder = createSleepyProcessBuilder()
val process = processBuilder.start()
val stdin = process.inputStream.asSource().buffered()
val stdout = process.outputStream.asSink().buffered()
val stderr = process.errorStream.asSource().buffered()
val transport = StdioClientTransport(
input = stdin,
output = stdout,
error = stderr,
) {
println("💥Ah-oh!, error: \"$it\"")
StdioClientTransport.StderrSeverity.FATAL
}
val client = Client(
clientInfo = Implementation(
name = "test-client",
version = "1.0",
),
)
// The error in stderr should cause connecting to fail
assertThrows<McpException> {
client.connect(transport)
}
process.destroyForcibly()
}
@OptIn(ExperimentalAtomicApi::class)
@Test
fun `should start then close cleanly`() = runTest {
// Run loopback process
val processBuilder = createTeeProcessBuilder()
val process = processBuilder.start()
val input = process.inputStream.asSource().buffered()
val output = process.outputStream.asSink().buffered()
val error = process.errorStream.asSource().buffered()
val transport = StdioClientTransport(
input = input,
output = output,
error = error,
)
transport.onError { error ->
fail("Unexpected error: $error")
}
val didClose = AtomicBoolean(false)
transport.onClose { didClose.store(true) }
transport.start()
delay(1.seconds)
assertFalse(didClose.load(), "Transport should not be closed immediately after start")
// Destroy process BEFORE close() to unblock stdin reader
process.destroyForcibly()
delay(100.milliseconds) // Give time for EOF to propagate
transport.close()
assertTrue(didClose.load(), "Transport should be closed after close() call")
}
@Test
fun `should read messages`() = runTest {
val processBuilder = createTeeProcessBuilder()
val process = processBuilder.start()
val input = process.inputStream.asSource().buffered()
val output = process.outputStream.asSink().buffered()
val transport = StdioClientTransport(
input = input,
output = output,
)
testTransportRead(transport)
process.waitFor()
process.destroyForcibly()
}
@Test
fun `should ignore first output messages`() = runTest {
val processBuilder = createTeeProcessBuilder()
val process = processBuilder.start()
process.outputStream.write("Stdio server started".toByteArray())
val input = process.inputStream.asSource().buffered()
val output = process.outputStream.asSink().buffered()
val transport = StdioClientTransport(
input = input,
output = output,
)
testTransportRead(transport)
process.waitFor()
process.destroyForcibly()
}
}

View file

@ -0,0 +1,700 @@
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
import io.kotest.assertions.withClue
import io.kotest.matchers.string.shouldContain
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument
import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.Role
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class AbstractPromptIntegrationTest : KotlinTestBase() {
private val basicPromptName = "basic-prompt"
private val basicPromptDescription = "A basic prompt for testing"
private val complexPromptName = "multimodal-prompt"
private val complexPromptDescription = "A prompt with multiple content types"
private val conversationPromptName = "conversation"
private val conversationPromptDescription = "A prompt with multiple messages and roles"
private val strictPromptName = "strict-prompt"
private val strictPromptDescription = "A prompt with required arguments"
private val largePromptName = "large-prompt"
private val largePromptDescription = "A very large prompt for testing"
private val largePromptContent = "X".repeat(100_000) // 100KB of data
private val specialCharsPromptName = "special-chars-prompt"
private val specialCharsPromptDescription = "A prompt with special characters"
private val specialCharsContent = "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t"
override fun configureServerCapabilities(): ServerCapabilities = ServerCapabilities(
prompts = ServerCapabilities.Prompts(
listChanged = true,
),
)
override fun configureServer() {
// basic prompt with a name parameter
server.addPrompt(
name = basicPromptName,
description = basicPromptDescription,
arguments = listOf(
PromptArgument(
name = "name",
description = "The name to greet",
required = true,
),
),
) { request ->
val name = request.params.arguments?.get("name") ?: "World"
GetPromptResult(
description = basicPromptDescription,
messages = listOf(
PromptMessage(
role = Role.User,
content = TextContent(text = "Hello, $name!"),
),
PromptMessage(
role = Role.Assistant,
content = TextContent(text = "Greetings, $name! How can I assist you today?"),
),
),
)
}
// special chars prompt
server.addPrompt(
name = specialCharsPromptName,
description = specialCharsPromptDescription,
arguments = listOf(
PromptArgument(
name = "special",
description = "Special characters to include",
required = false,
),
),
) { request ->
val special = request.params.arguments?.get("special") ?: specialCharsContent
GetPromptResult(
description = specialCharsPromptDescription,
messages = listOf(
PromptMessage(
role = Role.User,
content = TextContent(text = "Special characters: $special"),
),
PromptMessage(
role = Role.Assistant,
content = TextContent(text = "Received special characters: $special"),
),
),
)
}
// very large prompt
server.addPrompt(
name = largePromptName,
description = largePromptDescription,
arguments = listOf(
PromptArgument(
name = "size",
description = "Size multiplier",
required = false,
),
),
) { request ->
val size = request.params.arguments?.get("size")?.toIntOrNull() ?: 1
val content = largePromptContent.repeat(size)
GetPromptResult(
description = largePromptDescription,
messages = listOf(
PromptMessage(
role = Role.User,
content = TextContent(text = "Generate a large response"),
),
PromptMessage(
role = Role.Assistant,
content = TextContent(text = content),
),
),
)
}
// complext prompt
server.addPrompt(
name = complexPromptName,
description = complexPromptDescription,
arguments = listOf(
PromptArgument(name = "arg1", description = "Argument 1", required = true),
PromptArgument(name = "arg2", description = "Argument 2", required = true),
PromptArgument(name = "arg3", description = "Argument 3", required = true),
PromptArgument(name = "arg4", description = "Argument 4", required = false),
PromptArgument(name = "arg5", description = "Argument 5", required = false),
PromptArgument(name = "arg6", description = "Argument 6", required = false),
PromptArgument(name = "arg7", description = "Argument 7", required = false),
PromptArgument(name = "arg8", description = "Argument 8", required = false),
PromptArgument(name = "arg9", description = "Argument 9", required = false),
PromptArgument(name = "arg10", description = "Argument 10", required = false),
),
) { request ->
// validate required arguments
val requiredArgs = listOf("arg1", "arg2", "arg3")
for (argName in requiredArgs) {
if (request.params.arguments?.get(argName) == null) {
throw IllegalArgumentException("Missing required argument: $argName")
}
}
val args = mutableMapOf<String, String>()
for (i in 1..10) {
val argName = "arg$i"
val argValue = request.params.arguments?.get(argName)
if (argValue != null) {
args[argName] = argValue
}
}
GetPromptResult(
description = complexPromptDescription,
messages = listOf(
PromptMessage(
role = Role.User,
content = TextContent(
text = "Arguments: ${
args.entries.joinToString {
"${it.key}=${it.value}"
}
}",
),
),
PromptMessage(
role = Role.Assistant,
content = TextContent(text = "Received ${args.size} arguments"),
),
),
)
}
// prompt with multiple messages and roles
server.addPrompt(
name = conversationPromptName,
description = conversationPromptDescription,
arguments = listOf(
PromptArgument(
name = "topic",
description = "The topic of the conversation",
required = false,
),
),
) { request ->
val topic = request.params.arguments?.get("topic") ?: "weather"
GetPromptResult(
description = conversationPromptDescription,
messages = listOf(
PromptMessage(
role = Role.User,
content = TextContent(text = "Let's talk about the $topic."),
),
PromptMessage(
role = Role.Assistant,
content = TextContent(
text = "Sure, I'd love to discuss the $topic. What would you like to know?",
),
),
PromptMessage(
role = Role.User,
content = TextContent(text = "What's your opinion on the $topic?"),
),
PromptMessage(
role = Role.Assistant,
content = TextContent(
text = "As an AI, I don't have personal opinions," +
" but I can provide information about $topic.",
),
),
PromptMessage(
role = Role.User,
content = TextContent(text = "That's helpful, thank you!"),
),
PromptMessage(
role = Role.Assistant,
content = TextContent(
text = "You're welcome! Let me know if you have more questions about $topic.",
),
),
),
)
}
// prompt with strict required arguments
server.addPrompt(
name = strictPromptName,
description = strictPromptDescription,
arguments = listOf(
PromptArgument(
name = "requiredArg1",
description = "First required argument",
required = true,
),
PromptArgument(
name = "requiredArg2",
description = "Second required argument",
required = true,
),
PromptArgument(
name = "optionalArg",
description = "Optional argument",
required = false,
),
),
) { request ->
val args = request.params.arguments ?: emptyMap()
val arg1 = args["requiredArg1"] ?: throw IllegalArgumentException(
"Missing required argument: requiredArg1",
)
val arg2 = args["requiredArg2"] ?: throw IllegalArgumentException(
"Missing required argument: requiredArg2",
)
val optArg = args["optionalArg"] ?: "default"
GetPromptResult(
description = strictPromptDescription,
messages = listOf(
PromptMessage(
role = Role.User,
content = TextContent(text = "Required arguments: $arg1, $arg2. Optional: $optArg"),
),
PromptMessage(
role = Role.Assistant,
content = TextContent(text = "I received your arguments: $arg1, $arg2, and $optArg"),
),
),
)
}
}
@Test
fun testListPrompts() = runBlocking(Dispatchers.IO) {
val result = client.listPrompts()
assertNotNull(result, "List prompts result should not be null")
assertTrue(result.prompts.isNotEmpty(), "Prompts list should not be empty")
val testPrompt = result.prompts.find { it.name == basicPromptName }
assertNotNull(testPrompt, "Test prompt should be in the list")
assertEquals(
basicPromptDescription,
testPrompt.description,
"Prompt description should match",
)
val arguments = testPrompt.arguments ?: error("Prompt arguments should not be null")
assertTrue(arguments.isNotEmpty(), "Prompt arguments should not be empty")
val nameArg = arguments.find { it.name == "name" }
assertNotNull(nameArg, "Name argument should be in the list")
assertEquals(
"The name to greet",
nameArg.description,
"Argument description should match",
)
assertEquals(true, nameArg.required, "Argument required flag should match")
}
@Test
fun testGetPrompt() = runBlocking(Dispatchers.IO) {
val testName = "Alice"
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = basicPromptName,
arguments = mapOf("name" to testName),
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(
basicPromptDescription,
result.description,
"Prompt description should match",
)
assertTrue(result.messages.isNotEmpty(), "Prompt messages should not be empty")
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
val userMessage = result.messages.find { it.role == Role.User }
assertNotNull(userMessage, "User message should be in the list")
val userContent = userMessage.content as? TextContent
assertNotNull(userContent, "User message content should be TextContent")
assertNotNull(userContent.text, "User message text should not be null")
assertEquals(
"Hello, $testName!",
userContent.text,
"User message content should match",
)
val assistantMessage = result.messages.find { it.role == Role.Assistant }
assertNotNull(assistantMessage, "Assistant message should be in the list")
val assistantContent = assistantMessage.content as? TextContent
assertNotNull(assistantContent, "Assistant message content should be TextContent")
assertNotNull(assistantContent.text, "Assistant message text should not be null")
assertEquals(
"Greetings, $testName! How can I assist you today?",
assistantContent.text,
"Assistant message content should match",
)
}
@Test
fun testMissingRequiredArguments() = runBlocking(Dispatchers.IO) {
val promptsList = client.listPrompts()
assertNotNull(promptsList, "Prompts list should not be null")
val strictPrompt = promptsList.prompts.find { it.name == strictPromptName }
assertNotNull(strictPrompt, "Strict prompt should be in the list")
val argsDef = strictPrompt.arguments ?: error("Prompt arguments should not be null")
val requiredArgs = argsDef.filter { it.required == true }
assertEquals(
2,
requiredArgs.size,
"Strict prompt should have 2 required arguments",
)
// test missing required arg
val exception = assertThrows<McpException> {
runBlocking {
client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = strictPromptName,
arguments = mapOf("requiredArg1" to "value1"),
),
),
)
}
}
withClue("Exception should mention the missing argument") {
exception.message shouldContain "requiredArg2"
}
// test with no args
val exception2 = assertThrows<McpException> {
runBlocking {
client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = strictPromptName,
arguments = emptyMap(),
),
),
)
}
}
withClue("Exception should mention a missing required argument") {
exception2.message shouldContain "requiredArg"
}
// test with all required args
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = strictPromptName,
arguments = mapOf(
"requiredArg1" to "value1",
"requiredArg2" to "value2",
),
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
val userMessage = result.messages.find { it.role == Role.User }
assertNotNull(userMessage, "User message should be in the list")
val userContent = userMessage.content as? TextContent
assertNotNull(userContent, "User message content should be TextContent")
val userText = requireNotNull(userContent.text)
assertTrue(userText.contains("value1"), "Message should contain first argument")
assertTrue(userText.contains("value2"), "Message should contain second argument")
}
@Test
fun testMultipleMessagesAndRoles() = runBlocking(Dispatchers.IO) {
val topic = "climate change"
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = conversationPromptName,
arguments = mapOf("topic" to topic),
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(
conversationPromptDescription,
result.description,
"Prompt description should match",
)
assertTrue(result.messages.isNotEmpty(), "Prompt messages should not be empty")
assertEquals(6, result.messages.size, "Prompt should have 6 messages")
val userMessages = result.messages.filter { it.role == Role.User }
val assistantMessages = result.messages.filter { it.role == Role.Assistant }
assertEquals(3, userMessages.size, "Should have 3 user messages")
assertEquals(3, assistantMessages.size, "Should have 3 assistant messages")
for (i in 0 until result.messages.size) {
val expectedRole = if (i % 2 == 0) Role.User else Role.Assistant
assertEquals(
expectedRole,
result.messages[i].role,
"Message $i should have role $expectedRole",
)
}
for (message in result.messages) {
val content = message.content as? TextContent
assertNotNull(content, "Message content should be TextContent")
val text = requireNotNull(content.text)
// Either the message contains the topic or it's a generic conversation message
val containsTopic = text.contains(topic)
val isGenericMessage = text.contains("thank you") || text.contains("welcome")
assertTrue(
containsTopic || isGenericMessage,
"Message should either contain the topic or be a generic conversation message",
)
}
}
@Test
fun testBasicPrompt() = runBlocking(Dispatchers.IO) {
val testName = "Alice"
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = basicPromptName,
arguments = mapOf("name" to testName),
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(basicPromptDescription, result.description, "Prompt description should match")
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
val userMessage = result.messages.find { it.role == Role.User }
assertNotNull(userMessage, "User message should be in the list")
val userContent = userMessage.content as? TextContent
assertNotNull(userContent, "User message content should be TextContent")
assertEquals("Hello, $testName!", userContent.text, "User message content should match")
val assistantMessage = result.messages.find { it.role == Role.Assistant }
assertNotNull(assistantMessage, "Assistant message should be in the list")
val assistantContent = assistantMessage.content as? TextContent
assertNotNull(assistantContent, "Assistant message content should be TextContent")
assertEquals(
"Greetings, $testName! How can I assist you today?",
assistantContent.text,
"Assistant message content should match",
)
}
@Test
fun testComplexPromptWithManyArguments() = runBlocking(Dispatchers.IO) {
val arguments = (1..10).associate { i -> "arg$i" to "value$i" }
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = complexPromptName,
arguments = arguments,
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(complexPromptDescription, result.description, "Prompt description should match")
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
val userMessage = result.messages.find { it.role == Role.User }
assertNotNull(userMessage, "User message should be in the list")
val userContent = userMessage.content as? TextContent
assertNotNull(userContent, "User message content should be TextContent")
// verify all arguments
val text = userContent.text
for (i in 1..10) {
assertTrue(text.contains("arg$i=value$i"), "Message should contain arg$i=value$i")
}
val assistantMessage = result.messages.find { it.role == Role.Assistant }
assertNotNull(assistantMessage, "Assistant message should be in the list")
val assistantContent = assistantMessage.content as? TextContent
assertNotNull(assistantContent, "Assistant message content should be TextContent")
assertEquals(
"Received 10 arguments",
assistantContent.text,
"Assistant message should indicate 10 arguments",
)
}
@Test
fun testLargePrompt() = runBlocking(Dispatchers.IO) {
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = largePromptName,
arguments = mapOf("size" to "1"),
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(largePromptDescription, result.description, "Prompt description should match")
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
val assistantMessage = result.messages.find { it.role == Role.Assistant }
assertNotNull(assistantMessage, "Assistant message should be in the list")
val assistantContent = assistantMessage.content as? TextContent
assertNotNull(assistantContent, "Assistant message content should be TextContent")
val text = assistantContent.text
assertEquals(100_000, text.length, "Assistant message should be 100KB in size")
}
@Test
fun testSpecialCharacters() = runBlocking(Dispatchers.IO) {
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = specialCharsPromptName,
arguments = mapOf("special" to specialCharsContent),
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(specialCharsPromptDescription, result.description, "Prompt description should match")
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
val userMessage = result.messages.find { it.role == Role.User }
assertNotNull(userMessage, "User message should be in the list")
val userContent = userMessage.content as? TextContent
assertNotNull(userContent, "User message content should be TextContent")
val userText = userContent.text
assertTrue(userText.contains(specialCharsContent), "User message should contain special characters")
val assistantMessage = result.messages.find { it.role == Role.Assistant }
assertNotNull(assistantMessage, "Assistant message should be in the list")
val assistantContent = assistantMessage.content as? TextContent
assertNotNull(assistantContent, "Assistant message content should be TextContent")
val assistantText = assistantContent.text
assertTrue(
assistantText.contains(specialCharsContent),
"Assistant message should contain special characters",
)
}
@Test
fun testConcurrentPromptRequests() = runTest {
val concurrentCount = 10
val results = mutableListOf<GetPromptResult?>()
runBlocking {
repeat(concurrentCount) { index ->
launch {
val promptName = when (index % 4) {
0 -> basicPromptName
1 -> complexPromptName
2 -> largePromptName
else -> specialCharsPromptName
}
val arguments = when (promptName) {
basicPromptName -> mapOf("name" to "User$index")
complexPromptName -> mapOf("arg1" to "v1", "arg2" to "v2", "arg3" to "v3")
largePromptName -> mapOf("size" to "1")
else -> mapOf("special" to "!@#$%^&*()")
}
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = promptName,
arguments = arguments,
),
),
)
synchronized(results) {
results.add(result)
}
}
}
}
assertEquals(concurrentCount, results.size, "All concurrent operations should complete")
results.forEach { result ->
assertNotNull(result, "Result should not be null")
assertTrue(result.messages.isNotEmpty(), "Result messages should not be empty")
}
}
@Test
fun testNonExistentPrompt() = runTest {
val nonExistentPromptName = "non-existent-prompt"
val exception = assertThrows<McpException> {
runBlocking {
client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = nonExistentPromptName,
arguments = mapOf("name" to "Test"),
),
),
)
}
}
val expectedMessage = "MCP error -32603: Prompt not found: non-existent-prompt"
assertEquals(
RPCError.ErrorCode.INTERNAL_ERROR,
exception.code,
"Exception code should be INTERNAL_ERROR: ${RPCError.ErrorCode.INTERNAL_ERROR}",
)
assertEquals(expectedMessage, exception.message, "Unexpected error message for non-existent prompt")
}
}

View file

@ -0,0 +1,317 @@
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
import io.modelcontextprotocol.kotlin.sdk.types.BlobResourceContents
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest
import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequestParams
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.Ignore
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class AbstractResourceIntegrationTest : KotlinTestBase() {
private val testResourceUri = "test://example.txt"
private val testResourceName = "Test Resource"
private val testResourceDescription = "A test resource for integration testing"
private val testResourceContent = "This is the content of the test resource."
private val binaryResourceUri = "test://image.png"
private val binaryResourceName = "Binary Resource"
private val binaryResourceDescription = "A binary resource for testing"
private val binaryResourceContent =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
private val largeResourceUri = "test://large.txt"
private val largeResourceName = "Large Resource"
private val largeResourceDescription = "A large text resource for testing"
private val largeResourceContent = "X".repeat(100_000) // 100KB of data
private val dynamicResourceUri = "test://dynamic.txt"
private val dynamicResourceName = "Dynamic Resource"
private val dynamicResourceContent = AtomicBoolean(false)
override fun configureServerCapabilities(): ServerCapabilities = ServerCapabilities(
resources = ServerCapabilities.Resources(
subscribe = true,
listChanged = true,
),
)
override fun configureServer() {
server.addResource(
uri = testResourceUri,
name = testResourceName,
description = testResourceDescription,
mimeType = "text/plain",
) { request ->
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = testResourceContent,
uri = request.params.uri,
mimeType = "text/plain",
),
),
)
}
server.addResource(
uri = testResourceUri,
name = testResourceName,
description = testResourceDescription,
mimeType = "text/plain",
) { request ->
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = testResourceContent,
uri = request.params.uri,
mimeType = "text/plain",
),
),
)
}
server.addResource(
uri = binaryResourceUri,
name = binaryResourceName,
description = binaryResourceDescription,
mimeType = "image/png",
) { request ->
ReadResourceResult(
contents = listOf(
BlobResourceContents(
blob = binaryResourceContent,
uri = request.params.uri,
mimeType = "image/png",
),
),
)
}
server.addResource(
uri = largeResourceUri,
name = largeResourceName,
description = largeResourceDescription,
mimeType = "text/plain",
) { request ->
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = largeResourceContent,
uri = request.params.uri,
mimeType = "text/plain",
),
),
)
}
server.addResource(
uri = dynamicResourceUri,
name = dynamicResourceName,
description = "A resource that can be updated",
mimeType = "text/plain",
) { request ->
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = if (dynamicResourceContent.get()) "Updated content" else "Original content",
uri = request.params.uri,
mimeType = "text/plain",
),
),
)
}
}
@Test
fun testListResources() = runBlocking(Dispatchers.IO) {
val result = client.listResources()
assertNotNull(result, "List resources result should not be null")
assertTrue(result.resources.isNotEmpty(), "Resources list should not be empty")
val testResource = result.resources.find { it.uri == testResourceUri }
assertNotNull(testResource, "Test resource should be in the list")
assertEquals(testResourceName, testResource.name, "Resource name should match")
assertEquals(testResourceDescription, testResource.description, "Resource description should match")
}
@Test
fun testReadResource() = runBlocking(Dispatchers.IO) {
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = testResourceUri)))
assertNotNull(result, "Read resource result should not be null")
assertTrue(result.contents.isNotEmpty(), "Resource contents should not be empty")
val content = result.contents.firstOrNull() as? TextResourceContents
assertNotNull(content, "Resource content should be TextResourceContents")
assertEquals(testResourceContent, content.text, "Resource content should match")
}
@Ignore("Blocked by https://github.com/modelcontextprotocol/kotlin-sdk/issues/249")
@Test
fun testSubscribeAndUnsubscribe() {
runBlocking(Dispatchers.IO) {
val subscribeResult =
client.subscribeResource(SubscribeRequest(SubscribeRequestParams(uri = testResourceUri)))
assertNotNull(subscribeResult, "Subscribe result should not be null")
val unsubscribeResult =
client.unsubscribeResource(UnsubscribeRequest(UnsubscribeRequestParams(uri = testResourceUri)))
assertNotNull(unsubscribeResult, "Unsubscribe result should not be null")
}
}
@Test
fun testBinaryResource() = runBlocking(Dispatchers.IO) {
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = binaryResourceUri)))
assertNotNull(result, "Read resource result should not be null")
assertTrue(result.contents.isNotEmpty(), "Resource contents should not be empty")
val content = result.contents.firstOrNull() as? BlobResourceContents
assertNotNull(content, "Resource content should be BlobResourceContents")
assertEquals(binaryResourceContent, content.blob, "Binary resource content should match")
assertEquals("image/png", content.mimeType, "MIME type should match")
}
@Test
fun testLargeResource() = runBlocking(Dispatchers.IO) {
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = largeResourceUri)))
assertNotNull(result, "Read resource result should not be null")
assertTrue(result.contents.isNotEmpty(), "Resource contents should not be empty")
val content = result.contents.firstOrNull() as? TextResourceContents
assertNotNull(content, "Resource content should be TextResourceContents")
assertEquals(100_000, content.text.length, "Large resource content length should match")
assertEquals("X".repeat(100_000), content.text, "Large resource content should match")
}
@Test
fun testInvalidResourceUri() = runTest {
val invalidUri = "test://nonexistent.txt"
val exception = assertThrows<McpException> {
runBlocking {
client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = invalidUri)))
}
}
val expectedMessage = "MCP error -32603: Resource not found: test://nonexistent.txt"
assertEquals(
RPCError.ErrorCode.INTERNAL_ERROR,
exception.code,
"Exception code should be INTERNAL_ERROR: ${RPCError.ErrorCode.INTERNAL_ERROR}",
)
assertEquals(expectedMessage, exception.message, "Unexpected error message for invalid resource URI")
}
@Test
fun testDynamicResource() = runBlocking(Dispatchers.IO) {
val initialResult =
client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = dynamicResourceUri)))
assertNotNull(initialResult, "Initial read result should not be null")
val initialContent = (initialResult.contents.firstOrNull() as? TextResourceContents)?.text
assertEquals("Original content", initialContent, "Initial content should match")
// update resource
dynamicResourceContent.set(true)
val updatedResult =
client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = dynamicResourceUri)))
assertNotNull(updatedResult, "Updated read result should not be null")
val updatedContent = (updatedResult.contents.firstOrNull() as? TextResourceContents)?.text
assertEquals("Updated content", updatedContent, "Updated content should match")
}
@Test
fun testResourceAddAndRemove() = runBlocking(Dispatchers.IO) {
val initialList = client.listResources()
assertNotNull(initialList, "Initial list result should not be null")
val initialCount = initialList.resources.size
val newResourceUri = "test://new-resource.txt"
server.addResource(
uri = newResourceUri,
name = "New Resource",
description = "A newly added resource",
mimeType = "text/plain",
) { request ->
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = "New resource content",
uri = request.params.uri,
mimeType = "text/plain",
),
),
)
}
val updatedList = client.listResources()
assertNotNull(updatedList, "Updated list result should not be null")
val updatedCount = updatedList.resources.size
assertEquals(initialCount + 1, updatedCount, "Resource count should increase by 1")
val newResource = updatedList.resources.find { it.uri == newResourceUri }
assertNotNull(newResource, "New resource should be in the list")
server.removeResource(newResourceUri)
val finalList = client.listResources()
assertNotNull(finalList, "Final list result should not be null")
val finalCount = finalList.resources.size
assertEquals(initialCount, finalCount, "Resource count should return to initial value")
val removedResource = finalList.resources.find { it.uri == newResourceUri }
assertEquals(null, removedResource, "Resource should be removed from the list")
}
@Test
fun testConcurrentResourceOperations() = runTest {
val concurrentCount = 10
val results = mutableListOf<ReadResourceResult?>()
runBlocking {
repeat(concurrentCount) { index ->
launch {
val uri = when (index % 3) {
0 -> testResourceUri
1 -> binaryResourceUri
else -> largeResourceUri
}
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = uri)))
synchronized(results) {
results.add(result)
}
}
}
}
assertEquals(concurrentCount, results.size, "All concurrent operations should complete")
results.forEach { result ->
assertNotNull(result, "Result should not be null")
assertTrue(result.contents.isNotEmpty(), "Result contents should not be empty")
}
}
}

View file

@ -0,0 +1,794 @@
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
import io.kotest.assertions.json.shouldEqualJson
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.types.ContentBlock
import io.modelcontextprotocol.kotlin.sdk.types.ImageContent
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.jupiter.api.Test
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Locale
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class AbstractToolIntegrationTest : KotlinTestBase() {
private val testToolName = "echo"
private val testToolDescription = "A simple echo tool that returns the input text"
private val complexToolName = "calculator"
private val complexToolDescription = "A calculator tool that performs operations on numbers"
private val errorToolName = "error-tool"
private val errorToolDescription = "A tool that demonstrates error handling"
private val multiContentToolName = "multi-content"
private val multiContentToolDescription = "A tool that returns multiple content types"
private val basicToolName = "basic-tool"
private val basicToolDescription = "A basic tool for testing"
private val largeToolName = "large-tool"
private val largeToolDescription = "A tool that returns a large response"
private val largeToolContent = "X".repeat(100_000) // 100KB of data
private val slowToolName = "slow-tool"
private val slowToolDescription = "A tool that takes time to respond"
private val specialCharsToolName = "special-chars-tool"
private val specialCharsToolDescription = "A tool that handles special characters"
private val specialCharsContent = "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t"
override fun configureServerCapabilities(): ServerCapabilities = ServerCapabilities(
tools = ServerCapabilities.Tools(
listChanged = true,
),
)
override fun configureServer() {
setupEchoTool()
setupCalculatorTool()
setupErrorHandlingTool()
setupMultiContentTool()
}
private fun setupEchoTool() {
server.addTool(
name = testToolName,
description = testToolDescription,
inputSchema = ToolSchema(
properties = buildJsonObject {
put(
"text",
buildJsonObject {
put("type", "string")
put("description", "The text to echo back")
},
)
},
required = listOf("text"),
),
) { request ->
val text = (request.params.arguments?.get("text") as? JsonPrimitive)?.content ?: "No text provided"
CallToolResult(
content = listOf(TextContent(text = "Echo: $text")),
structuredContent = buildJsonObject {
put("result", text)
},
)
}
}
private fun setupCalculatorTool() {
server.addTool(
name = basicToolName,
description = basicToolDescription,
inputSchema = ToolSchema(
properties = buildJsonObject {
put(
"text",
buildJsonObject {
put("type", "string")
put("description", "The text to echo back")
},
)
},
required = listOf("text"),
),
) { request ->
val text = (request.params.arguments?.get("text") as? JsonPrimitive)?.content ?: "No text provided"
CallToolResult(
content = listOf(TextContent(text = "Echo: $text")),
structuredContent = buildJsonObject {
put("result", text)
},
)
}
server.addTool(
name = specialCharsToolName,
description = specialCharsToolDescription,
inputSchema = ToolSchema(
properties = buildJsonObject {
put(
"special",
buildJsonObject {
put("type", "string")
put("description", "Special characters to process")
},
)
},
),
) { request ->
val special = (request.params.arguments?.get("special") as? JsonPrimitive)?.content ?: specialCharsContent
CallToolResult(
content = listOf(TextContent(text = "Received special characters: $special")),
structuredContent = buildJsonObject {
put("special", special)
put("length", special.length)
},
)
}
server.addTool(
name = slowToolName,
description = slowToolDescription,
inputSchema = ToolSchema(
properties = buildJsonObject {
put(
"delay",
buildJsonObject {
put("type", "integer")
put("description", "Delay in milliseconds")
},
)
},
),
) { request ->
val delay = (request.params.arguments?.get("delay") as? JsonPrimitive)?.content?.toIntOrNull() ?: 1000
// simulate slow operation
runBlocking {
delay(delay.toLong())
}
CallToolResult(
content = listOf(TextContent(text = "Completed after ${delay}ms delay")),
structuredContent = buildJsonObject {
put("delay", delay)
},
)
}
server.addTool(
name = largeToolName,
description = largeToolDescription,
inputSchema = ToolSchema(
properties = buildJsonObject {
put(
"size",
buildJsonObject {
put("type", "integer")
put("description", "Size multiplier")
},
)
},
),
) { request ->
val size = (request.params.arguments?.get("size") as? JsonPrimitive)?.content?.toIntOrNull() ?: 1
val content = largeToolContent.take(largeToolContent.length.coerceAtMost(size * 1000))
CallToolResult(
content = listOf(TextContent(text = content)),
structuredContent = buildJsonObject {
put("size", content.length)
},
)
}
server.addTool(
name = complexToolName,
description = complexToolDescription,
inputSchema = ToolSchema(
properties = buildJsonObject {
put(
"operation",
buildJsonObject {
put("type", "string")
put("description", "The operation to perform (add, subtract, multiply, divide)")
put(
"enum",
buildJsonArray {
add("add")
add("subtract")
add("multiply")
add("divide")
},
)
},
)
put(
"a",
buildJsonObject {
put("type", "number")
put("description", "First operand")
},
)
put(
"b",
buildJsonObject {
put("type", "number")
put("description", "Second operand")
},
)
put(
"precision",
buildJsonObject {
put("type", "integer")
put("description", "Number of decimal places (optional)")
put("default", 2)
},
)
put(
"showSteps",
buildJsonObject {
put("type", "boolean")
put("description", "Whether to show calculation steps")
put("default", false)
},
)
put(
"tags",
buildJsonObject {
put("type", "array")
put("description", "Optional tags for the calculation")
put(
"items",
buildJsonObject {
put("type", "string")
},
)
},
)
},
required = listOf("operation", "a", "b"),
),
) { request ->
val operation = (request.params.arguments?.get("operation") as? JsonPrimitive)?.content ?: "add"
val a = (request.params.arguments?.get("a") as? JsonPrimitive)?.content?.toDoubleOrNull() ?: 0.0
val b = (request.params.arguments?.get("b") as? JsonPrimitive)?.content?.toDoubleOrNull() ?: 0.0
val precision = (request.params.arguments?.get("precision") as? JsonPrimitive)?.content?.toIntOrNull() ?: 2
val showSteps =
(request.params.arguments?.get("showSteps") as? JsonPrimitive)?.content?.toBoolean() ?: false
val tags = (request.params.arguments?.get("tags") as? JsonArray)?.mapNotNull {
(it as? JsonPrimitive)?.content
} ?: emptyList()
val result = when (operation) {
"add" -> a + b
"subtract" -> a - b
"multiply" -> a * b
"divide" -> if (b != 0.0) a / b else Double.POSITIVE_INFINITY
else -> 0.0
}
val pattern = if (precision > 0) "0." + "0".repeat(precision) else "0"
val symbols = DecimalFormatSymbols(Locale.US).apply { decimalSeparator = '.' }
val df = DecimalFormat(pattern, symbols).apply { isGroupingUsed = false }
val formattedResult = df.format(result)
val textContent = if (showSteps) {
"Operation: $operation\nA: $a\nB: $b\nResult: $formattedResult\nTags: ${
tags.joinToString(", ")
}"
} else {
"Result: $formattedResult"
}
CallToolResult(
content = listOf(TextContent(text = textContent)),
structuredContent = buildJsonObject {
put("operation", operation)
put("a", a)
put("b", b)
put("result", result)
put("formattedResult", formattedResult)
put("precision", precision)
put("tags", buildJsonArray { tags.forEach { add(it) } })
},
)
}
}
private fun setupErrorHandlingTool() {
server.addTool(
name = errorToolName,
description = errorToolDescription,
inputSchema = ToolSchema(
properties = buildJsonObject {
put(
"errorType",
buildJsonObject {
put("type", "string")
put("description", "Type of error to simulate (none, exception, error)")
put(
"enum",
buildJsonArray {
add("none")
add("exception")
add("error")
},
)
},
)
put(
"message",
buildJsonObject {
put("type", "string")
put("description", "Custom error message")
put("default", "An error occurred")
},
)
},
required = listOf("errorType"),
),
) { request ->
val errorType = (request.params.arguments?.get("errorType") as? JsonPrimitive)?.content ?: "none"
val message = (request.params.arguments?.get("message") as? JsonPrimitive)?.content ?: "An error occurred"
when (errorType) {
"exception" -> throw IllegalArgumentException(message)
"error" -> CallToolResult(
content = listOf(TextContent(text = "Error: $message")),
structuredContent = buildJsonObject {
put("error", true)
put("message", message)
},
)
else -> CallToolResult(
content = listOf(TextContent(text = "No error occurred")),
structuredContent = buildJsonObject {
put("error", false)
put("message", "Success")
},
)
}
}
}
private fun setupMultiContentTool() {
server.addTool(
name = multiContentToolName,
description = multiContentToolDescription,
inputSchema = ToolSchema(
properties = buildJsonObject {
put(
"text",
buildJsonObject {
put("type", "string")
put("description", "Text to include in the response")
},
)
put(
"includeImage",
buildJsonObject {
put("type", "boolean")
put("description", "Whether to include an image in the response")
put("default", true)
},
)
},
required = listOf("text"),
),
) { request ->
val text = (request.params.arguments?.get("text") as? JsonPrimitive)?.content ?: "Default text"
val includeImage =
(request.params.arguments?.get("includeImage") as? JsonPrimitive)?.content?.toBoolean() ?: true
val content = mutableListOf<ContentBlock>(
TextContent(text = "Text content: $text"),
)
if (includeImage) {
content.add(
ImageContent(
data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
mimeType = "image/png",
),
)
}
CallToolResult(
content = content,
structuredContent = buildJsonObject {
put("text", text)
put("includeImage", includeImage)
},
)
}
}
@Test
fun testListTools(): Unit = runBlocking(Dispatchers.IO) {
val result = client.listTools()
assertNotNull(result, "List utils result should not be null")
assertTrue(result.tools.isNotEmpty(), "Tools list should not be empty")
val testTool = result.tools.find { it.name == testToolName }
assertNotNull(testTool, "Test tool should be in the list")
assertEquals(
testToolDescription,
testTool.description,
"Tool description should match",
)
}
@Test
fun testCallTool(): Unit = runBlocking(Dispatchers.IO) {
val testText = "Hello, world!"
val arguments = mapOf("text" to testText)
val result = client.callTool(testToolName, arguments)
val actualContent = result.structuredContent.toString()
val expectedContent = """
{"result":"Hello, world!"}
""".trimIndent()
actualContent shouldEqualJson expectedContent
}
@Test
fun testComplexInputSchemaTool(): Unit = runBlocking(Dispatchers.IO) {
val toolsList = client.listTools()
assertNotNull(toolsList, "Tools list should not be null")
val calculatorTool = toolsList.tools.find { it.name == complexToolName }
assertNotNull(calculatorTool, "Calculator tool should be in the list")
val arguments = mapOf(
"operation" to "multiply",
"a" to 5.5,
"b" to 2.0,
"precision" to 3,
"showSteps" to true,
"tags" to listOf("test", "calculator", "integration"),
)
val result = client.callTool(complexToolName, arguments)
val actualContent = result.structuredContent.toString()
val expectedContent = """
{
"operation" : "multiply",
"a" : 5.5,
"b" : 2.0,
"result" : 11.0,
"formattedResult" : "11.000",
"precision" : 3,
"tags" : ["test", "calculator", "integration"]
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
}
@Test
fun testToolErrorHandling(): Unit = runBlocking(Dispatchers.IO) {
val successArgs = mapOf("errorType" to "none")
val successResult = client.callTool(errorToolName, successArgs)
val actualContent = successResult.structuredContent.toString()
val expectedContent = """
{
"error" : false,
"message" : "Success"
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
val errorArgs = mapOf(
"errorType" to "error",
"message" to "Custom error message",
)
val errorResult = client.callTool(errorToolName, errorArgs)
val actualError = errorResult.structuredContent.toString()
val expectedError = """
{
"error" : true,
"message" : "Custom error message"
}
""".trimIndent()
actualError shouldEqualJson expectedError
val exceptionArgs = mapOf(
"errorType" to "exception",
"message" to "My exception message",
)
val exceptionResult = client.callTool(errorToolName, exceptionArgs)
assertTrue(exceptionResult.isError ?: false, "isError should be true for exception")
val exceptionContent = exceptionResult.content.firstOrNull { it is TextContent } as? TextContent
assertNotNull(exceptionContent, "Error content should be present in the result")
val exceptionText = exceptionContent.text
assertTrue(
exceptionText.contains("Error executing tool") && exceptionText.contains("My exception message"),
"Error message should contain the exception details",
)
}
@Test
fun testMultiContentTool(): Unit = runBlocking(Dispatchers.IO) {
val testText = "Test multi-content"
val arguments = mapOf(
"text" to testText,
"includeImage" to true,
)
val result = client.callTool(multiContentToolName, arguments)
assertEquals(
2,
result.content.size,
"Tool result should have 2 content items",
)
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
assertNotNull(textContent, "Result should contain TextContent")
assertNotNull(textContent.text, "Text content should not be null")
assertEquals(
"Text content: $testText",
textContent.text,
"Text content should match",
)
val imageContent = result.content.firstOrNull { it is ImageContent } as? ImageContent
assertNotNull(imageContent, "Result should contain ImageContent")
assertEquals("image/png", imageContent.mimeType, "Image MIME type should match")
assertTrue(imageContent.data.isNotEmpty(), "Image data should not be empty")
val actualContent = result.structuredContent.toString()
val expectedContent = """
{
"text" : "Test multi-content",
"includeImage" : true
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
val textOnlyArgs = mapOf(
"text" to testText,
"includeImage" to false,
)
val textOnlyResult = client.callTool(multiContentToolName, textOnlyArgs)
assertEquals(
1,
textOnlyResult.content.size,
"Text-only result should have 1 content item",
)
}
@Test
fun testComplexNestedSchema(): Unit = runBlocking(Dispatchers.IO) {
val userJson = buildJsonObject {
put("name", JsonPrimitive("John Galt"))
put("age", JsonPrimitive(30))
put(
"address",
buildJsonObject {
put("street", JsonPrimitive("123 Main St"))
put("city", JsonPrimitive("New York"))
put("country", JsonPrimitive("USA"))
},
)
}
val optionsJson = buildJsonArray {
add(JsonPrimitive("option1"))
add(JsonPrimitive("option2"))
add(JsonPrimitive("option3"))
}
val arguments = buildJsonObject {
put("user", userJson)
put("options", optionsJson)
}
val result = client.callTool(
CallToolRequest(
CallToolRequestParams(
name = complexToolName,
arguments = arguments,
),
),
)
val actualContent = result.structuredContent.toString()
val expectedContent = """
{
"operation": "add",
"a": 0.0,
"b": 0.0,
"result": 0.0,
"formattedResult": "0.00",
"precision": 2,
"tags": []
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
}
@Test
fun testLargeResponse(): Unit = runBlocking(Dispatchers.IO) {
val size = 10
val arguments = mapOf("size" to size)
val result = client.callTool(largeToolName, arguments)
val content = result.content.firstOrNull() as TextContent
assertNotNull(content, "Tool result content should be TextContent")
val actualContent = result.structuredContent.toString()
val expectedContent = """
{
"size" : 10000
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
}
@Test
fun testSlowTool(): Unit = runBlocking(Dispatchers.IO) {
val delay = 500
val arguments = mapOf("delay" to delay)
val startTime = System.currentTimeMillis()
val result = client.callTool(slowToolName, arguments)
val endTime = System.currentTimeMillis()
val content = result.content.firstOrNull() as? TextContent
assertNotNull(content, "Tool result content should be TextContent")
assertTrue(endTime - startTime >= delay, "Tool should take at least the specified delay")
val actualContent = result.structuredContent.toString()
val expectedContent = """
{
"delay" : 500
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
}
@Test
fun testSpecialCharacters() {
runBlocking(Dispatchers.IO) {
val arguments = mapOf("special" to specialCharsContent)
val result = client.callTool(specialCharsToolName, arguments)
val content = result.content.firstOrNull() as? TextContent
assertNotNull(content, "Tool result content should be TextContent")
val text = content.text
assertTrue(text.contains(specialCharsContent), "Result should contain the special characters")
val actualContent = result.structuredContent.toString()
val expectedContent = """
{
"special" : "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t",
"length" : 34
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
}
}
@Test
fun testConcurrentToolCalls() = runTest {
val concurrentCount = 10
val results = mutableListOf<CallToolResult?>()
runBlocking {
repeat(concurrentCount) { index ->
launch {
val toolName = when (index % 5) {
0 -> basicToolName
1 -> complexToolName
2 -> largeToolName
3 -> slowToolName
else -> specialCharsToolName
}
val arguments = when (toolName) {
basicToolName -> mapOf("text" to "Concurrent call $index")
complexToolName -> mapOf(
"user" to mapOf(
"name" to "User $index",
"age" to 20 + index,
"address" to mapOf(
"street" to "Street $index",
"city" to "City $index",
"country" to "Country $index",
),
),
)
largeToolName -> mapOf("size" to 1)
slowToolName -> mapOf("delay" to 100)
else -> mapOf("special" to "!@#$%^&*()")
}
val result = client.callTool(toolName, arguments)
synchronized(results) {
results.add(result)
}
}
}
}
assertEquals(concurrentCount, results.size, "All concurrent operations should complete")
results.forEach { result ->
assertNotNull(result, "Result should not be null")
assertTrue(result.content.isNotEmpty(), "Result content should not be empty")
}
}
@Test
fun testNonExistentTool() = runTest {
val nonExistentToolName = "non-existent-tool"
val arguments = mapOf("text" to "Test")
val result = runBlocking {
client.callTool(nonExistentToolName, arguments)
}
assertNotNull(result, "Tool call result should not be null")
assertTrue(result.isError ?: false, "isError should be true for non-existent tool")
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
assertNotNull(textContent, "Error content should be present in the result")
val errorText = textContent.text
assertTrue(
errorText.contains("non-existent-tool") && errorText.contains("not found"),
"Error message should indicate the tool was not found",
)
}
}

View file

@ -0,0 +1,185 @@
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.sse.SSE
import io.ktor.server.application.install
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
import io.modelcontextprotocol.kotlin.sdk.server.mcp
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.test.utils.Retry
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.io.Sink
import kotlinx.io.Source
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered
import org.awaitility.kotlin.await
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import java.io.PipedInputStream
import java.io.PipedOutputStream
import kotlin.time.Duration.Companion.seconds
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.sse.SSE as ServerSSE
@Retry(times = 3)
abstract class KotlinTestBase {
protected val host = "localhost"
protected var port: Int = 0
protected lateinit var server: Server
protected lateinit var client: Client
protected lateinit var serverEngine: EmbeddedServer<*, *>
// Transport selection
protected enum class TransportKind { SSE, STDIO }
protected open val transportKind: TransportKind = TransportKind.STDIO
// STDIO-specific fields
private var stdioServerTransport: StdioServerTransport? = null
private var stdioClientInput: Source? = null
private var stdioClientOutput: Sink? = null
protected abstract fun configureServerCapabilities(): ServerCapabilities
protected abstract fun configureServer()
@BeforeEach
fun setUp() {
setupServer()
if (transportKind == TransportKind.SSE) {
await
.ignoreExceptions()
.until {
port = runBlocking { serverEngine.engine.resolvedConnectors().first().port }
port != 0
}
}
runBlocking {
setupClient()
}
}
protected suspend fun setupClient() {
when (transportKind) {
TransportKind.SSE -> {
val transport = SseClientTransport(
HttpClient(CIO) {
install(SSE)
},
"http://$host:$port",
)
client = Client(
Implementation("test", "1.0"),
)
client.connect(transport)
}
TransportKind.STDIO -> {
val input = checkNotNull(stdioClientInput) { "STDIO client input not initialized" }
val output = checkNotNull(stdioClientOutput) { "STDIO client output not initialized" }
val transport = StdioClientTransport(
input = input,
output = output,
)
client = Client(
Implementation("test", "1.0"),
)
client.connect(transport)
}
}
}
protected fun setupServer() {
val capabilities = configureServerCapabilities()
server = Server(
Implementation(name = "test-server", version = "1.0"),
ServerOptions(capabilities = capabilities),
)
configureServer()
if (transportKind == TransportKind.SSE) {
serverEngine = embeddedServer(ServerCIO, host = host, port = port) {
install(ServerSSE)
routing {
mcp { server }
}
}.start(wait = false)
} else {
// Create in-memory stdio pipes: client->server and server->client
val clientToServerOut = PipedOutputStream()
val clientToServerIn = PipedInputStream(clientToServerOut)
val serverToClientOut = PipedOutputStream()
val serverToClientIn = PipedInputStream(serverToClientOut)
// Server transport reads from client and writes to client
val serverTransport = StdioServerTransport(
inputStream = clientToServerIn.asSource().buffered(),
outputStream = serverToClientOut.asSink().buffered(),
)
stdioServerTransport = serverTransport
// Prepare client-side streams for later client initialization
stdioClientInput = serverToClientIn.asSource().buffered()
stdioClientOutput = clientToServerOut.asSink().buffered()
// Start server transport by connecting the server
runBlocking {
server.createSession(serverTransport)
}
}
}
@AfterEach
fun tearDown() {
// close client
if (::client.isInitialized) {
try {
runBlocking {
withTimeout(3.seconds) {
client.close()
}
}
} catch (e: Exception) {
println("Warning: Error during client close: ${e.message}")
}
}
// stop server
if (transportKind == TransportKind.SSE) {
if (::serverEngine.isInitialized) {
try {
serverEngine.stop(500, 1000)
} catch (e: Exception) {
println("Warning: Error during server stop: ${e.message}")
}
}
} else {
stdioServerTransport?.let {
try {
runBlocking { it.close() }
} catch (e: Exception) {
println("Warning: Error during stdio server stop: ${e.message}")
} finally {
stdioServerTransport = null
stdioClientInput = null
stdioClientOutput = null
}
}
}
}
}

View file

@ -0,0 +1,698 @@
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
import io.modelcontextprotocol.kotlin.sdk.PromptArgument
import io.modelcontextprotocol.kotlin.sdk.PromptMessage
import io.modelcontextprotocol.kotlin.sdk.Role
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class OldSchemaAbstractPromptIntegrationTest : OldSchemaKotlinTestBase() {
private val basicPromptName = "basic-prompt"
private val basicPromptDescription = "A basic prompt for testing"
private val complexPromptName = "multimodal-prompt"
private val complexPromptDescription = "A prompt with multiple content types"
private val conversationPromptName = "conversation"
private val conversationPromptDescription = "A prompt with multiple messages and roles"
private val strictPromptName = "strict-prompt"
private val strictPromptDescription = "A prompt with required arguments"
private val largePromptName = "large-prompt"
private val largePromptDescription = "A very large prompt for testing"
private val largePromptContent = "X".repeat(100_000) // 100KB of data
private val specialCharsPromptName = "special-chars-prompt"
private val specialCharsPromptDescription = "A prompt with special characters"
private val specialCharsContent = "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t"
override fun configureServerCapabilities(): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities =
ServerCapabilities(
prompts = ServerCapabilities.Prompts(
listChanged = true,
),
)
override fun configureServer() {
// basic prompt with a name parameter
server.addPrompt(
name = basicPromptName,
description = basicPromptDescription,
arguments = listOf(
PromptArgument(
name = "name",
description = "The name to greet",
required = true,
),
),
) { request ->
val name = request.params.arguments?.get("name") ?: "World"
GetPromptResult(
description = basicPromptDescription,
messages = listOf(
PromptMessage(
role = Role.user,
content = TextContent(text = "Hello, $name!"),
),
PromptMessage(
role = Role.assistant,
content = TextContent(text = "Greetings, $name! How can I assist you today?"),
),
),
)
}
// special chars prompt
server.addPrompt(
name = specialCharsPromptName,
description = specialCharsPromptDescription,
arguments = listOf(
PromptArgument(
name = "special",
description = "Special characters to include",
required = false,
),
),
) { request ->
val special = request.params.arguments?.get("special") ?: specialCharsContent
GetPromptResult(
description = specialCharsPromptDescription,
messages = listOf(
PromptMessage(
role = Role.user,
content = TextContent(text = "Special characters: $special"),
),
PromptMessage(
role = Role.assistant,
content = TextContent(text = "Received special characters: $special"),
),
),
)
}
// very large prompt
server.addPrompt(
name = largePromptName,
description = largePromptDescription,
arguments = listOf(
PromptArgument(
name = "size",
description = "Size multiplier",
required = false,
),
),
) { request ->
val size = request.params.arguments?.get("size")?.toIntOrNull() ?: 1
val content = largePromptContent.repeat(size)
GetPromptResult(
description = largePromptDescription,
messages = listOf(
PromptMessage(
role = Role.user,
content = TextContent(text = "Generate a large response"),
),
PromptMessage(
role = Role.assistant,
content = TextContent(text = content),
),
),
)
}
// complext prompt
server.addPrompt(
name = complexPromptName,
description = complexPromptDescription,
arguments = listOf(
PromptArgument(name = "arg1", description = "Argument 1", required = true),
PromptArgument(name = "arg2", description = "Argument 2", required = true),
PromptArgument(name = "arg3", description = "Argument 3", required = true),
PromptArgument(name = "arg4", description = "Argument 4", required = false),
PromptArgument(name = "arg5", description = "Argument 5", required = false),
PromptArgument(name = "arg6", description = "Argument 6", required = false),
PromptArgument(name = "arg7", description = "Argument 7", required = false),
PromptArgument(name = "arg8", description = "Argument 8", required = false),
PromptArgument(name = "arg9", description = "Argument 9", required = false),
PromptArgument(name = "arg10", description = "Argument 10", required = false),
),
) { request ->
// validate required arguments
val requiredArgs = listOf("arg1", "arg2", "arg3")
for (argName in requiredArgs) {
if (request.params.arguments?.get(argName) == null) {
throw IllegalArgumentException("Missing required argument: $argName")
}
}
val args = mutableMapOf<String, String>()
for (i in 1..10) {
val argName = "arg$i"
val argValue = request.params.arguments?.get(argName)
if (argValue != null) {
args[argName] = argValue
}
}
GetPromptResult(
description = complexPromptDescription,
messages = listOf(
PromptMessage(
role = Role.user,
content = TextContent(
text = "Arguments: ${
args.entries.joinToString {
"${it.key}=${it.value}"
}
}",
),
),
PromptMessage(
role = Role.assistant,
content = TextContent(text = "Received ${args.size} arguments"),
),
),
)
}
// prompt with multiple messages and roles
server.addPrompt(
name = conversationPromptName,
description = conversationPromptDescription,
arguments = listOf(
PromptArgument(
name = "topic",
description = "The topic of the conversation",
required = false,
),
),
) { request ->
val topic = request.params.arguments?.get("topic") ?: "weather"
GetPromptResult(
description = conversationPromptDescription,
messages = listOf(
PromptMessage(
role = Role.user,
content = TextContent(text = "Let's talk about the $topic."),
),
PromptMessage(
role = Role.assistant,
content = TextContent(
text = "Sure, I'd love to discuss the $topic. What would you like to know?",
),
),
PromptMessage(
role = Role.user,
content = TextContent(text = "What's your opinion on the $topic?"),
),
PromptMessage(
role = Role.assistant,
content = TextContent(
text = "As an AI, I don't have personal opinions," +
" but I can provide information about $topic.",
),
),
PromptMessage(
role = Role.user,
content = TextContent(text = "That's helpful, thank you!"),
),
PromptMessage(
role = Role.assistant,
content = TextContent(
text = "You're welcome! Let me know if you have more questions about $topic.",
),
),
),
)
}
// prompt with strict required arguments
server.addPrompt(
name = strictPromptName,
description = strictPromptDescription,
arguments = listOf(
PromptArgument(
name = "requiredArg1",
description = "First required argument",
required = true,
),
PromptArgument(
name = "requiredArg2",
description = "Second required argument",
required = true,
),
PromptArgument(
name = "optionalArg",
description = "Optional argument",
required = false,
),
),
) { request ->
val args = request.params.arguments ?: emptyMap()
val arg1 = args["requiredArg1"] ?: throw IllegalArgumentException(
"Missing required argument: requiredArg1",
)
val arg2 = args["requiredArg2"] ?: throw IllegalArgumentException(
"Missing required argument: requiredArg2",
)
val optArg = args["optionalArg"] ?: "default"
GetPromptResult(
description = strictPromptDescription,
messages = listOf(
PromptMessage(
role = Role.user,
content = TextContent(text = "Required arguments: $arg1, $arg2. Optional: $optArg"),
),
PromptMessage(
role = Role.assistant,
content = TextContent(text = "I received your arguments: $arg1, $arg2, and $optArg"),
),
),
)
}
}
@Test
fun testListPrompts() = runBlocking(Dispatchers.IO) {
val result = client.listPrompts()
assertNotNull(result, "List prompts result should not be null")
assertTrue(result.prompts.isNotEmpty(), "Prompts list should not be empty")
val testPrompt = result.prompts.find { it.name == basicPromptName }
assertNotNull(testPrompt, "Test prompt should be in the list")
assertEquals(
basicPromptDescription,
testPrompt.description,
"Prompt description should match",
)
val arguments = testPrompt.arguments ?: error("Prompt arguments should not be null")
assertTrue(arguments.isNotEmpty(), "Prompt arguments should not be empty")
val nameArg = arguments.find { it.name == "name" }
assertNotNull(nameArg, "Name argument should be in the list")
assertEquals(
"The name to greet",
nameArg.description,
"Argument description should match",
)
assertEquals(true, nameArg.required, "Argument required flag should match")
}
@Test
fun testGetPrompt() = runBlocking(Dispatchers.IO) {
val testName = "Alice"
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = basicPromptName,
arguments = mapOf("name" to testName),
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(
basicPromptDescription,
result.description,
"Prompt description should match",
)
assertTrue(result.messages.isNotEmpty(), "Prompt messages should not be empty")
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
val userMessage = result.messages.find { it.role == Role.user }
assertNotNull(userMessage, "User message should be in the list")
val userContent = userMessage.content as? TextContent
assertNotNull(userContent, "User message content should be TextContent")
assertNotNull(userContent.text, "User message text should not be null")
assertEquals(
"Hello, $testName!",
userContent.text,
"User message content should match",
)
val assistantMessage = result.messages.find { it.role == Role.assistant }
assertNotNull(assistantMessage, "Assistant message should be in the list")
val assistantContent = assistantMessage.content as? TextContent
assertNotNull(assistantContent, "Assistant message content should be TextContent")
assertNotNull(assistantContent.text, "Assistant message text should not be null")
assertEquals(
"Greetings, $testName! How can I assist you today?",
assistantContent.text,
"Assistant message content should match",
)
}
@Test
fun testMissingRequiredArguments() = runBlocking(Dispatchers.IO) {
val promptsList = client.listPrompts()
assertNotNull(promptsList, "Prompts list should not be null")
val strictPrompt = promptsList.prompts.find { it.name == strictPromptName }
assertNotNull(strictPrompt, "Strict prompt should be in the list")
val argsDef = strictPrompt.arguments ?: error("Prompt arguments should not be null")
val requiredArgs = argsDef.filter { it.required == true }
assertEquals(
2,
requiredArgs.size,
"Strict prompt should have 2 required arguments",
)
// test missing required arg
val exception = assertThrows<McpException> {
runBlocking {
client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = strictPromptName,
arguments = mapOf("requiredArg1" to "value1"),
),
),
)
}
}
assertEquals(
true,
exception.message?.contains("requiredArg2"),
"Exception should mention the missing argument",
)
// test with no args
val exception2 = assertThrows<McpException> {
runBlocking {
client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = strictPromptName,
arguments = emptyMap(),
),
),
)
}
}
assertEquals(
exception2.message?.contains("requiredArg"),
true,
"Exception should mention a missing required argument",
)
// test with all required args
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = strictPromptName,
arguments = mapOf(
"requiredArg1" to "value1",
"requiredArg2" to "value2",
),
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
val userMessage = result.messages.find { it.role == Role.user }
assertNotNull(userMessage, "User message should be in the list")
val userContent = userMessage.content as? TextContent
assertNotNull(userContent, "User message content should be TextContent")
val userText = requireNotNull(userContent.text)
assertTrue(userText.contains("value1"), "Message should contain first argument")
assertTrue(userText.contains("value2"), "Message should contain second argument")
}
@Test
fun testMultipleMessagesAndRoles() = runBlocking(Dispatchers.IO) {
val topic = "climate change"
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = conversationPromptName,
arguments = mapOf("topic" to topic),
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(
conversationPromptDescription,
result.description,
"Prompt description should match",
)
assertTrue(result.messages.isNotEmpty(), "Prompt messages should not be empty")
assertEquals(6, result.messages.size, "Prompt should have 6 messages")
val userMessages = result.messages.filter { it.role == Role.user }
val assistantMessages = result.messages.filter { it.role == Role.assistant }
assertEquals(3, userMessages.size, "Should have 3 user messages")
assertEquals(3, assistantMessages.size, "Should have 3 assistant messages")
for (i in 0 until result.messages.size) {
val expectedRole = if (i % 2 == 0) Role.user else Role.assistant
assertEquals(
expectedRole,
result.messages[i].role,
"Message $i should have role $expectedRole",
)
}
for (message in result.messages) {
val content = message.content as? TextContent
assertNotNull(content, "Message content should be TextContent")
val text = requireNotNull(content.text)
// Either the message contains the topic or it's a generic conversation message
val containsTopic = text.contains(topic)
val isGenericMessage = text.contains("thank you") || text.contains("welcome")
assertTrue(
containsTopic || isGenericMessage,
"Message should either contain the topic or be a generic conversation message",
)
}
}
@Test
fun testBasicPrompt() = runBlocking(Dispatchers.IO) {
val testName = "Alice"
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = basicPromptName,
arguments = mapOf("name" to testName),
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(basicPromptDescription, result.description, "Prompt description should match")
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
val userMessage = result.messages.find { it.role == Role.user }
assertNotNull(userMessage, "User message should be in the list")
val userContent = userMessage.content as? TextContent
assertNotNull(userContent, "User message content should be TextContent")
assertEquals("Hello, $testName!", userContent.text, "User message content should match")
val assistantMessage = result.messages.find { it.role == Role.assistant }
assertNotNull(assistantMessage, "Assistant message should be in the list")
val assistantContent = assistantMessage.content as? TextContent
assertNotNull(assistantContent, "Assistant message content should be TextContent")
assertEquals(
"Greetings, $testName! How can I assist you today?",
assistantContent.text,
"Assistant message content should match",
)
}
@Test
fun testComplexPromptWithManyArguments() = runBlocking(Dispatchers.IO) {
val arguments = (1..10).associate { i -> "arg$i" to "value$i" }
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = complexPromptName,
arguments = arguments,
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(complexPromptDescription, result.description, "Prompt description should match")
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
val userMessage = result.messages.find { it.role == Role.user }
assertNotNull(userMessage, "User message should be in the list")
val userContent = userMessage.content as? TextContent
assertNotNull(userContent, "User message content should be TextContent")
// verify all arguments
val text = userContent.text ?: ""
for (i in 1..10) {
assertTrue(text.contains("arg$i=value$i"), "Message should contain arg$i=value$i")
}
val assistantMessage = result.messages.find { it.role == Role.assistant }
assertNotNull(assistantMessage, "Assistant message should be in the list")
val assistantContent = assistantMessage.content as? TextContent
assertNotNull(assistantContent, "Assistant message content should be TextContent")
assertEquals(
"Received 10 arguments",
assistantContent.text,
"Assistant message should indicate 10 arguments",
)
}
@Test
fun testLargePrompt() = runBlocking(Dispatchers.IO) {
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = largePromptName,
arguments = mapOf("size" to "1"),
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(largePromptDescription, result.description, "Prompt description should match")
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
val assistantMessage = result.messages.find { it.role == Role.assistant }
assertNotNull(assistantMessage, "Assistant message should be in the list")
val assistantContent = assistantMessage.content as? TextContent
assertNotNull(assistantContent, "Assistant message content should be TextContent")
val text = assistantContent.text ?: ""
assertEquals(100_000, text.length, "Assistant message should be 100KB in size")
}
@Test
fun testSpecialCharacters() = runBlocking(Dispatchers.IO) {
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = specialCharsPromptName,
arguments = mapOf("special" to specialCharsContent),
),
),
)
assertNotNull(result, "Get prompt result should not be null")
assertEquals(specialCharsPromptDescription, result.description, "Prompt description should match")
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
val userMessage = result.messages.find { it.role == Role.user }
assertNotNull(userMessage, "User message should be in the list")
val userContent = userMessage.content as? TextContent
assertNotNull(userContent, "User message content should be TextContent")
val userText = userContent.text ?: ""
assertTrue(userText.contains(specialCharsContent), "User message should contain special characters")
val assistantMessage = result.messages.find { it.role == Role.assistant }
assertNotNull(assistantMessage, "Assistant message should be in the list")
val assistantContent = assistantMessage.content as? TextContent
assertNotNull(assistantContent, "Assistant message content should be TextContent")
val assistantText = assistantContent.text ?: ""
assertTrue(
assistantText.contains(specialCharsContent),
"Assistant message should contain special characters",
)
}
@Test
fun testConcurrentPromptRequests() = runTest {
val concurrentCount = 10
val results = mutableListOf<GetPromptResult?>()
runBlocking {
repeat(concurrentCount) { index ->
launch {
val promptName = when (index % 4) {
0 -> basicPromptName
1 -> complexPromptName
2 -> largePromptName
else -> specialCharsPromptName
}
val arguments = when (promptName) {
basicPromptName -> mapOf("name" to "User$index")
complexPromptName -> mapOf("arg1" to "v1", "arg2" to "v2", "arg3" to "v3")
largePromptName -> mapOf("size" to "1")
else -> mapOf("special" to "!@#$%^&*()")
}
val result = client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = promptName,
arguments = arguments,
),
),
)
synchronized(results) {
results.add(result)
}
}
}
}
assertEquals(concurrentCount, results.size, "All concurrent operations should complete")
results.forEach { result ->
assertNotNull(result, "Result should not be null")
assertTrue(result.messages.isNotEmpty(), "Result messages should not be empty")
}
}
@Test
fun testNonExistentPrompt() = runTest {
val nonExistentPromptName = "non-existent-prompt"
val exception = assertThrows<McpException> {
runBlocking {
client.getPrompt(
GetPromptRequest(
GetPromptRequestParams(
name = nonExistentPromptName,
arguments = mapOf("name" to "Test"),
),
),
)
}
}
val msg = exception.message ?: ""
val expectedMessage = "MCP error -32603: Prompt not found: non-existent-prompt"
assertEquals(expectedMessage, msg, "Unexpected error message for non-existent prompt")
}
}

View file

@ -0,0 +1,319 @@
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
import io.modelcontextprotocol.kotlin.sdk.BlobResourceContents
import io.modelcontextprotocol.kotlin.sdk.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.SubscribeRequest
import io.modelcontextprotocol.kotlin.sdk.TextResourceContents
import io.modelcontextprotocol.kotlin.sdk.UnsubscribeRequest
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequestParams
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertNull
import org.junit.jupiter.api.assertThrows
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.Ignore
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class OldSchemaAbstractResourceIntegrationTest : OldSchemaKotlinTestBase() {
private val testResourceUri = "test://example.txt"
private val testResourceName = "Test Resource"
private val testResourceDescription = "A test resource for integration testing"
private val testResourceContent = "This is the content of the test resource."
private val binaryResourceUri = "test://image.png"
private val binaryResourceName = "Binary Resource"
private val binaryResourceDescription = "A binary resource for testing"
private val binaryResourceContent =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
private val largeResourceUri = "test://large.txt"
private val largeResourceName = "Large Resource"
private val largeResourceDescription = "A large text resource for testing"
private val largeResourceContent = "X".repeat(100_000) // 100KB of data
private val dynamicResourceUri = "test://dynamic.txt"
private val dynamicResourceName = "Dynamic Resource"
private val dynamicResourceContent = AtomicBoolean(false)
override fun configureServerCapabilities(): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities =
ServerCapabilities(
resources = ServerCapabilities.Resources(
subscribe = true,
listChanged = true,
),
)
override fun configureServer() {
server.addResource(
uri = testResourceUri,
name = testResourceName,
description = testResourceDescription,
mimeType = "text/plain",
) { request ->
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = testResourceContent,
uri = request.params.uri,
mimeType = "text/plain",
),
),
)
}
server.addResource(
uri = testResourceUri,
name = testResourceName,
description = testResourceDescription,
mimeType = "text/plain",
) { request ->
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = testResourceContent,
uri = request.params.uri,
mimeType = "text/plain",
),
),
)
}
server.addResource(
uri = binaryResourceUri,
name = binaryResourceName,
description = binaryResourceDescription,
mimeType = "image/png",
) { request ->
ReadResourceResult(
contents = listOf(
BlobResourceContents(
blob = binaryResourceContent,
uri = request.params.uri,
mimeType = "image/png",
),
),
)
}
server.addResource(
uri = largeResourceUri,
name = largeResourceName,
description = largeResourceDescription,
mimeType = "text/plain",
) { request ->
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = largeResourceContent,
uri = request.params.uri,
mimeType = "text/plain",
),
),
)
}
server.addResource(
uri = dynamicResourceUri,
name = dynamicResourceName,
description = "A resource that can be updated",
mimeType = "text/plain",
) { request ->
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = if (dynamicResourceContent.get()) "Updated content" else "Original content",
uri = request.params.uri,
mimeType = "text/plain",
),
),
)
}
}
@Test
fun testListResources() = runBlocking(Dispatchers.IO) {
val result = client.listResources()
assertNotNull(result, "List resources result should not be null")
assertNull(actual = result.meta, "List resources meta should be null")
assertTrue(result.resources.isNotEmpty(), "Resources list should not be empty")
val testResource = result.resources.find { it.uri == testResourceUri }
assertNotNull(testResource, "Test resource should be in the list")
assertEquals(testResourceName, testResource.name, "Resource name should match")
assertEquals(testResourceDescription, testResource.description, "Resource description should match")
}
@Test
fun testReadResource() = runBlocking(Dispatchers.IO) {
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = testResourceUri)))
assertNotNull(result, "Read resource result should not be null")
assertNull(actual = result.meta, "Read resource result meta should be null")
assertTrue(result.contents.isNotEmpty(), "Resource contents should not be empty")
val content = result.contents.firstOrNull() as? TextResourceContents
assertNotNull(content, "Resource content should be TextResourceContents")
assertEquals(testResourceContent, content.text, "Resource content should match")
}
@Ignore("Blocked by https://github.com/modelcontextprotocol/kotlin-sdk/issues/249")
@Test
fun testSubscribeAndUnsubscribe() {
runBlocking(Dispatchers.IO) {
val subscribeResult =
client.subscribeResource(SubscribeRequest(SubscribeRequestParams(uri = testResourceUri)))
assertNotNull(subscribeResult, "Subscribe result should not be null")
val unsubscribeResult =
client.unsubscribeResource(UnsubscribeRequest(UnsubscribeRequestParams(uri = testResourceUri)))
assertNotNull(unsubscribeResult, "Unsubscribe result should not be null")
}
}
@Test
fun testBinaryResource() = runBlocking(Dispatchers.IO) {
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = binaryResourceUri)))
assertNotNull(result, "Read resource result should not be null")
assertTrue(result.contents.isNotEmpty(), "Resource contents should not be empty")
val content = result.contents.firstOrNull() as? BlobResourceContents
assertNotNull(content, "Resource content should be BlobResourceContents")
assertEquals(binaryResourceContent, content.blob, "Binary resource content should match")
assertEquals("image/png", content.mimeType, "MIME type should match")
}
@Test
fun testLargeResource() = runBlocking(Dispatchers.IO) {
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = largeResourceUri)))
assertNotNull(result, "Read resource result should not be null")
assertTrue(result.contents.isNotEmpty(), "Resource contents should not be empty")
val content = result.contents.firstOrNull() as? TextResourceContents
assertNotNull(content, "Resource content should be TextResourceContents")
assertEquals(100_000, content.text.length, "Large resource content length should match")
assertEquals("X".repeat(100_000), content.text, "Large resource content should match")
}
@Test
fun testInvalidResourceUri() = runTest {
val invalidUri = "test://nonexistent.txt"
val exception = assertThrows<McpException> {
runBlocking {
client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = invalidUri)))
}
}
val msg = exception.message ?: ""
val expectedMessage =
"MCP error -32603: Resource not found: test://nonexistent.txt"
assertEquals(expectedMessage, msg, "Unexpected error message for invalid resource URI")
}
@Test
fun testDynamicResource() = runBlocking(Dispatchers.IO) {
val initialResult = client.readResource(
ReadResourceRequest(ReadResourceRequestParams(uri = dynamicResourceUri)),
)
assertNotNull(initialResult, "Initial read result should not be null")
val initialContent = (initialResult.contents.firstOrNull() as? TextResourceContents)?.text
assertEquals("Original content", initialContent, "Initial content should match")
// update resource
dynamicResourceContent.set(true)
val updatedResult = client.readResource(
ReadResourceRequest(ReadResourceRequestParams(uri = dynamicResourceUri)),
)
assertNotNull(updatedResult, "Updated read result should not be null")
val updatedContent = (updatedResult.contents.firstOrNull() as? TextResourceContents)?.text
assertEquals("Updated content", updatedContent, "Updated content should match")
}
@Test
fun testResourceAddAndRemove() = runBlocking(Dispatchers.IO) {
val initialList = client.listResources()
assertNotNull(initialList, "Initial list result should not be null")
val initialCount = initialList.resources.size
val newResourceUri = "test://new-resource.txt"
server.addResource(
uri = newResourceUri,
name = "New Resource",
description = "A newly added resource",
mimeType = "text/plain",
) { request ->
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = "New resource content",
uri = request.params.uri,
mimeType = "text/plain",
),
),
)
}
val updatedList = client.listResources()
assertNotNull(updatedList, "Updated list result should not be null")
val updatedCount = updatedList.resources.size
assertEquals(initialCount + 1, updatedCount, "Resource count should increase by 1")
val newResource = updatedList.resources.find { it.uri == newResourceUri }
assertNotNull(newResource, "New resource should be in the list")
server.removeResource(newResourceUri)
val finalList = client.listResources()
assertNotNull(finalList, "Final list result should not be null")
val finalCount = finalList.resources.size
assertEquals(initialCount, finalCount, "Resource count should return to initial value")
val removedResource = finalList.resources.find { it.uri == newResourceUri }
assertEquals(null, removedResource, "Resource should be removed from the list")
}
@Test
fun testConcurrentResourceOperations() = runTest {
val concurrentCount = 10
val results = mutableListOf<ReadResourceResult?>()
runBlocking {
repeat(concurrentCount) { index ->
launch {
val uri = when (index % 3) {
0 -> testResourceUri
1 -> binaryResourceUri
else -> largeResourceUri
}
val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = uri)))
synchronized(results) {
results.add(result)
}
}
}
}
assertEquals(concurrentCount, results.size, "All concurrent operations should complete")
results.forEach { result ->
assertNotNull(result, "Result should not be null")
assertTrue(result.contents.isNotEmpty(), "Result contents should not be empty")
}
}
}

View file

@ -0,0 +1,820 @@
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
import io.kotest.assertions.json.shouldEqualJson
import io.modelcontextprotocol.kotlin.sdk.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.ImageContent
import io.modelcontextprotocol.kotlin.sdk.PromptMessageContent
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.TextContent
import io.modelcontextprotocol.kotlin.sdk.Tool
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertNull
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Locale
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class OldSchemaAbstractToolIntegrationTest : OldSchemaKotlinTestBase() {
private val testToolName = "echo"
private val testToolDescription = "A simple echo tool that returns the input text"
private val complexToolName = "calculator"
private val complexToolDescription = "A calculator tool that performs operations on numbers"
private val errorToolName = "error-tool"
private val errorToolDescription = "A tool that demonstrates error handling"
private val multiContentToolName = "multi-content"
private val multiContentToolDescription = "A tool that returns multiple content types"
private val basicToolName = "basic-tool"
private val basicToolDescription = "A basic tool for testing"
private val largeToolName = "large-tool"
private val largeToolDescription = "A tool that returns a large response"
private val largeToolContent = "X".repeat(100_000) // 100KB of data
private val slowToolName = "slow-tool"
private val slowToolDescription = "A tool that takes time to respond"
private val specialCharsToolName = "special-chars-tool"
private val specialCharsToolDescription = "A tool that handles special characters"
private val specialCharsContent = "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t"
override fun configureServerCapabilities(): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities =
ServerCapabilities(
tools = ServerCapabilities.Tools(
listChanged = true,
),
)
override fun configureServer() {
setupEchoTool()
setupCalculatorTool()
setupErrorHandlingTool()
setupMultiContentTool()
}
private fun setupEchoTool() {
server.addTool(
name = testToolName,
description = testToolDescription,
inputSchema = Tool.Input(
properties = buildJsonObject {
put(
"text",
buildJsonObject {
put("type", "string")
put("description", "The text to echo back")
},
)
},
required = listOf("text"),
),
) { request ->
val text = (request.params.arguments?.get("text") as? JsonPrimitive)?.content ?: "No text provided"
CallToolResult(
content = listOf(TextContent(text = "Echo: $text")),
structuredContent = buildJsonObject {
put("result", text)
},
)
}
}
private fun setupCalculatorTool() {
server.addTool(
name = basicToolName,
description = basicToolDescription,
inputSchema = Tool.Input(
properties = buildJsonObject {
put(
"text",
buildJsonObject {
put("type", "string")
put("description", "The text to echo back")
},
)
},
required = listOf("text"),
),
) { request ->
val text = (request.params.arguments?.get("text") as? JsonPrimitive)?.content ?: "No text provided"
CallToolResult(
content = listOf(TextContent(text = "Echo: $text")),
structuredContent = buildJsonObject {
put("result", text)
},
)
}
server.addTool(
name = specialCharsToolName,
description = specialCharsToolDescription,
inputSchema = Tool.Input(
properties = buildJsonObject {
put(
"special",
buildJsonObject {
put("type", "string")
put("description", "Special characters to process")
},
)
},
),
) { request ->
val special = (request.params.arguments?.get("special") as? JsonPrimitive)?.content ?: specialCharsContent
CallToolResult(
content = listOf(TextContent(text = "Received special characters: $special")),
structuredContent = buildJsonObject {
put("special", special)
put("length", special.length)
},
)
}
server.addTool(
name = slowToolName,
description = slowToolDescription,
inputSchema = Tool.Input(
properties = buildJsonObject {
put(
"delay",
buildJsonObject {
put("type", "integer")
put("description", "Delay in milliseconds")
},
)
},
),
) { request ->
val delay = (request.params.arguments?.get("delay") as? JsonPrimitive)?.content?.toIntOrNull() ?: 1000
// simulate slow operation
runBlocking {
delay(delay.toLong())
}
CallToolResult(
content = listOf(TextContent(text = "Completed after ${delay}ms delay")),
structuredContent = buildJsonObject {
put("delay", delay)
},
)
}
server.addTool(
name = largeToolName,
description = largeToolDescription,
inputSchema = Tool.Input(
properties = buildJsonObject {
put(
"size",
buildJsonObject {
put("type", "integer")
put("description", "Size multiplier")
},
)
},
),
) { request ->
val size = (request.params.arguments?.get("size") as? JsonPrimitive)?.content?.toIntOrNull() ?: 1
val content = largeToolContent.take(largeToolContent.length.coerceAtMost(size * 1000))
CallToolResult(
content = listOf(TextContent(text = content)),
structuredContent = buildJsonObject {
put("size", content.length)
},
)
}
server.addTool(
name = complexToolName,
description = complexToolDescription,
inputSchema = Tool.Input(
properties = buildJsonObject {
put(
"operation",
buildJsonObject {
put("type", "string")
put("description", "The operation to perform (add, subtract, multiply, divide)")
put(
"enum",
buildJsonArray {
add("add")
add("subtract")
add("multiply")
add("divide")
},
)
},
)
put(
"a",
buildJsonObject {
put("type", "number")
put("description", "First operand")
},
)
put(
"b",
buildJsonObject {
put("type", "number")
put("description", "Second operand")
},
)
put(
"precision",
buildJsonObject {
put("type", "integer")
put("description", "Number of decimal places (optional)")
put("default", 2)
},
)
put(
"showSteps",
buildJsonObject {
put("type", "boolean")
put("description", "Whether to show calculation steps")
put("default", false)
},
)
put(
"tags",
buildJsonObject {
put("type", "array")
put("description", "Optional tags for the calculation")
put(
"items",
buildJsonObject {
put("type", "string")
},
)
},
)
},
required = listOf("operation", "a", "b"),
),
) { request ->
val operation = (request.params.arguments?.get("operation") as? JsonPrimitive)?.content ?: "add"
val a = (request.params.arguments?.get("a") as? JsonPrimitive)?.content?.toDoubleOrNull() ?: 0.0
val b = (request.params.arguments?.get("b") as? JsonPrimitive)?.content?.toDoubleOrNull() ?: 0.0
val precision = (request.params.arguments?.get("precision") as? JsonPrimitive)?.content?.toIntOrNull() ?: 2
val showSteps =
(request.params.arguments?.get("showSteps") as? JsonPrimitive)?.content?.toBoolean() ?: false
val tags = (request.params.arguments?.get("tags") as? JsonArray)?.mapNotNull {
(it as? JsonPrimitive)?.content
} ?: emptyList()
val result = when (operation) {
"add" -> a + b
"subtract" -> a - b
"multiply" -> a * b
"divide" -> if (b != 0.0) a / b else Double.POSITIVE_INFINITY
else -> 0.0
}
val pattern = if (precision > 0) "0." + "0".repeat(precision) else "0"
val symbols = DecimalFormatSymbols(Locale.US).apply { decimalSeparator = '.' }
val df = DecimalFormat(pattern, symbols).apply { isGroupingUsed = false }
val formattedResult = df.format(result)
val textContent = if (showSteps) {
"Operation: $operation\nA: $a\nB: $b\nResult: $formattedResult\nTags: ${
tags.joinToString(", ")
}"
} else {
"Result: $formattedResult"
}
CallToolResult(
content = listOf(TextContent(text = textContent)),
structuredContent = buildJsonObject {
put("operation", operation)
put("a", a)
put("b", b)
put("result", result)
put("formattedResult", formattedResult)
put("precision", precision)
put("tags", buildJsonArray { tags.forEach { add(it) } })
},
)
}
}
private fun setupErrorHandlingTool() {
server.addTool(
name = errorToolName,
description = errorToolDescription,
inputSchema = Tool.Input(
properties = buildJsonObject {
put(
"errorType",
buildJsonObject {
put("type", "string")
put("description", "Type of error to simulate (none, exception, error)")
put(
"enum",
buildJsonArray {
add("none")
add("exception")
add("error")
},
)
},
)
put(
"message",
buildJsonObject {
put("type", "string")
put("description", "Custom error message")
put("default", "An error occurred")
},
)
},
required = listOf("errorType"),
),
) { request ->
val errorType = (request.params.arguments?.get("errorType") as? JsonPrimitive)?.content ?: "none"
val message = (request.params.arguments?.get("message") as? JsonPrimitive)?.content ?: "An error occurred"
when (errorType) {
"exception" -> throw IllegalArgumentException(message)
"error" -> CallToolResult(
content = listOf(TextContent(text = "Error: $message")),
structuredContent = buildJsonObject {
put("error", true)
put("message", message)
},
)
else -> CallToolResult(
content = listOf(TextContent(text = "No error occurred")),
structuredContent = buildJsonObject {
put("error", false)
put("message", "Success")
},
)
}
}
}
private fun setupMultiContentTool() {
server.addTool(
name = multiContentToolName,
description = multiContentToolDescription,
inputSchema = Tool.Input(
properties = buildJsonObject {
put(
"text",
buildJsonObject {
put("type", "string")
put("description", "Text to include in the response")
},
)
put(
"includeImage",
buildJsonObject {
put("type", "boolean")
put("description", "Whether to include an image in the response")
put("default", true)
},
)
},
required = listOf("text"),
),
) { request ->
val text = (request.params.arguments?.get("text") as? JsonPrimitive)?.content ?: "Default text"
val includeImage =
(request.params.arguments?.get("includeImage") as? JsonPrimitive)?.content?.toBoolean() ?: true
val content = mutableListOf<PromptMessageContent>(
TextContent(text = "Text content: $text"),
)
if (includeImage) {
content.add(
ImageContent(
data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
mimeType = "image/png",
),
)
}
CallToolResult(
content = content,
structuredContent = buildJsonObject {
put("text", text)
put("includeImage", includeImage)
},
)
}
}
@Test
fun testListTools(): Unit = runBlocking(Dispatchers.IO) {
val result = client.listTools()
assertNotNull(result, "List tools result should not be null")
assertNull(actual = result.meta, "List tools result meta should be null")
assertTrue(result.tools.isNotEmpty(), "Tools list should not be empty")
val testTool = result.tools.find { it.name == testToolName }
assertNotNull(testTool, "Test tool should be in the list")
assertEquals(
testToolDescription,
testTool.description,
"Tool description should match",
)
}
@Test
fun testCallTool(): Unit = runBlocking(Dispatchers.IO) {
val testText = "Hello, world!"
val arguments = mapOf("text" to testText)
val result = client.callTool(testToolName, arguments)
assertNull(actual = result.meta, "CallTool result meta should be null")
val actualContent = result.structuredContent.toString()
val expectedContent = """
{"result":"Hello, world!"}
""".trimIndent()
actualContent shouldEqualJson expectedContent
}
@Test
fun testComplexInputSchemaTool(): Unit = runBlocking(Dispatchers.IO) {
val toolsList = client.listTools()
assertNotNull(toolsList, "Tools list should not be null")
val calculatorTool = toolsList.tools.find { it.name == complexToolName }
assertNotNull(calculatorTool, "Calculator tool should be in the list")
val arguments = mapOf(
"operation" to "multiply",
"a" to 5.5,
"b" to 2.0,
"precision" to 3,
"showSteps" to true,
"tags" to listOf("test", "calculator", "integration"),
)
val result = client.callTool(
complexToolName,
arguments,
)
val actualContent = result.structuredContent.toString()
val expectedContent = """
{
"operation" : "multiply",
"a" : 5.5,
"b" : 2.0,
"result" : 11.0,
"formattedResult" : "11.000",
"precision" : 3,
"tags" : ["test", "calculator", "integration"]
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
}
@Test
fun testToolErrorHandling(): Unit = runBlocking(Dispatchers.IO) {
val successArgs = mapOf("errorType" to "none")
val successResult = client.callTool(errorToolName, successArgs)
val actualContent = successResult?.structuredContent.toString()
val expectedContent = """
{
"error" : false,
"message" : "Success"
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
val errorArgs = mapOf(
"errorType" to "error",
"message" to "Custom error message",
)
val errorResult = client.callTool(
errorToolName,
errorArgs,
)
val actualError = errorResult.structuredContent.toString()
val expectedError = """
{
"error" : true,
"message" : "Custom error message"
}
""".trimIndent()
actualError shouldEqualJson expectedError
val exceptionArgs = mapOf(
"errorType" to "exception",
"message" to "My exception message",
)
val exceptionResult = client.callTool(
errorToolName,
exceptionArgs,
)
assertTrue(exceptionResult.isError ?: false, "isError should be true for exception")
val exceptionContent = exceptionResult.content.firstOrNull { it is TextContent } as? TextContent
assertNotNull(exceptionContent, "Error content should be present in the result")
val exceptionText = exceptionContent.text ?: ""
assertTrue(
exceptionText.contains("Error executing tool") && exceptionText.contains("My exception message"),
"Error message should contain the exception details",
)
}
@Test
fun testMultiContentTool(): Unit = runBlocking(Dispatchers.IO) {
val testText = "Test multi-content"
val arguments = mapOf(
"text" to testText,
"includeImage" to true,
)
val result = client.callTool(
multiContentToolName,
arguments,
)
assertEquals(
2,
result.content.size,
"Tool result should have 2 content items",
)
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
assertNotNull(textContent, "Result should contain TextContent")
assertNotNull(textContent.text, "Text content should not be null")
assertEquals(
"Text content: $testText",
textContent.text,
"Text content should match",
)
val imageContent = result.content.firstOrNull { it is ImageContent } as? ImageContent
assertNotNull(imageContent, "Result should contain ImageContent")
assertEquals("image/png", imageContent.mimeType, "Image MIME type should match")
assertTrue(imageContent.data.isNotEmpty(), "Image data should not be empty")
val actualContent = result.structuredContent.toString()
val expectedContent = """
{
"text" : "Test multi-content",
"includeImage" : true
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
val textOnlyArgs = mapOf(
"text" to testText,
"includeImage" to false,
)
val textOnlyResult = client.callTool(
multiContentToolName,
textOnlyArgs,
)
assertEquals(
1,
textOnlyResult.content.size,
"Text-only result should have 1 content item",
)
}
@Test
fun testComplexNestedSchema(): Unit = runBlocking(Dispatchers.IO) {
val userJson = buildJsonObject {
put("name", JsonPrimitive("John Galt"))
put("age", JsonPrimitive(30))
put(
"address",
buildJsonObject {
put("street", JsonPrimitive("123 Main St"))
put("city", JsonPrimitive("New York"))
put("country", JsonPrimitive("USA"))
},
)
}
val optionsJson = buildJsonArray {
add(JsonPrimitive("option1"))
add(JsonPrimitive("option2"))
add(JsonPrimitive("option3"))
}
val arguments = buildJsonObject {
put("user", userJson)
put("options", optionsJson)
}
val result = client.callTool(
CallToolRequest(
CallToolRequestParams(
name = complexToolName,
arguments = arguments,
),
),
)
val actualContent = result.structuredContent.toString()
val expectedContent = """
{
"operation": "add",
"a": 0.0,
"b": 0.0,
"result": 0.0,
"formattedResult": "0.00",
"precision": 2,
"tags": []
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
}
@Test
fun testLargeResponse(): Unit = runBlocking(Dispatchers.IO) {
val size = 10
val arguments = mapOf("size" to size)
val result = client.callTool(
largeToolName,
arguments,
)
val content = result.content.firstOrNull() as TextContent
assertNotNull(content, "Tool result content should be TextContent")
val actualContent = result.structuredContent.toString()
val expectedContent = """
{
"size" : 10000
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
}
@Test
fun testSlowTool(): Unit = runBlocking(Dispatchers.IO) {
val delay = 500
val arguments = mapOf("delay" to delay)
val startTime = System.currentTimeMillis()
val result = client.callTool(slowToolName, arguments)
val endTime = System.currentTimeMillis()
val content = result.content.firstOrNull() as? TextContent
assertNotNull(content, "Tool result content should be TextContent")
assertTrue(endTime - startTime >= delay, "Tool should take at least the specified delay")
val actualContent = result.structuredContent.toString()
val expectedContent = """
{
"delay" : 500
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
}
@Test
fun testSpecialCharacters() {
runBlocking(Dispatchers.IO) {
val arguments = mapOf("special" to specialCharsContent)
val result = client.callTool(
specialCharsToolName,
arguments,
)
val content = result.content.firstOrNull() as? TextContent
assertNotNull(content, "Tool result content should be TextContent")
val text = content.text ?: ""
assertTrue(text.contains(specialCharsContent), "Result should contain the special characters")
val actualContent = result.structuredContent.toString()
val expectedContent = """
{
"special" : "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t",
"length" : 34
}
""".trimIndent()
actualContent shouldEqualJson expectedContent
}
}
@Test
fun testConcurrentToolCalls() = runTest {
val concurrentCount = 10
val results = mutableListOf<io.modelcontextprotocol.kotlin.sdk.types.CallToolResult?>()
runBlocking {
repeat(concurrentCount) { index ->
launch {
val toolName = when (index % 5) {
0 -> basicToolName
1 -> complexToolName
2 -> largeToolName
3 -> slowToolName
else -> specialCharsToolName
}
val arguments = when (toolName) {
basicToolName -> mapOf("text" to "Concurrent call $index")
complexToolName -> mapOf(
"user" to mapOf(
"name" to "User $index",
"age" to 20 + index,
"address" to mapOf(
"street" to "Street $index",
"city" to "City $index",
"country" to "Country $index",
),
),
)
largeToolName -> mapOf("size" to 1)
slowToolName -> mapOf("delay" to 100)
else -> mapOf("special" to "!@#$%^&*()")
}
val result = client.callTool(toolName, arguments)
synchronized(results) {
results.add(result)
}
}
}
}
assertEquals(concurrentCount, results.size, "All concurrent operations should complete")
results.forEach { result ->
assertNotNull(result, "Result should not be null")
assertTrue(result.content.isNotEmpty(), "Result content should not be empty")
}
}
@Test
fun testNonExistentTool() = runTest {
val nonExistentToolName = "non-existent-tool"
val arguments = mapOf("text" to "Test")
val result = runBlocking {
client.callTool(nonExistentToolName, arguments)
}
assertNotNull(result, "Tool call result should not be null")
val callResult = result
assertTrue(callResult.isError ?: false, "isError should be true for non-existent tool")
val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent
assertNotNull(textContent, "Error content should be present in the result")
val errorText = textContent.text ?: ""
assertTrue(
errorText.contains("non-existent-tool") && errorText.contains("not found"),
"Error message should indicate the tool was not found",
)
}
}

Some files were not shown because too many files have changed in this diff Show more