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:
parent
6fee82ff1a
commit
29d34b8e48
75 changed files with 243 additions and 39 deletions
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
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.Implementation
|
||||
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.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 OldSchemaKotlinTestBase {
|
||||
|
||||
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(): io.modelcontextprotocol.kotlin.sdk.types.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue