From 435698a611b106efcf5dfa737b4291e5036edfff Mon Sep 17 00:00:00 2001 From: Pavel Gorgulov Date: Thu, 26 Mar 2026 13:19:10 +0100 Subject: [PATCH] refactor(integration-test): modernize ts tests, update versions, rename SSE -> HTTP (#618) - add tsconfig - fix tsignore - rename sse -> http - clean up code ## How Has This Been Tested? all tests passed ## Breaking Changes none ## Types of changes - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed --- .../main/kotlin/mcp.multiplatform.gradle.kts | 1 - config/detekt/detekt.yml | 4 + integration-test/detekt-baseline-test.xml | 14 -- .../integration/AbstractAuthenticationTest.kt | 1 - ...tractKotlinClientTsServerEdgeCasesTest.kt} | 45 ++--- .../sdk/integration/typescript/TsTestBase.kt | 26 +-- .../typescript/TypeScriptClient.kt | 10 +- .../typescript/TypeScriptServer.kt | 20 +-- .../KotlinClientTsServerEdgeCasesTestHttp.kt | 14 ++ .../KotlinClientTsServerTestHttp.kt} | 9 +- .../KotlinServerForTsClientHttp.kt} | 48 ++--- .../TsClientKotlinServerTestHttp.kt} | 10 +- .../TsEdgeCasesTestHttp.kt} | 26 +-- .../KotlinClientTsServerEdgeCasesTestStdio.kt | 167 +----------------- .../client/{sse-client.ts => http-client.ts} | 39 ++-- .../jvmTest/typescript/client/stdio-client.ts | 21 +-- .../src/jvmTest/typescript/package-lock.json | 71 ++------ .../src/jvmTest/typescript/package.json | 10 +- .../server/{sse-server.ts => http-server.ts} | 55 ++---- .../src/jvmTest/typescript/tsconfig.json | 12 ++ kotlin-sdk-client/detekt-baseline-test.xml | 2 - .../kotlin/test/utils/testing.kt | 1 - 22 files changed, 169 insertions(+), 437 deletions(-) rename integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/{sse/KotlinClientTsServerEdgeCasesTestSse.kt => AbstractKotlinClientTsServerEdgeCasesTest.kt} (82%) create mode 100644 integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerEdgeCasesTestHttp.kt rename integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/{sse/KotlinClientTsServerTestSse.kt => http/KotlinClientTsServerTestHttp.kt} (75%) rename integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/{sse/KotlinServerForTsClientSse.kt => http/KotlinServerForTsClientHttp.kt} (92%) rename integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/{sse/TsClientKotlinServerTestSse.kt => http/TsClientKotlinServerTestHttp.kt} (85%) rename integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/{sse/TsEdgeCasesTestSse.kt => http/TsEdgeCasesTestHttp.kt} (90%) rename integration-test/src/jvmTest/typescript/client/{sse-client.ts => http-client.ts} (67%) rename integration-test/src/jvmTest/typescript/server/{sse-server.ts => http-server.ts} (80%) create mode 100644 integration-test/src/jvmTest/typescript/tsconfig.json diff --git a/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts b/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts index f3d4616..36097ff 100644 --- a/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts +++ b/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts @@ -39,7 +39,6 @@ kotlin { forkEvery = 100 testLogging { exceptionFormat = TestExceptionFormat.SHORT - showStandardStreams = true events("failed") } systemProperty("kotest.output.ansi", "true") diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 8c19749..2cb135a 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -14,6 +14,10 @@ complexity: LongMethod: excludes: *testFolders +coroutines: + InjectDispatcher: + excludes: *testFolders + empty-blocks: EmptyFunctionBlock: excludes: *testFolders diff --git a/integration-test/detekt-baseline-test.xml b/integration-test/detekt-baseline-test.xml index 710b729..2df611a 100644 --- a/integration-test/detekt-baseline-test.xml +++ b/integration-test/detekt-baseline-test.xml @@ -8,24 +8,10 @@ AbstractClassCanBeConcreteClass:BaseTransportTest.kt:BaseTransportTest$BaseTransportTest CyclomaticComplexMethod:AbstractToolIntegrationTest.kt:AbstractToolIntegrationTest$private fun setupCalculatorTool ForbiddenComment:StdioClientTransportTest.kt:StdioClientTransportTest$// TODO: fix running on windows - InjectDispatcher:AbstractKotlinClientTsServerTest.kt:AbstractKotlinClientTsServerTest$IO - InjectDispatcher:AbstractPromptIntegrationTest.kt:AbstractPromptIntegrationTest$IO - InjectDispatcher:AbstractResourceIntegrationTest.kt:AbstractResourceIntegrationTest$IO - InjectDispatcher:AbstractToolIntegrationTest.kt:AbstractToolIntegrationTest$IO - InjectDispatcher:KotlinClientTsServerEdgeCasesTestSse.kt:KotlinClientTsServerEdgeCasesTestSse$IO - InjectDispatcher:KotlinClientTsServerEdgeCasesTestStdio.kt:KotlinClientTsServerEdgeCasesTestStdio$IO - InjectDispatcher:SseIntegrationTest.kt:SseIntegrationTest$IO - InjectDispatcher:StdioClientTransportTest.kt:StdioClientTransportTest$IO - InjectDispatcher:StreamableHttpIntegrationTest.kt:StreamableHttpIntegrationTest$IO - InjectDispatcher:TsEdgeCasesTestSse.kt:TsEdgeCasesTestSse$IO - InjectDispatcher:WebSocketIntegrationTest.kt:WebSocketIntegrationTest$IO MatchingDeclarationName:PromptIntegrationTestSse.kt:SchemaPromptIntegrationTestSse : AbstractPromptIntegrationTest - SleepInsteadOfDelay:KotlinServerForTsClientSse.kt:KotlinServerForTsClient$sleep(500) ThrowsCount:AbstractPromptIntegrationTest.kt:AbstractPromptIntegrationTest$override fun configureServer - UnusedPrivateProperty:KotlinClientTsServerTestSse.kt:KotlinClientTsServerTestSse$private val host = "localhost" UseCheckOrError:ClientTest.kt:ClientTest.<no name provided>$throw IllegalStateException("Test error") UseOrEmpty:AbstractToolIntegrationTest.kt:AbstractToolIntegrationTest$(request.params.arguments?.get("tags") as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList() UseRequire:AbstractPromptIntegrationTest.kt:AbstractPromptIntegrationTest$throw IllegalArgumentException("Missing required argument: $argName") - VarCouldBeVal:KotlinClientTsServerEdgeCasesTestSse.kt:KotlinClientTsServerEdgeCasesTestSse$private lateinit var client: Client diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/AbstractAuthenticationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/AbstractAuthenticationTest.kt index 1e88558..ec42041 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/AbstractAuthenticationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/AbstractAuthenticationTest.kt @@ -42,7 +42,6 @@ import io.ktor.server.sse.SSE as ServerSSE /** * Base class for MCP authentication integration tests. */ -@Suppress("InjectDispatcher") abstract class AbstractAuthenticationTest { protected companion object { diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerEdgeCasesTestSse.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerEdgeCasesTest.kt similarity index 82% rename from integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerEdgeCasesTestSse.kt rename to integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerEdgeCasesTest.kt index 356fd30..d977bd0 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerEdgeCasesTestSse.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerEdgeCasesTest.kt @@ -1,53 +1,29 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse +package io.modelcontextprotocol.kotlin.sdk.integration.typescript import io.modelcontextprotocol.kotlin.sdk.client.Client -import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind -import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase import io.modelcontextprotocol.kotlin.sdk.types.TextContent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout import java.util.concurrent.TimeUnit import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds -class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() { +abstract class AbstractKotlinClientTsServerEdgeCasesTest : TsTestBase() { - override val transportKind = TransportKind.SSE - - private val serverUrl: String by lazy { getSharedSseUrl() } - - private lateinit var client: Client - - @AfterEach - fun tearDown() { - if (::client.isInitialized) { - try { - runBlocking { - withTimeout(3.seconds) { - client.close() - } - } - } catch (e: Exception) { - println("Warning: Error during client close: ${e.message}") - } - } - } + protected abstract suspend fun useClient(block: suspend (Client) -> T): T @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testNonExistentTool(): Unit = runBlocking(Dispatchers.IO) { - withClient(serverUrl) { client -> + useClient { client -> val nonExistentToolName = "non-existent-tool" val arguments = mapOf("name" to "TestUser") @@ -70,7 +46,7 @@ class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() { @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testSpecialCharactersInArguments(): Unit = runBlocking(Dispatchers.IO) { - withClient(serverUrl) { client -> + useClient { client -> val specialChars = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/" val arguments = mapOf("name" to specialChars) @@ -91,7 +67,7 @@ class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() { @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testLargePayload(): Unit = runBlocking(Dispatchers.IO) { - withClient(serverUrl) { client -> + useClient { client -> val largeName = "A".repeat(10 * 1024) val arguments = mapOf("name" to largeName) @@ -112,7 +88,7 @@ class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() { @Test @Timeout(60, unit = TimeUnit.SECONDS) fun testConcurrentRequests(): Unit = runBlocking(Dispatchers.IO) { - withClient(serverUrl) { client -> + useClient { client -> val concurrentCount = 5 val responses = coroutineScope { val results = (1..concurrentCount).map { i -> @@ -147,7 +123,7 @@ class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() { @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testInvalidArguments(): Unit = runBlocking(Dispatchers.IO) { - withClient(serverUrl) { client -> + useClient { client -> val invalidArguments = mapOf( "name" to JsonObject(mapOf("nested" to JsonPrimitive("value"))), ) @@ -171,7 +147,7 @@ class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() { @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testMultipleToolCalls(): Unit = runBlocking(Dispatchers.IO) { - withClient(serverUrl) { client -> + useClient { client -> repeat(10) { i -> val name = "SequentialClient$i" val arguments = mapOf("name" to name) @@ -179,8 +155,7 @@ class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() { val result = client.callTool("greet", arguments) assertNotNull(result, "Tool call result should not be null for call $i") - val callResult = result - val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + val textContent = result.content.firstOrNull { it is TextContent } as? TextContent assertNotNull(textContent, "Text content should be present for call $i") assertEquals( diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TsTestBase.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TsTestBase.kt index aa47a70..29a6de7 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TsTestBase.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TsTestBase.kt @@ -6,7 +6,7 @@ import io.ktor.client.plugins.sse.SSE import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport import io.modelcontextprotocol.kotlin.sdk.client.mcpStreamableHttp -import io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse.KotlinServerForTsClient +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.http.KotlinServerForTsClient import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport import io.modelcontextprotocol.kotlin.sdk.types.Implementation @@ -26,7 +26,7 @@ import java.util.concurrent.TimeUnit import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -enum class TransportKind { SSE, STDIO } +enum class TransportKind { HTTP, STDIO } @Retry(times = 3) abstract class TsTestBase { @@ -43,10 +43,10 @@ abstract class TsTestBase { protected val typescriptDir = File(projectRoot, "src/jvmTest/typescript") @JvmStatic - private var sharedSseServer: Process? = null + private var sharedHttpServer: Process? = null @JvmStatic - private var sharedSsePort: Int = 0 + private var sharedHttpPort: Int = 0 init { installDependencies(typescriptDir) @@ -54,23 +54,23 @@ abstract class TsTestBase { @JvmStatic @Synchronized - protected fun getSharedSseUrl(): String { - if (sharedSseServer == null || !sharedSseServer!!.isAlive) { - sharedSsePort = io.modelcontextprotocol.kotlin.test.utils.findFreePort() + protected fun getSharedHttpUrl(): String { + if (sharedHttpServer == null || !sharedHttpServer!!.isAlive) { + sharedHttpPort = io.modelcontextprotocol.kotlin.test.utils.findFreePort() val server = TypeScriptServer(typescriptDir) - sharedSseServer = server.startSse(sharedSsePort) - println("Shared TypeScript SSE server started on port $sharedSsePort") + sharedHttpServer = server.startHttp(sharedHttpPort) + println("Shared TypeScript HTTP server started on port $sharedHttpPort") Runtime.getRuntime().addShutdownHook( Thread { - sharedSseServer?.let { - println("Stopping shared TypeScript SSE server") + sharedHttpServer?.let { + println("Stopping shared TypeScript HTTP server") io.modelcontextprotocol.kotlin.test.utils.stopProcess(it) } }, ) } - return "http://localhost:$sharedSsePort/mcp" + return "http://localhost:$sharedHttpPort/mcp" } @JvmStatic @@ -141,7 +141,7 @@ abstract class TsTestBase { println("$name stopped") } - // ===== SSE client helpers ===== + // ===== HTTP client helpers ===== protected suspend fun newClient(serverUrl: String): Client = HttpClient(CIO) { install(SSE) }.mcpStreamableHttp(serverUrl) diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptClient.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptClient.kt index 7664abf..109eab4 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptClient.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptClient.kt @@ -7,28 +7,28 @@ import java.io.File /** * Component for starting and managing TypeScript MCP clients for integration tests. * - * This class provides methods to start TypeScript clients using SSE or STDIO transports. + * This class provides methods to start TypeScript clients using HTTP or STDIO transports. * The clients are started using npx tsx to execute TypeScript files directly. */ class TypeScriptClient(private val typescriptDir: File) : AutoCloseable { private var process: Process? = null /** - * Starts a TypeScript MCP client using SSE transport. + * Starts a TypeScript MCP client using Streamable HTTP transport. * * @param arguments Arguments to pass to the client (typically server URL, tool name, and tool args) * @param log Whether to automatically log process output/error * @return The started Process */ - fun startSse(arguments: List, log: Boolean = true): Process { - val scriptPath = File(typescriptDir, "client/sse-client.ts").absolutePath + fun startHttp(arguments: List, log: Boolean = true): Process { + val scriptPath = File(typescriptDir, "client/http-client.ts").absolutePath val proc = TypeScriptRunner.run( typescriptDir = typescriptDir, scriptPath = scriptPath, arguments = arguments, env = emptyMap(), redirectErrorStream = false, - logPrefix = "TS-CLIENT-SSE", + logPrefix = "TS-CLIENT-HTTP", log = log, ) process = proc diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptServer.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptServer.kt index 579f96d..411a212 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptServer.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptServer.kt @@ -12,23 +12,23 @@ import java.util.concurrent.TimeUnit * Factory class for starting and managing TypeScript MCP servers for integration tests. * * This class provides methods to: - * - Start TypeScript servers using SSE or STDIO transports via npx tsx + * - Start TypeScript servers using HTTP or STDIO transports via npx tsx * - Install npm dependencies in the TypeScript test directory * - Manage server lifecycle (start/stop) * - Handle port management and process cleanup * * The servers are started using npx tsx to execute TypeScript files directly: * - `npx tsx server/stdio-server.ts` for STDIO transport - * - `npx tsx server/sse-server.ts` for SSE transport + * - `npx tsx server/http-server.ts` for HTTP transport * * Usage: * ```kotlin * val typescriptDir = File("kotlin-sdk-test/src/jvmTest/typescript") * val server = TypeScriptServer(typescriptDir) * - * // Start SSE server + * // Start HTTP server * val port = TypeScriptServer.findFreePort() - * val process = server.startSse(port) + * val process = server.startHttp(port) * * // Stop server * server.stop() @@ -38,27 +38,27 @@ class TypeScriptServer(private val typescriptDir: File) { private var process: Process? = null /** - * Starts a TypeScript MCP server using SSE transport. + * Starts a TypeScript MCP server using Streamable HTTP transport. * - * @param port The port number to bind the SSE server to + * @param port The port number to bind the HTTP server to * @return The started Process * @throws IllegalStateException if the server fails to start within timeout */ - fun startSse(port: Int): Process { + fun startHttp(port: Int): Process { killProcessOnPort(port) - val serverPath = File(typescriptDir, "server/sse-server.ts").absolutePath + val serverPath = File(typescriptDir, "server/http-server.ts").absolutePath val proc = TypeScriptRunner.run( typescriptDir = typescriptDir, scriptPath = serverPath, env = mapOf("MCP_PORT" to port.toString()), redirectErrorStream = true, - logPrefix = "TS-SERVER-SSE", + logPrefix = "TS-SERVER-HTTP", ) process = proc check(waitForPort(port = port, timeoutSeconds = 60)) { proc.destroyForcibly() - "TypeScript SSE server did not become ready on localhost:$port within timeout" + "TypeScript HTTP server did not become ready on localhost:$port within timeout" } return proc } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerEdgeCasesTestHttp.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerEdgeCasesTestHttp.kt new file mode 100644 index 0000000..016aa1c --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerEdgeCasesTestHttp.kt @@ -0,0 +1,14 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http + +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerEdgeCasesTest +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind + +class KotlinClientTsServerEdgeCasesTestHttp : AbstractKotlinClientTsServerEdgeCasesTest() { + + override val transportKind = TransportKind.HTTP + + private val serverUrl: String by lazy { getSharedHttpUrl() } + + override suspend fun useClient(block: suspend (Client) -> T): T = withClient(serverUrl, block) +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerTestSse.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerTestHttp.kt similarity index 75% rename from integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerTestSse.kt rename to integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerTestHttp.kt index 2006285..7d919af 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerTestSse.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerTestHttp.kt @@ -1,4 +1,4 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerTest @@ -6,11 +6,10 @@ import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind import kotlinx.coroutines.withTimeout import kotlin.time.Duration.Companion.seconds -class KotlinClientTsServerTestSse : AbstractKotlinClientTsServerTest() { - override val transportKind = TransportKind.SSE +class KotlinClientTsServerTestHttp : AbstractKotlinClientTsServerTest() { + override val transportKind = TransportKind.HTTP - private val host = "localhost" - private val serverUrl: String by lazy { getSharedSseUrl() } + private val serverUrl: String by lazy { getSharedHttpUrl() } override suspend fun useClient(block: suspend (Client) -> T): T = withClient(serverUrl) { client -> try { diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinServerForTsClientSse.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinServerForTsClientHttp.kt similarity index 92% rename from integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinServerForTsClientSse.kt rename to integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinServerForTsClientHttp.kt index 97acc29..ebd0499 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinServerForTsClientSse.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinServerForTsClientHttp.kt @@ -1,4 +1,4 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.http.ContentType @@ -42,8 +42,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -132,14 +133,16 @@ class KotlinServerForTsClient { call.response.header("mcp-session-id", newSessionId) - val serverThread = Thread { - runBlocking { + launch(Dispatchers.IO) { + try { mcpServer.createSession(transport) + } catch (e: Exception) { + logger.error(e) { "Failed to create MCP session" } + transport.ready.completeExceptionally(e) } } - serverThread.start() - Thread.sleep(500) + transport.ready.await() transport.handleRequest(call, jsonElement) } else { @@ -173,9 +176,7 @@ class KotlinServerForTsClient { logger.info { "Terminating session: $sessionId" } val transport = serverTransports[sessionId]!! serverTransports.remove(sessionId) - runBlocking { - transport.close() - } + transport.close() call.respond(HttpStatusCode.OK) } else { logger.warn { "Invalid session termination request: $sessionId" } @@ -313,9 +314,10 @@ class HttpServerTransport(private val sessionId: String) : AbstractTransport() { private val logger = KotlinLogging.logger {} private val pendingResponses = ConcurrentHashMap>() private val messageQueue = Channel(Channel.UNLIMITED) + internal val ready = CompletableDeferred() suspend fun stream(call: ApplicationCall) { - logger.debug { "Starting SSE stream for session: $sessionId" } + logger.debug { "Starting Event stream for session: $sessionId" } call.response.header("Cache-Control", "no-cache") call.response.header("Connection", "keep-alive") call.respondTextWriter(ContentType.Text.EventStream) { @@ -331,29 +333,28 @@ class HttpServerTransport(private val sessionId: String) : AbstractTransport() { flush() } } catch (e: Exception) { - logger.warn(e) { "SSE stream terminated for session: $sessionId" } + logger.warn(e) { "Event stream terminated for session: $sessionId" } } finally { - logger.debug { "SSE stream closed for session: $sessionId" } + logger.debug { "Event stream closed for session: $sessionId" } } } } suspend fun handleRequest(call: ApplicationCall, requestBody: JsonElement) { try { - logger.info { "Handling request body: $requestBody" } + logger.trace { "Handling request body: $requestBody" } val message = McpJson.decodeFromJsonElement(requestBody) - logger.info { "Decoded message: $message" } + logger.debug { "Decoded message: $message" } if (message is JSONRPCRequest) { val id = message.id.toString() - logger.info { "Received request with ID: $id, method: ${message.method}" } + logger.debug { "Received request with ID: $id, method: ${message.method}" } val responseDeferred = CompletableDeferred() pendingResponses[id] = responseDeferred - logger.info { "Created deferred response for ID: $id" } - logger.info { "Invoking onMessage handler" } + logger.trace { "Invoking onMessage handler for ID: $id" } _onMessage.invoke(message) - logger.info { "onMessage handler completed" } + logger.trace { "onMessage handler completed for ID: $id" } try { val response = withTimeoutOrNull(10000) { @@ -426,29 +427,28 @@ class HttpServerTransport(private val sessionId: String) : AbstractTransport() { override suspend fun start() { logger.debug { "Starting HTTP server transport for session: $sessionId" } + ready.complete(Unit) } override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { - logger.info { "Sending message: $message" } + logger.trace { "Sending message: $message" } if (message is JSONRPCResponse) { val id = message.id.toString() - logger.info { "Sending response for request ID: $id" } + logger.debug { "Completing 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}" } + logger.debug { "Sending request with ID: ${message.id}" } } else if (message is JSONRPCNotification) { - logger.info { "Sending notification: ${message.method}" } + logger.debug { "Sending notification: ${message.method}" } } - logger.info { "Queueing message for next client request" } messageQueue.send(message) } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsClientKotlinServerTestSse.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/TsClientKotlinServerTestHttp.kt similarity index 85% rename from integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsClientKotlinServerTestSse.kt rename to integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/TsClientKotlinServerTestHttp.kt index dd43b70..eff91e5 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsClientKotlinServerTestSse.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/TsClientKotlinServerTestHttp.kt @@ -1,4 +1,4 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractTsClientKotlinServerTest import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind @@ -7,9 +7,9 @@ import io.modelcontextprotocol.kotlin.test.utils.killProcessOnPort import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach -class TsClientKotlinServerTestSse : AbstractTsClientKotlinServerTest() { +class TsClientKotlinServerTestHttp : AbstractTsClientKotlinServerTest() { - override val transportKind = TransportKind.SSE + override val transportKind = TransportKind.HTTP private var port: Int = 0 private lateinit var serverUrl: String @@ -36,11 +36,11 @@ class TsClientKotlinServerTestSse : AbstractTsClientKotlinServerTest() { } override fun runClient(vararg args: String): String = tsClient.use { client -> - val process = client.startSse(listOf(serverUrl) + args.toList(), log = true) + val process = client.startHttp(listOf(serverUrl) + args.toList(), log = true) val output = StringBuilder() process.inputStream.bufferedReader().useLines { lines -> lines.forEach { line -> - println("[TS-CLIENT-SSE] $line") + println("[TS-CLIENT-HTTP] $line") output.append(line).append("\n") } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsEdgeCasesTestSse.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/TsEdgeCasesTestHttp.kt similarity index 90% rename from integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsEdgeCasesTestSse.kt rename to integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/TsEdgeCasesTestHttp.kt index ee81ce2..9326cc6 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsEdgeCasesTestSse.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/TsEdgeCasesTestHttp.kt @@ -1,4 +1,4 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase @@ -21,9 +21,9 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.test.fail -class TsEdgeCasesTestSse : TsTestBase() { +class TsEdgeCasesTestHttp : TsTestBase() { - override val transportKind = TransportKind.SSE + override val transportKind = TransportKind.HTTP private var port: Int = 0 private lateinit var serverUrl: String @@ -55,7 +55,7 @@ class TsEdgeCasesTestSse : TsTestBase() { @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testInvalidURL() = runTest { - val nonExistentToolCommand = "npx tsx client/sse-client.ts $serverUrl non-existent-tool" + val nonExistentToolCommand = "npx tsx client/http-client.ts $serverUrl non-existent-tool" val nonExistentToolOutput = executeCommandAllowingFailure(nonExistentToolCommand, typescriptDir) assertTrue( @@ -63,7 +63,7 @@ class TsEdgeCasesTestSse : TsTestBase() { "Client should handle non-existent tool gracefully", ) - val invalidUrlCommand = "npx tsx client/sse-client.ts http://localhost:${port + 1000}/mcp greet TestUser" + val invalidUrlCommand = "npx tsx client/http-client.ts http://localhost:${port + 1000}/mcp greet TestUser" val invalidUrlOutput = executeCommandAllowingFailure(invalidUrlCommand, typescriptDir) assertTrue( @@ -84,7 +84,7 @@ class TsEdgeCasesTestSse : TsTestBase() { tempFile.deleteOnExit() val specialCharsContent = tempFile.readText() - val specialCharsCommand = "npx tsx client/sse-client.ts $serverUrl greet \"$specialCharsContent\"" + val specialCharsCommand = "npx tsx client/http-client.ts $serverUrl greet \"$specialCharsContent\"" val specialCharsOutput = executeCommand(specialCharsCommand, typescriptDir) assertTrue( @@ -109,7 +109,7 @@ class TsEdgeCasesTestSse : TsTestBase() { tempFile.deleteOnExit() val largeNameContent = tempFile.readText() - val largePayloadCommand = "npx tsx client/sse-client.ts $serverUrl greet \"$largeNameContent\"" + val largePayloadCommand = "npx tsx client/http-client.ts $serverUrl greet \"$largeNameContent\"" val largePayloadOutput = executeCommand(largePayloadCommand, typescriptDir) tempFile.delete() @@ -157,11 +157,11 @@ class TsEdgeCasesTestSse : TsTestBase() { } val commands = listOf( - "npx tsx client/sse-client.ts $serverUrl greet \"Client1\"", - "npx tsx client/sse-client.ts $serverUrl multi-greet \"Client2\"", - "npx tsx client/sse-client.ts $serverUrl greet \"Client3\"", - "npx tsx client/sse-client.ts $serverUrl", - "npx tsx client/sse-client.ts $serverUrl multi-greet \"Client5\"", + "npx tsx client/http-client.ts $serverUrl greet \"Client1\"", + "npx tsx client/http-client.ts $serverUrl multi-greet \"Client2\"", + "npx tsx client/http-client.ts $serverUrl greet \"Client3\"", + "npx tsx client/http-client.ts $serverUrl", + "npx tsx client/http-client.ts $serverUrl multi-greet \"Client5\"", ) coroutineScope { @@ -242,7 +242,7 @@ class TsEdgeCasesTestSse : TsTestBase() { @Timeout(120, unit = TimeUnit.SECONDS) fun testRapidSequentialRequests() = runBlocking { val outputs = (1..10).map { i -> - val command = "npx tsx client/sse-client.ts $serverUrl greet \"RapidClient$i\"" + val command = "npx tsx client/http-client.ts $serverUrl greet \"RapidClient$i\"" val output = executeCommand(command, typescriptDir) assertTrue( diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/KotlinClientTsServerEdgeCasesTestStdio.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/KotlinClientTsServerEdgeCasesTestStdio.kt index 2bdd361..b8c8e93 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/KotlinClientTsServerEdgeCasesTestStdio.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/KotlinClientTsServerEdgeCasesTestStdio.kt @@ -1,173 +1,12 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerEdgeCasesTest import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind -import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Timeout -import java.util.concurrent.TimeUnit -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -class KotlinClientTsServerEdgeCasesTestStdio : TsTestBase() { +class KotlinClientTsServerEdgeCasesTestStdio : AbstractKotlinClientTsServerEdgeCasesTest() { override val transportKind = TransportKind.STDIO - @Test - @Timeout(30, unit = TimeUnit.SECONDS) - fun testNonExistentToolOverStdio(): Unit = runBlocking(Dispatchers.IO) { - withClientStdio { client: Client, _ -> - val nonExistentToolName = "non-existent-tool" - val arguments = mapOf("name" to "TestUser") - - val result = client.callTool(nonExistentToolName, arguments) - assertNotNull(result, "Tool call result should not be null") - - assertTrue(result.isError ?: false, "isError should be true for non-existent tool") - - val textContent = result.content.firstOrNull { it is TextContent } as? TextContent - assertNotNull(textContent, "Error content should be present in the result") - - val errorText = textContent.text - assertTrue( - errorText.contains("non-existent-tool") && errorText.contains("not found"), - "Error message should indicate the tool was not found", - ) - } - } - - @Test - @Timeout(30, unit = TimeUnit.SECONDS) - fun testSpecialCharactersInArgumentsOverStdio(): Unit = runBlocking(Dispatchers.IO) { - withClientStdio { client: Client, _ -> - val specialChars = "!@#$%^&*()_+{}[]|\\:;\"'<>.,?/" - val arguments = mapOf("name" to specialChars) - - val result = client.callTool("greet", arguments) - assertNotNull(result, "Tool call result should not be null") - - val textContent = result.content.firstOrNull { it is TextContent } as? TextContent - assertNotNull(textContent, "Text content should be present in the result") - - val text = textContent.text - assertTrue( - text.contains(specialChars), - "Tool response should contain the special characters", - ) - } - } - - @Test - @Timeout(30, unit = TimeUnit.SECONDS) - fun testLargePayloadOverStdio(): Unit = runBlocking(Dispatchers.IO) { - withClientStdio { client: Client, _ -> - val largeName = "A".repeat(10 * 1024) - val arguments = mapOf("name" to largeName) - - val result = client.callTool("greet", arguments) - assertNotNull(result, "Tool call result should not be null") - - val callResult = result - 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(30, unit = TimeUnit.SECONDS) - fun testConcurrentRequestsOverStdio(): Unit = runBlocking(Dispatchers.IO) { - withClientStdio { client: Client, _ -> - val concurrentCount = 5 - val responses = coroutineScope { - val results = (1..concurrentCount).map { i -> - async { - val name = "ConcurrentClient$i" - val arguments = mapOf("name" to name) - - val result = client.callTool("greet", arguments) - assertNotNull(result, "Tool call result should not be null for client $i") - - val textContent = result.content.firstOrNull { it is TextContent } as? TextContent - assertNotNull(textContent, "Text content should be present for client $i") - - textContent.text - } - } - results.awaitAll() - } - - for (i in 1..concurrentCount) { - val expectedName = "ConcurrentClient$i" - val matchingResponses = responses.filter { it.contains("Hello, $expectedName!") } - assertEquals( - 1, - matchingResponses.size, - "Should have exactly one response for $expectedName", - ) - } - } - } - - @Test - @Timeout(30, unit = TimeUnit.SECONDS) - fun testInvalidArgumentsOverStdio(): Unit = runBlocking(Dispatchers.IO) { - withClientStdio { client: Client, _ -> - val invalidArguments = mapOf( - "name" to JsonObject(mapOf("nested" to JsonPrimitive("value"))), - ) - - val result = client.callTool("greet", invalidArguments) - assertNotNull(result, "Tool call result should not be null") - - val callResult = result - assertTrue(callResult.isError ?: false, "isError should be true for invalid arguments") - - val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent - assertNotNull(textContent, "Error content should be present in the result") - - val errorText = textContent.text - assertTrue( - errorText.contains("Invalid arguments") && errorText.contains("greet"), - "Error message should indicate invalid arguments for tool greet", - ) - } - } - - @Test - @Timeout(30, unit = TimeUnit.SECONDS) - fun testMultipleToolCallsOverStdio(): Unit = runBlocking(Dispatchers.IO) { - withClientStdio { client: Client, _ -> - repeat(10) { i -> - val name = "SequentialClient$i" - val arguments = mapOf("name" to name) - - val result = client.callTool("greet", arguments) - assertNotNull(result, "Tool call result should not be null for call $i") - - val textContent = result.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", - ) - } - } - } + override suspend fun useClient(block: suspend (Client) -> T): T = withClientStdio { c, _ -> block(c) } } diff --git a/integration-test/src/jvmTest/typescript/client/sse-client.ts b/integration-test/src/jvmTest/typescript/client/http-client.ts similarity index 67% rename from integration-test/src/jvmTest/typescript/client/sse-client.ts rename to integration-test/src/jvmTest/typescript/client/http-client.ts index 0acc256..5956a37 100644 --- a/integration-test/src/jvmTest/typescript/client/sse-client.ts +++ b/integration-test/src/jvmTest/typescript/client/http-client.ts @@ -2,14 +2,14 @@ import {Client} from '@modelcontextprotocol/sdk/client/index'; import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp'; const args = process.argv.slice(2); -const serverUrl = args[0] || 'http://localhost:3001/mcp'; +const serverUrl = args[0] || 'http://localhost:3000/mcp'; const toolName = args[1]; const toolArgs = args.slice(2); -const PROTOCOL_VERSION = "2024-11-05"; +const PROTOCOL_VERSION = "2025-11-25"; async function main() { if (!toolName) { - console.log('Usage: npx tsx client/sse-client.ts [server-url] [tool-args...]'); + console.log('Usage: npx tsx client/http-client.ts [server-url] [tool-args...]'); console.log('Using default server URL:', serverUrl); console.log('Available utils will be listed after connection'); } @@ -22,6 +22,10 @@ async function main() { const client = new Client({ name: 'test-client', version: '1.0.0' + }, { + fallbackNotificationHandler: async (notification) => { + console.log('Notification:', notification.method, JSON.stringify(notification)); + } }); const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); @@ -30,37 +34,20 @@ async function main() { 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: '); - } - }); - } - } catch { - } - const toolsResult = await client.listTools(); const tools = toolsResult.tools; - console.log('Available utils:', tools.map((t: { name: any; }) => t.name).join(', ')); + console.log('Available utils:', tools.map((t) => t.name).join(', ')); if (!toolName) { - await client.close(); return; } - const tool = tools.find((t: { name: string; }) => t.name === toolName); + const tool = tools.find((t) => t.name === toolName); if (!tool) { - console.error(`Tool "${toolName}" not found`); - // @ts-ignore - process.exit(1); + throw new Error(`Tool "${toolName}" not found`); } - const toolArguments = {}; + const toolArguments: Record = {}; if (toolName === "greet" && toolArgs.length > 0) { toolArguments["name"] = toolArgs[0]; @@ -93,8 +80,7 @@ async function main() { } catch (error) { console.error('Error:', error); - // @ts-ignore - process.exit(1); + process.exitCode = 1; } finally { await client.close(); console.log('Disconnected from server'); @@ -103,6 +89,5 @@ async function main() { main().catch(error => { console.error('Unhandled error:', error); - // @ts-ignore process.exit(1); }); diff --git a/integration-test/src/jvmTest/typescript/client/stdio-client.ts b/integration-test/src/jvmTest/typescript/client/stdio-client.ts index 82c25de..a9c765c 100644 --- a/integration-test/src/jvmTest/typescript/client/stdio-client.ts +++ b/integration-test/src/jvmTest/typescript/client/stdio-client.ts @@ -3,7 +3,7 @@ import {Client} from '@modelcontextprotocol/sdk/client/index'; const args = process.argv.slice(2); const toolName = args[0]; const toolArgs = args.slice(1); -const PROTOCOL_VERSION = "2024-11-05"; +const PROTOCOL_VERSION = "2025-11-25"; async function main() { if (!toolName) { @@ -65,6 +65,10 @@ async function main() { const client = new Client({ name: 'test-client', version: '1.0.0' + }, { + fallbackNotificationHandler: async (notification) => { + console.error('Notification:', notification.method, JSON.stringify(notification)); + } }); const transport = new MinimalStdioClientTransport(); @@ -73,21 +77,6 @@ async function main() { await client.connect(transport, {protocolVersion: PROTOCOL_VERSION}); console.error('Connected to server over stdio'); - 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.error('Notification:', method, JSON.stringify(n)); - } catch { - console.error('Notification: '); - } - }); - } - } catch { - // ignore - } - const toolsResult = await client.listTools(); const tools = toolsResult.tools; console.error('Available utils:', tools.map((t: { name: any; }) => t.name).join(', ')); diff --git a/integration-test/src/jvmTest/typescript/package-lock.json b/integration-test/src/jvmTest/typescript/package-lock.json index fddbba4..1f26245 100644 --- a/integration-test/src/jvmTest/typescript/package-lock.json +++ b/integration-test/src/jvmTest/typescript/package-lock.json @@ -8,18 +8,14 @@ "name": "mcp-typescript-test-sdk", "version": "1.0.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", - "body-parser": "^2.2.2", + "@modelcontextprotocol/sdk": "^1.28.0", "express": "^5.2.1", - "uuid": "^13.0.0", "zod": "^4.3.5" }, "devDependencies": { - "@eslint/js": "^9.39.2", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^24.10.6", - "@types/uuid": "^10.0.0", "cors": "^2.8.5", "tsx": "^4.21.0", "typescript": "^5.9.3" @@ -467,23 +463,10 @@ "node": ">=18" } }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -493,9 +476,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", + "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -640,13 +623,6 @@ "@types/node": "*" } }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "dev": true, - "license": "MIT" - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1029,12 +1005,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -1217,9 +1193,9 @@ } }, "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1268,9 +1244,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -1786,19 +1762,6 @@ "node": ">= 0.8" } }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/integration-test/src/jvmTest/typescript/package.json b/integration-test/src/jvmTest/typescript/package.json index 6972151..3ce560c 100644 --- a/integration-test/src/jvmTest/typescript/package.json +++ b/integration-test/src/jvmTest/typescript/package.json @@ -4,23 +4,19 @@ "private": true, "scripts": { "client:stdio": "tsx client/stdio-client.ts", - "client:sse": "tsx client/sse-client.ts http://localhost:3000/mcp greet Hello", + "client:http": "tsx client/http-client.ts http://localhost:3000/mcp greet Hello", "server:stdio": "tsx server/stdio-server.ts", - "server:sse": "tsx server/sse-server.ts" + "server:http": "tsx server/http-server.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", - "body-parser": "^2.2.2", + "@modelcontextprotocol/sdk": "^1.28.0", "express": "^5.2.1", - "uuid": "^13.0.0", "zod": "^4.3.5" }, "devDependencies": { - "@eslint/js": "^9.39.2", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^24.10.6", - "@types/uuid": "^10.0.0", "cors": "^2.8.5", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/integration-test/src/jvmTest/typescript/server/sse-server.ts b/integration-test/src/jvmTest/typescript/server/http-server.ts similarity index 80% rename from integration-test/src/jvmTest/typescript/server/sse-server.ts rename to integration-test/src/jvmTest/typescript/server/http-server.ts index 48ea453..c42e115 100644 --- a/integration-test/src/jvmTest/typescript/server/sse-server.ts +++ b/integration-test/src/jvmTest/typescript/server/http-server.ts @@ -1,30 +1,24 @@ -// @ts-ignore import express, {Request, Response} from 'express'; import {randomUUID} from 'node:crypto'; -// @ts-ignore import cors from 'cors'; import {McpServer} from '@modelcontextprotocol/sdk/server/mcp'; -import {StreamableHTTPServerTransport,} from '@modelcontextprotocol/sdk/server/streamableHttp'; +import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp'; import {registerTestUtils} from './server-common'; import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router'; import {requireBearerAuth} from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth'; -import {isInitializeRequest,} from '@modelcontextprotocol/sdk/types'; +import {isInitializeRequest} from '@modelcontextprotocol/sdk/types'; import {InMemoryEventStore} from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore'; import {setupAuthServer} from '@modelcontextprotocol/sdk/examples/server/demoInMemoryOAuthProvider'; import {checkResourceAllowed} from '@modelcontextprotocol/sdk/shared/auth-utils'; import {OAuthMetadata} from "@modelcontextprotocol/sdk/shared/auth"; -// import {type OAuthMetadata} from '@modelcontextprotocol/sdk/auth.js'; - async function main() { - // 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', @@ -42,16 +36,15 @@ async function main() { const app = express(); app.use(express.json()); -// Allow CORS all domains, expose the Mcp-Session-Id header + // Allow CORS all domains, expose the Mcp-Session-Id header app.use(cors({ - origin: '*', // Allow all origins + origin: '*', exposedHeaders: ["Mcp-Session-Id"] })); -// Set up OAuth if enabled + // 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}`); @@ -79,7 +72,6 @@ async function main() { }).toString() }); - if (!response.ok) { throw new Error(`Invalid or expired token: ${await response.text()}`); } @@ -95,7 +87,6 @@ async function main() { } } - // Convert the response to AuthInfo format return { token, clientId: data.client_id, @@ -104,7 +95,7 @@ async function main() { }; } } - // Add metadata routes to the main MCP server + app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl, @@ -119,10 +110,10 @@ async function main() { }); } -// Map to store transports by session ID + // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; -// MCP POST endpoint with optional auth + // 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) { @@ -137,23 +128,18 @@ async function main() { 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 + eventStore, 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]) { @@ -162,15 +148,12 @@ async function main() { } }; - // 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 + return; } else { - // Invalid request - no session ID or not initialization request res.status(400).json({ jsonrpc: '2.0', error: { @@ -182,8 +165,6 @@ async function main() { 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); @@ -200,14 +181,13 @@ async function main() { } }; -// 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) + // Handle GET requests for event streams const mcpGetHandler = async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { @@ -216,29 +196,27 @@ async function main() { } if (useOAuth && req.auth) { - console.log('Authenticated SSE connection from user:', req.auth); + console.log('Authenticated event stream 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}`); + console.log(`Establishing new event 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) + // 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]) { @@ -259,7 +237,6 @@ async function main() { } }; -// Set up DELETE route with conditional auth middleware if (useOAuth && authMiddleware) { app.delete('/mcp', authMiddleware, mcpDeleteHandler); } else { @@ -274,11 +251,10 @@ async function main() { console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); }); -// Handle server shutdown + // 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}`); @@ -291,7 +267,6 @@ async function main() { console.log('Server shutdown complete'); process.exit(0); }); - } main().catch((err) => { diff --git a/integration-test/src/jvmTest/typescript/tsconfig.json b/integration-test/src/jvmTest/typescript/tsconfig.json new file mode 100644 index 0000000..58e3c67 --- /dev/null +++ b/integration-test/src/jvmTest/typescript/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/kotlin-sdk-client/detekt-baseline-test.xml b/kotlin-sdk-client/detekt-baseline-test.xml index b16e8eb..dd6adfa 100644 --- a/kotlin-sdk-client/detekt-baseline-test.xml +++ b/kotlin-sdk-client/detekt-baseline-test.xml @@ -4,8 +4,6 @@ AbstractClassCanBeConcreteClass:AbstractStreamableHttpClientTest.kt:AbstractStreamableHttpClientTest$AbstractStreamableHttpClientTest ForbiddenComment:StreamableHttpClientTest.kt:StreamableHttpClientTest$// TODO: how to get notifications via Client API? - InjectDispatcher:StdioClientTransportErrorHandlingTest.kt:StdioClientTransportErrorHandlingTest$IO - InjectDispatcher:StreamableHttpClientTransportTest.kt:StreamableHttpClientTransportTest$Default LongParameterList:MockMcp.kt:MockMcp$fun handleJSONRPCRequest LongParameterList:MockMcp.kt:MockMcp$fun handleWithResult LongParameterList:MockMcp.kt:MockMcp$fun onInitialize diff --git a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/testing.kt b/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/testing.kt index ee73162..32f55c9 100644 --- a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/testing.kt +++ b/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/testing.kt @@ -8,7 +8,6 @@ import kotlin.coroutines.CoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -@Suppress("InjectDispatcher") // this is constant private val defaultTestDispatcher: CoroutineContext = Dispatchers.IO.limitedParallelism(100) private val defaultTestTimeout: Duration = 5.seconds