Add Stdio coverage for integration tests (#276)
This PR adds integration test coverage by covering the Stdio transport. The tests were refactored to accommodate Stdio without adding duplication. ## 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 - [x] Test improvement ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [ ] I have added appropriate error handling - [ ] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions --> --------- Co-authored-by: Pavel Gorgulov <devcrocod@gmail.com>
This commit is contained in:
parent
cdda68577c
commit
8ead8d3d4d
32 changed files with 2228 additions and 1727 deletions
|
|
@ -17,13 +17,17 @@ import kotlin.test.assertEquals
|
|||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PromptEdgeCasesTest : KotlinTestBase() {
|
||||
abstract class AbstractPromptIntegrationTest : KotlinTestBase() {
|
||||
|
||||
private val basicPromptName = "basic-prompt"
|
||||
private val basicPromptDescription = "A basic prompt for testing"
|
||||
|
||||
private val complexPromptName = "complex-prompt"
|
||||
private val complexPromptDescription = "A complex prompt with many arguments"
|
||||
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"
|
||||
|
|
@ -40,6 +44,7 @@ class PromptEdgeCasesTest : KotlinTestBase() {
|
|||
)
|
||||
|
||||
override fun configureServer() {
|
||||
// basic prompt with a name parameter
|
||||
server.addPrompt(
|
||||
name = basicPromptName,
|
||||
description = basicPromptDescription,
|
||||
|
|
@ -68,6 +73,66 @@ class PromptEdgeCasesTest : KotlinTestBase() {
|
|||
)
|
||||
}
|
||||
|
||||
// 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.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.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,
|
||||
|
|
@ -122,65 +187,301 @@ class PromptEdgeCasesTest : KotlinTestBase() {
|
|||
)
|
||||
}
|
||||
|
||||
// very large prompt
|
||||
// prompt with multiple messages and roles
|
||||
server.addPrompt(
|
||||
name = largePromptName,
|
||||
description = largePromptDescription,
|
||||
name = conversationPromptName,
|
||||
description = conversationPromptDescription,
|
||||
arguments = listOf(
|
||||
PromptArgument(
|
||||
name = "size",
|
||||
description = "Size multiplier",
|
||||
name = "topic",
|
||||
description = "The topic of the conversation",
|
||||
required = false,
|
||||
),
|
||||
),
|
||||
) { request ->
|
||||
val size = request.arguments?.get("size")?.toIntOrNull() ?: 1
|
||||
val content = largePromptContent.repeat(size)
|
||||
val topic = request.arguments?.get("topic") ?: "weather"
|
||||
|
||||
GetPromptResult(
|
||||
description = largePromptDescription,
|
||||
description = conversationPromptDescription,
|
||||
messages = listOf(
|
||||
PromptMessage(
|
||||
role = Role.user,
|
||||
content = TextContent(text = "Generate a large response"),
|
||||
content = TextContent(text = "Let's talk about the $topic."),
|
||||
),
|
||||
PromptMessage(
|
||||
role = Role.assistant,
|
||||
content = TextContent(text = content),
|
||||
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 = specialCharsPromptName,
|
||||
description = specialCharsPromptDescription,
|
||||
name = strictPromptName,
|
||||
description = strictPromptDescription,
|
||||
arguments = listOf(
|
||||
PromptArgument(
|
||||
name = "special",
|
||||
description = "Special characters to include",
|
||||
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 special = request.arguments?.get("special") ?: specialCharsContent
|
||||
val args = request.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 = specialCharsPromptDescription,
|
||||
description = strictPromptDescription,
|
||||
messages = listOf(
|
||||
PromptMessage(
|
||||
role = Role.user,
|
||||
content = TextContent(text = "Special characters: $special"),
|
||||
content = TextContent(text = "Required arguments: $arg1, $arg2. Optional: $optArg"),
|
||||
),
|
||||
PromptMessage(
|
||||
role = Role.assistant,
|
||||
content = TextContent(text = "Received special characters: $special"),
|
||||
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(
|
||||
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<IllegalStateException> {
|
||||
runBlocking {
|
||||
client.getPrompt(
|
||||
GetPromptRequest(
|
||||
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<IllegalStateException> {
|
||||
runBlocking {
|
||||
client.getPrompt(
|
||||
GetPromptRequest(
|
||||
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(
|
||||
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(
|
||||
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"
|
||||
|
|
@ -305,25 +606,6 @@ class PromptEdgeCasesTest : KotlinTestBase() {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMissingRequiredArguments() = runTest {
|
||||
val exception = assertThrows<IllegalStateException> {
|
||||
runBlocking {
|
||||
client.getPrompt(
|
||||
GetPromptRequest(
|
||||
name = complexPromptName,
|
||||
arguments = mapOf("arg4" to "value4", "arg5" to "value5"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val msg = exception.message ?: ""
|
||||
val expectedMessage = "JSONRPCError(code=InternalError, message=Missing required argument: arg1, data={})"
|
||||
|
||||
assertEquals(expectedMessage, msg, "Unexpected error message for missing required argument")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testConcurrentPromptRequests() = runTest {
|
||||
val concurrentCount = 10
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.BlobResourceContents
|
||||
import io.modelcontextprotocol.kotlin.sdk.EmptyRequestResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.Method
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
abstract class AbstractResourceIntegrationTest : KotlinTestBase() {
|
||||
|
||||
private val testResourceUri = "test://example.txt"
|
||||
private val testResourceName = "Test Resource"
|
||||
private val testResourceDescription = "A test resource for integration testing"
|
||||
private val testResourceContent = "This is the content of the test resource."
|
||||
|
||||
private val binaryResourceUri = "test://image.png"
|
||||
private val binaryResourceName = "Binary Resource"
|
||||
private val binaryResourceDescription = "A binary resource for testing"
|
||||
private val binaryResourceContent =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
|
||||
private val largeResourceUri = "test://large.txt"
|
||||
private val largeResourceName = "Large Resource"
|
||||
private val largeResourceDescription = "A large text resource for testing"
|
||||
private val largeResourceContent = "X".repeat(100_000) // 100KB of data
|
||||
|
||||
private val dynamicResourceUri = "test://dynamic.txt"
|
||||
private val dynamicResourceName = "Dynamic Resource"
|
||||
private val dynamicResourceContent = AtomicBoolean(false)
|
||||
|
||||
override fun configureServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
resources = ServerCapabilities.Resources(
|
||||
subscribe = true,
|
||||
listChanged = true,
|
||||
),
|
||||
)
|
||||
|
||||
override fun configureServer() {
|
||||
server.addResource(
|
||||
uri = testResourceUri,
|
||||
name = testResourceName,
|
||||
description = testResourceDescription,
|
||||
mimeType = "text/plain",
|
||||
) { request ->
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents(
|
||||
text = testResourceContent,
|
||||
uri = request.uri,
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
server.setRequestHandler<SubscribeRequest>(Method.Defined.ResourcesSubscribe) { _, _ ->
|
||||
EmptyRequestResult()
|
||||
}
|
||||
|
||||
server.setRequestHandler<UnsubscribeRequest>(Method.Defined.ResourcesUnsubscribe) { _, _ ->
|
||||
EmptyRequestResult()
|
||||
}
|
||||
|
||||
server.addResource(
|
||||
uri = testResourceUri,
|
||||
name = testResourceName,
|
||||
description = testResourceDescription,
|
||||
mimeType = "text/plain",
|
||||
) { request ->
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents(
|
||||
text = testResourceContent,
|
||||
uri = request.uri,
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
server.addResource(
|
||||
uri = binaryResourceUri,
|
||||
name = binaryResourceName,
|
||||
description = binaryResourceDescription,
|
||||
mimeType = "image/png",
|
||||
) { request ->
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
BlobResourceContents(
|
||||
blob = binaryResourceContent,
|
||||
uri = request.uri,
|
||||
mimeType = "image/png",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
server.addResource(
|
||||
uri = largeResourceUri,
|
||||
name = largeResourceName,
|
||||
description = largeResourceDescription,
|
||||
mimeType = "text/plain",
|
||||
) { request ->
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents(
|
||||
text = largeResourceContent,
|
||||
uri = request.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.uri,
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testListResources() = runBlocking(Dispatchers.IO) {
|
||||
val result = client.listResources()
|
||||
|
||||
assertNotNull(result, "List resources result should not be null")
|
||||
assertTrue(result.resources.isNotEmpty(), "Resources list should not be empty")
|
||||
|
||||
val testResource = result.resources.find { it.uri == testResourceUri }
|
||||
assertNotNull(testResource, "Test resource should be in the list")
|
||||
assertEquals(testResourceName, testResource.name, "Resource name should match")
|
||||
assertEquals(testResourceDescription, testResource.description, "Resource description should match")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadResource() = runBlocking(Dispatchers.IO) {
|
||||
val result = client.readResource(ReadResourceRequest(uri = testResourceUri))
|
||||
|
||||
assertNotNull(result, "Read resource result should not be null")
|
||||
assertTrue(result.contents.isNotEmpty(), "Resource contents should not be empty")
|
||||
|
||||
val content = result.contents.firstOrNull() as? TextResourceContents
|
||||
assertNotNull(content, "Resource content should be TextResourceContents")
|
||||
assertEquals(testResourceContent, content.text, "Resource content should match")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSubscribeAndUnsubscribe() {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
val subscribeResult = client.subscribeResource(SubscribeRequest(uri = testResourceUri))
|
||||
assertNotNull(subscribeResult, "Subscribe result should not be null")
|
||||
|
||||
val unsubscribeResult = client.unsubscribeResource(UnsubscribeRequest(uri = testResourceUri))
|
||||
assertNotNull(unsubscribeResult, "Unsubscribe result should not be null")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBinaryResource() = runBlocking(Dispatchers.IO) {
|
||||
val result = client.readResource(ReadResourceRequest(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(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<IllegalStateException> {
|
||||
runBlocking {
|
||||
client.readResource(ReadResourceRequest(uri = invalidUri))
|
||||
}
|
||||
}
|
||||
|
||||
val msg = exception.message ?: ""
|
||||
val expectedMessage =
|
||||
"JSONRPCError(code=InternalError, message=Resource not found: test://nonexistent.txt, data={})"
|
||||
|
||||
assertEquals(expectedMessage, msg, "Unexpected error message for invalid resource URI")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDynamicResource() = runBlocking(Dispatchers.IO) {
|
||||
val initialResult = client.readResource(ReadResourceRequest(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(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.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(uri = uri))
|
||||
synchronized(results) {
|
||||
results.add(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(concurrentCount, results.size, "All concurrent operations should complete")
|
||||
results.forEach { result ->
|
||||
assertNotNull(result, "Result should not be null")
|
||||
assertTrue(result.contents.isNotEmpty(), "Result contents should not be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,784 @@
|
|||
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.CallToolResultBase
|
||||
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 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.assertThrows
|
||||
import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
import java.util.Locale
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
abstract class AbstractToolIntegrationTest : KotlinTestBase() {
|
||||
private val testToolName = "echo"
|
||||
private val testToolDescription = "A simple echo tool that returns the input text"
|
||||
private val complexToolName = "calculator"
|
||||
private val complexToolDescription = "A calculator tool that performs operations on numbers"
|
||||
private val errorToolName = "error-tool"
|
||||
private val errorToolDescription = "A tool that demonstrates error handling"
|
||||
private val multiContentToolName = "multi-content"
|
||||
private val multiContentToolDescription = "A tool that returns multiple content types"
|
||||
|
||||
private val basicToolName = "basic-tool"
|
||||
private val basicToolDescription = "A basic tool for testing"
|
||||
|
||||
private val largeToolName = "large-tool"
|
||||
private val largeToolDescription = "A tool that returns a large response"
|
||||
private val largeToolContent = "X".repeat(100_000) // 100KB of data
|
||||
|
||||
private val slowToolName = "slow-tool"
|
||||
private val slowToolDescription = "A tool that takes time to respond"
|
||||
|
||||
private val specialCharsToolName = "special-chars-tool"
|
||||
private val specialCharsToolDescription = "A tool that handles special characters"
|
||||
private val specialCharsContent = "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t"
|
||||
|
||||
override fun configureServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(
|
||||
listChanged = true,
|
||||
),
|
||||
)
|
||||
|
||||
override fun configureServer() {
|
||||
setupEchoTool()
|
||||
setupCalculatorTool()
|
||||
setupErrorHandlingTool()
|
||||
setupMultiContentTool()
|
||||
}
|
||||
|
||||
private fun setupEchoTool() {
|
||||
server.addTool(
|
||||
name = testToolName,
|
||||
description = testToolDescription,
|
||||
inputSchema = Tool.Input(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"text",
|
||||
buildJsonObject {
|
||||
put("type", "string")
|
||||
put("description", "The text to echo back")
|
||||
},
|
||||
)
|
||||
},
|
||||
required = listOf("text"),
|
||||
),
|
||||
) { request ->
|
||||
val text = (request.arguments["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.arguments["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.arguments["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.arguments["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.arguments["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.arguments["operation"] as? JsonPrimitive)?.content ?: "add"
|
||||
val a = (request.arguments["a"] as? JsonPrimitive)?.content?.toDoubleOrNull() ?: 0.0
|
||||
val b = (request.arguments["b"] as? JsonPrimitive)?.content?.toDoubleOrNull() ?: 0.0
|
||||
val precision = (request.arguments["precision"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 2
|
||||
val showSteps = (request.arguments["showSteps"] as? JsonPrimitive)?.content?.toBoolean() ?: false
|
||||
val tags = (request.arguments["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.arguments["errorType"] as? JsonPrimitive)?.content ?: "none"
|
||||
val message = (request.arguments["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.arguments["text"] as? JsonPrimitive)?.content ?: "Default text"
|
||||
val includeImage = (request.arguments["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 utils result should not be null")
|
||||
assertTrue(result.tools.isNotEmpty(), "Tools list should not be empty")
|
||||
|
||||
val testTool = result.tools.find { it.name == testToolName }
|
||||
|
||||
assertNotNull(testTool, "Test tool should be in the list")
|
||||
assertEquals(
|
||||
testToolDescription,
|
||||
testTool.description,
|
||||
"Tool description should match",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCallTool(): Unit = runBlocking(Dispatchers.IO) {
|
||||
val testText = "Hello, world!"
|
||||
val arguments = mapOf("text" to testText)
|
||||
|
||||
val result = client.callTool(testToolName, arguments) as CallToolResultBase
|
||||
|
||||
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) as CallToolResultBase
|
||||
|
||||
val actualContent = result.structuredContent.toString()
|
||||
val expectedContent = """
|
||||
{
|
||||
"operation" : "multiply",
|
||||
"a" : 5.5,
|
||||
"b" : 2.0,
|
||||
"result" : 11.0,
|
||||
"formattedResult" : "11,000",
|
||||
"precision" : 3,
|
||||
"tags" : [ ]
|
||||
}
|
||||
""".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) as CallToolResultBase
|
||||
|
||||
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 exception = assertThrows<IllegalStateException> {
|
||||
runBlocking {
|
||||
client.callTool(errorToolName, exceptionArgs)
|
||||
}
|
||||
}
|
||||
|
||||
val msg = exception.message ?: ""
|
||||
val expectedMessage = "JSONRPCError(code=InternalError, message=My exception message, data={})"
|
||||
|
||||
assertEquals(expectedMessage, msg, "Unexpected error message for exception")
|
||||
}
|
||||
|
||||
@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) as CallToolResultBase
|
||||
|
||||
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) as CallToolResultBase
|
||||
|
||||
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(
|
||||
name = complexToolName,
|
||||
arguments = arguments,
|
||||
),
|
||||
) as CallToolResultBase
|
||||
|
||||
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) as CallToolResultBase
|
||||
|
||||
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) as CallToolResultBase
|
||||
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) as CallToolResultBase
|
||||
|
||||
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<CallToolResultBase?>()
|
||||
|
||||
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 exception = assertThrows<IllegalStateException> {
|
||||
runBlocking {
|
||||
client.callTool(nonExistentToolName, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
val msg = exception.message ?: ""
|
||||
val expectedMessage = "JSONRPCError(code=InternalError, message=Tool not found: non-existent-tool, data={})"
|
||||
|
||||
assertEquals(expectedMessage, msg, "Unexpected error message for non-existent tool")
|
||||
}
|
||||
}
|
||||
|
|
@ -11,15 +11,24 @@ import io.modelcontextprotocol.kotlin.sdk.Implementation
|
|||
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
|
||||
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.integration.utils.Retry
|
||||
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 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
|
||||
|
|
@ -34,34 +43,62 @@ abstract class KotlinTestBase {
|
|||
protected lateinit var client: Client
|
||||
protected lateinit var serverEngine: EmbeddedServer<*, *>
|
||||
|
||||
// Transport selection
|
||||
protected enum class TransportKind { SSE, STDIO }
|
||||
protected open val transportKind: TransportKind = TransportKind.STDIO
|
||||
|
||||
// STDIO-specific fields
|
||||
private var stdioServerTransport: StdioServerTransport? = null
|
||||
private var stdioClientInput: Source? = null
|
||||
private var stdioClientOutput: Sink? = null
|
||||
|
||||
protected abstract fun configureServerCapabilities(): ServerCapabilities
|
||||
protected abstract fun configureServer()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
setupServer()
|
||||
await
|
||||
.ignoreExceptions()
|
||||
.until {
|
||||
port = runBlocking { serverEngine.engine.resolvedConnectors().first().port }
|
||||
port != 0
|
||||
}
|
||||
if (transportKind == TransportKind.SSE) {
|
||||
await
|
||||
.ignoreExceptions()
|
||||
.until {
|
||||
port = runBlocking { serverEngine.engine.resolvedConnectors().first().port }
|
||||
port != 0
|
||||
}
|
||||
}
|
||||
runBlocking {
|
||||
setupClient()
|
||||
}
|
||||
}
|
||||
|
||||
protected suspend fun setupClient() {
|
||||
val transport = SseClientTransport(
|
||||
HttpClient(CIO) {
|
||||
install(SSE)
|
||||
},
|
||||
"http://$host:$port",
|
||||
)
|
||||
client = Client(
|
||||
Implementation("test", "1.0"),
|
||||
)
|
||||
client.connect(transport)
|
||||
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() {
|
||||
|
|
@ -74,12 +111,37 @@ abstract class KotlinTestBase {
|
|||
|
||||
configureServer()
|
||||
|
||||
serverEngine = embeddedServer(ServerCIO, host = host, port = port) {
|
||||
install(ServerSSE)
|
||||
routing {
|
||||
mcp { server }
|
||||
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.connect(serverTransport)
|
||||
}
|
||||
}.start(wait = false)
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
|
@ -98,11 +160,25 @@ abstract class KotlinTestBase {
|
|||
}
|
||||
|
||||
// stop server
|
||||
if (::serverEngine.isInitialized) {
|
||||
try {
|
||||
serverEngine.stop(500, 1000)
|
||||
} catch (e: Exception) {
|
||||
println("Warning: Error during server stop: ${e.message}")
|
||||
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,94 +0,0 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.EmptyRequestResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.Method
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ResourceIntegrationTest : KotlinTestBase() {
|
||||
|
||||
private val testResourceUri = "test://example.txt"
|
||||
private val testResourceName = "Test Resource"
|
||||
private val testResourceDescription = "A test resource for integration testing"
|
||||
private val testResourceContent = "This is the content of the test resource."
|
||||
|
||||
override fun configureServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
resources = ServerCapabilities.Resources(
|
||||
subscribe = true,
|
||||
listChanged = true,
|
||||
),
|
||||
)
|
||||
|
||||
override fun configureServer() {
|
||||
server.addResource(
|
||||
uri = testResourceUri,
|
||||
name = testResourceName,
|
||||
description = testResourceDescription,
|
||||
mimeType = "text/plain",
|
||||
) { request ->
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents(
|
||||
text = testResourceContent,
|
||||
uri = request.uri,
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
server.setRequestHandler<SubscribeRequest>(Method.Defined.ResourcesSubscribe) { _, _ ->
|
||||
EmptyRequestResult()
|
||||
}
|
||||
|
||||
server.setRequestHandler<UnsubscribeRequest>(Method.Defined.ResourcesUnsubscribe) { _, _ ->
|
||||
EmptyRequestResult()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testListResources() = runBlocking(Dispatchers.IO) {
|
||||
val result = client.listResources()
|
||||
|
||||
assertNotNull(result, "List resources result should not be null")
|
||||
assertTrue(result.resources.isNotEmpty(), "Resources list should not be empty")
|
||||
|
||||
val testResource = result.resources.find { it.uri == testResourceUri }
|
||||
assertNotNull(testResource, "Test resource should be in the list")
|
||||
assertEquals(testResourceName, testResource.name, "Resource name should match")
|
||||
assertEquals(testResourceDescription, testResource.description, "Resource description should match")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadResource() = runBlocking(Dispatchers.IO) {
|
||||
val result = client.readResource(ReadResourceRequest(uri = testResourceUri))
|
||||
|
||||
assertNotNull(result, "Read resource result should not be null")
|
||||
assertTrue(result.contents.isNotEmpty(), "Resource contents should not be empty")
|
||||
|
||||
val content = result.contents.firstOrNull() as? TextResourceContents
|
||||
assertNotNull(content, "Resource content should be TextResourceContents")
|
||||
assertEquals(testResourceContent, content.text, "Resource content should match")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSubscribeAndUnsubscribe() {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
val subscribeResult = client.subscribeResource(SubscribeRequest(uri = testResourceUri))
|
||||
assertNotNull(subscribeResult, "Subscribe result should not be null")
|
||||
|
||||
val unsubscribeResult = client.unsubscribeResource(UnsubscribeRequest(uri = testResourceUri))
|
||||
assertNotNull(unsubscribeResult, "Unsubscribe result should not be null")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,489 +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.CallToolResultBase
|
||||
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.Tool
|
||||
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.JsonObject
|
||||
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.assertThrows
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ToolEdgeCasesTest : KotlinTestBase() {
|
||||
|
||||
private val basicToolName = "basic-tool"
|
||||
private val basicToolDescription = "A basic tool for testing"
|
||||
|
||||
private val complexToolName = "complex-tool"
|
||||
private val complexToolDescription = "A complex tool with nested schema"
|
||||
|
||||
private val largeToolName = "large-tool"
|
||||
private val largeToolDescription = "A tool that returns a large response"
|
||||
private val largeToolContent = "X".repeat(100_000) // 100KB of data
|
||||
|
||||
private val slowToolName = "slow-tool"
|
||||
private val slowToolDescription = "A tool that takes time to respond"
|
||||
|
||||
private val specialCharsToolName = "special-chars-tool"
|
||||
private val specialCharsToolDescription = "A tool that handles special characters"
|
||||
private val specialCharsContent = "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t"
|
||||
|
||||
override fun configureServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(
|
||||
listChanged = true,
|
||||
),
|
||||
)
|
||||
|
||||
override fun configureServer() {
|
||||
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.arguments["text"] as? JsonPrimitive)?.content ?: "No text provided"
|
||||
|
||||
CallToolResult(
|
||||
content = listOf(TextContent(text = "Echo: $text")),
|
||||
structuredContent = buildJsonObject {
|
||||
put("result", text)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
server.addTool(
|
||||
name = complexToolName,
|
||||
description = complexToolDescription,
|
||||
inputSchema = Tool.Input(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"user",
|
||||
buildJsonObject {
|
||||
put("type", "object")
|
||||
put("description", "User information")
|
||||
put(
|
||||
"properties",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"name",
|
||||
buildJsonObject {
|
||||
put("type", "string")
|
||||
put("description", "User's name")
|
||||
},
|
||||
)
|
||||
put(
|
||||
"age",
|
||||
buildJsonObject {
|
||||
put("type", "integer")
|
||||
put("description", "User's age")
|
||||
},
|
||||
)
|
||||
put(
|
||||
"address",
|
||||
buildJsonObject {
|
||||
put("type", "object")
|
||||
put("description", "User's address")
|
||||
put(
|
||||
"properties",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"street",
|
||||
buildJsonObject {
|
||||
put("type", "string")
|
||||
},
|
||||
)
|
||||
put(
|
||||
"city",
|
||||
buildJsonObject {
|
||||
put("type", "string")
|
||||
},
|
||||
)
|
||||
put(
|
||||
"country",
|
||||
buildJsonObject {
|
||||
put("type", "string")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
put(
|
||||
"options",
|
||||
buildJsonObject {
|
||||
put("type", "array")
|
||||
put("description", "Additional options")
|
||||
put(
|
||||
"items",
|
||||
buildJsonObject {
|
||||
put("type", "string")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
required = listOf("user"),
|
||||
),
|
||||
) { request ->
|
||||
val user = request.arguments["user"] as? JsonObject
|
||||
val name = (user?.get("name") as? JsonPrimitive)?.content ?: "Unknown"
|
||||
val age = (user?.get("age") as? JsonPrimitive)?.content?.toIntOrNull() ?: 0
|
||||
|
||||
val address = user?.get("address") as? JsonObject
|
||||
val street = (address?.get("street") as? JsonPrimitive)?.content ?: "Unknown"
|
||||
val city = (address?.get("city") as? JsonPrimitive)?.content ?: "Unknown"
|
||||
val country = (address?.get("country") as? JsonPrimitive)?.content ?: "Unknown"
|
||||
|
||||
val options = (request.arguments["options"] as? JsonArray)?.mapNotNull {
|
||||
(it as? JsonPrimitive)?.content
|
||||
} ?: emptyList()
|
||||
|
||||
val summary =
|
||||
"User: $name, Age: $age, Address: $street, $city, $country, Options: ${options.joinToString(", ")}"
|
||||
|
||||
CallToolResult(
|
||||
content = listOf(TextContent(text = summary)),
|
||||
structuredContent = buildJsonObject {
|
||||
put("name", name)
|
||||
put("age", age)
|
||||
put(
|
||||
"address",
|
||||
buildJsonObject {
|
||||
put("street", street)
|
||||
put("city", city)
|
||||
put("country", country)
|
||||
},
|
||||
)
|
||||
put(
|
||||
"options",
|
||||
buildJsonArray {
|
||||
options.forEach { add(it) }
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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.arguments["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 = slowToolName,
|
||||
description = slowToolDescription,
|
||||
inputSchema = Tool.Input(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"delay",
|
||||
buildJsonObject {
|
||||
put("type", "integer")
|
||||
put("description", "Delay in milliseconds")
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
) { request ->
|
||||
val delay = (request.arguments["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 = specialCharsToolName,
|
||||
description = specialCharsToolDescription,
|
||||
inputSchema = Tool.Input(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"special",
|
||||
buildJsonObject {
|
||||
put("type", "string")
|
||||
put("description", "Special characters to process")
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
) { request ->
|
||||
val special = (request.arguments["special"] as? JsonPrimitive)?.content ?: specialCharsContent
|
||||
|
||||
CallToolResult(
|
||||
content = listOf(TextContent(text = "Received special characters: $special")),
|
||||
structuredContent = buildJsonObject {
|
||||
put("special", special)
|
||||
put("length", special.length)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBasicTool(): Unit = runBlocking(Dispatchers.IO) {
|
||||
val testText = "Hello, world!"
|
||||
val arguments = mapOf("text" to testText)
|
||||
|
||||
val result = client.callTool(basicToolName, arguments) as CallToolResultBase
|
||||
|
||||
val expectedToolResult = "[TextContent(text=Echo: Hello, world!, annotations=null)]"
|
||||
assertEquals(expectedToolResult, result.content.toString(), "Unexpected tool result")
|
||||
|
||||
val actualContent = result.structuredContent.toString()
|
||||
val expectedContent = """
|
||||
{
|
||||
"result" : "Hello, world!"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
actualContent shouldEqualJson expectedContent
|
||||
}
|
||||
|
||||
@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(
|
||||
name = complexToolName,
|
||||
arguments = arguments,
|
||||
),
|
||||
) as CallToolResultBase
|
||||
|
||||
val actualContent = result.structuredContent.toString()
|
||||
val expectedContent = """
|
||||
{
|
||||
"name" : "John Galt",
|
||||
"age" : 30,
|
||||
"address" : {
|
||||
"street" : "123 Main St",
|
||||
"city" : "New York",
|
||||
"country" : "USA"
|
||||
},
|
||||
"options" : [ "option1", "option2", "option3" ]
|
||||
}
|
||||
""".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) as CallToolResultBase
|
||||
|
||||
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) as CallToolResultBase
|
||||
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) as CallToolResultBase
|
||||
|
||||
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<CallToolResultBase?>()
|
||||
|
||||
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 exception = assertThrows<IllegalStateException> {
|
||||
runBlocking {
|
||||
client.callTool(nonExistentToolName, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
val msg = exception.message ?: ""
|
||||
val expectedMessage = "JSONRPCError(code=InternalError, message=Tool not found: non-existent-tool, data={})"
|
||||
|
||||
assertEquals(expectedMessage, msg, "Unexpected error message for non-existent tool")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.sse
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.AbstractPromptIntegrationTest
|
||||
|
||||
class PromptIntegrationTestSse : AbstractPromptIntegrationTest() {
|
||||
override val transportKind: TransportKind = TransportKind.SSE
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.sse
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.AbstractResourceIntegrationTest
|
||||
|
||||
class ResourceIntegrationTestSse : AbstractResourceIntegrationTest() {
|
||||
override val transportKind: TransportKind = TransportKind.SSE
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.sse
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.AbstractToolIntegrationTest
|
||||
|
||||
class ToolIntegrationTestSse : AbstractToolIntegrationTest() {
|
||||
override val transportKind: TransportKind = TransportKind.SSE
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.stdio
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.AbstractPromptIntegrationTest
|
||||
|
||||
class PromptIntegrationTestStdio : AbstractPromptIntegrationTest() {
|
||||
override val transportKind: TransportKind = TransportKind.STDIO
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.stdio
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.AbstractResourceIntegrationTest
|
||||
|
||||
class ResourceIntegrationTestStdio : AbstractResourceIntegrationTest() {
|
||||
override val transportKind: TransportKind = TransportKind.STDIO
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.stdio
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.AbstractToolIntegrationTest
|
||||
|
||||
class ToolIntegrationTestStdio : AbstractToolIntegrationTest() {
|
||||
override val transportKind: TransportKind = TransportKind.STDIO
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
|
||||
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 AbstractKotlinClientTsServerTest : 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 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Timeout
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
abstract class AbstractTsClientKotlinServerTest : TsTestBase() {
|
||||
|
||||
protected open fun beforeServer() {}
|
||||
protected open fun afterServer() {}
|
||||
|
||||
/**
|
||||
* Run the TypeScript client against the prepared server and return its console output.
|
||||
*/
|
||||
protected abstract fun runClient(vararg args: String): String
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun toolCall() = runTest {
|
||||
beforeServer()
|
||||
try {
|
||||
val testName = "TestUser"
|
||||
val out = runClient("greet", testName)
|
||||
assertTrue(out.contains("Text content:"), "Output should contain the text content section.\n$out")
|
||||
assertTrue(out.contains("Hello, $testName!"), "Tool response should contain the greeting.\n$out")
|
||||
assertTrue(
|
||||
out.contains("Structured content:"),
|
||||
"Output should contain the structured content section.\n$out",
|
||||
)
|
||||
assertTrue(
|
||||
out.contains(
|
||||
"\"greeting\": \"Hello, $testName!\"",
|
||||
) ||
|
||||
out.contains("greeting") ||
|
||||
out.contains("greet"),
|
||||
"Structured content should contain the greeting.\n$out",
|
||||
)
|
||||
} finally {
|
||||
afterServer()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(60, unit = TimeUnit.SECONDS)
|
||||
fun notifications() = runTest {
|
||||
beforeServer()
|
||||
try {
|
||||
val name = "NotifUser"
|
||||
val out = runClient("multi-greet", name)
|
||||
assertTrue(
|
||||
out.contains("Multiple greetings") || out.contains("greeting"),
|
||||
"Tool response should contain greeting message.\n$out",
|
||||
)
|
||||
assertTrue(
|
||||
out.contains("\"notificationCount\": 3") || out.contains("notificationCount: 3"),
|
||||
"Structured content should indicate that 3 notifications were emitted by the server.\nOutput:\n$out",
|
||||
)
|
||||
} finally {
|
||||
afterServer()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(120, unit = TimeUnit.SECONDS)
|
||||
fun multipleClientSequence() = runTest {
|
||||
beforeServer()
|
||||
try {
|
||||
val out1 = runClient("greet", "FirstClient")
|
||||
assertTrue(out1.contains("Hello, FirstClient!"), "Should greet first client.\n$out1")
|
||||
|
||||
val out2 = runClient("multi-greet", "SecondClient")
|
||||
assertTrue(
|
||||
out2.contains("Multiple greetings") || out2.contains("greeting"),
|
||||
"Should respond for second client.\n$out2",
|
||||
)
|
||||
|
||||
val out3 = runClient()
|
||||
assertTrue(out3.contains("Available utils:"), "Should list available utils.\n$out3")
|
||||
assertTrue(out3.contains("greet"), "Greet tool should be available.\n$out3")
|
||||
assertTrue(out3.contains("multi-greet"), "Multi-greet tool should be available.\n$out3")
|
||||
} finally {
|
||||
afterServer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
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 KotlinClientTypeScriptServerTest : TypeScriptTestBase() {
|
||||
|
||||
private var port: Int = 0
|
||||
private val host = "localhost"
|
||||
private lateinit var serverUrl: String
|
||||
|
||||
private lateinit var client: Client
|
||||
private lateinit var tsServerProcess: Process
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
port = findFreePort()
|
||||
serverUrl = "http://$host:$port/mcp"
|
||||
tsServerProcess = startTypeScriptServer(port)
|
||||
println("TypeScript server started on port $port")
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
if (::client.isInitialized) {
|
||||
try {
|
||||
runBlocking {
|
||||
withTimeout(3.seconds) {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Warning: Error during client close: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
if (::tsServerProcess.isInitialized) {
|
||||
try {
|
||||
println("Stopping TypeScript server")
|
||||
stopProcess(tsServerProcess)
|
||||
} catch (e: Exception) {
|
||||
println("Warning: Error during TypeScript server stop: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testKotlinClientConnectsToTypeScriptServer(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClient(serverUrl) { client ->
|
||||
assertNotNull(client, "Client should be initialized")
|
||||
|
||||
val pingResult = client.ping()
|
||||
assertNotNull(pingResult, "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 testListTools(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClient(serverUrl) { client ->
|
||||
val result = client.listTools()
|
||||
assertNotNull(result, "Tools list should not be null")
|
||||
assertTrue(result.tools.isNotEmpty(), "Tools list should not be empty")
|
||||
|
||||
// Verify specific utils are available
|
||||
val toolNames = result.tools.map { it.name }
|
||||
assertTrue(toolNames.contains("greet"), "Greet tool should be available")
|
||||
assertTrue(toolNames.contains("multi-greet"), "Multi-greet tool should be available")
|
||||
assertTrue(toolNames.contains("collect-user-info"), "Collect-user-info tool should be available")
|
||||
|
||||
println("Available utils: ${toolNames.joinToString()}")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testToolCall(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClient(serverUrl) { 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 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,
|
||||
"Tool response should contain the greeting with the provided name",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testMultipleClients(): Unit = runBlocking(Dispatchers.IO) {
|
||||
val client1 = newClient(serverUrl)
|
||||
val client2 = newClient(serverUrl)
|
||||
try {
|
||||
val tools1 = client1.listTools()
|
||||
assertNotNull(tools1, "Tools list for first client should not be null")
|
||||
assertTrue(tools1.tools.isNotEmpty(), "Tools list for first client should not be empty")
|
||||
|
||||
val tools2 = client2.listTools()
|
||||
assertNotNull(tools2, "Tools list for second client should not be null")
|
||||
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(toolNames1.contains("greet"), "Greet tool should be available to first client")
|
||||
assertTrue(toolNames1.contains("multi-greet"), "Multi-greet tool should be available to first client")
|
||||
assertTrue(toolNames2.contains("greet"), "Greet tool should be available to second client")
|
||||
assertTrue(toolNames2.contains("multi-greet"), "Multi-greet tool should be available to second client")
|
||||
} finally {
|
||||
client1.close()
|
||||
client2.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,454 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.sse.SSE
|
||||
import io.modelcontextprotocol.kotlin.sdk.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.mcpStreamableHttp
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse.KotlinServerForTsClient
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.utils.Retry
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
|
||||
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.BeforeAll
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
enum class TransportKind { SSE, STDIO, DEFAULT }
|
||||
|
||||
@Retry(times = 3)
|
||||
abstract class TsTestBase {
|
||||
|
||||
protected open val transportKind: TransportKind = TransportKind.DEFAULT
|
||||
|
||||
protected val projectRoot: File get() = File(System.getProperty("user.dir"))
|
||||
protected val tsClientDir: File
|
||||
get() {
|
||||
val base = File(
|
||||
projectRoot,
|
||||
"src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript",
|
||||
)
|
||||
|
||||
// Allow override via system property for CI: -Dts.transport=stdio|sse
|
||||
val fromProp = System.getProperty("ts.transport")?.lowercase()
|
||||
val overrideSubDir = when (fromProp) {
|
||||
"stdio" -> "stdio"
|
||||
"sse" -> "sse"
|
||||
else -> null
|
||||
}
|
||||
|
||||
val subDirName = overrideSubDir ?: when (transportKind) {
|
||||
TransportKind.STDIO -> "stdio"
|
||||
TransportKind.SSE -> "sse"
|
||||
TransportKind.DEFAULT -> null
|
||||
}
|
||||
if (subDirName != null) {
|
||||
val sub = File(base, subDirName)
|
||||
if (sub.exists()) return sub
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
private val tempRootDir: File = createTempDirectory("typescript-sdk-").toFile().apply { deleteOnExit() }
|
||||
|
||||
@JvmStatic
|
||||
protected val sdkDir: File = File(tempRootDir, "typescript-sdk")
|
||||
|
||||
@JvmStatic
|
||||
@BeforeAll
|
||||
fun setupTypeScriptSdk() {
|
||||
println("Cloning TypeScript SDK repository")
|
||||
|
||||
if (!sdkDir.exists()) {
|
||||
val process = ProcessBuilder(
|
||||
"git",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"https://github.com/modelcontextprotocol/typescript-sdk.git",
|
||||
sdkDir.absolutePath,
|
||||
)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val exitCode = process.waitFor()
|
||||
if (exitCode != 0) {
|
||||
throw RuntimeException("Failed to clone TypeScript SDK repository: exit code $exitCode")
|
||||
}
|
||||
}
|
||||
|
||||
println("Installing TypeScript SDK dependencies")
|
||||
executeCommand("npm install", sdkDir, allowFailure = false, timeoutSeconds = null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
protected fun killProcessOnPort(port: Int) {
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val killCommand = if (isWindows) {
|
||||
"netstat -ano | findstr :$port | for /f \"tokens=5\" %a in ('more')" +
|
||||
" do taskkill /F /PID %a 2>nul || echo No process found"
|
||||
} else {
|
||||
"lsof -ti:$port | xargs kill -9 2>/dev/null || true"
|
||||
}
|
||||
executeCommand(killCommand, File("."), allowFailure = true, timeoutSeconds = null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
protected fun findFreePort(): Int {
|
||||
ServerSocket(0).use { socket ->
|
||||
return socket.localPort
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
protected fun executeCommand(
|
||||
command: String,
|
||||
workingDir: File,
|
||||
allowFailure: Boolean = false,
|
||||
timeoutSeconds: Long? = null,
|
||||
): String {
|
||||
if (!workingDir.exists()) {
|
||||
if (!workingDir.mkdirs()) {
|
||||
throw RuntimeException("Failed to create working directory: ${workingDir.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
if (!workingDir.isDirectory || !workingDir.canRead()) {
|
||||
throw RuntimeException("Working directory is not accessible: ${workingDir.absolutePath}")
|
||||
}
|
||||
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val processBuilder = if (isWindows) {
|
||||
ProcessBuilder()
|
||||
.command("cmd.exe", "/c", "set TYPESCRIPT_SDK_DIR=${sdkDir.absolutePath} && $command")
|
||||
} else {
|
||||
ProcessBuilder()
|
||||
.command("bash", "-c", "TYPESCRIPT_SDK_DIR='${sdkDir.absolutePath}' $command")
|
||||
}
|
||||
|
||||
val process = processBuilder
|
||||
.directory(workingDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
val output = StringBuilder()
|
||||
BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
println(line)
|
||||
output.append(line).append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (timeoutSeconds == null) {
|
||||
val exitCode = process.waitFor()
|
||||
if (!allowFailure && exitCode != 0) {
|
||||
throw RuntimeException(
|
||||
"Command execution failed with exit code $exitCode: $command\n" +
|
||||
"Working dir: ${workingDir.absolutePath}\nOutput:\n$output",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
return output.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForProcessTermination(process: Process, timeoutSeconds: Long): Boolean {
|
||||
if (process.isAlive && !process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly()
|
||||
process.waitFor(2, TimeUnit.SECONDS)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun createProcessOutputReader(process: Process, prefix: String = "TS-SERVER"): Thread {
|
||||
val outputReader = Thread {
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines ->
|
||||
for (line in lines) {
|
||||
println("[$prefix] $line")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Warning: Error reading process output: ${e.message}")
|
||||
}
|
||||
}
|
||||
outputReader.isDaemon = true
|
||||
return outputReader
|
||||
}
|
||||
|
||||
private fun createProcessErrorReader(process: Process, prefix: String = "TS-SERVER"): Thread {
|
||||
val errorReader = Thread {
|
||||
try {
|
||||
process.errorStream.bufferedReader().useLines { lines ->
|
||||
for (line in lines) {
|
||||
println("[$prefix][err] $line")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Warning: Error reading process error stream: ${e.message}")
|
||||
}
|
||||
}
|
||||
errorReader.isDaemon = true
|
||||
return errorReader
|
||||
}
|
||||
|
||||
protected fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Long = 10): Boolean = try {
|
||||
await.atMost(timeoutSeconds, TimeUnit.SECONDS)
|
||||
.pollDelay(200, TimeUnit.MILLISECONDS)
|
||||
.pollInterval(100, TimeUnit.MILLISECONDS)
|
||||
.until {
|
||||
try {
|
||||
Socket(host, port).use { true }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
protected fun executeCommandAllowingFailure(command: String, workingDir: File, timeoutSeconds: Long = 20): String =
|
||||
executeCommand(command, workingDir, allowFailure = true, timeoutSeconds = timeoutSeconds)
|
||||
|
||||
protected fun startTypeScriptServer(port: Int): Process {
|
||||
killProcessOnPort(port)
|
||||
|
||||
if (!sdkDir.exists() || !sdkDir.isDirectory) {
|
||||
throw IllegalStateException(
|
||||
"TypeScript SDK directory does not exist or is not accessible: ${sdkDir.absolutePath}",
|
||||
)
|
||||
}
|
||||
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val localServerPath = File(tsClientDir, "simpleStreamableHttp.ts").absolutePath
|
||||
val processBuilder = if (isWindows) {
|
||||
ProcessBuilder()
|
||||
.command(
|
||||
"cmd.exe",
|
||||
"/c",
|
||||
"set MCP_PORT=$port && set NODE_PATH=${sdkDir.absolutePath}\\node_modules && npx --prefix \"${sdkDir.absolutePath}\" tsx \"$localServerPath\"",
|
||||
)
|
||||
} else {
|
||||
ProcessBuilder()
|
||||
.command(
|
||||
"bash",
|
||||
"-c",
|
||||
"MCP_PORT=$port NODE_PATH='${sdkDir.absolutePath}/node_modules' npx --prefix '${sdkDir.absolutePath}' tsx \"$localServerPath\"",
|
||||
)
|
||||
}
|
||||
|
||||
processBuilder.environment()["TYPESCRIPT_SDK_DIR"] = sdkDir.absolutePath
|
||||
|
||||
val process = processBuilder
|
||||
.directory(tsClientDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
createProcessOutputReader(process).start()
|
||||
|
||||
if (!waitForPort(port = port, timeoutSeconds = 20)) {
|
||||
throw IllegalStateException("TypeScript server did not become ready on localhost:$port within timeout")
|
||||
}
|
||||
return process
|
||||
}
|
||||
|
||||
protected fun stopProcess(process: Process, waitSeconds: Long = 3, name: String = "TypeScript server") {
|
||||
process.destroy()
|
||||
if (waitForProcessTermination(process, waitSeconds)) {
|
||||
println("$name stopped gracefully")
|
||||
} else {
|
||||
println("$name did not stop gracefully, forced termination")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SSE client helpers =====
|
||||
protected suspend fun newClient(serverUrl: String): Client =
|
||||
HttpClient(CIO) { install(SSE) }.mcpStreamableHttp(serverUrl)
|
||||
|
||||
protected suspend fun <T> withClient(serverUrl: String, block: suspend (Client) -> T): T {
|
||||
val client = newClient(serverUrl)
|
||||
return try {
|
||||
withTimeout(20.seconds) { block(client) }
|
||||
} finally {
|
||||
try {
|
||||
withTimeout(3.seconds) { client.close() }
|
||||
} catch (_: Exception) {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== STDIO client + server helpers =====
|
||||
protected fun startTypeScriptServerStdio(): Process {
|
||||
if (!sdkDir.exists() || !sdkDir.isDirectory) {
|
||||
throw IllegalStateException(
|
||||
"TypeScript SDK directory does not exist or is not accessible: ${sdkDir.absolutePath}",
|
||||
)
|
||||
}
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val localServerPath = File(tsClientDir, "simpleStdio.ts").absolutePath
|
||||
val processBuilder = if (isWindows) {
|
||||
ProcessBuilder()
|
||||
.command(
|
||||
"cmd.exe",
|
||||
"/c",
|
||||
"set NODE_PATH=${sdkDir.absolutePath}\\node_modules && npx --prefix \"${sdkDir.absolutePath}\" tsx \"$localServerPath\"",
|
||||
)
|
||||
} else {
|
||||
ProcessBuilder()
|
||||
.command(
|
||||
"bash",
|
||||
"-c",
|
||||
"NODE_PATH='${sdkDir.absolutePath}/node_modules' npx --prefix '${sdkDir.absolutePath}' tsx \"$localServerPath\"",
|
||||
)
|
||||
}
|
||||
processBuilder.environment()["TYPESCRIPT_SDK_DIR"] = sdkDir.absolutePath
|
||||
val process = processBuilder
|
||||
.directory(tsClientDir)
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
// For stdio transports, do NOT read from stdout (it's used for protocol). Read stderr for logs only.
|
||||
createProcessErrorReader(process, prefix = "TS-SERVER-STDIO").start()
|
||||
// Give the process a moment to start
|
||||
await.atMost(2, TimeUnit.SECONDS)
|
||||
.pollDelay(200, TimeUnit.MILLISECONDS)
|
||||
.pollInterval(100, TimeUnit.MILLISECONDS)
|
||||
.until { process.isAlive }
|
||||
return process
|
||||
}
|
||||
|
||||
protected suspend fun newClientStdio(process: Process): Client {
|
||||
val input: Source = process.inputStream.asSource().buffered()
|
||||
val output: Sink = process.outputStream.asSink().buffered()
|
||||
val transport = StdioClientTransport(input = input, output = output)
|
||||
val client = Client(Implementation("test", "1.0"))
|
||||
client.connect(transport)
|
||||
return client
|
||||
}
|
||||
|
||||
protected suspend fun <T> withClientStdio(block: suspend (Client, Process) -> T): T {
|
||||
val proc = startTypeScriptServerStdio()
|
||||
val client = newClientStdio(proc)
|
||||
return try {
|
||||
withTimeout(20.seconds) { block(client, proc) }
|
||||
} finally {
|
||||
try {
|
||||
withTimeout(3.seconds) { client.close() }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
stopProcess(proc, name = "TypeScript stdio server")
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Helpers to run TypeScript client over STDIO against Kotlin server over STDIO =====
|
||||
protected fun runStdioClient(vararg args: String): String {
|
||||
// Start Node stdio client (it will speak MCP over its stdout/stdin)
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val clientPath = File(tsClientDir, "myClient.ts").absolutePath
|
||||
|
||||
val process = if (isWindows) {
|
||||
ProcessBuilder()
|
||||
.command(
|
||||
"cmd.exe",
|
||||
"/c",
|
||||
(
|
||||
"set TYPESCRIPT_SDK_DIR=${sdkDir.absolutePath} && " +
|
||||
"set NODE_PATH=${sdkDir.absolutePath}\\node_modules && " +
|
||||
"npx --prefix \"${sdkDir.absolutePath}\" tsx \"$clientPath\" " +
|
||||
args.joinToString(" ")
|
||||
),
|
||||
)
|
||||
.directory(tsClientDir)
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
} else {
|
||||
ProcessBuilder()
|
||||
.command(
|
||||
"bash",
|
||||
"-c",
|
||||
(
|
||||
"TYPESCRIPT_SDK_DIR='${sdkDir.absolutePath}' " +
|
||||
"NODE_PATH='${sdkDir.absolutePath}/node_modules' " +
|
||||
"npx --prefix '${sdkDir.absolutePath}' tsx \"$clientPath\" " +
|
||||
args.joinToString(" ")
|
||||
),
|
||||
)
|
||||
.directory(tsClientDir)
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
}
|
||||
|
||||
// Create Kotlin server and attach stdio transport to the process streams
|
||||
val server: Server = KotlinServerForTsClient().createMcpServer()
|
||||
val transport = StdioServerTransport(
|
||||
inputStream = process.inputStream.asSource().buffered(),
|
||||
outputStream = process.outputStream.asSink().buffered(),
|
||||
)
|
||||
|
||||
// Connect server in a background thread to avoid blocking
|
||||
val serverThread = Thread {
|
||||
try {
|
||||
kotlinx.coroutines.runBlocking { server.connect(transport) }
|
||||
} catch (e: Exception) {
|
||||
println("[STDIO-SERVER] Error connecting: ${e.message}")
|
||||
}
|
||||
}
|
||||
serverThread.isDaemon = true
|
||||
serverThread.start()
|
||||
|
||||
// Read ONLY stderr from client for human-readable output
|
||||
val output = StringBuilder()
|
||||
val errReader = Thread {
|
||||
try {
|
||||
process.errorStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
println("[TS-CLIENT-STDIO][err] $line")
|
||||
output.append(line).append('\n')
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Warning: Error reading stdio client stderr: ${e.message}")
|
||||
}
|
||||
}
|
||||
errReader.isDaemon = true
|
||||
errReader.start()
|
||||
|
||||
// Wait up to 25s for client to exit
|
||||
val finished = process.waitFor(25, TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
println("Stdio client did not finish in time; destroying")
|
||||
process.destroyForcibly()
|
||||
}
|
||||
|
||||
try {
|
||||
kotlinx.coroutines.runBlocking { transport.close() }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
return output.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Timeout
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.Ignore
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TypeScriptClientKotlinServerTest : TypeScriptTestBase() {
|
||||
|
||||
private var port: Int = 0
|
||||
private lateinit var serverUrl: String
|
||||
private var httpServer: KotlinServerForTypeScriptClient? = null
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
port = findFreePort()
|
||||
serverUrl = "http://localhost:$port/mcp"
|
||||
killProcessOnPort(port)
|
||||
httpServer = KotlinServerForTypeScriptClient()
|
||||
httpServer?.start(port)
|
||||
if (!waitForPort(port = port)) {
|
||||
throw IllegalStateException("Kotlin test server did not become ready on localhost:$port within timeout")
|
||||
}
|
||||
println("Kotlin server started on port $port")
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
try {
|
||||
httpServer?.stop()
|
||||
println("HTTP server stopped")
|
||||
} catch (e: Exception) {
|
||||
println("Error during server shutdown: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testToolCall() = runTest {
|
||||
val testName = "TestUser"
|
||||
val command = "npx tsx myClient.ts $serverUrl greet $testName"
|
||||
val output = executeCommand(command, tsClientDir)
|
||||
|
||||
assertTrue(
|
||||
output.contains("Hello, $testName!"),
|
||||
"Tool response should contain the greeting with the provided name",
|
||||
)
|
||||
assertTrue(output.contains("Tool result:"), "Output should indicate a successful tool call")
|
||||
assertTrue(output.contains("Text content:"), "Output should contain the text content section")
|
||||
assertTrue(output.contains("Structured content:"), "Output should contain the structured content section")
|
||||
assertTrue(
|
||||
output.contains("\"greeting\": \"Hello, $testName!\""),
|
||||
"Structured content should contain the greeting",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testNotifications() = runTest {
|
||||
val name = "NotifUser"
|
||||
val command = "npx tsx myClient.ts $serverUrl multi-greet $name"
|
||||
val output = executeCommand(command, tsClientDir)
|
||||
|
||||
assertTrue(
|
||||
output.contains("Multiple greetings") || output.contains("greeting"),
|
||||
"Tool response should contain greeting message",
|
||||
)
|
||||
// verify that the server sent 3 notifications
|
||||
assertTrue(
|
||||
output.contains("\"notificationCount\": 3") || output.contains("notificationCount: 3"),
|
||||
"Structured content should indicate that 3 notifications were emitted by the server.\nOutput:\n$output",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(120, unit = TimeUnit.SECONDS)
|
||||
fun testMultipleClientSequence() = runTest {
|
||||
val testName1 = "FirstClient"
|
||||
val command1 = "npx tsx myClient.ts $serverUrl greet $testName1"
|
||||
val output1 = executeCommand(command1, tsClientDir)
|
||||
|
||||
assertTrue(output1.contains("Connected to server"), "First client should connect to server")
|
||||
assertTrue(output1.contains("Hello, $testName1!"), "Tool response should contain the greeting for first client")
|
||||
assertTrue(output1.contains("Disconnected from server"), "First client should disconnect cleanly")
|
||||
|
||||
val testName2 = "SecondClient"
|
||||
val command2 = "npx tsx myClient.ts $serverUrl multi-greet $testName2"
|
||||
val output2 = executeCommand(command2, tsClientDir)
|
||||
|
||||
assertTrue(output2.contains("Connected to server"), "Second client should connect to server")
|
||||
assertTrue(
|
||||
output2.contains("Multiple greetings") || output2.contains("greeting"),
|
||||
"Tool response should contain greeting message",
|
||||
)
|
||||
assertTrue(output2.contains("Disconnected from server"), "Second client should disconnect cleanly")
|
||||
|
||||
val command3 = "npx tsx myClient.ts $serverUrl"
|
||||
val output3 = executeCommand(command3, tsClientDir)
|
||||
|
||||
assertTrue(output3.contains("Connected to server"), "Third client should connect to server")
|
||||
assertTrue(output3.contains("Available utils:"), "Third client should list available utils")
|
||||
assertTrue(output3.contains("greet"), "Greet tool should be available to third client")
|
||||
assertTrue(output3.contains("multi-greet"), "Multi-greet tool should be available to third client")
|
||||
assertTrue(output3.contains("Disconnected from server"), "Third client should disconnect cleanly")
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
@Ignore // Ignored due to flaky, see issue https://github.com/modelcontextprotocol/kotlin-sdk/issues/262
|
||||
fun testMultipleClientParallel() = runTest {
|
||||
val clientCount = 3
|
||||
val clients = listOf(
|
||||
"FirstClient" to "greet",
|
||||
"SecondClient" to "multi-greet",
|
||||
"ThirdClient" to "",
|
||||
)
|
||||
|
||||
val threads = mutableListOf<Thread>()
|
||||
val outputs = mutableListOf<Pair<Int, String>>()
|
||||
val exceptions = mutableListOf<Exception>()
|
||||
|
||||
for (i in 0 until clientCount) {
|
||||
val (clientName, toolName) = clients[i]
|
||||
val thread = Thread {
|
||||
try {
|
||||
val command = if (toolName.isEmpty()) {
|
||||
"npx tsx myClient.ts $serverUrl"
|
||||
} else {
|
||||
"npx tsx myClient.ts $serverUrl $toolName $clientName"
|
||||
}
|
||||
|
||||
val output = executeCommand(command, tsClientDir)
|
||||
synchronized(outputs) {
|
||||
outputs.add(i to output)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
synchronized(exceptions) {
|
||||
exceptions.add(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
threads.add(thread)
|
||||
thread.start()
|
||||
Thread.sleep(500)
|
||||
}
|
||||
|
||||
threads.forEach { it.join() }
|
||||
|
||||
if (exceptions.isNotEmpty()) {
|
||||
println(
|
||||
"Exceptions occurred in parallel clients: ${
|
||||
exceptions.joinToString {
|
||||
it.message ?: it.toString()
|
||||
}
|
||||
}",
|
||||
)
|
||||
}
|
||||
|
||||
val sortedOutputs = outputs.sortedBy { it.first }.map { it.second }
|
||||
|
||||
sortedOutputs.forEachIndexed { index, output ->
|
||||
val clientName = clients[index].first
|
||||
val toolName = clients[index].second
|
||||
|
||||
when (toolName) {
|
||||
"greet" -> {
|
||||
val containsGreeting = output.contains("Hello, $clientName!") ||
|
||||
output.contains("\"greeting\": \"Hello, $clientName!\"")
|
||||
assertTrue(
|
||||
containsGreeting,
|
||||
"Tool response should contain the greeting for $clientName",
|
||||
)
|
||||
}
|
||||
|
||||
"multi-greet" -> {
|
||||
val containsGreeting = output.contains("Multiple greetings") ||
|
||||
output.contains("greeting") ||
|
||||
output.contains("greet")
|
||||
assertTrue(
|
||||
containsGreeting,
|
||||
"Tool response should contain greeting message for $clientName",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.sse.SSE
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.mcpStreamableHttp
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.utils.Retry
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Retry(times = 3)
|
||||
abstract class TypeScriptTestBase {
|
||||
|
||||
protected val projectRoot: File get() = File(System.getProperty("user.dir"))
|
||||
protected val tsClientDir: File
|
||||
get() = File(
|
||||
projectRoot,
|
||||
"src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript",
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
private val tempRootDir: File = Files.createTempDirectory("typescript-sdk-").toFile().apply { deleteOnExit() }
|
||||
|
||||
@JvmStatic
|
||||
protected val sdkDir: File = File(tempRootDir, "typescript-sdk")
|
||||
|
||||
@JvmStatic
|
||||
@BeforeAll
|
||||
fun setupTypeScriptSdk() {
|
||||
println("Cloning TypeScript SDK repository")
|
||||
|
||||
if (!sdkDir.exists()) {
|
||||
val process = ProcessBuilder(
|
||||
"git",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"https://github.com/modelcontextprotocol/typescript-sdk.git",
|
||||
sdkDir.absolutePath,
|
||||
)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val exitCode = process.waitFor()
|
||||
if (exitCode != 0) {
|
||||
throw RuntimeException("Failed to clone TypeScript SDK repository: exit code $exitCode")
|
||||
}
|
||||
}
|
||||
|
||||
println("Installing TypeScript SDK dependencies")
|
||||
executeCommand("npm install", sdkDir, allowFailure = false, timeoutSeconds = null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
protected fun killProcessOnPort(port: Int) {
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val killCommand = if (isWindows) {
|
||||
"netstat -ano | findstr :$port | for /f \"tokens=5\" %a in ('more') do taskkill /F /PID %a 2>nul || echo No process found"
|
||||
} else {
|
||||
"lsof -ti:$port | xargs kill -9 2>/dev/null || true"
|
||||
}
|
||||
executeCommand(killCommand, File("."), allowFailure = true, timeoutSeconds = null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
protected fun findFreePort(): Int {
|
||||
ServerSocket(0).use { socket ->
|
||||
return socket.localPort
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
protected fun executeCommand(
|
||||
command: String,
|
||||
workingDir: File,
|
||||
allowFailure: Boolean = false,
|
||||
timeoutSeconds: Long? = null,
|
||||
): String {
|
||||
if (!workingDir.exists()) {
|
||||
if (!workingDir.mkdirs()) {
|
||||
throw RuntimeException("Failed to create working directory: ${workingDir.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
if (!workingDir.isDirectory || !workingDir.canRead()) {
|
||||
throw RuntimeException("Working directory is not accessible: ${workingDir.absolutePath}")
|
||||
}
|
||||
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val processBuilder = if (isWindows) {
|
||||
ProcessBuilder()
|
||||
.command("cmd.exe", "/c", "set TYPESCRIPT_SDK_DIR=${sdkDir.absolutePath} && $command")
|
||||
} else {
|
||||
ProcessBuilder()
|
||||
.command("bash", "-c", "TYPESCRIPT_SDK_DIR='${sdkDir.absolutePath}' $command")
|
||||
}
|
||||
|
||||
val process = processBuilder
|
||||
.directory(workingDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
val output = StringBuilder()
|
||||
BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
println(line)
|
||||
output.append(line).append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (timeoutSeconds == null) {
|
||||
val exitCode = process.waitFor()
|
||||
if (!allowFailure && exitCode != 0) {
|
||||
throw RuntimeException(
|
||||
"Command execution failed with exit code $exitCode: $command\nWorking dir: ${workingDir.absolutePath}\nOutput:\n$output",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
return output.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForProcessTermination(process: Process, timeoutSeconds: Long): Boolean {
|
||||
if (process.isAlive && !process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly()
|
||||
process.waitFor(2, TimeUnit.SECONDS)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun createProcessOutputReader(process: Process, prefix: String = "TS-SERVER"): Thread {
|
||||
val outputReader = Thread {
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines ->
|
||||
for (line in lines) {
|
||||
println("[$prefix] $line")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Warning: Error reading process output: ${e.message}")
|
||||
}
|
||||
}
|
||||
outputReader.isDaemon = true
|
||||
return outputReader
|
||||
}
|
||||
|
||||
protected fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Long = 10): Boolean {
|
||||
val deadline = System.currentTimeMillis() + timeoutSeconds * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
try {
|
||||
Socket(host, port).use { return true }
|
||||
} catch (_: Exception) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
protected fun executeCommandAllowingFailure(command: String, workingDir: File, timeoutSeconds: Long = 20): String =
|
||||
executeCommand(command, workingDir, allowFailure = true, timeoutSeconds = timeoutSeconds)
|
||||
|
||||
protected fun startTypeScriptServer(port: Int): Process {
|
||||
killProcessOnPort(port)
|
||||
|
||||
if (!sdkDir.exists() || !sdkDir.isDirectory) {
|
||||
throw IllegalStateException(
|
||||
"TypeScript SDK directory does not exist or is not accessible: ${sdkDir.absolutePath}",
|
||||
)
|
||||
}
|
||||
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val localServerPath = File(tsClientDir, "simpleStreamableHttp.ts").absolutePath
|
||||
val processBuilder = if (isWindows) {
|
||||
ProcessBuilder()
|
||||
.command(
|
||||
"cmd.exe",
|
||||
"/c",
|
||||
"set MCP_PORT=$port && set NODE_PATH=${sdkDir.absolutePath}\\node_modules && npx --prefix \"${sdkDir.absolutePath}\" tsx \"$localServerPath\"",
|
||||
)
|
||||
} else {
|
||||
ProcessBuilder()
|
||||
.command(
|
||||
"bash",
|
||||
"-c",
|
||||
"MCP_PORT=$port NODE_PATH='${sdkDir.absolutePath}/node_modules' npx --prefix '${sdkDir.absolutePath}' tsx \"$localServerPath\"",
|
||||
)
|
||||
}
|
||||
|
||||
processBuilder.environment()["TYPESCRIPT_SDK_DIR"] = sdkDir.absolutePath
|
||||
|
||||
val process = processBuilder
|
||||
.directory(tsClientDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
createProcessOutputReader(process).start()
|
||||
|
||||
if (!waitForPort(port = port, timeoutSeconds = 20)) {
|
||||
throw IllegalStateException("TypeScript server did not become ready on localhost:$port within timeout")
|
||||
}
|
||||
return process
|
||||
}
|
||||
|
||||
protected fun stopProcess(process: Process, waitSeconds: Long = 3, name: String = "TypeScript server") {
|
||||
process.destroy()
|
||||
if (waitForProcessTermination(process, waitSeconds)) {
|
||||
println("$name stopped gracefully")
|
||||
} else {
|
||||
println("$name did not stop gracefully, forced termination")
|
||||
}
|
||||
}
|
||||
|
||||
protected suspend fun newClient(serverUrl: String): Client =
|
||||
HttpClient(CIO) { install(SSE) }.mcpStreamableHttp(serverUrl)
|
||||
|
||||
protected suspend fun <T> withClient(serverUrl: String, block: suspend (Client) -> T): T {
|
||||
val client = newClient(serverUrl)
|
||||
return try {
|
||||
withTimeout(20.seconds) { block(client) }
|
||||
} finally {
|
||||
try {
|
||||
withTimeout(3.seconds) { client.close() }
|
||||
} catch (_: Exception) {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
|
||||
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.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Timeout
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() {
|
||||
|
||||
override val transportKind = TransportKind.SSE
|
||||
|
||||
private var port: Int = 0
|
||||
private val host = "localhost"
|
||||
private lateinit var serverUrl: String
|
||||
|
||||
private lateinit var client: Client
|
||||
private lateinit var tsServerProcess: Process
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
port = findFreePort()
|
||||
serverUrl = "http://$host:$port/mcp"
|
||||
tsServerProcess = startTypeScriptServer(port)
|
||||
println("TypeScript server started on port $port")
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
if (::client.isInitialized) {
|
||||
try {
|
||||
runBlocking {
|
||||
withTimeout(3.seconds) {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Warning: Error during client close: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
if (::tsServerProcess.isInitialized) {
|
||||
try {
|
||||
println("Stopping TypeScript server")
|
||||
stopProcess(tsServerProcess)
|
||||
} catch (e: Exception) {
|
||||
println("Warning: Error during TypeScript server stop: ${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 exception = assertThrows<IllegalStateException> {
|
||||
client.callTool(nonExistentToolName, arguments)
|
||||
}
|
||||
|
||||
val expectedMessage =
|
||||
"JSONRPCError(code=InvalidParams, message=MCP error -32602: Tool non-existent-tool not found, data={})"
|
||||
assertEquals(
|
||||
expectedMessage,
|
||||
exception.message,
|
||||
"Unexpected error message for non-existent tool",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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 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 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 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 exception = assertThrows<IllegalStateException> {
|
||||
client.callTool("greet", invalidArguments)
|
||||
}
|
||||
|
||||
val msg = exception.message ?: ""
|
||||
val expectedMessage = """
|
||||
JSONRPCError(code=InvalidParams, message=MCP error -32602: Invalid arguments for tool greet: [
|
||||
{
|
||||
"code": "invalid_type",
|
||||
"expected": "string",
|
||||
"received": "object",
|
||||
"path": [
|
||||
"name"
|
||||
],
|
||||
"message": "Expected string, received object"
|
||||
}
|
||||
], data={})
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(expectedMessage, msg, "Unexpected error message for invalid arguments")
|
||||
}
|
||||
}
|
||||
|
||||
@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 CallToolResult
|
||||
val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Text content should be present for call $i")
|
||||
|
||||
assertEquals(
|
||||
"Hello, $name!",
|
||||
textContent.text,
|
||||
"Tool response should contain the greeting with the provided name",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue