Introduce Kotlin client/server <--> TypeScript client/server integration tests (#216)
* Introduce Kotlin<->TypeScript integration tests Signed-off-by: Sergey Karpov <sergey.karpov@jetbrains.com> * Add notifications test Signed-off-by: Sergey Karpov <sergey.karpov@jetbrains.com> * fixup! Introduce Kotlin integration tests Signed-off-by: Sergey Karpov <sergey.karpov@jetbrains.com> * fixup! Introduce Kotlin integration tests Signed-off-by: Sergey Karpov <sergey.karpov@jetbrains.com> * fixup! Introduce Kotlin integration tests Signed-off-by: Sergey Karpov <sergey.karpov@jetbrains.com> * cleanup Signed-off-by: Sergey Karpov <sergey.karpov@jetbrains.com> * Fix tests on Windows * fixup! Fix tests on Windows Signed-off-by: Sergey Karpov <sergey.karpov@jetbrains.com> * fixup! Fix tests on Windows * fixup! Fix tests on Windows Signed-off-by: Sergey Karpov <sergey.karpov@jetbrains.com> * fixup! Introduce Kotlin integration tests * fixup! Introduce Kotlin integration tests * fixup! Introduce Kotlin integration tests * fixup! Introduce Kotlin integration tests * fixup! Introduce Kotlin integration tests * fixup! Introduce Kotlin integration tests * fixup! Introduce Kotlin integration tests * fixup! Introduce Kotlin integration tests * fixup! Introduce Kotlin integration tests * fixup! Introduce Kotlin integration tests * fixup! Introduce Kotlin integration tests * fixup! Introduce Kotlin integration tests --------- Signed-off-by: Sergey Karpov <sergey.karpov@jetbrains.com>
This commit is contained in:
parent
1d4e2919de
commit
c455974e86
18 changed files with 4724 additions and 41 deletions
|
|
@ -1,41 +0,0 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.client
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.ListToolsResult
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.io.asSink
|
||||
import kotlinx.io.asSource
|
||||
import kotlinx.io.buffered
|
||||
import org.junit.jupiter.api.Disabled
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.net.Socket
|
||||
|
||||
class ClientIntegrationTest {
|
||||
|
||||
fun createTransport(): StdioClientTransport {
|
||||
val socket = Socket("localhost", 3000)
|
||||
|
||||
return StdioClientTransport(
|
||||
socket.inputStream.asSource().buffered(),
|
||||
socket.outputStream.asSink().buffered(),
|
||||
)
|
||||
}
|
||||
|
||||
@Disabled("This test requires a running server")
|
||||
@Test
|
||||
fun testRequestTools() = runTest {
|
||||
val client = Client(
|
||||
Implementation("test", "1.0"),
|
||||
)
|
||||
|
||||
val transport = createTransport()
|
||||
try {
|
||||
client.connect(transport)
|
||||
|
||||
val response: ListToolsResult = client.listTools()
|
||||
println(response.tools)
|
||||
} finally {
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.sse.SSE
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.routing.routing
|
||||
import io.modelcontextprotocol.kotlin.sdk.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport
|
||||
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.mcp
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import io.ktor.server.cio.CIO as ServerCIO
|
||||
import io.ktor.server.sse.SSE as ServerSSE
|
||||
|
||||
@Retry(times = 3)
|
||||
abstract class KotlinTestBase {
|
||||
|
||||
protected val host = "localhost"
|
||||
protected abstract val port: Int
|
||||
|
||||
protected lateinit var server: Server
|
||||
protected lateinit var client: Client
|
||||
protected lateinit var serverEngine: EmbeddedServer<*, *>
|
||||
|
||||
protected abstract fun configureServerCapabilities(): ServerCapabilities
|
||||
protected abstract fun configureServer()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
setupServer()
|
||||
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)
|
||||
}
|
||||
|
||||
protected fun setupServer() {
|
||||
val capabilities = configureServerCapabilities()
|
||||
|
||||
server = Server(
|
||||
Implementation(name = "test-server", version = "1.0"),
|
||||
ServerOptions(capabilities = capabilities),
|
||||
)
|
||||
|
||||
configureServer()
|
||||
|
||||
serverEngine = embeddedServer(ServerCIO, host = host, port = port) {
|
||||
install(ServerSSE)
|
||||
routing {
|
||||
mcp { server }
|
||||
}
|
||||
}.start(wait = false)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
// close client
|
||||
if (::client.isInitialized) {
|
||||
try {
|
||||
runBlocking {
|
||||
withTimeout(3.seconds) {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Warning: Error during client close: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// stop server
|
||||
if (::serverEngine.isInitialized) {
|
||||
try {
|
||||
serverEngine.stop(500, 1000)
|
||||
} catch (e: Exception) {
|
||||
println("Warning: Error during server stop: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.PromptArgument
|
||||
import io.modelcontextprotocol.kotlin.sdk.PromptMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.Role
|
||||
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.utils.TestUtils.runTest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
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 PromptEdgeCasesTest : KotlinTestBase() {
|
||||
|
||||
override val port = 3008
|
||||
|
||||
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 largePromptName = "large-prompt"
|
||||
private val largePromptDescription = "A very large prompt for testing"
|
||||
private val largePromptContent = "X".repeat(100_000) // 100KB of data
|
||||
|
||||
private val specialCharsPromptName = "special-chars-prompt"
|
||||
private val specialCharsPromptDescription = "A prompt with special characters"
|
||||
private val specialCharsContent = "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t"
|
||||
|
||||
override fun configureServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
prompts = ServerCapabilities.Prompts(
|
||||
listChanged = true,
|
||||
),
|
||||
)
|
||||
|
||||
override fun configureServer() {
|
||||
server.addPrompt(
|
||||
name = basicPromptName,
|
||||
description = basicPromptDescription,
|
||||
arguments = listOf(
|
||||
PromptArgument(
|
||||
name = "name",
|
||||
description = "The name to greet",
|
||||
required = true,
|
||||
),
|
||||
),
|
||||
) { request ->
|
||||
val name = request.arguments?.get("name") ?: "World"
|
||||
|
||||
GetPromptResult(
|
||||
description = basicPromptDescription,
|
||||
messages = listOf(
|
||||
PromptMessage(
|
||||
role = Role.user,
|
||||
content = TextContent(text = "Hello, $name!"),
|
||||
),
|
||||
PromptMessage(
|
||||
role = Role.assistant,
|
||||
content = TextContent(text = "Greetings, $name! How can I assist you today?"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
server.addPrompt(
|
||||
name = complexPromptName,
|
||||
description = complexPromptDescription,
|
||||
arguments = listOf(
|
||||
PromptArgument(name = "arg1", description = "Argument 1", required = true),
|
||||
PromptArgument(name = "arg2", description = "Argument 2", required = true),
|
||||
PromptArgument(name = "arg3", description = "Argument 3", required = true),
|
||||
PromptArgument(name = "arg4", description = "Argument 4", required = false),
|
||||
PromptArgument(name = "arg5", description = "Argument 5", required = false),
|
||||
PromptArgument(name = "arg6", description = "Argument 6", required = false),
|
||||
PromptArgument(name = "arg7", description = "Argument 7", required = false),
|
||||
PromptArgument(name = "arg8", description = "Argument 8", required = false),
|
||||
PromptArgument(name = "arg9", description = "Argument 9", required = false),
|
||||
PromptArgument(name = "arg10", description = "Argument 10", required = false),
|
||||
),
|
||||
) { request ->
|
||||
// validate required arguments
|
||||
val requiredArgs = listOf("arg1", "arg2", "arg3")
|
||||
for (argName in requiredArgs) {
|
||||
if (request.arguments?.get(argName) == null) {
|
||||
throw IllegalArgumentException("Missing required argument: $argName")
|
||||
}
|
||||
}
|
||||
|
||||
val args = mutableMapOf<String, String>()
|
||||
for (i in 1..10) {
|
||||
val argName = "arg$i"
|
||||
val argValue = request.arguments?.get(argName)
|
||||
if (argValue != null) {
|
||||
args[argName] = argValue
|
||||
}
|
||||
}
|
||||
|
||||
GetPromptResult(
|
||||
description = complexPromptDescription,
|
||||
messages = listOf(
|
||||
PromptMessage(
|
||||
role = Role.user,
|
||||
content = TextContent(
|
||||
text = "Arguments: ${
|
||||
args.entries.joinToString {
|
||||
"${it.key}=${it.value}"
|
||||
}
|
||||
}",
|
||||
),
|
||||
),
|
||||
PromptMessage(
|
||||
role = Role.assistant,
|
||||
content = TextContent(text = "Received ${args.size} arguments"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBasicPrompt() = runTest {
|
||||
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")
|
||||
|
||||
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
|
||||
|
||||
val userMessage = result.messages.find { it.role == Role.user }
|
||||
assertNotNull(userMessage, "User message should be in the list")
|
||||
val userContent = userMessage.content as? TextContent
|
||||
assertNotNull(userContent, "User message content should be TextContent")
|
||||
assertEquals("Hello, $testName!", userContent.text, "User message content should match")
|
||||
|
||||
val assistantMessage = result.messages.find { it.role == Role.assistant }
|
||||
assertNotNull(assistantMessage, "Assistant message should be in the list")
|
||||
val assistantContent = assistantMessage.content as? TextContent
|
||||
assertNotNull(assistantContent, "Assistant message content should be TextContent")
|
||||
assertEquals(
|
||||
"Greetings, $testName! How can I assist you today?",
|
||||
assistantContent.text,
|
||||
"Assistant message content should match",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testComplexPromptWithManyArguments() = runTest {
|
||||
val arguments = (1..10).associate { i -> "arg$i" to "value$i" }
|
||||
|
||||
val result = client.getPrompt(
|
||||
GetPromptRequest(
|
||||
name = complexPromptName,
|
||||
arguments = arguments,
|
||||
),
|
||||
)
|
||||
|
||||
assertNotNull(result, "Get prompt result should not be null")
|
||||
assertEquals(complexPromptDescription, result.description, "Prompt description should match")
|
||||
|
||||
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
|
||||
|
||||
val userMessage = result.messages.find { it.role == Role.user }
|
||||
assertNotNull(userMessage, "User message should be in the list")
|
||||
val userContent = userMessage.content as? TextContent
|
||||
assertNotNull(userContent, "User message content should be TextContent")
|
||||
|
||||
// verify all arguments
|
||||
val text = userContent.text ?: ""
|
||||
for (i in 1..10) {
|
||||
assertTrue(text.contains("arg$i=value$i"), "Message should contain arg$i=value$i")
|
||||
}
|
||||
|
||||
val assistantMessage = result.messages.find { it.role == Role.assistant }
|
||||
assertNotNull(assistantMessage, "Assistant message should be in the list")
|
||||
val assistantContent = assistantMessage.content as? TextContent
|
||||
assertNotNull(assistantContent, "Assistant message content should be TextContent")
|
||||
assertEquals(
|
||||
"Received 10 arguments",
|
||||
assistantContent.text,
|
||||
"Assistant message should indicate 10 arguments",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLargePrompt() = runTest {
|
||||
val result = client.getPrompt(
|
||||
GetPromptRequest(
|
||||
name = largePromptName,
|
||||
arguments = mapOf("size" to "1"),
|
||||
),
|
||||
)
|
||||
|
||||
assertNotNull(result, "Get prompt result should not be null")
|
||||
assertEquals(largePromptDescription, result.description, "Prompt description should match")
|
||||
|
||||
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
|
||||
|
||||
val assistantMessage = result.messages.find { it.role == Role.assistant }
|
||||
assertNotNull(assistantMessage, "Assistant message should be in the list")
|
||||
val assistantContent = assistantMessage.content as? TextContent
|
||||
assertNotNull(assistantContent, "Assistant message content should be TextContent")
|
||||
val text = assistantContent.text ?: ""
|
||||
assertEquals(100_000, text.length, "Assistant message should be 100KB in size")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSpecialCharacters() = runTest {
|
||||
val result = client.getPrompt(
|
||||
GetPromptRequest(
|
||||
name = specialCharsPromptName,
|
||||
arguments = mapOf("special" to specialCharsContent),
|
||||
),
|
||||
)
|
||||
|
||||
assertNotNull(result, "Get prompt result should not be null")
|
||||
assertEquals(specialCharsPromptDescription, result.description, "Prompt description should match")
|
||||
|
||||
assertEquals(2, result.messages.size, "Prompt should have 2 messages")
|
||||
|
||||
val userMessage = result.messages.find { it.role == Role.user }
|
||||
assertNotNull(userMessage, "User message should be in the list")
|
||||
val userContent = userMessage.content as? TextContent
|
||||
assertNotNull(userContent, "User message content should be TextContent")
|
||||
val userText = userContent.text ?: ""
|
||||
assertTrue(userText.contains(specialCharsContent), "User message should contain special characters")
|
||||
|
||||
val assistantMessage = result.messages.find { it.role == Role.assistant }
|
||||
assertNotNull(assistantMessage, "Assistant message should be in the list")
|
||||
val assistantContent = assistantMessage.content as? TextContent
|
||||
assertNotNull(assistantContent, "Assistant message content should be TextContent")
|
||||
val assistantText = assistantContent.text ?: ""
|
||||
assertTrue(
|
||||
assistantText.contains(specialCharsContent),
|
||||
"Assistant message should contain special characters",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun 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
|
||||
val results = mutableListOf<GetPromptResult?>()
|
||||
|
||||
runBlocking {
|
||||
repeat(concurrentCount) { index ->
|
||||
launch {
|
||||
val promptName = when (index % 4) {
|
||||
0 -> basicPromptName
|
||||
1 -> complexPromptName
|
||||
2 -> largePromptName
|
||||
else -> specialCharsPromptName
|
||||
}
|
||||
|
||||
val arguments = when (promptName) {
|
||||
basicPromptName -> mapOf("name" to "User$index")
|
||||
complexPromptName -> mapOf("arg1" to "v1", "arg2" to "v2", "arg3" to "v3")
|
||||
largePromptName -> mapOf("size" to "1")
|
||||
else -> mapOf("special" to "!@#$%^&*()")
|
||||
}
|
||||
|
||||
val result = client.getPrompt(
|
||||
GetPromptRequest(
|
||||
name = promptName,
|
||||
arguments = arguments,
|
||||
),
|
||||
)
|
||||
|
||||
synchronized(results) {
|
||||
results.add(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(concurrentCount, results.size, "All concurrent operations should complete")
|
||||
|
||||
results.forEach { result ->
|
||||
assertNotNull(result, "Result should not be null")
|
||||
assertTrue(result.messages.isNotEmpty(), "Result messages should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNonExistentPrompt() = runTest {
|
||||
val nonExistentPromptName = "non-existent-prompt"
|
||||
|
||||
val exception = assertThrows<IllegalStateException> {
|
||||
runBlocking {
|
||||
client.getPrompt(
|
||||
GetPromptRequest(
|
||||
name = nonExistentPromptName,
|
||||
arguments = mapOf("name" to "Test"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val msg = exception.message ?: ""
|
||||
val expectedMessage = "JSONRPCError(code=InternalError, message=Prompt not found: non-existent-prompt, data={})"
|
||||
|
||||
assertEquals(expectedMessage, msg, "Unexpected error message for non-existent prompt")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,470 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.ImageContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.PromptArgument
|
||||
import io.modelcontextprotocol.kotlin.sdk.PromptMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.PromptMessageContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.Role
|
||||
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.utils.TestUtils.runTest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
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 PromptIntegrationTest : KotlinTestBase() {
|
||||
|
||||
override val port = 3004
|
||||
private val testPromptName = "greeting"
|
||||
private val testPromptDescription = "A simple greeting prompt"
|
||||
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"
|
||||
|
||||
override fun configureServerCapabilities(): ServerCapabilities = ServerCapabilities(
|
||||
prompts = ServerCapabilities.Prompts(
|
||||
listChanged = true,
|
||||
),
|
||||
)
|
||||
|
||||
override fun configureServer() {
|
||||
// simple prompt with a name parameter
|
||||
server.addPrompt(
|
||||
name = testPromptName,
|
||||
description = testPromptDescription,
|
||||
arguments = listOf(
|
||||
PromptArgument(
|
||||
name = "name",
|
||||
description = "The name to greet",
|
||||
required = true,
|
||||
),
|
||||
),
|
||||
) { request ->
|
||||
val name = request.arguments?.get("name") ?: "World"
|
||||
|
||||
GetPromptResult(
|
||||
description = testPromptDescription,
|
||||
messages = listOf(
|
||||
PromptMessage(
|
||||
role = Role.user,
|
||||
content = TextContent(text = "Hello, $name!"),
|
||||
),
|
||||
PromptMessage(
|
||||
role = Role.assistant,
|
||||
content = TextContent(text = "Greetings, $name! How can I assist you today?"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// prompt with multiple content types
|
||||
server.addPrompt(
|
||||
name = complexPromptName,
|
||||
description = complexPromptDescription,
|
||||
arguments = listOf(
|
||||
PromptArgument(
|
||||
name = "topic",
|
||||
description = "The topic to discuss",
|
||||
required = false,
|
||||
),
|
||||
PromptArgument(
|
||||
name = "includeImage",
|
||||
description = "Whether to include an image",
|
||||
required = false,
|
||||
),
|
||||
),
|
||||
) { request ->
|
||||
val topic = request.arguments?.get("topic") ?: "general knowledge"
|
||||
val includeImage = request.arguments?.get("includeImage")?.toBoolean() ?: true
|
||||
|
||||
val messages = mutableListOf<PromptMessage>()
|
||||
|
||||
messages.add(
|
||||
PromptMessage(
|
||||
role = Role.user,
|
||||
content = TextContent(text = "I'd like to discuss $topic."),
|
||||
),
|
||||
)
|
||||
|
||||
val assistantContents = mutableListOf<PromptMessageContent>()
|
||||
assistantContents.add(TextContent(text = "I'd be happy to discuss $topic with you."))
|
||||
|
||||
if (includeImage) {
|
||||
assistantContents.add(
|
||||
ImageContent(
|
||||
data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BmMIQAAAABJRU5ErkJggg==",
|
||||
mimeType = "image/png",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
messages.add(
|
||||
PromptMessage(
|
||||
role = Role.assistant,
|
||||
content = assistantContents[0],
|
||||
),
|
||||
)
|
||||
|
||||
GetPromptResult(
|
||||
description = complexPromptDescription,
|
||||
messages = messages,
|
||||
)
|
||||
}
|
||||
|
||||
// prompt with multiple messages and roles
|
||||
server.addPrompt(
|
||||
name = conversationPromptName,
|
||||
description = conversationPromptDescription,
|
||||
arguments = listOf(
|
||||
PromptArgument(
|
||||
name = "topic",
|
||||
description = "The topic of the conversation",
|
||||
required = false,
|
||||
),
|
||||
),
|
||||
) { request ->
|
||||
val topic = request.arguments?.get("topic") ?: "weather"
|
||||
|
||||
GetPromptResult(
|
||||
description = conversationPromptDescription,
|
||||
messages = listOf(
|
||||
PromptMessage(
|
||||
role = Role.user,
|
||||
content = TextContent(text = "Let's talk about the $topic."),
|
||||
),
|
||||
PromptMessage(
|
||||
role = Role.assistant,
|
||||
content = TextContent(
|
||||
text = "Sure, I'd love to discuss the $topic. What would you like to know?",
|
||||
),
|
||||
),
|
||||
PromptMessage(
|
||||
role = Role.user,
|
||||
content = TextContent(text = "What's your opinion on the $topic?"),
|
||||
),
|
||||
PromptMessage(
|
||||
role = Role.assistant,
|
||||
content = TextContent(
|
||||
text = "As an AI, I don't have personal opinions," +
|
||||
" but I can provide information about $topic.",
|
||||
),
|
||||
),
|
||||
PromptMessage(
|
||||
role = Role.user,
|
||||
content = TextContent(text = "That's helpful, thank you!"),
|
||||
),
|
||||
PromptMessage(
|
||||
role = Role.assistant,
|
||||
content = TextContent(
|
||||
text = "You're welcome! Let me know if you have more questions about $topic.",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// prompt with strict required arguments
|
||||
server.addPrompt(
|
||||
name = strictPromptName,
|
||||
description = strictPromptDescription,
|
||||
arguments = listOf(
|
||||
PromptArgument(
|
||||
name = "requiredArg1",
|
||||
description = "First required argument",
|
||||
required = true,
|
||||
),
|
||||
PromptArgument(
|
||||
name = "requiredArg2",
|
||||
description = "Second required argument",
|
||||
required = true,
|
||||
),
|
||||
PromptArgument(
|
||||
name = "optionalArg",
|
||||
description = "Optional argument",
|
||||
required = false,
|
||||
),
|
||||
),
|
||||
) { request ->
|
||||
val args = request.arguments ?: emptyMap()
|
||||
val arg1 = args["requiredArg1"] ?: throw IllegalArgumentException(
|
||||
"Missing required argument: requiredArg1",
|
||||
)
|
||||
val arg2 = args["requiredArg2"] ?: throw IllegalArgumentException(
|
||||
"Missing required argument: requiredArg2",
|
||||
)
|
||||
val optArg = args["optionalArg"] ?: "default"
|
||||
|
||||
GetPromptResult(
|
||||
description = strictPromptDescription,
|
||||
messages = listOf(
|
||||
PromptMessage(
|
||||
role = Role.user,
|
||||
content = TextContent(text = "Required arguments: $arg1, $arg2. Optional: $optArg"),
|
||||
),
|
||||
PromptMessage(
|
||||
role = Role.assistant,
|
||||
content = TextContent(text = "I received your arguments: $arg1, $arg2, and $optArg"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testListPrompts() = runTest {
|
||||
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 == testPromptName }
|
||||
assertNotNull(testPrompt, "Test prompt should be in the list")
|
||||
assertEquals(
|
||||
testPromptDescription,
|
||||
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() = runTest {
|
||||
val testName = "Alice"
|
||||
val result = client.getPrompt(
|
||||
GetPromptRequest(
|
||||
name = testPromptName,
|
||||
arguments = mapOf("name" to testName),
|
||||
),
|
||||
)
|
||||
|
||||
assertNotNull(result, "Get prompt result should not be null")
|
||||
assertEquals(
|
||||
testPromptDescription,
|
||||
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() = runTest {
|
||||
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 testComplexContentTypes() = runTest {
|
||||
val topic = "artificial intelligence"
|
||||
val result = client.getPrompt(
|
||||
GetPromptRequest(
|
||||
name = complexPromptName,
|
||||
arguments = mapOf(
|
||||
"topic" to topic,
|
||||
"includeImage" to "true",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertNotNull(result, "Get prompt result should not be null")
|
||||
assertEquals(
|
||||
complexPromptDescription,
|
||||
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")
|
||||
val userText2 = requireNotNull(userContent.text)
|
||||
assertTrue(userText2.contains(topic), "User message should contain the topic")
|
||||
|
||||
val assistantMessage = result.messages.find { it.role == Role.assistant }
|
||||
assertNotNull(assistantMessage, "Assistant message should be in the list")
|
||||
val assistantContent = assistantMessage.content as? TextContent
|
||||
assertNotNull(assistantContent, "Assistant message content should be TextContent")
|
||||
val assistantText = requireNotNull(assistantContent.text)
|
||||
assertTrue(
|
||||
assistantText.contains(topic),
|
||||
"Assistant message should contain the topic",
|
||||
)
|
||||
|
||||
val resultNoImage = client.getPrompt(
|
||||
GetPromptRequest(
|
||||
name = complexPromptName,
|
||||
arguments = mapOf(
|
||||
"topic" to topic,
|
||||
"includeImage" to "false",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertNotNull(resultNoImage, "Get prompt result (no image) should not be null")
|
||||
assertEquals(2, resultNoImage.messages.size, "Prompt should have 2 messages")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMultipleMessagesAndRoles() = runTest {
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
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 io.modelcontextprotocol.kotlin.sdk.integration.utils.TestUtils.runTest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
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
|
||||
|
||||
class ResourceEdgeCasesTest : KotlinTestBase() {
|
||||
|
||||
override val port = 3007
|
||||
|
||||
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.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",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
server.setRequestHandler<SubscribeRequest>(Method.Defined.ResourcesSubscribe) { _, _ ->
|
||||
EmptyRequestResult()
|
||||
}
|
||||
|
||||
server.setRequestHandler<UnsubscribeRequest>(Method.Defined.ResourcesUnsubscribe) { _, _ ->
|
||||
EmptyRequestResult()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBinaryResource() = runTest {
|
||||
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() = runTest {
|
||||
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() = runTest {
|
||||
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() = runTest {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSubscribeAndUnsubscribe() {
|
||||
runTest {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
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 io.modelcontextprotocol.kotlin.sdk.integration.utils.TestUtils.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ResourceIntegrationTest : KotlinTestBase() {
|
||||
|
||||
override val port = 3005
|
||||
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() = runTest {
|
||||
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() = runTest {
|
||||
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() {
|
||||
runTest {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,498 @@
|
|||
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 io.modelcontextprotocol.kotlin.sdk.integration.utils.TestUtils.runTest
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
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() {
|
||||
|
||||
override val port = 3009
|
||||
|
||||
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() {
|
||||
runTest {
|
||||
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() {
|
||||
runTest {
|
||||
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() {
|
||||
runTest {
|
||||
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() {
|
||||
runTest {
|
||||
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() {
|
||||
runTest {
|
||||
val arguments = mapOf("special" to specialCharsContent)
|
||||
|
||||
val result = client.callTool(specialCharsToolName, arguments) as CallToolResultBase
|
||||
|
||||
val content = result.content.firstOrNull() as? TextContent
|
||||
assertNotNull(content, "Tool result content should be TextContent")
|
||||
val text = content.text ?: ""
|
||||
|
||||
assertTrue(text.contains(specialCharsContent), "Result should contain the special characters")
|
||||
|
||||
val actualContent = result.structuredContent.toString()
|
||||
val expectedContent = """
|
||||
{
|
||||
"special" : "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t",
|
||||
"length" : 34
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
actualContent shouldEqualJson expectedContent
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testConcurrentToolCalls() = runTest {
|
||||
val concurrentCount = 10
|
||||
val results = mutableListOf<CallToolResultBase?>()
|
||||
|
||||
runBlocking {
|
||||
repeat(concurrentCount) { index ->
|
||||
launch {
|
||||
val toolName = when (index % 5) {
|
||||
0 -> basicToolName
|
||||
1 -> complexToolName
|
||||
2 -> largeToolName
|
||||
3 -> slowToolName
|
||||
else -> specialCharsToolName
|
||||
}
|
||||
|
||||
val arguments = when (toolName) {
|
||||
basicToolName -> mapOf("text" to "Concurrent call $index")
|
||||
|
||||
complexToolName -> mapOf(
|
||||
"user" to mapOf(
|
||||
"name" to "User $index",
|
||||
"age" to 20 + index,
|
||||
"address" to mapOf(
|
||||
"street" to "Street $index",
|
||||
"city" to "City $index",
|
||||
"country" to "Country $index",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
largeToolName -> mapOf("size" to 1)
|
||||
|
||||
slowToolName -> mapOf("delay" to 100)
|
||||
|
||||
else -> mapOf("special" to "!@#$%^&*()")
|
||||
}
|
||||
|
||||
val result = client.callTool(toolName, arguments)
|
||||
|
||||
synchronized(results) {
|
||||
results.add(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(concurrentCount, results.size, "All concurrent operations should complete")
|
||||
results.forEach { result ->
|
||||
assertNotNull(result, "Result should not be null")
|
||||
assertTrue(result.content.isNotEmpty(), "Result content should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNonExistentTool() = runTest {
|
||||
val nonExistentToolName = "non-existent-tool"
|
||||
val arguments = mapOf("text" to "Test")
|
||||
|
||||
val exception = assertThrows<IllegalStateException> {
|
||||
runBlocking {
|
||||
client.callTool(nonExistentToolName, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
val msg = exception.message ?: ""
|
||||
val expectedMessage = "JSONRPCError(code=InternalError, message=Tool not found: non-existent-tool, data={})"
|
||||
|
||||
assertEquals(expectedMessage, msg, "Unexpected error message for non-existent tool")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,475 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin
|
||||
|
||||
import io.kotest.assertions.json.shouldEqualJson
|
||||
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 io.modelcontextprotocol.kotlin.sdk.integration.utils.TestUtils.runTest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
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
|
||||
|
||||
class ToolIntegrationTest : KotlinTestBase() {
|
||||
|
||||
override val port = 3006
|
||||
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"
|
||||
|
||||
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 = 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() = runTest {
|
||||
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() {
|
||||
runTest {
|
||||
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() {
|
||||
runTest {
|
||||
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() = runTest {
|
||||
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() = runTest {
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
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 io.modelcontextprotocol.kotlin.sdk.integration.utils.TestUtils.runTest
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
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 KotlinClientTypeScriptServerEdgeCasesTest : 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 testNonExistentTool() = runTest {
|
||||
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() = runTest {
|
||||
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() = runTest {
|
||||
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() = runTest {
|
||||
withClient(serverUrl) { client ->
|
||||
val concurrentCount = 5
|
||||
val responses = kotlinx.coroutines.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() = runTest {
|
||||
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() = runTest {
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
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 io.modelcontextprotocol.kotlin.sdk.integration.utils.TestUtils.runTest
|
||||
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() = runTest {
|
||||
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() = runTest {
|
||||
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() = runTest {
|
||||
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() = runTest {
|
||||
val client1 = newClient(serverUrl)
|
||||
val client2 = newClient(serverUrl)
|
||||
try {
|
||||
val tools1 = client1.listTools()
|
||||
assertNotNull(tools1, "Tools list for first client should not be null")
|
||||
assertTrue(tools1.tools.isNotEmpty(), "Tools list for first client should not be empty")
|
||||
|
||||
val tools2 = client2.listTools()
|
||||
assertNotNull(tools2, "Tools list for second client should not be null")
|
||||
assertTrue(tools2.tools.isNotEmpty(), "Tools list for second client should not be empty")
|
||||
|
||||
val toolNames1 = tools1.tools.map { it.name }
|
||||
val toolNames2 = tools2.tools.map { it.name }
|
||||
|
||||
assertTrue(toolNames1.contains("greet"), "Greet tool should be available to first client")
|
||||
assertTrue(toolNames1.contains("multi-greet"), "Multi-greet tool should be available to first client")
|
||||
assertTrue(toolNames2.contains("greet"), "Greet tool should be available to second client")
|
||||
assertTrue(toolNames2.contains("multi-greet"), "Multi-greet tool should be available to second client")
|
||||
} finally {
|
||||
client1.close()
|
||||
client2.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,492 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.request.header
|
||||
import io.ktor.server.request.receiveText
|
||||
import io.ktor.server.response.header
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.response.respondTextWriter
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.routing
|
||||
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.ErrorCode
|
||||
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCError
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse
|
||||
import io.modelcontextprotocol.kotlin.sdk.LoggingLevel
|
||||
import io.modelcontextprotocol.kotlin.sdk.LoggingMessageNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.PromptArgument
|
||||
import io.modelcontextprotocol.kotlin.sdk.PromptMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.RequestId
|
||||
import io.modelcontextprotocol.kotlin.sdk.Role
|
||||
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.TextResourceContents
|
||||
import io.modelcontextprotocol.kotlin.sdk.Tool
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
class KotlinServerForTypeScriptClient {
|
||||
private val serverTransports = ConcurrentHashMap<String, HttpServerTransport>()
|
||||
private val jsonFormat = Json { ignoreUnknownKeys = true }
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
|
||||
fun start(port: Int = 3000) {
|
||||
logger.info { "Starting HTTP server on port $port" }
|
||||
|
||||
server = embeddedServer(CIO, port = port) {
|
||||
routing {
|
||||
get("/mcp") {
|
||||
val sessionId = call.request.header("mcp-session-id")
|
||||
if (sessionId == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Missing mcp-session-id header")
|
||||
return@get
|
||||
}
|
||||
val transport = serverTransports[sessionId]
|
||||
if (transport == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Invalid mcp-session-id")
|
||||
return@get
|
||||
}
|
||||
transport.stream(call)
|
||||
}
|
||||
|
||||
post("/mcp") {
|
||||
val sessionId = call.request.header("mcp-session-id")
|
||||
val requestBody = call.receiveText()
|
||||
|
||||
logger.debug { "Received request with sessionId: $sessionId" }
|
||||
logger.trace { "Request body: $requestBody" }
|
||||
|
||||
val jsonElement = try {
|
||||
jsonFormat.parseToJsonElement(requestBody)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to parse request body as JSON" }
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
jsonFormat.encodeToString(
|
||||
JsonObject.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"jsonrpc" to JsonPrimitive("2.0"),
|
||||
"error" to JsonObject(
|
||||
mapOf(
|
||||
"code" to JsonPrimitive(-32700),
|
||||
"message" to JsonPrimitive("Parse error: ${e.message}"),
|
||||
),
|
||||
),
|
||||
"id" to JsonNull,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return@post
|
||||
}
|
||||
|
||||
if (sessionId != null && serverTransports.containsKey(sessionId)) {
|
||||
logger.debug { "Using existing transport for session: $sessionId" }
|
||||
val transport = serverTransports[sessionId]!!
|
||||
transport.handleRequest(call, jsonElement)
|
||||
} else {
|
||||
if (isInitializeRequest(jsonElement)) {
|
||||
val newSessionId = UUID.randomUUID().toString()
|
||||
logger.info { "Creating new session with ID: $newSessionId" }
|
||||
|
||||
val transport = HttpServerTransport(newSessionId)
|
||||
|
||||
serverTransports[newSessionId] = transport
|
||||
|
||||
val mcpServer = createMcpServer()
|
||||
|
||||
call.response.header("mcp-session-id", newSessionId)
|
||||
|
||||
val serverThread = Thread {
|
||||
runBlocking {
|
||||
mcpServer.connect(transport)
|
||||
}
|
||||
}
|
||||
serverThread.start()
|
||||
|
||||
Thread.sleep(500)
|
||||
|
||||
transport.handleRequest(call, jsonElement)
|
||||
} else {
|
||||
logger.warn { "Invalid request: no session ID or not an initialization request" }
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
jsonFormat.encodeToString(
|
||||
JsonObject.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"jsonrpc" to JsonPrimitive("2.0"),
|
||||
"error" to JsonObject(
|
||||
mapOf(
|
||||
"code" to JsonPrimitive(-32000),
|
||||
"message" to
|
||||
JsonPrimitive("Bad Request: No valid session ID provided"),
|
||||
),
|
||||
),
|
||||
"id" to JsonNull,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete("/mcp") {
|
||||
val sessionId = call.request.header("mcp-session-id")
|
||||
if (sessionId != null && serverTransports.containsKey(sessionId)) {
|
||||
logger.info { "Terminating session: $sessionId" }
|
||||
val transport = serverTransports[sessionId]!!
|
||||
serverTransports.remove(sessionId)
|
||||
runBlocking {
|
||||
transport.close()
|
||||
}
|
||||
call.respond(HttpStatusCode.OK)
|
||||
} else {
|
||||
logger.warn { "Invalid session termination request: $sessionId" }
|
||||
call.respond(HttpStatusCode.BadRequest, "Invalid or missing session ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server?.start(wait = false)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
logger.info { "Stopping HTTP server" }
|
||||
server?.stop(500, 1000)
|
||||
server = null
|
||||
}
|
||||
|
||||
private fun createMcpServer(): Server {
|
||||
val server = Server(
|
||||
Implementation(
|
||||
name = "kotlin-http-server",
|
||||
version = "1.0.0",
|
||||
),
|
||||
ServerOptions(
|
||||
capabilities = ServerCapabilities(
|
||||
prompts = ServerCapabilities.Prompts(listChanged = true),
|
||||
resources = ServerCapabilities.Resources(subscribe = true, listChanged = true),
|
||||
tools = ServerCapabilities.Tools(listChanged = true),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
server.addTool(
|
||||
name = "greet",
|
||||
description = "A simple greeting tool",
|
||||
inputSchema = Tool.Input(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"name",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put("description", JsonPrimitive("Name to greet"))
|
||||
},
|
||||
)
|
||||
},
|
||||
required = listOf("name"),
|
||||
),
|
||||
) { request ->
|
||||
val name = (request.arguments["name"] as? JsonPrimitive)?.content ?: "World"
|
||||
CallToolResult(
|
||||
content = listOf(TextContent("Hello, $name!")),
|
||||
structuredContent = buildJsonObject {
|
||||
put("greeting", JsonPrimitive("Hello, $name!"))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
server.addTool(
|
||||
name = "multi-greet",
|
||||
description = "A greeting tool that sends multiple notifications",
|
||||
inputSchema = Tool.Input(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"name",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put("description", JsonPrimitive("Name to greet"))
|
||||
},
|
||||
)
|
||||
},
|
||||
required = listOf("name"),
|
||||
),
|
||||
) { request ->
|
||||
val name = (request.arguments["name"] as? JsonPrimitive)?.content ?: "World"
|
||||
|
||||
server.sendToolListChanged()
|
||||
server.sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotification.Params(
|
||||
level = LoggingLevel.info,
|
||||
data = JsonPrimitive("Preparing greeting for $name"),
|
||||
),
|
||||
),
|
||||
)
|
||||
server.sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotification.Params(
|
||||
level = LoggingLevel.info,
|
||||
data = JsonPrimitive("Halfway there for $name"),
|
||||
),
|
||||
),
|
||||
)
|
||||
server.sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotification.Params(
|
||||
level = LoggingLevel.info,
|
||||
data = JsonPrimitive("Done sending greetings to $name"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
CallToolResult(
|
||||
content = listOf(TextContent("Multiple greetings sent to $name!")),
|
||||
structuredContent = buildJsonObject {
|
||||
put("greeting", JsonPrimitive("Multiple greetings sent to $name!"))
|
||||
put("notificationCount", JsonPrimitive(3))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
server.addPrompt(
|
||||
name = "greeting-template",
|
||||
description = "A simple greeting prompt template",
|
||||
arguments = listOf(
|
||||
PromptArgument(
|
||||
name = "name",
|
||||
description = "Name to include in greeting",
|
||||
required = true,
|
||||
),
|
||||
),
|
||||
) { request ->
|
||||
GetPromptResult(
|
||||
"Greeting for ${request.name}",
|
||||
messages = listOf(
|
||||
PromptMessage(
|
||||
role = Role.user,
|
||||
content = TextContent(
|
||||
"Please greet ${request.arguments?.get("name") ?: "someone"} in a friendly manner.",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
server.addResource(
|
||||
uri = "https://example.com/greetings/default",
|
||||
name = "Default Greeting",
|
||||
description = "A simple greeting resource",
|
||||
mimeType = "text/plain",
|
||||
) { request ->
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents("Hello, world!", request.uri, "text/plain"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
private fun isInitializeRequest(json: JsonElement): Boolean {
|
||||
if (json !is JsonObject) return false
|
||||
|
||||
val method = json["method"]?.jsonPrimitive?.contentOrNull
|
||||
return method == "initialize"
|
||||
}
|
||||
}
|
||||
|
||||
class HttpServerTransport(private val sessionId: String) : AbstractTransport() {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val pendingResponses = ConcurrentHashMap<String, CompletableDeferred<JSONRPCMessage>>()
|
||||
private val messageQueue = Channel<JSONRPCMessage>(Channel.UNLIMITED)
|
||||
|
||||
suspend fun stream(call: ApplicationCall) {
|
||||
logger.debug { "Starting SSE stream for session: $sessionId" }
|
||||
call.response.header("Cache-Control", "no-cache")
|
||||
call.response.header("Connection", "keep-alive")
|
||||
call.respondTextWriter(ContentType.Text.EventStream) {
|
||||
try {
|
||||
while (true) {
|
||||
val result = messageQueue.receiveCatching()
|
||||
val msg = result.getOrNull() ?: break
|
||||
val json = McpJson.encodeToString(msg)
|
||||
write("event: message\n")
|
||||
write("data: ")
|
||||
write(json)
|
||||
write("\n\n")
|
||||
flush()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "SSE stream terminated for session: $sessionId" }
|
||||
} finally {
|
||||
logger.debug { "SSE stream closed for session: $sessionId" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleRequest(call: ApplicationCall, requestBody: JsonElement) {
|
||||
try {
|
||||
logger.info { "Handling request body: $requestBody" }
|
||||
val message = McpJson.decodeFromJsonElement<JSONRPCMessage>(requestBody)
|
||||
logger.info { "Decoded message: $message" }
|
||||
|
||||
if (message is JSONRPCRequest) {
|
||||
val id = message.id.toString()
|
||||
logger.info { "Received request with ID: $id, method: ${message.method}" }
|
||||
val responseDeferred = CompletableDeferred<JSONRPCMessage>()
|
||||
pendingResponses[id] = responseDeferred
|
||||
logger.info { "Created deferred response for ID: $id" }
|
||||
|
||||
logger.info { "Invoking onMessage handler" }
|
||||
_onMessage.invoke(message)
|
||||
logger.info { "onMessage handler completed" }
|
||||
|
||||
try {
|
||||
val response = withTimeoutOrNull(10000) {
|
||||
responseDeferred.await()
|
||||
}
|
||||
|
||||
if (response != null) {
|
||||
val jsonResponse = McpJson.encodeToString(response)
|
||||
call.respondText(jsonResponse, ContentType.Application.Json)
|
||||
} else {
|
||||
logger.warn { "Timeout waiting for response to request ID: $id" }
|
||||
call.respondText(
|
||||
McpJson.encodeToString(
|
||||
JSONRPCResponse(
|
||||
id = message.id,
|
||||
error = JSONRPCError(
|
||||
code = ErrorCode.Defined.RequestTimeout,
|
||||
message = "Request timed out",
|
||||
),
|
||||
),
|
||||
),
|
||||
ContentType.Application.Json,
|
||||
)
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
logger.warn { "Request cancelled for ID: $id" }
|
||||
pendingResponses.remove(id)
|
||||
if (!call.response.isCommitted) {
|
||||
call.respondText(
|
||||
McpJson.encodeToString(
|
||||
JSONRPCResponse(
|
||||
id = message.id,
|
||||
error = JSONRPCError(
|
||||
code = ErrorCode.Defined.ConnectionClosed,
|
||||
message = "Request cancelled",
|
||||
),
|
||||
),
|
||||
),
|
||||
ContentType.Application.Json,
|
||||
HttpStatusCode.ServiceUnavailable,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
call.respondText("", ContentType.Application.Json, HttpStatusCode.Accepted)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Error handling request" }
|
||||
if (!call.response.isCommitted) {
|
||||
try {
|
||||
val errorResponse = JSONRPCResponse(
|
||||
id = RequestId.NumberId(0),
|
||||
error = JSONRPCError(
|
||||
code = ErrorCode.Defined.InternalError,
|
||||
message = "Internal server error: ${e.message}",
|
||||
),
|
||||
)
|
||||
|
||||
call.respondText(
|
||||
McpJson.encodeToString(errorResponse),
|
||||
ContentType.Application.Json,
|
||||
HttpStatusCode.InternalServerError,
|
||||
)
|
||||
} catch (responseEx: Exception) {
|
||||
logger.error(responseEx) { "Failed to send error response" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
logger.debug { "Starting HTTP server transport for session: $sessionId" }
|
||||
}
|
||||
|
||||
override suspend fun send(message: JSONRPCMessage) {
|
||||
logger.info { "Sending message: $message" }
|
||||
|
||||
if (message is JSONRPCResponse) {
|
||||
val id = message.id.toString()
|
||||
logger.info { "Sending response for request ID: $id" }
|
||||
val deferred = pendingResponses.remove(id)
|
||||
if (deferred != null) {
|
||||
logger.info { "Found pending response for ID: $id, completing deferred" }
|
||||
deferred.complete(message)
|
||||
return
|
||||
} else {
|
||||
logger.warn { "No pending response found for ID: $id" }
|
||||
}
|
||||
} else if (message is JSONRPCRequest) {
|
||||
logger.info { "Sending request with ID: ${message.id}" }
|
||||
} else if (message is JSONRPCNotification) {
|
||||
logger.info { "Sending notification: ${message.method}" }
|
||||
}
|
||||
|
||||
logger.info { "Queueing message for next client request" }
|
||||
messageQueue.send(message)
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
logger.debug { "Closing HTTP server transport for session: $sessionId" }
|
||||
messageQueue.close()
|
||||
_onClose.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun main() {
|
||||
val server = KotlinServerForTypeScriptClient()
|
||||
server.start()
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.utils.TestUtils.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.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)
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.utils.TestUtils.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 org.junit.jupiter.api.condition.EnabledOnOs
|
||||
import org.junit.jupiter.api.condition.OS
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TypeScriptEdgeCasesTest : 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 testInvalidURL() = runTest {
|
||||
val nonExistentToolCommand = "npx tsx myClient.ts $serverUrl non-existent-tool"
|
||||
val nonExistentToolOutput = executeCommandAllowingFailure(nonExistentToolCommand, tsClientDir)
|
||||
|
||||
assertTrue(
|
||||
nonExistentToolOutput.contains("Tool \"non-existent-tool\" not found"),
|
||||
"Client should handle non-existent tool gracefully",
|
||||
)
|
||||
|
||||
val invalidUrlCommand = "npx tsx myClient.ts http://localhost:${port + 1000}/mcp greet TestUser"
|
||||
val invalidUrlOutput = executeCommandAllowingFailure(invalidUrlCommand, tsClientDir)
|
||||
|
||||
assertTrue(
|
||||
invalidUrlOutput.contains("Invalid URL") ||
|
||||
invalidUrlOutput.contains("ERR_INVALID_URL") ||
|
||||
invalidUrlOutput.contains("ECONNREFUSED"),
|
||||
"Client should handle connection errors gracefully",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testSpecialCharacters() = runTest {
|
||||
val specialChars = "!@#$+-[].,?"
|
||||
|
||||
val tempFile = File.createTempFile("special_chars", ".txt")
|
||||
tempFile.writeText(specialChars)
|
||||
tempFile.deleteOnExit()
|
||||
|
||||
val specialCharsContent = tempFile.readText()
|
||||
val specialCharsCommand = "npx tsx myClient.ts $serverUrl greet \"$specialCharsContent\""
|
||||
val specialCharsOutput = executeCommand(specialCharsCommand, tsClientDir)
|
||||
|
||||
assertTrue(
|
||||
specialCharsOutput.contains("Hello, $specialChars!"),
|
||||
"Tool should handle special characters in arguments",
|
||||
)
|
||||
assertTrue(
|
||||
specialCharsOutput.contains("Disconnected from server"),
|
||||
"Client should disconnect cleanly after handling special characters",
|
||||
)
|
||||
}
|
||||
|
||||
// skip on windows as it can't handle long commands
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
@EnabledOnOs(OS.MAC, OS.LINUX)
|
||||
fun testLargePayload() = runTest {
|
||||
val largeName = "A".repeat(10 * 1024)
|
||||
|
||||
val tempFile = File.createTempFile("large_name", ".txt")
|
||||
tempFile.writeText(largeName)
|
||||
tempFile.deleteOnExit()
|
||||
|
||||
val largeNameContent = tempFile.readText()
|
||||
val largePayloadCommand = "npx tsx myClient.ts $serverUrl greet \"$largeNameContent\""
|
||||
val largePayloadOutput = executeCommand(largePayloadCommand, tsClientDir)
|
||||
|
||||
tempFile.delete()
|
||||
|
||||
assertTrue(
|
||||
largePayloadOutput.contains("Hello,") && largePayloadOutput.contains("A".repeat(20)),
|
||||
"Tool should handle large payloads",
|
||||
)
|
||||
assertTrue(
|
||||
largePayloadOutput.contains("Disconnected from server"),
|
||||
"Client should disconnect cleanly after handling large payload",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(60, unit = TimeUnit.SECONDS)
|
||||
fun testComplexConcurrentRequests() = runTest {
|
||||
val commands = listOf(
|
||||
"npx tsx myClient.ts $serverUrl greet \"Client1\"",
|
||||
"npx tsx myClient.ts $serverUrl multi-greet \"Client2\"",
|
||||
"npx tsx myClient.ts $serverUrl greet \"Client3\"",
|
||||
"npx tsx myClient.ts $serverUrl",
|
||||
"npx tsx myClient.ts $serverUrl multi-greet \"Client5\"",
|
||||
)
|
||||
|
||||
val threads = commands.mapIndexed { index, command ->
|
||||
Thread {
|
||||
println("Starting client $index")
|
||||
val output = executeCommand(command, tsClientDir)
|
||||
println("Client $index completed")
|
||||
|
||||
assertTrue(
|
||||
output.contains("Connected to server"),
|
||||
"Client $index should connect to server",
|
||||
)
|
||||
assertTrue(
|
||||
output.contains("Disconnected from server"),
|
||||
"Client $index should disconnect cleanly",
|
||||
)
|
||||
|
||||
when {
|
||||
command.contains("greet \"Client1\"") ->
|
||||
assertTrue(output.contains("Hello, Client1!"), "Client 1 should receive correct greeting")
|
||||
|
||||
command.contains("multi-greet \"Client2\"") ->
|
||||
assertTrue(output.contains("Multiple greetings"), "Client 2 should receive multiple greetings")
|
||||
|
||||
command.contains("greet \"Client3\"") ->
|
||||
assertTrue(output.contains("Hello, Client3!"), "Client 3 should receive correct greeting")
|
||||
|
||||
!command.contains("greet") && !command.contains("multi-greet") ->
|
||||
assertTrue(output.contains("Available utils:"), "Client 4 should list available tools")
|
||||
|
||||
command.contains("multi-greet \"Client5\"") ->
|
||||
assertTrue(output.contains("Multiple greetings"), "Client 5 should receive multiple greetings")
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
||||
threads.forEach { it.join() }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(120, unit = TimeUnit.SECONDS)
|
||||
fun testRapidSequentialRequests() = runTest {
|
||||
val outputs = (1..10).map { i ->
|
||||
val command = "npx tsx myClient.ts $serverUrl greet \"RapidClient$i\""
|
||||
val output = executeCommand(command, tsClientDir)
|
||||
|
||||
assertTrue(
|
||||
output.contains("Connected to server"),
|
||||
"Client $i should connect to server",
|
||||
)
|
||||
assertTrue(
|
||||
output.contains("Hello, RapidClient$i!"),
|
||||
"Client $i should receive correct greeting",
|
||||
)
|
||||
assertTrue(
|
||||
output.contains("Disconnected from server"),
|
||||
"Client $i should disconnect cleanly",
|
||||
)
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
assertEquals(10, outputs.size, "All 10 rapid requests should complete successfully")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.sse.SSE
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.mcpStreamableHttp
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.utils.Retry
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Retry(times = 3)
|
||||
abstract class TypeScriptTestBase {
|
||||
|
||||
protected val projectRoot: File get() = File(System.getProperty("user.dir"))
|
||||
protected val tsClientDir: File
|
||||
get() = File(
|
||||
projectRoot,
|
||||
"src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript",
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
private val tempRootDir: File = Files.createTempDirectory("typescript-sdk-").toFile().apply { deleteOnExit() }
|
||||
|
||||
@JvmStatic
|
||||
protected val sdkDir: File = File(tempRootDir, "typescript-sdk")
|
||||
|
||||
@JvmStatic
|
||||
@BeforeAll
|
||||
fun setupTypeScriptSdk() {
|
||||
println("Cloning TypeScript SDK repository")
|
||||
|
||||
if (!sdkDir.exists()) {
|
||||
val process = ProcessBuilder(
|
||||
"git",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"https://github.com/modelcontextprotocol/typescript-sdk.git",
|
||||
sdkDir.absolutePath,
|
||||
)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val exitCode = process.waitFor()
|
||||
if (exitCode != 0) {
|
||||
throw RuntimeException("Failed to clone TypeScript SDK repository: exit code $exitCode")
|
||||
}
|
||||
}
|
||||
|
||||
println("Installing TypeScript SDK dependencies")
|
||||
executeCommand("npm install", sdkDir, allowFailure = false, timeoutSeconds = null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
protected fun killProcessOnPort(port: Int) {
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val killCommand = if (isWindows) {
|
||||
"netstat -ano | findstr :$port | for /f \"tokens=5\" %a in ('more') do taskkill /F /PID %a 2>nul || echo No process found"
|
||||
} else {
|
||||
"lsof -ti:$port | xargs kill -9 2>/dev/null || true"
|
||||
}
|
||||
executeCommand(killCommand, File("."), allowFailure = true, timeoutSeconds = null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
protected fun findFreePort(): Int {
|
||||
ServerSocket(0).use { socket ->
|
||||
return socket.localPort
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
protected fun executeCommand(
|
||||
command: String,
|
||||
workingDir: File,
|
||||
allowFailure: Boolean = false,
|
||||
timeoutSeconds: Long? = null,
|
||||
): String {
|
||||
if (!workingDir.exists()) {
|
||||
if (!workingDir.mkdirs()) {
|
||||
throw RuntimeException("Failed to create working directory: ${workingDir.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
if (!workingDir.isDirectory || !workingDir.canRead()) {
|
||||
throw RuntimeException("Working directory is not accessible: ${workingDir.absolutePath}")
|
||||
}
|
||||
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val processBuilder = if (isWindows) {
|
||||
ProcessBuilder()
|
||||
.command("cmd.exe", "/c", "set TYPESCRIPT_SDK_DIR=${sdkDir.absolutePath} && $command")
|
||||
} else {
|
||||
ProcessBuilder()
|
||||
.command("bash", "-c", "TYPESCRIPT_SDK_DIR='${sdkDir.absolutePath}' $command")
|
||||
}
|
||||
|
||||
val process = processBuilder
|
||||
.directory(workingDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
val output = StringBuilder()
|
||||
BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
println(line)
|
||||
output.append(line).append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (timeoutSeconds == null) {
|
||||
val exitCode = process.waitFor()
|
||||
if (!allowFailure && exitCode != 0) {
|
||||
throw RuntimeException(
|
||||
"Command execution failed with exit code $exitCode: $command\nWorking dir: ${workingDir.absolutePath}\nOutput:\n$output",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
return output.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForProcessTermination(process: Process, timeoutSeconds: Long): Boolean {
|
||||
if (process.isAlive && !process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly()
|
||||
process.waitFor(2, TimeUnit.SECONDS)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun createProcessOutputReader(process: Process, prefix: String = "TS-SERVER"): Thread {
|
||||
val outputReader = Thread {
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines ->
|
||||
for (line in lines) {
|
||||
println("[$prefix] $line")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Warning: Error reading process output: ${e.message}")
|
||||
}
|
||||
}
|
||||
outputReader.isDaemon = true
|
||||
return outputReader
|
||||
}
|
||||
|
||||
protected fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Long = 10): Boolean {
|
||||
val deadline = System.currentTimeMillis() + timeoutSeconds * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
try {
|
||||
Socket(host, port).use { return true }
|
||||
} catch (_: Exception) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
protected fun executeCommandAllowingFailure(command: String, workingDir: File, timeoutSeconds: Long = 20): String =
|
||||
executeCommand(command, workingDir, allowFailure = true, timeoutSeconds = timeoutSeconds)
|
||||
|
||||
protected fun startTypeScriptServer(port: Int): Process {
|
||||
killProcessOnPort(port)
|
||||
|
||||
if (!sdkDir.exists() || !sdkDir.isDirectory) {
|
||||
throw IllegalStateException(
|
||||
"TypeScript SDK directory does not exist or is not accessible: ${sdkDir.absolutePath}",
|
||||
)
|
||||
}
|
||||
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val localServerPath = File(tsClientDir, "simpleStreamableHttp.ts").absolutePath
|
||||
val processBuilder = if (isWindows) {
|
||||
ProcessBuilder()
|
||||
.command(
|
||||
"cmd.exe",
|
||||
"/c",
|
||||
"set MCP_PORT=$port && set NODE_PATH=${sdkDir.absolutePath}\\node_modules && npx --prefix \"${sdkDir.absolutePath}\" tsx \"$localServerPath\"",
|
||||
)
|
||||
} else {
|
||||
ProcessBuilder()
|
||||
.command(
|
||||
"bash",
|
||||
"-c",
|
||||
"MCP_PORT=$port NODE_PATH='${sdkDir.absolutePath}/node_modules' npx --prefix '${sdkDir.absolutePath}' tsx \"$localServerPath\"",
|
||||
)
|
||||
}
|
||||
|
||||
processBuilder.environment()["TYPESCRIPT_SDK_DIR"] = sdkDir.absolutePath
|
||||
|
||||
val process = processBuilder
|
||||
.directory(tsClientDir)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
createProcessOutputReader(process).start()
|
||||
|
||||
if (!waitForPort(port = port, timeoutSeconds = 20)) {
|
||||
throw IllegalStateException("TypeScript server did not become ready on localhost:$port within timeout")
|
||||
}
|
||||
return process
|
||||
}
|
||||
|
||||
protected fun stopProcess(process: Process, waitSeconds: Long = 3, name: String = "TypeScript server") {
|
||||
process.destroy()
|
||||
if (waitForProcessTermination(process, waitSeconds)) {
|
||||
println("$name stopped gracefully")
|
||||
} else {
|
||||
println("$name did not stop gracefully, forced termination")
|
||||
}
|
||||
}
|
||||
|
||||
protected suspend fun newClient(serverUrl: String): Client =
|
||||
HttpClient(CIO) { install(SSE) }.mcpStreamableHttp(serverUrl)
|
||||
|
||||
protected suspend fun <T> withClient(serverUrl: String, block: suspend (Client) -> T): T {
|
||||
val client = newClient(serverUrl)
|
||||
return try {
|
||||
withTimeout(20.seconds) { block(client) }
|
||||
} finally {
|
||||
try {
|
||||
withTimeout(3.seconds) { client.close() }
|
||||
} catch (_: Exception) {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
// @ts-ignore
|
||||
const args = process.argv.slice(2);
|
||||
const serverUrl = args[0] || 'http://localhost:3001/mcp';
|
||||
const toolName = args[1];
|
||||
const toolArgs = args.slice(2);
|
||||
const PROTOCOL_VERSION = "2024-11-05";
|
||||
|
||||
// @ts-ignore
|
||||
async function main() {
|
||||
// @ts-ignore
|
||||
const sdkDirRaw = process.env.TYPESCRIPT_SDK_DIR;
|
||||
const sdkDir = sdkDirRaw ? sdkDirRaw.trim() : undefined;
|
||||
let Client: any;
|
||||
let StreamableHTTPClientTransport: any;
|
||||
if (sdkDir) {
|
||||
// @ts-ignore
|
||||
const path = await import('path');
|
||||
// @ts-ignore
|
||||
const { pathToFileURL } = await import('url');
|
||||
const clientUrl = pathToFileURL(path.join(sdkDir, 'src', 'client', 'index.ts')).href;
|
||||
const streamUrl = pathToFileURL(path.join(sdkDir, 'src', 'client', 'streamableHttp.js')).href;
|
||||
// @ts-ignore
|
||||
({ Client } = await import(clientUrl));
|
||||
// @ts-ignore
|
||||
({ StreamableHTTPClientTransport } = await import(streamUrl));
|
||||
} else {
|
||||
// @ts-ignore
|
||||
({Client} = await import("../../../../../../../resources/typescript-sdk/src/client"));
|
||||
// @ts-ignore
|
||||
({StreamableHTTPClientTransport} = await import("../../../../../../../resources/typescript-sdk/src/client/streamableHttp.js"));
|
||||
}
|
||||
if (!toolName) {
|
||||
console.log('Usage: npx tsx myClient.ts [server-url] <tool-name> [tool-args...]');
|
||||
console.log('Using default server URL:', serverUrl);
|
||||
console.log('Available utils will be listed after connection');
|
||||
}
|
||||
|
||||
console.log(`Connecting to server at ${serverUrl}`);
|
||||
if (toolName) {
|
||||
console.log(`Will call tool: ${toolName} with args: ${toolArgs.join(', ')}`);
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
|
||||
try {
|
||||
await client.connect(transport, {protocolVersion: PROTOCOL_VERSION});
|
||||
console.log('Connected to server');
|
||||
|
||||
try {
|
||||
if (typeof (client as any).on === 'function') {
|
||||
(client as any).on('notification', (n: any) => {
|
||||
try {
|
||||
const method = (n && (n.method || (n.params && n.params.method))) || 'unknown';
|
||||
console.log('Notification:', method, JSON.stringify(n));
|
||||
} catch {
|
||||
console.log('Notification: <unparsable>');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
const tools = toolsResult.tools;
|
||||
console.log('Available utils:', tools.map((t: { name: any; }) => t.name).join(', '));
|
||||
|
||||
if (!toolName) {
|
||||
await client.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = tools.find((t: { name: string; }) => t.name === toolName);
|
||||
if (!tool) {
|
||||
console.error(`Tool "${toolName}" not found`);
|
||||
// @ts-ignore
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const toolArguments = {};
|
||||
|
||||
if (toolName === "greet" && toolArgs.length > 0) {
|
||||
toolArguments["name"] = toolArgs[0];
|
||||
} else if (tool.input && tool.input.properties) {
|
||||
const propNames = Object.keys(tool.input.properties);
|
||||
if (propNames.length > 0 && toolArgs.length > 0) {
|
||||
toolArguments[propNames[0]] = toolArgs[0];
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Calling tool ${toolName} with arguments:`, toolArguments);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: toolName,
|
||||
arguments: toolArguments
|
||||
});
|
||||
console.log('Tool result:', result);
|
||||
|
||||
if (result.content) {
|
||||
for (const content of result.content) {
|
||||
if (content.type === 'text') {
|
||||
console.log('Text content:', content.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.structuredContent) {
|
||||
console.log('Structured content:', JSON.stringify(result.structuredContent, null, 2));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
// @ts-ignore
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await client.close();
|
||||
console.log('Disconnected from server');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Unhandled error:', error);
|
||||
// @ts-ignore
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,701 @@
|
|||
// @ts-nocheck
|
||||
import express, {Request, Response} from 'express';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {z} from 'zod';
|
||||
import cors from 'cors';
|
||||
import path from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
|
||||
const SDK_DIR = process.env.TYPESCRIPT_SDK_DIR;
|
||||
if (!SDK_DIR) {
|
||||
throw new Error('TYPESCRIPT_SDK_DIR environment variable is not set. It should point to the cloned typescript-sdk directory.');
|
||||
}
|
||||
|
||||
async function importFromSdk<T = any>(rel: string): Promise<T> {
|
||||
const full = path.resolve(SDK_DIR!, rel);
|
||||
const url = pathToFileURL(full).href;
|
||||
return (await import(url)) as unknown as T;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const {McpServer} = await importFromSdk<any>('src/server/mcp.ts');
|
||||
const {StreamableHTTPServerTransport} = await importFromSdk<any>('src/server/streamableHttp.ts');
|
||||
const {
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
mcpAuthMetadataRouter
|
||||
} = await importFromSdk<any>('src/server/auth/router.ts');
|
||||
const {requireBearerAuth} = await importFromSdk<any>('src/server/auth/middleware/bearerAuth.ts');
|
||||
const {
|
||||
isInitializeRequest,
|
||||
} = await importFromSdk<any>('src/types.ts');
|
||||
const {InMemoryEventStore} = await importFromSdk<any>('src/examples/shared/inMemoryEventStore.ts');
|
||||
const {setupAuthServer} = await importFromSdk<any>('src/examples/server/demoInMemoryOAuthProvider.ts');
|
||||
const {checkResourceAllowed} = await importFromSdk<any>('src/shared/auth-utils.ts');
|
||||
|
||||
// Check for OAuth flag
|
||||
const useOAuth = process.argv.includes('--oauth');
|
||||
const strictOAuth = process.argv.includes('--oauth-strict');
|
||||
|
||||
// Create an MCP server with implementation details
|
||||
const getServer = () => {
|
||||
const server = new McpServer({
|
||||
name: 'simple-streamable-http-server',
|
||||
version: '1.0.0'
|
||||
}, {capabilities: {logging: {}}});
|
||||
|
||||
// Register a simple tool that returns a greeting
|
||||
server.registerTool(
|
||||
'greet',
|
||||
{
|
||||
title: 'Greeting Tool', // Display name for UI
|
||||
description: 'A simple greeting tool',
|
||||
inputSchema: {
|
||||
name: z.string().describe('Name to greet'),
|
||||
},
|
||||
},
|
||||
async ({name}): Promise<CallToolResult> => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Hello, ${name}!`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register a tool that sends multiple greetings with notifications (with annotations)
|
||||
server.tool(
|
||||
'multi-greet',
|
||||
'A tool that sends different greetings with delays between them',
|
||||
{
|
||||
name: z.string().describe('Name to greet'),
|
||||
},
|
||||
{
|
||||
title: 'Multiple Greeting Tool',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false
|
||||
},
|
||||
async ({name}, extra): Promise<CallToolResult> => {
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
await server.sendLoggingMessage({
|
||||
level: "debug",
|
||||
data: `Starting multi-greet for ${name}`
|
||||
}, extra.sessionId);
|
||||
|
||||
await sleep(1000); // Wait 1 second before first greeting
|
||||
|
||||
await server.sendLoggingMessage({
|
||||
level: "info",
|
||||
data: `Sending first greeting to ${name}`
|
||||
}, extra.sessionId);
|
||||
|
||||
await sleep(1000); // Wait another second before second greeting
|
||||
|
||||
await server.sendLoggingMessage({
|
||||
level: "info",
|
||||
data: `Sending second greeting to ${name}`
|
||||
}, extra.sessionId);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Good morning, ${name}!`,
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
// Register a tool that demonstrates elicitation (user input collection)
|
||||
// This creates a closure that captures the server instance
|
||||
server.tool(
|
||||
'collect-user-info',
|
||||
'A tool that collects user information through elicitation',
|
||||
{
|
||||
infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect'),
|
||||
},
|
||||
async ({infoType}): Promise<CallToolResult> => {
|
||||
let message: string;
|
||||
let requestedSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, PrimitiveSchemaDefinition>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
switch (infoType) {
|
||||
case 'contact':
|
||||
message = 'Please provide your contact information';
|
||||
requestedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Full Name',
|
||||
description: 'Your full name',
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
title: 'Email Address',
|
||||
description: 'Your email address',
|
||||
format: 'email',
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
title: 'Phone Number',
|
||||
description: 'Your phone number (optional)',
|
||||
},
|
||||
},
|
||||
required: ['name', 'email'],
|
||||
};
|
||||
break;
|
||||
case 'preferences':
|
||||
message = 'Please set your preferences';
|
||||
requestedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
theme: {
|
||||
type: 'string',
|
||||
title: 'Theme',
|
||||
description: 'Choose your preferred theme',
|
||||
enum: ['light', 'dark', 'auto'],
|
||||
enumNames: ['Light', 'Dark', 'Auto'],
|
||||
},
|
||||
notifications: {
|
||||
type: 'boolean',
|
||||
title: 'Enable Notifications',
|
||||
description: 'Would you like to receive notifications?',
|
||||
default: true,
|
||||
},
|
||||
frequency: {
|
||||
type: 'string',
|
||||
title: 'Notification Frequency',
|
||||
description: 'How often would you like notifications?',
|
||||
enum: ['daily', 'weekly', 'monthly'],
|
||||
enumNames: ['Daily', 'Weekly', 'Monthly'],
|
||||
},
|
||||
},
|
||||
required: ['theme'],
|
||||
};
|
||||
break;
|
||||
case 'feedback':
|
||||
message = 'Please provide your feedback';
|
||||
requestedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rating: {
|
||||
type: 'integer',
|
||||
title: 'Rating',
|
||||
description: 'Rate your experience (1-5)',
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
},
|
||||
comments: {
|
||||
type: 'string',
|
||||
title: 'Comments',
|
||||
description: 'Additional comments (optional)',
|
||||
maxLength: 500,
|
||||
},
|
||||
recommend: {
|
||||
type: 'boolean',
|
||||
title: 'Would you recommend this?',
|
||||
description: 'Would you recommend this to others?',
|
||||
},
|
||||
},
|
||||
required: ['rating', 'recommend'],
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown info type: ${infoType}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the underlying server instance to elicit input from the client
|
||||
const result = await server.server.elicitInput({
|
||||
message,
|
||||
requestedSchema,
|
||||
});
|
||||
|
||||
if (result.action === 'accept') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (result.action === 'decline') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No information was collected. User declined ${infoType} information request.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Information collection was cancelled by the user.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error collecting ${infoType} information: ${error}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Register a simple prompt with title
|
||||
server.registerPrompt(
|
||||
'greeting-template',
|
||||
{
|
||||
title: 'Greeting Template', // Display name for UI
|
||||
description: 'A simple greeting prompt template',
|
||||
argsSchema: {
|
||||
name: z.string().describe('Name to include in greeting'),
|
||||
},
|
||||
},
|
||||
async ({name}): Promise<GetPromptResult> => {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Please greet ${name} in a friendly manner.`,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register a tool specifically for testing resumability
|
||||
server.tool(
|
||||
'start-notification-stream',
|
||||
'Starts sending periodic notifications for testing resumability',
|
||||
{
|
||||
interval: z.number().describe('Interval in milliseconds between notifications').default(100),
|
||||
count: z.number().describe('Number of notifications to send (0 for 100)').default(50),
|
||||
},
|
||||
async ({interval, count}, extra): Promise<CallToolResult> => {
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
let counter = 0;
|
||||
|
||||
while (count === 0 || counter < count) {
|
||||
counter++;
|
||||
try {
|
||||
await server.sendLoggingMessage({
|
||||
level: "info",
|
||||
data: `Periodic notification #${counter} at ${new Date().toISOString()}`
|
||||
}, extra.sessionId);
|
||||
} catch (error) {
|
||||
console.error("Error sending notification:", error);
|
||||
}
|
||||
// Wait for the specified interval
|
||||
await sleep(interval);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Started sending periodic notifications every ${interval}ms`,
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create a simple resource at a fixed URI
|
||||
server.registerResource(
|
||||
'greeting-resource',
|
||||
'https://example.com/greetings/default',
|
||||
{
|
||||
title: 'Default Greeting', // Display name for UI
|
||||
description: 'A simple greeting resource',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'https://example.com/greetings/default',
|
||||
text: 'Hello, world!',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create additional resources for ResourceLink demonstration
|
||||
server.registerResource(
|
||||
'example-file-1',
|
||||
'file:///example/file1.txt',
|
||||
{
|
||||
title: 'Example File 1',
|
||||
description: 'First example file for ResourceLink demonstration',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'file:///example/file1.txt',
|
||||
text: 'This is the content of file 1',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'example-file-2',
|
||||
'file:///example/file2.txt',
|
||||
{
|
||||
title: 'Example File 2',
|
||||
description: 'Second example file for ResourceLink demonstration',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'file:///example/file2.txt',
|
||||
text: 'This is the content of file 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register a tool that returns ResourceLinks
|
||||
server.registerTool(
|
||||
'list-files',
|
||||
{
|
||||
title: 'List Files with ResourceLinks',
|
||||
description: 'Returns a list of files as ResourceLinks without embedding their content',
|
||||
inputSchema: {
|
||||
includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links'),
|
||||
},
|
||||
},
|
||||
async ({includeDescriptions = true}): Promise<CallToolResult> => {
|
||||
const resourceLinks: ResourceLink[] = [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'https://example.com/greetings/default',
|
||||
name: 'Default Greeting',
|
||||
mimeType: 'text/plain',
|
||||
...(includeDescriptions && {description: 'A simple greeting resource'})
|
||||
},
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///example/file1.txt',
|
||||
name: 'Example File 1',
|
||||
mimeType: 'text/plain',
|
||||
...(includeDescriptions && {description: 'First example file for ResourceLink demonstration'})
|
||||
},
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///example/file2.txt',
|
||||
name: 'Example File 2',
|
||||
mimeType: 'text/plain',
|
||||
...(includeDescriptions && {description: 'Second example file for ResourceLink demonstration'})
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Here are the available files as resource links:',
|
||||
},
|
||||
...resourceLinks,
|
||||
{
|
||||
type: 'text',
|
||||
text: '\nYou can read any of these resources using their URI.',
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
};
|
||||
|
||||
const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000;
|
||||
const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Allow CORS all domains, expose the Mcp-Session-Id header
|
||||
app.use(cors({
|
||||
origin: '*', // Allow all origins
|
||||
exposedHeaders: ["Mcp-Session-Id"]
|
||||
}));
|
||||
|
||||
// Set up OAuth if enabled
|
||||
let authMiddleware = null;
|
||||
if (useOAuth) {
|
||||
// Create auth middleware for MCP endpoints
|
||||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
|
||||
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
|
||||
|
||||
const oauthMetadata: OAuthMetadata = setupAuthServer({
|
||||
authServerUrl,
|
||||
mcpServerUrl,
|
||||
strictResource: strictOAuth
|
||||
});
|
||||
|
||||
const tokenVerifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
const endpoint = oauthMetadata.introspection_endpoint;
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error('No token verification endpoint available in metadata');
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
token: token
|
||||
}).toString()
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Invalid or expired token: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (strictOAuth) {
|
||||
if (!data.aud) {
|
||||
throw new Error(`Resource Indicator (RFC8707) missing`);
|
||||
}
|
||||
if (!checkResourceAllowed({requestedResource: data.aud, configuredResource: mcpServerUrl})) {
|
||||
throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the response to AuthInfo format
|
||||
return {
|
||||
token,
|
||||
clientId: data.client_id,
|
||||
scopes: data.scope ? data.scope.split(' ') : [],
|
||||
expiresAt: data.exp,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Add metadata routes to the main MCP server
|
||||
app.use(mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: mcpServerUrl,
|
||||
scopesSupported: ['mcp:tools'],
|
||||
resourceName: 'MCP Demo Server',
|
||||
}));
|
||||
|
||||
authMiddleware = requireBearerAuth({
|
||||
verifier: tokenVerifier,
|
||||
requiredScopes: [],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
|
||||
});
|
||||
}
|
||||
|
||||
// Map to store transports by session ID
|
||||
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
|
||||
|
||||
// MCP POST endpoint with optional auth
|
||||
const mcpPostHandler = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId) {
|
||||
console.log(`Received MCP request for session: ${sessionId}`);
|
||||
} else {
|
||||
console.log('Request body:', req.body);
|
||||
}
|
||||
|
||||
if (useOAuth && req.auth) {
|
||||
console.log('Authenticated user:', req.auth);
|
||||
}
|
||||
try {
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
// Reuse existing transport
|
||||
transport = transports[sessionId];
|
||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||
// New initialization request
|
||||
const eventStore = new InMemoryEventStore();
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore, // Enable resumability
|
||||
onsessioninitialized: (sessionId) => {
|
||||
// Store the transport by session ID when session is initialized
|
||||
// This avoids race conditions where requests might come in before the session is stored
|
||||
console.log(`Session initialized with ID: ${sessionId}`);
|
||||
transports[sessionId] = transport;
|
||||
}
|
||||
});
|
||||
|
||||
// Set up onclose handler to clean up transport when closed
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid && transports[sid]) {
|
||||
console.log(`Transport closed for session ${sid}, removing from transports map`);
|
||||
delete transports[sid];
|
||||
}
|
||||
};
|
||||
|
||||
// Connect the transport to the MCP server BEFORE handling the request
|
||||
// so responses can flow back through the same transport
|
||||
const server = getServer();
|
||||
await server.connect(transport);
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
return; // Already handled
|
||||
} else {
|
||||
// Invalid request - no session ID or not initialization request
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: No valid session ID provided',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the request with existing transport - no need to reconnect
|
||||
// The existing transport is already connected to the server
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error('Error handling MCP request:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal server error',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set up routes with conditional auth middleware
|
||||
if (useOAuth && authMiddleware) {
|
||||
app.post('/mcp', authMiddleware, mcpPostHandler);
|
||||
} else {
|
||||
app.post('/mcp', mcpPostHandler);
|
||||
}
|
||||
|
||||
// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
|
||||
const mcpGetHandler = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (useOAuth && req.auth) {
|
||||
console.log('Authenticated SSE connection from user:', req.auth);
|
||||
}
|
||||
|
||||
// Check for Last-Event-ID header for resumability
|
||||
const lastEventId = req.headers['last-event-id'] as string | undefined;
|
||||
if (lastEventId) {
|
||||
console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
|
||||
} else {
|
||||
console.log(`Establishing new SSE stream for session ${sessionId}`);
|
||||
}
|
||||
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
};
|
||||
|
||||
// Set up GET route with conditional auth middleware
|
||||
if (useOAuth && authMiddleware) {
|
||||
app.get('/mcp', authMiddleware, mcpGetHandler);
|
||||
} else {
|
||||
app.get('/mcp', mcpGetHandler);
|
||||
}
|
||||
|
||||
// Handle DELETE requests for session termination (according to MCP spec)
|
||||
const mcpDeleteHandler = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Received session termination request for session ${sessionId}`);
|
||||
|
||||
try {
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
} catch (error) {
|
||||
console.error('Error handling session termination:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Error processing session termination');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set up DELETE route with conditional auth middleware
|
||||
if (useOAuth && authMiddleware) {
|
||||
app.delete('/mcp', authMiddleware, mcpDeleteHandler);
|
||||
} else {
|
||||
app.delete('/mcp', mcpDeleteHandler);
|
||||
}
|
||||
|
||||
app.listen(MCP_PORT, (error) => {
|
||||
if (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
|
||||
});
|
||||
|
||||
// Handle server shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Shutting down server...');
|
||||
|
||||
// Close all active transports to properly clean up resources
|
||||
for (const sessionId in transports) {
|
||||
try {
|
||||
console.log(`Closing transport for session ${sessionId}`);
|
||||
await transports[sessionId].close();
|
||||
delete transports[sessionId];
|
||||
} catch (error) {
|
||||
console.error(`Error closing transport for session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
console.log('Server shutdown complete');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.utils
|
||||
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.junit.jupiter.api.extension.ExtensionContext
|
||||
import org.junit.jupiter.api.extension.InvocationInterceptor
|
||||
import org.junit.jupiter.api.extension.InvocationInterceptor.Invocation
|
||||
import org.junit.jupiter.api.extension.ReflectiveInvocationContext
|
||||
import org.opentest4j.TestAbortedException
|
||||
import java.lang.reflect.AnnotatedElement
|
||||
import java.lang.reflect.Method
|
||||
import java.util.Optional
|
||||
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@ExtendWith(RetryExtension::class)
|
||||
annotation class Retry(val times: Int = 3, val delayMs: Long = 1000)
|
||||
|
||||
class RetryExtension : InvocationInterceptor {
|
||||
override fun interceptTestMethod(
|
||||
invocation: Invocation<Void>,
|
||||
invocationContext: ReflectiveInvocationContext<Method>,
|
||||
extensionContext: ExtensionContext,
|
||||
) {
|
||||
executeWithRetry(invocation, extensionContext)
|
||||
}
|
||||
|
||||
private fun resolveRetryAnnotation(extensionContext: ExtensionContext): Retry? {
|
||||
val classAnn = extensionContext.testClass.flatMap { findRetry(it) }
|
||||
return classAnn.orElse(null)
|
||||
}
|
||||
|
||||
private fun findRetry(element: AnnotatedElement): Optional<Retry> =
|
||||
Optional.ofNullable(element.getAnnotation(Retry::class.java))
|
||||
|
||||
private fun executeWithRetry(invocation: Invocation<Void>, extensionContext: ExtensionContext) {
|
||||
val retry = resolveRetryAnnotation(extensionContext)
|
||||
if (retry == null || retry.times <= 1) {
|
||||
invocation.proceed()
|
||||
return
|
||||
}
|
||||
|
||||
val maxAttempts = retry.times
|
||||
val delay = retry.delayMs
|
||||
var lastError: Throwable? = null
|
||||
|
||||
for (attempt in 1..maxAttempts) {
|
||||
if (attempt > 1 && delay > 0) {
|
||||
try {
|
||||
Thread.sleep(delay)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (attempt == 1) {
|
||||
invocation.proceed()
|
||||
} else {
|
||||
val instance = extensionContext.requiredTestInstance
|
||||
val testMethod = extensionContext.requiredTestMethod
|
||||
testMethod.isAccessible = true
|
||||
testMethod.invoke(instance)
|
||||
}
|
||||
return
|
||||
} catch (t: Throwable) {
|
||||
if (t is TestAbortedException) throw t
|
||||
lastError = if (t is java.lang.reflect.InvocationTargetException) t.targetException ?: t else t
|
||||
if (attempt == maxAttempts) {
|
||||
println(
|
||||
"[Retry] Giving up after $attempt attempts for ${
|
||||
describeTest(
|
||||
extensionContext,
|
||||
)
|
||||
}: ${lastError.message}",
|
||||
)
|
||||
throw lastError
|
||||
}
|
||||
println(
|
||||
"[Retry] Failure on attempt $attempt/$maxAttempts for ${
|
||||
describeTest(
|
||||
extensionContext,
|
||||
)
|
||||
}: ${lastError.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?: IllegalStateException("Unexpected state in retry logic")
|
||||
}
|
||||
|
||||
private fun describeTest(ctx: ExtensionContext): String {
|
||||
val methodName = ctx.testMethod.map(Method::getName).orElse("<unknown>")
|
||||
val className = ctx.testClass.map { it.name }.orElse("<unknown>")
|
||||
return "$className#$methodName"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.utils
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object TestUtils {
|
||||
fun <T> runTest(block: suspend () -> T): T = runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue