Deprecation cycle (#498)
- increase the deprecation level - remove tests that use deprecated objects with deprecation level = error ## Motivation and Context deprecation lifecycle ## How Has This Been Tested? - ## Breaking Changes - ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist - [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
6f1c120c27
commit
92823b987f
54 changed files with 224 additions and 8255 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,123 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,698 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,319 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,820 +0,0 @@
|
||||||
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",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.sse
|
|
||||||
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.OldSchemaAbstractPromptIntegrationTest
|
|
||||||
|
|
||||||
class OldSchemaPromptIntegrationTestSse : OldSchemaAbstractPromptIntegrationTest() {
|
|
||||||
override val transportKind: TransportKind = TransportKind.SSE
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.sse
|
|
||||||
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.OldSchemaAbstractResourceIntegrationTest
|
|
||||||
|
|
||||||
class OldSchemaResourceIntegrationTestSse : OldSchemaAbstractResourceIntegrationTest() {
|
|
||||||
override val transportKind: TransportKind = TransportKind.SSE
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.sse
|
|
||||||
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.OldSchemaAbstractToolIntegrationTest
|
|
||||||
|
|
||||||
class OldSchemaToolIntegrationTestSse : OldSchemaAbstractToolIntegrationTest() {
|
|
||||||
override val transportKind: TransportKind = TransportKind.SSE
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.stdio
|
|
||||||
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.OldSchemaAbstractPromptIntegrationTest
|
|
||||||
|
|
||||||
class OldSchemaPromptIntegrationTestStdio : OldSchemaAbstractPromptIntegrationTest() {
|
|
||||||
override val transportKind: TransportKind = TransportKind.STDIO
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.stdio
|
|
||||||
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.OldSchemaAbstractResourceIntegrationTest
|
|
||||||
|
|
||||||
class OldSchemaResourceIntegrationTestStdio : OldSchemaAbstractResourceIntegrationTest() {
|
|
||||||
override val transportKind: TransportKind = TransportKind.STDIO
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.stdio
|
|
||||||
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.OldSchemaAbstractToolIntegrationTest
|
|
||||||
|
|
||||||
class OldSchemaToolIntegrationTestStdio : OldSchemaAbstractToolIntegrationTest() {
|
|
||||||
override val transportKind: TransportKind = TransportKind.STDIO
|
|
||||||
}
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
@file:Suppress("DEPRECATION")
|
|
||||||
|
|
||||||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.websocket
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.server.application.install
|
|
||||||
import io.ktor.server.cio.CIOApplicationEngine
|
|
||||||
import io.ktor.server.engine.EmbeddedServer
|
|
||||||
import io.ktor.server.engine.embeddedServer
|
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.Implementation
|
|
||||||
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.client.Client
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.client.mcpWebSocketTransport
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.server.mcpWebSocket
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import io.ktor.client.engine.cio.CIO as ClientCIO
|
|
||||||
import io.ktor.client.plugins.websocket.WebSockets as ClientWebSocket
|
|
||||||
import io.ktor.server.cio.CIO as ServerCIO
|
|
||||||
import io.ktor.server.websocket.WebSockets as ServerWebSockets
|
|
||||||
|
|
||||||
class OldSchemaWebSocketIntegrationTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `client should be able to connect to websocket server 2`() = runBlocking(Dispatchers.IO) {
|
|
||||||
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
|
||||||
var client: Client? = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
server = initServer()
|
|
||||||
val port = server.engine.resolvedConnectors().first().port
|
|
||||||
client = initClient(serverPort = port)
|
|
||||||
} finally {
|
|
||||||
client?.close()
|
|
||||||
server?.stop(1000, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Case #1: One opened connection, a client gets a prompt
|
|
||||||
*
|
|
||||||
* 1. Open WebSocket from Client A.
|
|
||||||
* 2. Send a POST request from Client A to POST /prompts/get.
|
|
||||||
* 3. Observe that Client A receives a response related to it.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `single websocket connection`() = runBlocking(Dispatchers.IO) {
|
|
||||||
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
|
||||||
var client: Client? = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
server = initServer()
|
|
||||||
val port = server.engine.resolvedConnectors().first().port
|
|
||||||
client = initClient("Client A", port)
|
|
||||||
|
|
||||||
val promptA = getPrompt(client, "Client A")
|
|
||||||
assertTrue { "Client A" in promptA }
|
|
||||||
} finally {
|
|
||||||
client?.close()
|
|
||||||
server?.stop(1000, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Case #1: Two open connections, each client gets a client-specific prompt
|
|
||||||
*
|
|
||||||
* 1. Open WebSocket connection #1 from Client A and note the sessionId=<sessionId#1> value.
|
|
||||||
* 2. Open WebSocket connection #2 from Client B and note the sessionId=<sessionId#2> value.
|
|
||||||
* 3. Send a POST request to POST /message with the corresponding sessionId#1.
|
|
||||||
* 4. Observe that Client B (connection #2) receives a response related to sessionId#1.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `multiple websocket connections`() = runBlocking(Dispatchers.IO) {
|
|
||||||
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
|
||||||
var clientA: Client? = null
|
|
||||||
var clientB: Client? = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
server = initServer()
|
|
||||||
val port = server.engine.resolvedConnectors().first().port
|
|
||||||
clientA = initClient("Client A", port)
|
|
||||||
clientB = initClient("Client B", port)
|
|
||||||
|
|
||||||
// Step 3: Send a prompt request from Client A
|
|
||||||
val promptA = getPrompt(clientA, "Client A")
|
|
||||||
// Step 4: Send a prompt request from Client B
|
|
||||||
val promptB = getPrompt(clientB, "Client B")
|
|
||||||
|
|
||||||
assertTrue { "Client A" in promptA }
|
|
||||||
assertTrue { "Client B" in promptB }
|
|
||||||
} finally {
|
|
||||||
clientA?.close()
|
|
||||||
clientB?.close()
|
|
||||||
server?.stop(1000, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun initClient(name: String = "", serverPort: Int): Client {
|
|
||||||
val client = Client(
|
|
||||||
Implementation(name = name, version = "1.0.0"),
|
|
||||||
)
|
|
||||||
|
|
||||||
val httpClient = HttpClient(ClientCIO) {
|
|
||||||
install(ClientWebSocket)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a transport wrapper that captures the session ID and received messages
|
|
||||||
val transport = httpClient.mcpWebSocketTransport {
|
|
||||||
url {
|
|
||||||
host = URL
|
|
||||||
port = serverPort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client.connect(transport)
|
|
||||||
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initServer(): EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration> {
|
|
||||||
val server = Server(
|
|
||||||
Implementation(name = "websocket-server", version = "1.0.0"),
|
|
||||||
ServerOptions(
|
|
||||||
capabilities = ServerCapabilities(prompts = ServerCapabilities.Prompts(listChanged = true)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
server.addPrompt(
|
|
||||||
name = "prompt",
|
|
||||||
description = "Prompt description",
|
|
||||||
arguments = listOf(
|
|
||||||
PromptArgument(
|
|
||||||
name = "client",
|
|
||||||
description = "Client name who requested a prompt",
|
|
||||||
required = true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) { request ->
|
|
||||||
GetPromptResult(
|
|
||||||
description = "Prompt for ${request.params.name}",
|
|
||||||
messages = listOf(
|
|
||||||
PromptMessage(
|
|
||||||
role = Role.user,
|
|
||||||
content = TextContent("Prompt for client ${request.params.arguments?.get("client")}"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val ktorServer = embeddedServer(ServerCIO, host = URL, port = PORT) {
|
|
||||||
install(ServerWebSockets)
|
|
||||||
routing {
|
|
||||||
mcpWebSocket(block = { server })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ktorServer.start(wait = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a prompt result using the provided client and client name.
|
|
||||||
*/
|
|
||||||
private suspend fun getPrompt(client: Client, clientName: String): String {
|
|
||||||
val response = client.getPrompt(
|
|
||||||
GetPromptRequest(
|
|
||||||
GetPromptRequestParams(
|
|
||||||
"prompt",
|
|
||||||
arguments = mapOf("client" to clientName),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (response.messages.first().content as? TextContent)?.text
|
|
||||||
?: error("Failed to receive prompt for Client $clientName")
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val PORT = 0
|
|
||||||
private const val URL = "127.0.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
@file:Suppress("DEPRECATION")
|
|
||||||
|
|
||||||
package io.modelcontextprotocol.kotlin.sdk.integration.sse
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.plugins.sse.SSE
|
|
||||||
import io.ktor.server.application.install
|
|
||||||
import io.ktor.server.cio.CIOApplicationEngine
|
|
||||||
import io.ktor.server.engine.EmbeddedServer
|
|
||||||
import io.ktor.server.engine.embeddedServer
|
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.Implementation
|
|
||||||
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.client.Client
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.client.mcpSseTransport
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.server.mcp
|
|
||||||
import io.ktor.client.engine.cio.CIO as ClientCIO
|
|
||||||
import io.ktor.server.cio.CIO as ServerCIO
|
|
||||||
import io.ktor.server.sse.SSE as ServerSSE
|
|
||||||
|
|
||||||
open class OldSchemaAbstractSseIntegrationTest {
|
|
||||||
|
|
||||||
suspend fun initTestClient(serverPort: Int, name: String? = null): Client {
|
|
||||||
val client = Client(
|
|
||||||
Implementation(name = name ?: DEFAULT_CLIENT_NAME, version = VERSION),
|
|
||||||
)
|
|
||||||
|
|
||||||
val httpClient = HttpClient(ClientCIO) {
|
|
||||||
install(SSE)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a transport wrapper that captures the session ID and received messages
|
|
||||||
val transport = httpClient.mcpSseTransport {
|
|
||||||
url {
|
|
||||||
host = URL
|
|
||||||
port = serverPort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client.connect(transport)
|
|
||||||
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun initTestServer(
|
|
||||||
name: String? = null,
|
|
||||||
): EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration> {
|
|
||||||
val server = Server(
|
|
||||||
Implementation(name = name ?: DEFAULT_SERVER_NAME, version = VERSION),
|
|
||||||
ServerOptions(
|
|
||||||
capabilities = ServerCapabilities(prompts = ServerCapabilities.Prompts(listChanged = true)),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
addPrompt(
|
|
||||||
name = "prompt",
|
|
||||||
description = "Prompt description",
|
|
||||||
arguments = listOf(
|
|
||||||
PromptArgument(
|
|
||||||
name = "client",
|
|
||||||
description = "Client name who requested a prompt",
|
|
||||||
required = true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) { request ->
|
|
||||||
GetPromptResult(
|
|
||||||
description = "Prompt for ${request.params.name}",
|
|
||||||
messages = listOf(
|
|
||||||
PromptMessage(
|
|
||||||
role = Role.user,
|
|
||||||
content = TextContent("Prompt for client ${request.params.arguments?.get("client")}"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val ktorServer = embeddedServer(
|
|
||||||
ServerCIO,
|
|
||||||
host = URL,
|
|
||||||
port = PORT,
|
|
||||||
) {
|
|
||||||
install(ServerSSE)
|
|
||||||
routing {
|
|
||||||
mcp { server }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ktorServer.startSuspend(wait = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val DEFAULT_CLIENT_NAME = "sse-test-client"
|
|
||||||
private const val DEFAULT_SERVER_NAME = "sse-test-server"
|
|
||||||
private const val VERSION = "1.0.0"
|
|
||||||
private const val URL = "127.0.0.1"
|
|
||||||
private const val PORT = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
@file:Suppress("DEPRECATION")
|
|
||||||
|
|
||||||
package io.modelcontextprotocol.kotlin.sdk.integration.sse
|
|
||||||
|
|
||||||
import io.ktor.server.cio.CIOApplicationEngine
|
|
||||||
import io.ktor.server.engine.EmbeddedServer
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.TextContent
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
|
|
||||||
import io.modelcontextprotocol.kotlin.test.utils.actualPort
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
class OldSchemaSseIntegrationTest : OldSchemaAbstractSseIntegrationTest() {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `client should be able to connect to sse server`() = runBlocking(Dispatchers.IO) {
|
|
||||||
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
|
||||||
var client: Client? = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
server = initTestServer()
|
|
||||||
val port = server.actualPort()
|
|
||||||
client = initTestClient(serverPort = port)
|
|
||||||
} finally {
|
|
||||||
client?.close()
|
|
||||||
server?.stopSuspend(1000, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Case #1: One opened connection, a client gets a prompt
|
|
||||||
*
|
|
||||||
* 1. Open SSE from Client A.
|
|
||||||
* 2. Send a POST request from Client A to POST /prompts/get.
|
|
||||||
* 3. Observe that Client A receives a response related to it.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `single sse connection`() = runBlocking(Dispatchers.IO) {
|
|
||||||
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
|
||||||
var client: Client? = null
|
|
||||||
try {
|
|
||||||
server = initTestServer()
|
|
||||||
val port = server.actualPort()
|
|
||||||
client = initTestClient(port, "Client A")
|
|
||||||
|
|
||||||
val promptA = getPrompt(client, "Client A")
|
|
||||||
assertTrue { "Client A" in promptA }
|
|
||||||
} finally {
|
|
||||||
client?.close()
|
|
||||||
server?.stopSuspend(1000, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Case #1: Two open connections, each client gets a client-specific prompt
|
|
||||||
*
|
|
||||||
* 1. Open SSE connection #1 from Client A and note the sessionId=<sessionId#1> value.
|
|
||||||
* 2. Open SSE connection #2 from Client B and note the sessionId=<sessionId#2> value.
|
|
||||||
* 3. Send a POST request to POST /message with the corresponding sessionId#1.
|
|
||||||
* 4. Observe that Client B (connection #2) receives a response related to sessionId#1.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `multiple sse connections`() = runBlocking(Dispatchers.IO) {
|
|
||||||
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
|
||||||
var clientA: Client? = null
|
|
||||||
var clientB: Client? = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
server = initTestServer()
|
|
||||||
val port = server.engine.resolvedConnectors().first().port
|
|
||||||
|
|
||||||
clientA = initTestClient(port, "Client A")
|
|
||||||
clientB = initTestClient(port, "Client B")
|
|
||||||
|
|
||||||
// Step 3: Send a prompt request from Client A
|
|
||||||
val promptA = getPrompt(clientA, "Client A")
|
|
||||||
// Step 4: Send a prompt request from Client B
|
|
||||||
val promptB = getPrompt(clientB, "Client B")
|
|
||||||
|
|
||||||
assertTrue { "Client A" in promptA }
|
|
||||||
assertTrue { "Client B" in promptB }
|
|
||||||
} finally {
|
|
||||||
clientA?.close()
|
|
||||||
clientB?.close()
|
|
||||||
server?.stopSuspend(1000, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a prompt result using the provided client and client name.
|
|
||||||
*/
|
|
||||||
private suspend fun getPrompt(client: Client, clientName: String): String {
|
|
||||||
val response = client.getPrompt(
|
|
||||||
GetPromptRequest(
|
|
||||||
GetPromptRequestParams(
|
|
||||||
"prompt",
|
|
||||||
arguments = mapOf("client" to clientName),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (response.messages.first().content as? TextContent)?.text
|
|
||||||
?: error("Failed to receive prompt for Client $clientName")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
|
||||||
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.TextContent
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.Timeout
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
abstract class OldSchemaAbstractKotlinClientTsServerTest : TsTestBase() {
|
|
||||||
protected abstract suspend fun <T> useClient(block: suspend (Client) -> T): T
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
|
||||||
fun connectsAndPings() = runBlocking(Dispatchers.IO) {
|
|
||||||
useClient { client ->
|
|
||||||
assertNotNull(client, "Client should be initialized")
|
|
||||||
val ping = client.ping()
|
|
||||||
assertNotNull(ping, "Ping result should not be null")
|
|
||||||
val serverImpl = client.serverVersion
|
|
||||||
assertNotNull(serverImpl, "Server implementation should not be null")
|
|
||||||
println("Connected to TypeScript server: ${serverImpl.name} v${serverImpl.version}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
|
||||||
fun listsTools() = runBlocking(Dispatchers.IO) {
|
|
||||||
useClient { client ->
|
|
||||||
val result = client.listTools()
|
|
||||||
assertNotNull(result, "Tools list should not be null")
|
|
||||||
assertTrue(result.tools.isNotEmpty(), "Tools list should not be empty")
|
|
||||||
val toolNames = result.tools.map { it.name }
|
|
||||||
assertTrue("greet" in toolNames, "Greet tool should be available")
|
|
||||||
assertTrue("multi-greet" in toolNames, "Multi-greet tool should be available")
|
|
||||||
// Some tests also check collect-user-info; keep base minimal and non-breaking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
|
||||||
fun callGreet() = runBlocking(Dispatchers.IO) {
|
|
||||||
useClient { client ->
|
|
||||||
val testName = "TestUser"
|
|
||||||
val arguments = mapOf("name" to testName)
|
|
||||||
val result = client.callTool("greet", arguments)
|
|
||||||
assertNotNull(result, "Tool call result should not be null")
|
|
||||||
val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
|
||||||
val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent
|
|
||||||
assertNotNull(textContent, "Text content should be present in the result")
|
|
||||||
assertEquals("Hello, $testName!", textContent.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
|
||||||
fun multipleClients() = runBlocking(Dispatchers.IO) {
|
|
||||||
useClient { client1 ->
|
|
||||||
useClient { client2 ->
|
|
||||||
val tools1 = client1.listTools()
|
|
||||||
val tools2 = client2.listTools()
|
|
||||||
assertTrue(tools1.tools.isNotEmpty(), "Tools list for first client should not be empty")
|
|
||||||
assertTrue(tools2.tools.isNotEmpty(), "Tools list for second client should not be empty")
|
|
||||||
val toolNames1 = tools1.tools.map { it.name }
|
|
||||||
val toolNames2 = tools2.tools.map { it.name }
|
|
||||||
assertTrue("greet" in toolNames1, "Greet tool should be available to first client")
|
|
||||||
assertTrue("multi-greet" in toolNames1, "Multi-greet tool should be available to first client")
|
|
||||||
assertTrue("greet" in toolNames2, "Greet tool should be available to second client")
|
|
||||||
assertTrue("multi-greet" in toolNames2, "Multi-greet tool should be available to second client")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse
|
|
||||||
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.TextContent
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import org.junit.jupiter.api.AfterEach
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.Timeout
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
class OldSchemaKotlinClientTsServerEdgeCasesTestSse : TsTestBase() {
|
|
||||||
|
|
||||||
override val transportKind = TransportKind.SSE
|
|
||||||
|
|
||||||
private val host = "localhost"
|
|
||||||
private val serverUrl: String by lazy { getSharedSseUrl() }
|
|
||||||
|
|
||||||
private lateinit var client: Client
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
fun tearDown() {
|
|
||||||
if (::client.isInitialized) {
|
|
||||||
try {
|
|
||||||
runBlocking {
|
|
||||||
withTimeout(3.seconds) {
|
|
||||||
client.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Warning: Error during client close: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
|
||||||
fun testNonExistentTool(): Unit = runBlocking(Dispatchers.IO) {
|
|
||||||
withClient(serverUrl) { client ->
|
|
||||||
val nonExistentToolName = "non-existent-tool"
|
|
||||||
val arguments = mapOf("name" to "TestUser")
|
|
||||||
|
|
||||||
val result = client.callTool(nonExistentToolName, arguments)
|
|
||||||
assertNotNull(result, "Tool call result should not be null")
|
|
||||||
|
|
||||||
val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
|
||||||
fun testSpecialCharactersInArguments(): Unit = runBlocking(Dispatchers.IO) {
|
|
||||||
withClient(serverUrl) { client ->
|
|
||||||
val specialChars = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/"
|
|
||||||
val arguments = mapOf("name" to specialChars)
|
|
||||||
|
|
||||||
val result = client.callTool("greet", arguments)
|
|
||||||
assertNotNull(result, "Tool call result should not be null")
|
|
||||||
|
|
||||||
val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
|
||||||
val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent
|
|
||||||
assertNotNull(textContent, "Text content should be present in the result")
|
|
||||||
|
|
||||||
val text = textContent.text ?: ""
|
|
||||||
assertTrue(
|
|
||||||
text.contains(specialChars),
|
|
||||||
"Tool response should contain the special characters",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
|
||||||
fun testLargePayload(): Unit = runBlocking(Dispatchers.IO) {
|
|
||||||
withClient(serverUrl) { client ->
|
|
||||||
val largeName = "A".repeat(10 * 1024)
|
|
||||||
val arguments = mapOf("name" to largeName)
|
|
||||||
|
|
||||||
val result = client.callTool("greet", arguments)
|
|
||||||
assertNotNull(result, "Tool call result should not be null")
|
|
||||||
|
|
||||||
val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
|
||||||
val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent
|
|
||||||
assertNotNull(textContent, "Text content should be present in the result")
|
|
||||||
|
|
||||||
val text = textContent.text ?: ""
|
|
||||||
assertTrue(
|
|
||||||
text.contains("Hello,") && text.contains("A"),
|
|
||||||
"Tool response should contain the greeting with the large name",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Timeout(60, unit = TimeUnit.SECONDS)
|
|
||||||
fun testConcurrentRequests(): Unit = runBlocking(Dispatchers.IO) {
|
|
||||||
withClient(serverUrl) { client ->
|
|
||||||
val concurrentCount = 5
|
|
||||||
val responses = coroutineScope {
|
|
||||||
val results = (1..concurrentCount).map { i ->
|
|
||||||
async {
|
|
||||||
val name = "ConcurrentClient$i"
|
|
||||||
val arguments = mapOf("name" to name)
|
|
||||||
|
|
||||||
val result = client.callTool("greet", arguments)
|
|
||||||
assertNotNull(result, "Tool call result should not be null for client $i")
|
|
||||||
|
|
||||||
val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
|
||||||
val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent
|
|
||||||
assertNotNull(textContent, "Text content should be present for client $i")
|
|
||||||
|
|
||||||
textContent.text ?: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results.awaitAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i in 1..concurrentCount) {
|
|
||||||
val expectedName = "ConcurrentClient$i"
|
|
||||||
val matchingResponses = responses.filter { it.contains("Hello, $expectedName!") }
|
|
||||||
assertEquals(
|
|
||||||
1,
|
|
||||||
matchingResponses.size,
|
|
||||||
"Should have exactly one response for $expectedName",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
|
||||||
fun testInvalidArguments(): Unit = runBlocking(Dispatchers.IO) {
|
|
||||||
withClient(serverUrl) { client ->
|
|
||||||
val invalidArguments = mapOf(
|
|
||||||
"name" to JsonObject(mapOf("nested" to JsonPrimitive("value"))),
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = client.callTool("greet", invalidArguments)
|
|
||||||
assertNotNull(result, "Tool call result should not be null")
|
|
||||||
|
|
||||||
val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
|
||||||
assertTrue(callResult.isError ?: false, "isError should be true for invalid arguments")
|
|
||||||
|
|
||||||
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("Invalid arguments") && errorText.contains("greet"),
|
|
||||||
"Error message should indicate invalid arguments for tool greet",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
|
||||||
fun testMultipleToolCalls(): Unit = runBlocking(Dispatchers.IO) {
|
|
||||||
withClient(serverUrl) { client ->
|
|
||||||
repeat(10) { i ->
|
|
||||||
val name = "SequentialClient$i"
|
|
||||||
val arguments = mapOf("name" to name)
|
|
||||||
|
|
||||||
val result = client.callTool("greet", arguments)
|
|
||||||
assertNotNull(result, "Tool call result should not be null for call $i")
|
|
||||||
|
|
||||||
val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
|
||||||
val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent
|
|
||||||
assertNotNull(textContent, "Text content should be present for call $i")
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"Hello, $name!",
|
|
||||||
textContent.text,
|
|
||||||
"Tool response should contain the greeting with the provided name",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue