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:
Sergey Karpov 2025-09-16 23:23:51 +03:00 • committed by GitHub
commit 8ead8d3d4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2228 additions and 1727 deletions

View file

@ -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

View file

@ -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")
}
}
}

View file

@ -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")
}
}

View file

@ -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
}
}
}
}

View file

@ -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")
}
}
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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")
}
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}

View file

@ -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",
)
}
}
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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