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
25 changed files with 224 additions and 8255 deletions
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.OldSchemaAbstractKotlinClientTsServerTest
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class OldSchemaKotlinClientTsServerTestSse : OldSchemaAbstractKotlinClientTsServerTest() {
|
||||
|
||||
override val transportKind = TransportKind.SSE
|
||||
|
||||
private val host = "localhost"
|
||||
private val serverUrl: String by lazy { getSharedSseUrl() }
|
||||
|
||||
override suspend fun <T> useClient(block: suspend (Client) -> T): T = withClient(serverUrl) { client ->
|
||||
try {
|
||||
withTimeout(20.seconds) { block(client) }
|
||||
} finally {
|
||||
try {
|
||||
withTimeout(3.seconds) { client.close() }
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,466 +0,0 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.request.header
|
||||
import io.ktor.server.request.receiveText
|
||||
import io.ktor.server.response.header
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.response.respondTextWriter
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.routing
|
||||
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.ErrorCode
|
||||
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCError
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse
|
||||
import io.modelcontextprotocol.kotlin.sdk.PromptArgument
|
||||
import io.modelcontextprotocol.kotlin.sdk.PromptMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.RequestId
|
||||
import io.modelcontextprotocol.kotlin.sdk.Role
|
||||
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.TextResourceContents
|
||||
import io.modelcontextprotocol.kotlin.sdk.Tool
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
|
||||
import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
class OldSchemaKotlinServerForTsClient {
|
||||
private val serverTransports = ConcurrentHashMap<String, OldSchemaHttpServerTransport>()
|
||||
private val jsonFormat = Json { ignoreUnknownKeys = true }
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
|
||||
fun start(port: Int = 3000) {
|
||||
logger.info { "Starting HTTP server on port $port" }
|
||||
|
||||
server = embeddedServer(CIO, port = port) {
|
||||
routing {
|
||||
get("/mcp") {
|
||||
val sessionId = call.request.header("mcp-session-id")
|
||||
if (sessionId == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Missing mcp-session-id header")
|
||||
return@get
|
||||
}
|
||||
val transport = serverTransports[sessionId]
|
||||
if (transport == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Invalid mcp-session-id")
|
||||
return@get
|
||||
}
|
||||
transport.stream(call)
|
||||
}
|
||||
|
||||
post("/mcp") {
|
||||
val sessionId = call.request.header("mcp-session-id")
|
||||
val requestBody = call.receiveText()
|
||||
|
||||
logger.debug { "Received request with sessionId: $sessionId" }
|
||||
logger.trace { "Request body: $requestBody" }
|
||||
|
||||
val jsonElement = try {
|
||||
jsonFormat.parseToJsonElement(requestBody)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to parse request body as JSON" }
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
jsonFormat.encodeToString(
|
||||
JsonObject.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"jsonrpc" to JsonPrimitive("2.0"),
|
||||
"error" to JsonObject(
|
||||
mapOf(
|
||||
"code" to JsonPrimitive(-32700),
|
||||
"message" to JsonPrimitive("Parse error: ${e.message}"),
|
||||
),
|
||||
),
|
||||
"id" to JsonNull,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return@post
|
||||
}
|
||||
|
||||
if (sessionId != null && serverTransports.containsKey(sessionId)) {
|
||||
logger.debug { "Using existing transport for session: $sessionId" }
|
||||
val transport = serverTransports[sessionId]!!
|
||||
transport.handleRequest(call, jsonElement)
|
||||
} else {
|
||||
if (isInitializeRequest(jsonElement)) {
|
||||
val newSessionId = UUID.randomUUID().toString()
|
||||
logger.info { "Creating new session with ID: $newSessionId" }
|
||||
|
||||
val transport = OldSchemaHttpServerTransport(newSessionId)
|
||||
|
||||
serverTransports[newSessionId] = transport
|
||||
|
||||
val mcpServer = createMcpServer()
|
||||
|
||||
call.response.header("mcp-session-id", newSessionId)
|
||||
|
||||
val serverThread = Thread {
|
||||
runBlocking {
|
||||
mcpServer.createSession(transport)
|
||||
}
|
||||
}
|
||||
serverThread.start()
|
||||
|
||||
Thread.sleep(500)
|
||||
|
||||
transport.handleRequest(call, jsonElement)
|
||||
} else {
|
||||
logger.warn { "Invalid request: no session ID or not an initialization request" }
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
jsonFormat.encodeToString(
|
||||
JsonObject.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"jsonrpc" to JsonPrimitive("2.0"),
|
||||
"error" to JsonObject(
|
||||
mapOf(
|
||||
"code" to JsonPrimitive(-32000),
|
||||
"message" to
|
||||
JsonPrimitive("Bad Request: No valid session ID provided"),
|
||||
),
|
||||
),
|
||||
"id" to JsonNull,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete("/mcp") {
|
||||
val sessionId = call.request.header("mcp-session-id")
|
||||
if (sessionId != null && serverTransports.containsKey(sessionId)) {
|
||||
logger.info { "Terminating session: $sessionId" }
|
||||
val transport = serverTransports[sessionId]!!
|
||||
serverTransports.remove(sessionId)
|
||||
runBlocking {
|
||||
transport.close()
|
||||
}
|
||||
call.respond(HttpStatusCode.OK)
|
||||
} else {
|
||||
logger.warn { "Invalid session termination request: $sessionId" }
|
||||
call.respond(HttpStatusCode.BadRequest, "Invalid or missing session ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server?.start(wait = false)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
logger.info { "Stopping HTTP server" }
|
||||
server?.stop(500, 1000)
|
||||
server = null
|
||||
}
|
||||
|
||||
fun createMcpServer(): Server {
|
||||
val server = Server(
|
||||
Implementation(
|
||||
name = "kotlin-http-server",
|
||||
version = "1.0.0",
|
||||
),
|
||||
ServerOptions(
|
||||
capabilities = ServerCapabilities(
|
||||
prompts = ServerCapabilities.Prompts(listChanged = true),
|
||||
resources = ServerCapabilities.Resources(subscribe = true, listChanged = true),
|
||||
tools = ServerCapabilities.Tools(listChanged = true),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
server.addTool(
|
||||
name = "greet",
|
||||
description = "A simple greeting tool",
|
||||
inputSchema = Tool.Input(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"name",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put("description", JsonPrimitive("Name to greet"))
|
||||
},
|
||||
)
|
||||
},
|
||||
required = listOf("name"),
|
||||
),
|
||||
) { request ->
|
||||
val name = (request.params.arguments?.get("name") as? JsonPrimitive)?.content ?: "World"
|
||||
CallToolResult(
|
||||
content = listOf(TextContent("Hello, $name!")),
|
||||
structuredContent = buildJsonObject {
|
||||
put("greeting", JsonPrimitive("Hello, $name!"))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
server.addTool(
|
||||
name = "multi-greet",
|
||||
description = "A greeting tool that sends multiple notifications",
|
||||
inputSchema = Tool.Input(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"name",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put("description", JsonPrimitive("Name to greet"))
|
||||
},
|
||||
)
|
||||
},
|
||||
required = listOf("name"),
|
||||
),
|
||||
) { request ->
|
||||
val name = (request.params.arguments?.get("name") as? JsonPrimitive)?.content ?: "World"
|
||||
|
||||
CallToolResult(
|
||||
content = listOf(TextContent("Multiple greetings sent to $name!")),
|
||||
structuredContent = buildJsonObject {
|
||||
put("greeting", JsonPrimitive("Multiple greetings sent to $name!"))
|
||||
put("notificationCount", JsonPrimitive(3))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
server.addPrompt(
|
||||
name = "greeting-template",
|
||||
description = "A simple greeting prompt template",
|
||||
arguments = listOf(
|
||||
PromptArgument(
|
||||
name = "name",
|
||||
description = "Name to include in greeting",
|
||||
required = true,
|
||||
),
|
||||
),
|
||||
) { request ->
|
||||
GetPromptResult(
|
||||
description = "Greeting for ${request.params.name}",
|
||||
messages = listOf(
|
||||
PromptMessage(
|
||||
role = Role.user,
|
||||
content = TextContent(
|
||||
"Please greet ${request.params.arguments?.get("name") ?: "someone"} in a friendly manner.",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
server.addResource(
|
||||
uri = "https://example.com/greetings/default",
|
||||
name = "Default Greeting",
|
||||
description = "A simple greeting resource",
|
||||
mimeType = "text/plain",
|
||||
) { request ->
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents("Hello, world!", request.params.uri, "text/plain"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
private fun isInitializeRequest(json: JsonElement): Boolean {
|
||||
if (json !is JsonObject) return false
|
||||
|
||||
val method = json["method"]?.jsonPrimitive?.contentOrNull
|
||||
return method == "initialize"
|
||||
}
|
||||
}
|
||||
|
||||
class OldSchemaHttpServerTransport(private val sessionId: String) : AbstractTransport() {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val pendingResponses = ConcurrentHashMap<String, CompletableDeferred<JSONRPCMessage>>()
|
||||
private val messageQueue = Channel<JSONRPCMessage>(Channel.UNLIMITED)
|
||||
|
||||
suspend fun stream(call: ApplicationCall) {
|
||||
logger.debug { "Starting SSE stream for session: $sessionId" }
|
||||
call.response.header("Cache-Control", "no-cache")
|
||||
call.response.header("Connection", "keep-alive")
|
||||
call.respondTextWriter(ContentType.Text.EventStream) {
|
||||
try {
|
||||
while (true) {
|
||||
val result = messageQueue.receiveCatching()
|
||||
val msg = result.getOrNull() ?: break
|
||||
val json = McpJson.encodeToString(msg)
|
||||
write("event: message\n")
|
||||
write("data: ")
|
||||
write(json)
|
||||
write("\n\n")
|
||||
flush()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "SSE stream terminated for session: $sessionId" }
|
||||
} finally {
|
||||
logger.debug { "SSE stream closed for session: $sessionId" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleRequest(call: ApplicationCall, requestBody: JsonElement) {
|
||||
try {
|
||||
logger.info { "Handling request body: $requestBody" }
|
||||
val message = McpJson.decodeFromJsonElement<JSONRPCMessage>(requestBody)
|
||||
logger.info { "Decoded message: $message" }
|
||||
|
||||
if (message is JSONRPCRequest) {
|
||||
val id = message.id.toString()
|
||||
logger.info { "Received request with ID: $id, method: ${message.method}" }
|
||||
val responseDeferred = CompletableDeferred<JSONRPCMessage>()
|
||||
pendingResponses[id] = responseDeferred
|
||||
logger.info { "Created deferred response for ID: $id" }
|
||||
|
||||
logger.info { "Invoking onMessage handler" }
|
||||
_onMessage.invoke(message)
|
||||
logger.info { "onMessage handler completed" }
|
||||
|
||||
try {
|
||||
val response = withTimeoutOrNull(10000) {
|
||||
responseDeferred.await()
|
||||
}
|
||||
|
||||
if (response != null) {
|
||||
val jsonResponse = McpJson.encodeToString(response)
|
||||
call.respondText(jsonResponse, ContentType.Application.Json)
|
||||
} else {
|
||||
logger.warn { "Timeout waiting for response to request ID: $id" }
|
||||
call.respondText(
|
||||
McpJson.encodeToString(
|
||||
JSONRPCError(
|
||||
id = message.id,
|
||||
error = RPCError(
|
||||
code = ErrorCode.Defined.RequestTimeout,
|
||||
message = "Request timed out",
|
||||
),
|
||||
),
|
||||
),
|
||||
ContentType.Application.Json,
|
||||
)
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
logger.warn { "Request cancelled for ID: $id" }
|
||||
pendingResponses.remove(id)
|
||||
if (!call.response.isCommitted) {
|
||||
call.respondText(
|
||||
McpJson.encodeToString(
|
||||
JSONRPCError(
|
||||
id = message.id,
|
||||
error = RPCError(
|
||||
code = ErrorCode.Defined.ConnectionClosed,
|
||||
message = "Request cancelled",
|
||||
),
|
||||
),
|
||||
),
|
||||
ContentType.Application.Json,
|
||||
HttpStatusCode.ServiceUnavailable,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
call.respondText("", ContentType.Application.Json, HttpStatusCode.Accepted)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Error handling request" }
|
||||
if (!call.response.isCommitted) {
|
||||
try {
|
||||
val errorResponse = JSONRPCError(
|
||||
id = RequestId.NumberId(0),
|
||||
error = RPCError(
|
||||
code = ErrorCode.Defined.InternalError,
|
||||
message = "Internal server error: ${e.message}",
|
||||
),
|
||||
)
|
||||
|
||||
call.respondText(
|
||||
McpJson.encodeToString(errorResponse),
|
||||
ContentType.Application.Json,
|
||||
HttpStatusCode.InternalServerError,
|
||||
)
|
||||
} catch (responseEx: Exception) {
|
||||
logger.error(responseEx) { "Failed to send error response" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
logger.debug { "Starting HTTP server transport for session: $sessionId" }
|
||||
}
|
||||
|
||||
override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
|
||||
logger.info { "Sending message: $message" }
|
||||
|
||||
if (message is JSONRPCResponse) {
|
||||
val id = message.id.toString()
|
||||
logger.info { "Sending response for request ID: $id" }
|
||||
val deferred = pendingResponses.remove(id)
|
||||
if (deferred != null) {
|
||||
logger.info { "Found pending response for ID: $id, completing deferred" }
|
||||
deferred.complete(message)
|
||||
return
|
||||
} else {
|
||||
logger.warn { "No pending response found for ID: $id" }
|
||||
}
|
||||
} else if (message is JSONRPCRequest) {
|
||||
logger.info { "Sending request with ID: ${message.id}" }
|
||||
} else if (message is JSONRPCNotification) {
|
||||
logger.info { "Sending notification: ${message.method}" }
|
||||
}
|
||||
|
||||
logger.info { "Queueing message for next client request" }
|
||||
messageQueue.send(message)
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
logger.debug { "Closing HTTP server transport for session: $sessionId" }
|
||||
messageQueue.close()
|
||||
_onClose.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun main() {
|
||||
val server = OldSchemaKotlinServerForTsClient()
|
||||
server.start()
|
||||
}
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio
|
||||
|
||||
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.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
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
|
||||
|
||||
class OldSchemaKotlinClientTsServerEdgeCasesTestStdio : TsTestBase() {
|
||||
|
||||
override val transportKind = TransportKind.STDIO
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testNonExistentToolOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClientStdio { client: 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 testSpecialCharactersInArgumentsOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClientStdio { client: 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 testLargePayloadOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClientStdio { client: 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 testConcurrentRequestsOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClientStdio { client: 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 testInvalidArgumentsOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClientStdio { client: 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 testMultipleToolCallsOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClientStdio { client: 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.OldSchemaAbstractKotlinClientTsServerTest
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||
|
||||
class OldSchemaKotlinClientTsServerTestStdio : OldSchemaAbstractKotlinClientTsServerTest() {
|
||||
|
||||
override val transportKind = TransportKind.STDIO
|
||||
|
||||
override suspend fun <T> useClient(block: suspend (Client) -> T): T = withClientStdio { client, proc ->
|
||||
try {
|
||||
block(client)
|
||||
} finally {
|
||||
try {
|
||||
client.close()
|
||||
} catch (_: Exception) {}
|
||||
try {
|
||||
stopProcess(proc, name = "TypeScript stdio server")
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
|
||||
abstract class OldSchemaAbstractServerFeaturesTest {
|
||||
|
||||
protected lateinit var server: Server
|
||||
protected lateinit var client: Client
|
||||
|
||||
abstract fun getServerCapabilities(): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
|
||||
protected open fun getServerInstructionsProvider(): (() -> String)? = null
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
val serverOptions = ServerOptions(
|
||||
capabilities = getServerCapabilities(),
|
||||
)
|
||||
|
||||
server = Server(
|
||||
serverInfo = Implementation(name = "test server", version = "1.0"),
|
||||
options = serverOptions,
|
||||
instructionsProvider = getServerInstructionsProvider(),
|
||||
)
|
||||
|
||||
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
|
||||
|
||||
client = Client(
|
||||
clientInfo = Implementation(name = "test client", version = "1.0"),
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
// Connect client and server
|
||||
launch { client.connect(clientTransport) }
|
||||
launch { server.createSession(serverTransport) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue