From 6ad9bf212277ab352774621d166ed23cbd9321cb Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Sun, 11 Jan 2026 09:38:13 +0200 Subject: [PATCH] fix(integration-test): refactor integration test infrastructure, fix and enable tests with ts-client (#478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Refactor integration test infrastructure, fix and enable tests with ts-client - Refactor TypeScript integration test infrastructure to support parallel execution and real I/O operations. - Extract reusable TypeScriptClient component - Migrate some tests from `runTest` to `runBlocking` for tests that spawn actual processes and communicate over real network connections. ## Key Changes - Extract `TypeScriptClient` class for managing TS client processes - Move client scripts to integration-test/src/jvmTest/typescript/client/ - Migrate typescript: `myClient.ts`(2) → `sse-client.ts`, `stdio-client.ts` - Automate TypeScript dependency installation in static initializer - Enable JUnit parallel execution (dynamic strategy, concurrent mode) with test timeout of 2 minutes - Replace `mutableListOf` with `ConcurrentLinkedQueue` for thread-safe notification handling - Migrate integration tests from `runTest` to `runBlocking` **Rationale:** Integration tests that spawn real TypeScript processes, establish actual HTTP/SSE connections, and manage server lifecycles require real-time I/O behavior. The runTest framework's virtual time is designed for fast unit tests with simulated timing, not for integration scenarios where external processes and network communication must occur in real time. Parallel execution requires thread-safe data structures (ConcurrentLinkedQueue) to safely collect notifications from multiple concurrent test instances. - Add `EmbeddedServer.actualPort()` extension to get bound port - Standardize kotlin.time.Duration usage across test utilities - Update processUtils.stopProcess() to accept Duration parameter - Replace RuntimeException checks with idiomatic check() calls - Revert `--rerun-tasks` flag to Gradle workflows for reliable builds ## Motivation and Context Integration tests with typescript client are now broken. Enabling parallel test execution will force writing thread-safe independent tests and will highlight problems faster. ## How Has This Been Tested? CI ## Breaking Changes ## Types of changes - [x] CI Update - [x] Test Fix ## Checklist - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [ ] I have added appropriate error handling - [ ] I have added or updated documentation as needed ## Additional context Follow up for #477 --- .github/workflows/build.yml | 4 +- .github/workflows/gradle-publish.yml | 2 +- .../sdk/client/StdioClientTransportTest.kt | 3 +- .../OldSchemaWebSocketIntegrationTest.kt | 54 ++--- .../websocket/WebSocketIntegrationTest.kt | 52 ++--- .../sse/AbstractSseIntegrationTest.kt | 2 - .../OldSchemaAbstractSseIntegrationTest.kt | 4 +- .../sse/OldSchemaSseIntegrationTest.kt | 55 ++--- .../sdk/integration/sse/SseIntegrationTest.kt | 53 ++--- .../AbstractKotlinClientTsServerTest.kt | 2 - .../AbstractTsClientKotlinServerTest.kt | 15 +- .../sdk/integration/typescript/TsTestBase.kt | 139 +++-------- .../typescript/TypeScriptClient.kt | 69 ++++++ .../KotlinClientTsServerEdgeCasesTestSse.kt | 1 - .../sse/TsClientKotlinServerTestSse.kt | 22 +- .../typescript/sse/TsEdgeCasesTestSse.kt | 47 ++-- .../stdio/TsClientKotlinServerTestStdio.kt | 2 - .../typescript/stdio/TsEdgeCasesTestStdio.kt | 2 - ...erverResourcesNotificationSubscribeTest.kt | 219 +++++++----------- .../resources/junit-platform.properties | 6 + .../client/sse-client.ts} | 33 +-- .../client/stdio-client.ts} | 28 +-- .../src/jvmTest/typescript/package.json | 2 + test-utils/build.gradle.kts | 1 + .../kotlin/test/utils/TypeScriptRunner.kt | 104 +++++++-- .../kotlin/test/utils/ktor.kt | 17 ++ .../kotlin/test/utils/processUtils.kt | 6 +- 27 files changed, 461 insertions(+), 483 deletions(-) create mode 100644 integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptClient.kt create mode 100644 integration-test/src/jvmTest/resources/junit-platform.properties rename integration-test/src/jvmTest/{kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/myClient.ts => typescript/client/sse-client.ts} (71%) rename integration-test/src/jvmTest/{kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/myClient.ts => typescript/client/stdio-client.ts} (84%) create mode 100644 test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/ktor.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2d336cd..1922ffb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,7 +84,7 @@ jobs: - name: ${{ matrix.job-name }} run: |- - ./gradlew ${{ matrix.gradle-tasks }} -x :conformance-test:test --max-workers ${{ matrix.max-workers }} --continue --configure-on-demand --parallel + ./gradlew ${{ matrix.gradle-tasks }} -x :conformance-test:test --max-workers ${{ matrix.max-workers }} --continue --rerun-tasks --configure-on-demand --parallel - name: Upload Reports if: always() @@ -168,7 +168,7 @@ jobs: - name: "Build Sample: ${{ matrix.sample }}" working-directory: ./samples/${{ matrix.sample }} - run: ./gradlew build -Pmcp.kotlin.overrideVersion=1-SNAPSHOT + run: ./gradlew build -Pmcp.kotlin.overrideVersion=1-SNAPSHOT --rerun-tasks - name: Upload Reports if: ${{ !cancelled() }} diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml index 43f4766..a140872 100644 --- a/.github/workflows/gradle-publish.yml +++ b/.github/workflows/gradle-publish.yml @@ -35,7 +35,7 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Clean Build with Gradle - run: ./gradlew clean build -x :conformance-test:test + run: ./gradlew clean build -x :conformance-test:test --rerun-tasks - name: Publish to Maven Central Portal id: publish diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt index adea76b..be06cff 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt @@ -5,6 +5,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.test.utils.createSleepyProcessBuilder import io.modelcontextprotocol.kotlin.test.utils.createTeeProcessBuilder +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -30,7 +31,7 @@ import kotlin.time.Duration.Companion.seconds class StdioClientTransportTest : BaseTransportTest() { @Test - fun `handle stdio error`(): Unit = runBlocking { + fun `handle stdio error`(): Unit = runBlocking(Dispatchers.IO) { val processBuilder = createSleepyProcessBuilder() val process = processBuilder.start() diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/websocket/OldSchemaWebSocketIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/websocket/OldSchemaWebSocketIntegrationTest.kt index 0ac98e3..312e3be 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/websocket/OldSchemaWebSocketIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/websocket/OldSchemaWebSocketIntegrationTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.websocket import io.ktor.client.HttpClient @@ -21,11 +23,9 @@ import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.server.mcpWebSocket import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds import io.ktor.client.engine.cio.CIO as ClientCIO import io.ktor.client.plugins.websocket.WebSockets as ClientWebSocket import io.ktor.server.cio.CIO as ServerCIO @@ -34,16 +34,14 @@ import io.ktor.server.websocket.WebSockets as ServerWebSockets class OldSchemaWebSocketIntegrationTest { @Test - fun `client should be able to connect to websocket server 2`() = runTest(timeout = 5.seconds) { + fun `client should be able to connect to websocket server 2`() = runBlocking(Dispatchers.IO) { var server: EmbeddedServer? = null var client: Client? = null try { - withContext(Dispatchers.Default) { - server = initServer() - val port = server.engine.resolvedConnectors().first().port - client = initClient(serverPort = port) - } + server = initServer() + val port = server.engine.resolvedConnectors().first().port + client = initClient(serverPort = port) } finally { client?.close() server?.stop(1000, 2000) @@ -58,19 +56,17 @@ class OldSchemaWebSocketIntegrationTest { * 3. Observe that Client A receives a response related to it. */ @Test - fun `single websocket connection`() = runTest(timeout = 5.seconds) { + fun `single websocket connection`() = runBlocking(Dispatchers.IO) { var server: EmbeddedServer? = null var client: Client? = null try { - withContext(Dispatchers.Default) { - server = initServer() - val port = server.engine.resolvedConnectors().first().port - client = initClient("Client A", port) + server = initServer() + val port = server.engine.resolvedConnectors().first().port + client = initClient("Client A", port) - val promptA = getPrompt(client, "Client A") - assertTrue { "Client A" in promptA } - } + val promptA = getPrompt(client, "Client A") + assertTrue { "Client A" in promptA } } finally { client?.close() server?.stop(1000, 2000) @@ -86,26 +82,24 @@ class OldSchemaWebSocketIntegrationTest { * 4. Observe that Client B (connection #2) receives a response related to sessionId#1. */ @Test - fun `multiple websocket connections`() = runTest(timeout = 5.seconds) { + fun `multiple websocket connections`() = runBlocking(Dispatchers.IO) { var server: EmbeddedServer? = null var clientA: Client? = null var clientB: Client? = null try { - withContext(Dispatchers.Default) { - server = initServer() - val port = server.engine.resolvedConnectors().first().port - clientA = initClient("Client A", port) - clientB = initClient("Client B", port) + server = initServer() + val port = server.engine.resolvedConnectors().first().port + clientA = initClient("Client A", port) + clientB = initClient("Client B", port) - // Step 3: Send a prompt request from Client A - val promptA = getPrompt(clientA, "Client A") - // Step 4: Send a prompt request from Client B - val promptB = getPrompt(clientB, "Client B") + // Step 3: Send a prompt request from Client A + val promptA = getPrompt(clientA, "Client A") + // Step 4: Send a prompt request from Client B + val promptB = getPrompt(clientB, "Client B") - assertTrue { "Client A" in promptA } - assertTrue { "Client B" in promptB } - } + assertTrue { "Client A" in promptA } + assertTrue { "Client B" in promptB } } finally { clientA?.close() clientB?.close() diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/websocket/WebSocketIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/websocket/WebSocketIntegrationTest.kt index ab0d848..7d7898e 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/websocket/WebSocketIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/websocket/WebSocketIntegrationTest.kt @@ -21,11 +21,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.Role import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.TextContent import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds import io.ktor.client.engine.cio.CIO as ClientCIO import io.ktor.client.plugins.websocket.WebSockets as ClientWebSocket import io.ktor.server.cio.CIO as ServerCIO @@ -34,16 +32,14 @@ import io.ktor.server.websocket.WebSockets as ServerWebSockets class WebSocketIntegrationTest { @Test - fun `client should be able to connect to websocket server 2`() = runTest(timeout = 5.seconds) { + fun `client should be able to connect to websocket server 2`() = runBlocking(Dispatchers.IO) { var server: EmbeddedServer? = null var client: Client? = null try { - withContext(Dispatchers.Default) { - server = initServer() - val port = server.engine.resolvedConnectors().first().port - client = initClient(serverPort = port) - } + server = initServer() + val port = server.engine.resolvedConnectors().first().port + client = initClient(serverPort = port) } finally { client?.close() server?.stop(1000, 2000) @@ -58,19 +54,17 @@ class WebSocketIntegrationTest { * 3. Observe that Client A receives a response related to it. */ @Test - fun `single websocket connection`() = runTest(timeout = 5.seconds) { + fun `single websocket connection`() = runBlocking(Dispatchers.IO) { var server: EmbeddedServer? = null var client: Client? = null try { - withContext(Dispatchers.Default) { - server = initServer() - val port = server.engine.resolvedConnectors().first().port - client = initClient("Client A", port) + server = initServer() + val port = server.engine.resolvedConnectors().first().port + client = initClient("Client A", port) - val promptA = getPrompt(client, "Client A") - assertTrue { "Client A" in promptA } - } + val promptA = getPrompt(client, "Client A") + assertTrue { "Client A" in promptA } } finally { client?.close() server?.stop(1000, 2000) @@ -86,26 +80,24 @@ class WebSocketIntegrationTest { * 4. Observe that Client B (connection #2) receives a response related to sessionId#1. */ @Test - fun `multiple websocket connections`() = runTest(timeout = 5.seconds) { + fun `multiple websocket connections`() = runBlocking(Dispatchers.IO) { var server: EmbeddedServer? = null var clientA: Client? = null var clientB: Client? = null try { - withContext(Dispatchers.Default) { - server = initServer() - val port = server.engine.resolvedConnectors().first().port - clientA = initClient("Client A", port) - clientB = initClient("Client B", port) + server = initServer() + val port = server.engine.resolvedConnectors().first().port + clientA = initClient("Client A", port) + clientB = initClient("Client B", port) - // Step 3: Send a prompt request from Client A - val promptA = getPrompt(clientA, "Client A") - // Step 4: Send a prompt request from Client B - val promptB = getPrompt(clientB, "Client B") + // Step 3: Send a prompt request from Client A + val promptA = getPrompt(clientA, "Client A") + // Step 4: Send a prompt request from Client B + val promptB = getPrompt(clientB, "Client B") - assertTrue { "Client A" in promptA } - assertTrue { "Client B" in promptB } - } + assertTrue { "Client A" in promptA } + assertTrue { "Client B" in promptB } } finally { clientA?.close() clientB?.close() diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/AbstractSseIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/AbstractSseIntegrationTest.kt index 8f6aed6..2a2f381 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/AbstractSseIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/AbstractSseIntegrationTest.kt @@ -25,8 +25,6 @@ import io.ktor.server.sse.SSE as ServerSSE open class AbstractSseIntegrationTest { - suspend fun EmbeddedServer<*, *>.actualPort() = engine.resolvedConnectors().single().port - suspend fun initTestClient(serverPort: Int, name: String? = null): Client { val client = Client( Implementation(name = name ?: DEFAULT_CLIENT_NAME, version = VERSION), diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaAbstractSseIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaAbstractSseIntegrationTest.kt index a0b3e19..82d51b9 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaAbstractSseIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaAbstractSseIntegrationTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package io.modelcontextprotocol.kotlin.sdk.integration.sse import io.ktor.client.HttpClient @@ -25,8 +27,6 @@ import io.ktor.server.sse.SSE as ServerSSE open class OldSchemaAbstractSseIntegrationTest { - suspend fun EmbeddedServer<*, *>.actualPort() = engine.resolvedConnectors().single().port - suspend fun initTestClient(serverPort: Int, name: String? = null): Client { val client = Client( Implementation(name = name ?: DEFAULT_CLIENT_NAME, version = VERSION), diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaSseIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaSseIntegrationTest.kt index a5cb133..97313bd 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaSseIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaSseIntegrationTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package io.modelcontextprotocol.kotlin.sdk.integration.sse import io.ktor.server.cio.CIOApplicationEngine @@ -6,26 +8,23 @@ import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest import io.modelcontextprotocol.kotlin.sdk.TextContent import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams +import io.modelcontextprotocol.kotlin.test.utils.actualPort import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds class OldSchemaSseIntegrationTest : OldSchemaAbstractSseIntegrationTest() { @Test - fun `client should be able to connect to sse server`() = runTest(timeout = 5.seconds) { + fun `client should be able to connect to sse server`() = runBlocking(Dispatchers.IO) { var server: EmbeddedServer? = null var client: Client? = null try { - withContext(Dispatchers.Default) { - server = initTestServer() - val port = server.engine.resolvedConnectors().single().port - client = initTestClient(serverPort = port) - } + server = initTestServer() + val port = server.actualPort() + client = initTestClient(serverPort = port) } finally { client?.close() server?.stopSuspend(1000, 2000) @@ -40,18 +39,16 @@ class OldSchemaSseIntegrationTest : OldSchemaAbstractSseIntegrationTest() { * 3. Observe that Client A receives a response related to it. */ @Test - fun `single sse connection`() = runTest(timeout = 5.seconds) { + fun `single sse connection`() = runBlocking(Dispatchers.IO) { var server: EmbeddedServer? = null var client: Client? = null try { - withContext(Dispatchers.Default) { - server = initTestServer() - val port = server.engine.resolvedConnectors().single().port - client = initTestClient(port, "Client A") + server = initTestServer() + val port = server.actualPort() + client = initTestClient(port, "Client A") - val promptA = getPrompt(client, "Client A") - assertTrue { "Client A" in promptA } - } + val promptA = getPrompt(client, "Client A") + assertTrue { "Client A" in promptA } } finally { client?.close() server?.stopSuspend(1000, 2000) @@ -67,27 +64,25 @@ class OldSchemaSseIntegrationTest : OldSchemaAbstractSseIntegrationTest() { * 4. Observe that Client B (connection #2) receives a response related to sessionId#1. */ @Test - fun `multiple sse connections`() = runTest(timeout = 5.seconds) { + fun `multiple sse connections`() = runBlocking(Dispatchers.IO) { var server: EmbeddedServer? = null var clientA: Client? = null var clientB: Client? = null try { - withContext(Dispatchers.Default) { - server = initTestServer() - val port = server.engine.resolvedConnectors().first().port + server = initTestServer() + val port = server.engine.resolvedConnectors().first().port - clientA = initTestClient(port, "Client A") - clientB = initTestClient(port, "Client B") + clientA = initTestClient(port, "Client A") + clientB = initTestClient(port, "Client B") - // Step 3: Send a prompt request from Client A - val promptA = getPrompt(clientA, "Client A") - // Step 4: Send a prompt request from Client B - val promptB = getPrompt(clientB, "Client B") + // Step 3: Send a prompt request from Client A + val promptA = getPrompt(clientA, "Client A") + // Step 4: Send a prompt request from Client B + val promptB = getPrompt(clientB, "Client B") - assertTrue { "Client A" in promptA } - assertTrue { "Client B" in promptB } - } + assertTrue { "Client A" in promptA } + assertTrue { "Client B" in promptB } } finally { clientA?.close() clientB?.close() diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/SseIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/SseIntegrationTest.kt index 0826eb2..8f0516e 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/SseIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/SseIntegrationTest.kt @@ -6,26 +6,23 @@ import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.test.utils.actualPort import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds class SseIntegrationTest : AbstractSseIntegrationTest() { @Test - fun `client should be able to connect to sse server`() = runTest(timeout = 5.seconds) { + fun `client should be able to connect to sse server`() = runBlocking(Dispatchers.IO) { var server: EmbeddedServer? = null var client: Client? = null try { - withContext(Dispatchers.Default) { - server = initTestServer() - val port = server.engine.resolvedConnectors().single().port - client = initTestClient(serverPort = port) - } + server = initTestServer() + val port = server.actualPort() + client = initTestClient(serverPort = port) } finally { client?.close() server?.stopSuspend(1000, 2000) @@ -40,18 +37,16 @@ class SseIntegrationTest : AbstractSseIntegrationTest() { * 3. Observe that Client A receives a response related to it. */ @Test - fun `single sse connection`() = runTest(timeout = 5.seconds) { + fun `single sse connection`() = runBlocking(Dispatchers.IO) { var server: EmbeddedServer? = null var client: Client? = null try { - withContext(Dispatchers.Default) { - server = initTestServer() - val port = server.engine.resolvedConnectors().single().port - client = initTestClient(port, "Client A") + server = initTestServer() + val port = server.actualPort() + client = initTestClient(port, "Client A") - val promptA = getPrompt(client, "Client A") - assertTrue { "Client A" in promptA } - } + val promptA = getPrompt(client, "Client A") + assertTrue { "Client A" in promptA } } finally { client?.close() server?.stopSuspend(1000, 2000) @@ -67,27 +62,25 @@ class SseIntegrationTest : AbstractSseIntegrationTest() { * 4. Observe that Client B (connection #2) receives a response related to sessionId#1. */ @Test - fun `multiple sse connections`() = runTest(timeout = 5.seconds) { + fun `multiple sse connections`() = runBlocking(Dispatchers.IO) { var server: EmbeddedServer? = null var clientA: Client? = null var clientB: Client? = null try { - withContext(Dispatchers.Default) { - server = initTestServer() - val port = server.engine.resolvedConnectors().first().port + server = initTestServer() + val port = server.actualPort() - clientA = initTestClient(port, "Client A") - clientB = initTestClient(port, "Client B") + clientA = initTestClient(port, "Client A") + clientB = initTestClient(port, "Client B") - // Step 3: Send a prompt request from Client A - val promptA = getPrompt(clientA, "Client A") - // Step 4: Send a prompt request from Client B - val promptB = getPrompt(clientB, "Client B") + // Step 3: Send a prompt request from Client A + val promptA = getPrompt(clientA, "Client A") + // Step 4: Send a prompt request from Client B + val promptB = getPrompt(clientB, "Client B") - assertTrue { "Client A" in promptA } - assertTrue { "Client B" in promptB } - } + assertTrue { "Client A" in promptA } + assertTrue { "Client B" in promptB } } finally { clientA?.close() clientB?.close() diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerTest.kt index 9a384f1..1ed3cf4 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerTest.kt @@ -2,7 +2,6 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test @@ -12,7 +11,6 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue -@DisabledOnCI abstract class AbstractKotlinClientTsServerTest : TsTestBase() { protected abstract suspend fun useClient(block: suspend (Client) -> T): T diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractTsClientKotlinServerTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractTsClientKotlinServerTest.kt index 90088aa..7f71b6a 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractTsClientKotlinServerTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractTsClientKotlinServerTest.kt @@ -1,13 +1,14 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript -import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI +import io.kotest.assertions.nondeterministic.eventually +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout import java.util.concurrent.TimeUnit import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds -@DisabledOnCI abstract class AbstractTsClientKotlinServerTest : TsTestBase() { protected open fun beforeServer() {} @@ -25,8 +26,10 @@ abstract class AbstractTsClientKotlinServerTest : TsTestBase() { try { val testName = "TestUser" val out = runClient("greet", testName) - assertTrue(out.contains("Text content:"), "Output should contain the text content section.\n$out") - assertTrue(out.contains("Hello, $testName!"), "Tool response should contain the greeting.\n$out") + eventually(5.seconds) { + assertTrue(out.contains("Text content:"), "Output should contain the text content section.\n$out") + assertTrue(out.contains("Hello, $testName!"), "Tool response should contain the greeting.\n$out") + } assertTrue( out.contains("Structured content:"), "Output should contain the structured content section.\n$out", @@ -46,7 +49,7 @@ abstract class AbstractTsClientKotlinServerTest : TsTestBase() { @Test @Timeout(60, unit = TimeUnit.SECONDS) - fun notifications() = runTest { + fun notifications() = runBlocking { beforeServer() try { val name = "NotifUser" @@ -66,7 +69,7 @@ abstract class AbstractTsClientKotlinServerTest : TsTestBase() { @Test @Timeout(120, unit = TimeUnit.SECONDS) - fun multipleClientSequence() = runTest { + fun multipleClientSequence() = runBlocking { beforeServer() try { val out1 = runClient("greet", "FirstClient") 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 313480f..49632c4 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 @@ -11,7 +11,7 @@ import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.test.utils.Retry -import io.modelcontextprotocol.kotlin.test.utils.TypeScriptRunner +import io.modelcontextprotocol.kotlin.test.utils.TypeScriptRunner.installDependencies import io.modelcontextprotocol.kotlin.test.utils.isWindows import kotlinx.coroutines.withTimeout import kotlinx.io.Sink @@ -19,12 +19,11 @@ import kotlinx.io.Source import kotlinx.io.asSink import kotlinx.io.asSource import kotlinx.io.buffered -import org.junit.jupiter.api.BeforeAll import java.io.BufferedReader import java.io.File import java.io.InputStreamReader import java.util.concurrent.TimeUnit -import kotlin.io.path.createTempDirectory +import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds enum class TransportKind { SSE, STDIO } @@ -34,20 +33,14 @@ abstract class TsTestBase { protected abstract val transportKind: TransportKind + protected val tsClient: TypeScriptClient get() = TypeScriptClient(typescriptDir) + companion object { @JvmStatic - protected val projectRoot: File get() = File(System.getProperty("user.dir")) + protected val projectRoot = File(System.getProperty("user.dir")) @JvmStatic - protected val tsClientDir: File - get() = File(projectRoot, "src/jvmTest/typescript") - - @JvmStatic - protected val tsSourceDir: File - get() = File( - projectRoot, - "src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript", - ) + protected val typescriptDir = File(projectRoot, "src/jvmTest/typescript") @JvmStatic private var sharedSseServer: Process? = null @@ -55,12 +48,16 @@ abstract class TsTestBase { @JvmStatic private var sharedSsePort: Int = 0 + init { + installDependencies(typescriptDir) + } + @JvmStatic @Synchronized protected fun getSharedSseUrl(): String { if (sharedSseServer == null || !sharedSseServer!!.isAlive) { sharedSsePort = io.modelcontextprotocol.kotlin.test.utils.findFreePort() - val server = TypeScriptServer(tsClientDir) + val server = TypeScriptServer(typescriptDir) sharedSseServer = server.startSse(sharedSsePort) println("Shared TypeScript SSE server started on port $sharedSsePort") @@ -76,69 +73,6 @@ abstract class TsTestBase { return "http://localhost:$sharedSsePort/mcp" } - @JvmStatic - private val tempRootDir: File = createTempDirectory("typescript-sdk-").toFile().apply { deleteOnExit() } - - @JvmStatic - protected val sdkDir: File = File(tempRootDir, "typescript-sdk") - - @JvmStatic - @BeforeAll - @Deprecated("Use setupTypeScriptSdkWithDependencies instead") - 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() - require(exitCode == 0) { - "Failed to clone TypeScript SDK repository: exit code $exitCode" - } - } - - println("Installing TypeScript SDK dependencies") - val npmInstallResult = executeCommand("npm install", sdkDir, allowFailure = true, timeoutSeconds = null) - if (npmInstallResult.contains("EUNSUPPORTEDPROTOCOL") || npmInstallResult.contains("catalog:")) { - println("npm install failed due to catalog protocol. Trying pnpm...") - try { - // Try to use npx pnpm to ensure it's available - executeCommand("npx pnpm install", sdkDir, allowFailure = false, timeoutSeconds = null) - } catch (e: Exception) { - println("pnpm install failed or not found: ${e.message}") - println("Attempting to patch package.json to remove catalog: protocol and use npm") - patchPackageJson(sdkDir) - // Also patch other package.json files in the workspace if they exist - sdkDir.walk().filter { it.name == "package.json" }.forEach { patchPackageJson(it.parentFile) } - executeCommand("npm install", sdkDir, allowFailure = false, timeoutSeconds = null) - } - } - } - - @Deprecated("never patch package.json") - private fun patchPackageJson(dir: File) { - val packageJson = File(dir, "package.json") - if (!packageJson.exists()) return - var content = packageJson.readText() - // Replace "catalog:something" with "*" or a reasonable default if we knew it. - // Since we don't know the exact versions in the catalog, "*" is a risky but possible fallback for tests. - // However, a better approach is to use pnpm if available. - content = content.replace(Regex(""""catalog:[^"]*""""), "\"*\"") - // Also replace workspace: protocol if we are using npm fallback - content = content.replace(Regex(""""workspace:[^"]*""""), "\"*\"") - // And any other unsupported protocols that might be in the package.json - content = content.replace(Regex(""""link:[^"]*""""), "\"*\"") - packageJson.writeText(content) - } - @JvmStatic protected fun executeCommand( command: String, @@ -147,21 +81,21 @@ abstract class TsTestBase { timeoutSeconds: Long? = null, ): String { if (!workingDir.exists()) { - if (!workingDir.mkdirs()) { - throw RuntimeException("Failed to create working directory: ${workingDir.absolutePath}") + check(workingDir.mkdirs()) { + "Failed to create working directory: ${workingDir.absolutePath}" } } - if (!workingDir.isDirectory || !workingDir.canRead()) { - throw RuntimeException("Working directory is not accessible: ${workingDir.absolutePath}") + check(workingDir.isDirectory && workingDir.canRead()) { + "Working directory is not accessible: ${workingDir.absolutePath}" } val processBuilder = if (isWindows) { ProcessBuilder() - .command("cmd.exe", "/c", "set TYPESCRIPT_SDK_DIR=${sdkDir.absolutePath} && $command") + .command("cmd.exe", "/c", command) } else { ProcessBuilder() - .command("bash", "-c", "TYPESCRIPT_SDK_DIR='${sdkDir.absolutePath}' $command") + .command("bash", "-c", command) } val process = processBuilder @@ -192,24 +126,18 @@ abstract class TsTestBase { } } - private fun waitForProcessTermination(process: Process, timeoutSeconds: Long): Boolean { - io.modelcontextprotocol.kotlin.test.utils.stopProcess(process) - return true - } - protected fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Long = 10): Boolean = io.modelcontextprotocol.kotlin.test.utils.waitForPort(host, port, timeoutSeconds) protected fun executeCommandAllowingFailure(command: String, workingDir: File, timeoutSeconds: Long = 20): String = executeCommand(command, workingDir, allowFailure = true, timeoutSeconds = timeoutSeconds) - protected fun startTypeScriptServer(port: Int): Process { - val server = TypeScriptServer(tsClientDir) - return server.startSse(port) - } - - protected fun stopProcess(process: Process, waitSeconds: Long = 3, name: String = "TypeScript server") { - io.modelcontextprotocol.kotlin.test.utils.stopProcess(process) + protected fun stopProcess( + process: Process, + waitDuration: Duration = 3.seconds, + name: String = "TypeScript server", + ) { + io.modelcontextprotocol.kotlin.test.utils.stopProcess(process, waitDuration) println("$name stopped") } @@ -232,7 +160,7 @@ abstract class TsTestBase { // ===== STDIO client + server helpers ===== protected fun startTypeScriptServerStdio(): Process { - val server = TypeScriptServer(tsClientDir) + val server = TypeScriptServer(typescriptDir) return server.startStdio() } @@ -263,22 +191,9 @@ abstract class TsTestBase { } // ===== Helpers to run TypeScript client over STDIO against Kotlin server over STDIO ===== - protected fun runStdioClient(vararg args: String): String { + protected fun runStdioClient(vararg args: String): String = tsClient.use { client -> // Start Node stdio client (it will speak MCP over its stdout/stdin) - val subDirName = when (transportKind) { - TransportKind.STDIO -> "stdio" - TransportKind.SSE -> "sse" - } - val clientPath = File(tsSourceDir, "$subDirName/myClient.ts").absolutePath - - val process = TypeScriptRunner.run( - typescriptDir = tsClientDir, - scriptPath = clientPath, - arguments = args.toList(), - env = mapOf("TYPESCRIPT_SDK_DIR" to sdkDir.absolutePath), - redirectErrorStream = false, - logPrefix = "TS-CLIENT-STDIO", - ) + val process = client.startStdio(args.toList(), log = false) // Create Kotlin server and attach stdio transport to the process streams val server: Server = KotlinServerForTsClient().createMcpServer() @@ -327,6 +242,6 @@ abstract class TsTestBase { } catch (_: Exception) { } - return output.toString() + output.toString() } } 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 new file mode 100644 index 0000000..7664abf --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptClient.kt @@ -0,0 +1,69 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.typescript + +import io.modelcontextprotocol.kotlin.test.utils.TypeScriptRunner +import io.modelcontextprotocol.kotlin.test.utils.stopProcess +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. + * 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. + * + * @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 + val proc = TypeScriptRunner.run( + typescriptDir = typescriptDir, + scriptPath = scriptPath, + arguments = arguments, + env = emptyMap(), + redirectErrorStream = false, + logPrefix = "TS-CLIENT-SSE", + log = log, + ) + process = proc + return proc + } + + /** + * Starts a TypeScript MCP client using STDIO transport. + * + * @param arguments Arguments to pass to the client (typically tool name and tool args) + * @param log Whether to automatically log process output/error + * @return The started Process + */ + fun startStdio(arguments: List, log: Boolean = true): Process { + val scriptPath = File(typescriptDir, "client/stdio-client.ts").absolutePath + val proc = TypeScriptRunner.run( + typescriptDir = typescriptDir, + scriptPath = scriptPath, + arguments = arguments, + env = emptyMap(), + redirectErrorStream = false, + logPrefix = "TS-CLIENT-STDIO", + log = log, + ) + process = proc + return proc + } + + /** + * Stops the currently running client process. + */ + override fun close() { + process?.let { + stopProcess(it) + } + process = null + } +} 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/sse/KotlinClientTsServerEdgeCasesTestSse.kt index d434f89..356fd30 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/sse/KotlinClientTsServerEdgeCasesTestSse.kt @@ -25,7 +25,6 @@ class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() { override val transportKind = TransportKind.SSE - private val host = "localhost" private val serverUrl: String by lazy { getSharedSseUrl() } private lateinit var client: Client 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/sse/TsClientKotlinServerTestSse.kt index e1c8302..dd43b70 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/sse/TsClientKotlinServerTestSse.kt @@ -2,13 +2,11 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractTsClientKotlinServerTest import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind -import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI import io.modelcontextprotocol.kotlin.test.utils.findFreePort import io.modelcontextprotocol.kotlin.test.utils.killProcessOnPort import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach -@DisabledOnCI class TsClientKotlinServerTestSse : AbstractTsClientKotlinServerTest() { override val transportKind = TransportKind.SSE @@ -37,18 +35,16 @@ class TsClientKotlinServerTestSse : AbstractTsClientKotlinServerTest() { } } - override fun beforeServer() {} - override fun afterServer() {} - - override fun runClient(vararg args: String): String { - val cmd = buildString { - append("npx tsx myClient.ts ") - append(serverUrl) - if (args.isNotEmpty()) { - append(' ') - append(args.joinToString(" ")) + override fun runClient(vararg args: String): String = tsClient.use { client -> + val process = client.startSse(listOf(serverUrl) + args.toList(), log = true) + val output = StringBuilder() + process.inputStream.bufferedReader().useLines { lines -> + lines.forEach { line -> + println("[TS-CLIENT-SSE] $line") + output.append(line).append("\n") } } - return executeCommand(cmd, tsClientDir) + process.waitFor(25, java.util.concurrent.TimeUnit.SECONDS) + output.toString() } } 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/sse/TsEdgeCasesTestSse.kt index 8023678..ee81ce2 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/sse/TsEdgeCasesTestSse.kt @@ -2,18 +2,18 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase -import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI import io.modelcontextprotocol.kotlin.test.utils.findFreePort import io.modelcontextprotocol.kotlin.test.utils.killProcessOnPort import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout -import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.DisabledOnOs import org.junit.jupiter.api.condition.OS import java.io.File import java.util.concurrent.TimeUnit @@ -21,7 +21,6 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.test.fail -@DisabledOnCI class TsEdgeCasesTestSse : TsTestBase() { override val transportKind = TransportKind.SSE @@ -56,16 +55,16 @@ class TsEdgeCasesTestSse : TsTestBase() { @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testInvalidURL() = runTest { - val nonExistentToolCommand = "npx tsx myClient.ts $serverUrl non-existent-tool" - val nonExistentToolOutput = executeCommandAllowingFailure(nonExistentToolCommand, tsClientDir) + val nonExistentToolCommand = "npx tsx client/sse-client.ts $serverUrl non-existent-tool" + val nonExistentToolOutput = executeCommandAllowingFailure(nonExistentToolCommand, typescriptDir) 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) + val invalidUrlCommand = "npx tsx client/sse-client.ts http://localhost:${port + 1000}/mcp greet TestUser" + val invalidUrlOutput = executeCommandAllowingFailure(invalidUrlCommand, typescriptDir) assertTrue( invalidUrlOutput.contains("Invalid URL") || @@ -85,8 +84,8 @@ class TsEdgeCasesTestSse : TsTestBase() { tempFile.deleteOnExit() val specialCharsContent = tempFile.readText() - val specialCharsCommand = "npx tsx myClient.ts $serverUrl greet \"$specialCharsContent\"" - val specialCharsOutput = executeCommand(specialCharsCommand, tsClientDir) + val specialCharsCommand = "npx tsx client/sse-client.ts $serverUrl greet \"$specialCharsContent\"" + val specialCharsOutput = executeCommand(specialCharsCommand, typescriptDir) assertTrue( specialCharsOutput.contains("Hello, $specialChars!"), @@ -100,8 +99,8 @@ class TsEdgeCasesTestSse : TsTestBase() { // skip on windows as it can't handle long commands @Test - @Timeout(30, unit = TimeUnit.SECONDS) - @EnabledOnOs(OS.MAC, OS.LINUX) + @Timeout(40, unit = TimeUnit.SECONDS) + @DisabledOnOs(OS.WINDOWS) fun testLargePayload() = runTest { val largeName = "A".repeat(10 * 1024) @@ -110,8 +109,8 @@ class TsEdgeCasesTestSse : TsTestBase() { tempFile.deleteOnExit() val largeNameContent = tempFile.readText() - val largePayloadCommand = "npx tsx myClient.ts $serverUrl greet \"$largeNameContent\"" - val largePayloadOutput = executeCommand(largePayloadCommand, tsClientDir) + val largePayloadCommand = "npx tsx client/sse-client.ts $serverUrl greet \"$largeNameContent\"" + val largePayloadOutput = executeCommand(largePayloadCommand, typescriptDir) tempFile.delete() @@ -126,8 +125,8 @@ class TsEdgeCasesTestSse : TsTestBase() { } @Test - @Timeout(60, unit = TimeUnit.SECONDS) - fun testComplexConcurrentRequests() = runTest { + @Timeout(30, unit = TimeUnit.SECONDS) + fun testComplexConcurrentRequests(): Unit = runBlocking { fun prettyFail( index: Int, command: String, @@ -158,18 +157,18 @@ class TsEdgeCasesTestSse : TsTestBase() { } 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\"", + "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\"", ) coroutineScope { val jobs = commands.mapIndexed { index, command -> async(kotlinx.coroutines.Dispatchers.IO) { println("Starting client $index") - val output = executeCommand(command, tsClientDir) + val output = executeCommand(command, typescriptDir) println("Client $index completed") assertContains( @@ -241,10 +240,10 @@ class TsEdgeCasesTestSse : TsTestBase() { @Test @Timeout(120, unit = TimeUnit.SECONDS) - fun testRapidSequentialRequests() = runTest { + fun testRapidSequentialRequests() = runBlocking { val outputs = (1..10).map { i -> - val command = "npx tsx myClient.ts $serverUrl greet \"RapidClient$i\"" - val output = executeCommand(command, tsClientDir) + val command = "npx tsx client/sse-client.ts $serverUrl greet \"RapidClient$i\"" + val output = executeCommand(command, typescriptDir) assertTrue( output.contains("Connected to server"), diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/TsClientKotlinServerTestStdio.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/TsClientKotlinServerTestStdio.kt index 0751c2e..abf821b 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/TsClientKotlinServerTestStdio.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/TsClientKotlinServerTestStdio.kt @@ -2,9 +2,7 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractTsClientKotlinServerTest import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind -import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI -@DisabledOnCI class TsClientKotlinServerTestStdio : AbstractTsClientKotlinServerTest() { override val transportKind = TransportKind.STDIO override fun runClient(vararg args: String): String = runStdioClient(*args) diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/TsEdgeCasesTestStdio.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/TsEdgeCasesTestStdio.kt index 9b51194..dafda6d 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/TsEdgeCasesTestStdio.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/TsEdgeCasesTestStdio.kt @@ -2,7 +2,6 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase -import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout import org.junit.jupiter.api.condition.EnabledOnOs @@ -12,7 +11,6 @@ import java.util.concurrent.TimeUnit import kotlin.test.assertEquals import kotlin.test.assertTrue -@DisabledOnCI class TsEdgeCasesTestStdio : TsTestBase() { override val transportKind: TransportKind = TransportKind.STDIO diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationSubscribeTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationSubscribeTest.kt index b596cc4..1e102a7 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationSubscribeTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationSubscribeTest.kt @@ -1,6 +1,7 @@ package io.modelcontextprotocol.kotlin.sdk.server -import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.kotest.matchers.collections.shouldHaveSize +import io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsResourcesUpdated import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities @@ -8,13 +9,19 @@ import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import org.awaitility.kotlin.await import org.awaitility.kotlin.untilAsserted import org.junit.jupiter.api.Test +import java.util.concurrent.ConcurrentLinkedQueue import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +@OptIn(ExperimentalUuidApi::class) class ServerResourcesNotificationSubscribeTest : AbstractServerFeaturesTest() { override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities( @@ -22,159 +29,107 @@ class ServerResourcesNotificationSubscribeTest : AbstractServerFeaturesTest() { ) @Test - fun `removeResource should send resource update notification`() = runTest { - val notifications = mutableListOf() - client.setNotificationHandler(Method.Defined.NotificationsResourcesUpdated) { + fun `should send resource notifications`() = runBlocking { + val notifications = ConcurrentLinkedQueue() + + client.setNotificationHandler(NotificationsResourcesUpdated) { notifications.add(it) CompletableDeferred(Unit) } - // Add resources - val testResourceUri1 = "test://resource1" - val testResourceUri2 = "test://resource2" - - server.addResource( - uri = testResourceUri1, - name = "Test Resource 1", - description = "A test resource 1", - mimeType = "text/plain", - ) { - ReadResourceResult( - contents = listOf( - TextResourceContents( - text = "Test resource content 1", - uri = testResourceUri1, - mimeType = "text/plain", - ), - ), - ) - } - - server.addResource( - uri = testResourceUri2, - name = "Test Resource 2", - description = "A test resource 2", - mimeType = "text/plain", - ) { - ReadResourceResult( - contents = listOf( - TextResourceContents( - text = "Test resource content 2", - uri = testResourceUri2, - mimeType = "text/plain", - ), - ), - ) - } - - client.subscribeResource(SubscribeRequest(SubscribeRequestParams(uri = testResourceUri1))) - - // Remove the resource - val result = server.removeResource(testResourceUri1) - - // Verify the resource was removed - assertTrue(result, "Resource should be removed successfully") - - // Verify that the notification was sent - await untilAsserted { - assertTrue( - notifications.any { it.params.uri == testResourceUri1 }, - "Notification should be sent when resource 1 was deleted", - ) - } - } - - @Test - fun `removeResource for two resources should send two separate notifications`() = runTest { - val notifications = mutableListOf() - client.setNotificationHandler(Method.Defined.NotificationsResourcesUpdated) { - notifications.add(it) - CompletableDeferred(Unit) - } - - // Add resources - val testResourceUri1 = "test://resource1" - val testResourceUri2 = "test://resource2" - - server.addResource( - uri = testResourceUri1, - name = "Test Resource 1", - description = "A test resource 1", - mimeType = "text/plain", - ) { - ReadResourceResult( - contents = listOf( - TextResourceContents( - text = "Test resource content 1", - uri = testResourceUri1, - mimeType = "text/plain", - ), - ), - ) - } - - server.addResource( - uri = testResourceUri2, - name = "Test Resource 2", - description = "A test resource 2", - mimeType = "text/plain", - ) { - ReadResourceResult( - contents = listOf( - TextResourceContents( - text = "Test resource content 2", - uri = testResourceUri2, - mimeType = "text/plain", - ), - ), - ) - } + val testResourceUri1 = "test://resource1-${Uuid.random()}" + val testResourceUri2 = "test://resource2-${Uuid.random()}" client.subscribeResource(SubscribeRequest(SubscribeRequestParams(uri = testResourceUri1))) client.subscribeResource(SubscribeRequest(SubscribeRequestParams(uri = testResourceUri2))) - // Remove the resource - val result1 = server.removeResource(testResourceUri1) - val result2 = server.removeResource(testResourceUri2) - - // Verify the resource was removed - assertTrue(result1, "Resource 1 should be removed successfully") - assertTrue(result2, "Resource 2 should be removed successfully") - - println(notifications.map { it.params.uri }) - // Verify that the notification was sent - await untilAsserted { - assertTrue( - notifications.any { it.params.uri == testResourceUri1 }, - "Notification should be sent when resource 1 and resource 2 was deleted", + server.addResource( + uri = testResourceUri1, + name = "Test Resource 1", + description = "A test resource 1", + mimeType = "text/plain", + ) { + ReadResourceResult( + contents = listOf( + TextResourceContents( + text = "Test resource content 1", + uri = it.uri, + mimeType = "text/plain", + ), + ), ) } - val deletedResources = listOf(notifications[0].params.uri, notifications[1].params.uri) - assertTrue( - deletedResources.contains(testResourceUri1), - "Notification should contain the removed resource 1 URI", - ) - assertTrue( - deletedResources.contains(testResourceUri2), - "Notification should contain the removed resource 2 URI", - ) + server.addResource( + uri = testResourceUri2, + name = "Test Resource 2", + description = "A test resource 2", + mimeType = "text/plain", + ) { + ReadResourceResult( + contents = listOf( + TextResourceContents( + text = "Test resource content 2", + uri = it.uri, + mimeType = "text/plain", + ), + ), + ) + } + + // Verify that the notification was sent + await untilAsserted { + notifications.shouldHaveSize(2) + assertTrue( + notifications.any { it.params.uri == testResourceUri1 && it.method == NotificationsResourcesUpdated }, + "Notification should be sent when resource 1 was deleted", + ) + assertTrue( + notifications.any { it.params.uri == testResourceUri2 && it.method == NotificationsResourcesUpdated }, + "Notification should be sent when resource 2 was deleted", + ) + } + + notifications.clear() + + // Remove the resource + val result1 = server.removeResource(testResourceUri1) + assertTrue(result1, "Resource 1 should be removed successfully") + + val result2 = server.removeResource(testResourceUri2) + // Verify the resource was removed + assertTrue(result2, "Resource 2 should be removed successfully") + + // Verify that the notification was sent + await untilAsserted { + notifications.shouldHaveSize(2) + assertTrue( + notifications.any { it.params.uri == testResourceUri1 && it.method == NotificationsResourcesUpdated }, + "Notification should be sent when resource 1 was deleted", + ) + assertTrue( + notifications.any { it.params.uri == testResourceUri2 && it.method == NotificationsResourcesUpdated }, + "Notification should be sent when resource 2 was deleted", + ) + } } @Test - fun `notification should not be send when removed resource does not exists`() = runTest { + fun `notification should not be send when removed resource does not exists`() = runBlocking { + println("Thread ${Thread.currentThread().name} test 1") // Track notifications - val notifications = mutableListOf() - client.setNotificationHandler(Method.Defined.NotificationsResourcesUpdated) { + val notifications = ConcurrentLinkedQueue() + client.setNotificationHandler(NotificationsResourcesUpdated) { notifications.add(it) CompletableDeferred(Unit) } // Try to remove a non-existent resource val result = server.removeResource("non-existent-resource") + assertFalse(result, "Removing non-existent resource should return false") // Verify the result - assertFalse(result, "Removing non-existent resource should return false") + delay(1.seconds) assertTrue( notifications.isEmpty(), "No notification should be sent when resource doesn't exist", diff --git a/integration-test/src/jvmTest/resources/junit-platform.properties b/integration-test/src/jvmTest/resources/junit-platform.properties new file mode 100644 index 0000000..9af7987 --- /dev/null +++ b/integration-test/src/jvmTest/resources/junit-platform.properties @@ -0,0 +1,6 @@ +## https://docs.junit.org/5.3.0-M1/user-guide/index.html#writing-tests-parallel-execution +junit.jupiter.execution.parallel.enabled=true +junit.jupiter.execution.parallel.config.strategy=dynamic +junit.jupiter.execution.parallel.mode.default=concurrent +junit.jupiter.execution.parallel.mode.classes.default=concurrent +junit.jupiter.execution.timeout.default=2m diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/myClient.ts b/integration-test/src/jvmTest/typescript/client/sse-client.ts similarity index 71% rename from integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/myClient.ts rename to integration-test/src/jvmTest/typescript/client/sse-client.ts index 8a1a69c..0acc256 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/myClient.ts +++ b/integration-test/src/jvmTest/typescript/client/sse-client.ts @@ -1,36 +1,15 @@ -// @ts-ignore +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 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, 'packages', 'client', 'src', 'index.ts')).href; - const streamUrl = pathToFileURL(path.join(sdkDir, 'packages', 'client', 'src', 'streamableHttp.ts')).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-args...]'); + console.log('Usage: npx tsx client/sse-client.ts [server-url] [tool-args...]'); console.log('Using default server URL:', serverUrl); console.log('Available utils will be listed after connection'); } @@ -85,8 +64,8 @@ async function main() { if (toolName === "greet" && toolArgs.length > 0) { toolArguments["name"] = toolArgs[0]; - } else if (tool.input && tool.input.properties) { - const propNames = Object.keys(tool.input.properties); + } else if (tool.inputSchema && tool.inputSchema.properties) { + const propNames = Object.keys(tool.inputSchema.properties); if (propNames.length > 0 && toolArgs.length > 0) { toolArguments[propNames[0]] = toolArgs[0]; } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/myClient.ts b/integration-test/src/jvmTest/typescript/client/stdio-client.ts similarity index 84% rename from integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/myClient.ts rename to integration-test/src/jvmTest/typescript/client/stdio-client.ts index cce2354..c6eaca6 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/myClient.ts +++ b/integration-test/src/jvmTest/typescript/client/stdio-client.ts @@ -1,24 +1,11 @@ -// @ts-nocheck +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"; async function main() { - const sdkDirRaw = process.env.TYPESCRIPT_SDK_DIR; - const sdkDir = sdkDirRaw ? sdkDirRaw.trim() : undefined; - - let Client: any; - - if (!sdkDir) { - throw new Error('TYPESCRIPT_SDK_DIR environment variable is not set. It should point to the cloned typescript-sdk directory.'); - } - - const path = await import('path'); - const { pathToFileURL } = await import('url'); - const clientUrl = pathToFileURL(path.join(sdkDir, 'packages', 'client', 'src', 'index.ts')).href; - ({ Client } = await import(clientUrl)); - if (!toolName) { console.error('Available utils will be listed after connection'); } else { @@ -68,7 +55,10 @@ async function main() { async close(): Promise { this.closed = true; - try { process.stdin.pause(); } catch {} + try { + process.stdin.pause(); + } catch { + } } } @@ -80,7 +70,7 @@ async function main() { const transport = new MinimalStdioClientTransport(); try { - await client.connect(transport, { protocolVersion: PROTOCOL_VERSION }); + await client.connect(transport, {protocolVersion: PROTOCOL_VERSION}); console.error('Connected to server over stdio'); try { @@ -117,8 +107,8 @@ async function main() { if (toolName === 'greet' && toolArgs.length > 0) { toolArguments['name'] = toolArgs[0]; - } else if (tool.input && tool.input.properties) { - const propNames = Object.keys(tool.input.properties); + } else if (tool.inputSchema && tool.inputSchema.properties) { + const propNames = Object.keys(tool.inputSchema.properties); if (propNames.length > 0 && toolArgs.length > 0) { toolArguments[propNames[0]] = toolArgs[0]; } diff --git a/integration-test/src/jvmTest/typescript/package.json b/integration-test/src/jvmTest/typescript/package.json index 0ec09d1..24a1ac9 100644 --- a/integration-test/src/jvmTest/typescript/package.json +++ b/integration-test/src/jvmTest/typescript/package.json @@ -3,6 +3,8 @@ "version": "1.0.0", "private": true, "scripts": { + "client:stdio": "tsx client/stdio-client.ts", + "client:sse": "tsx client/sse-client.ts http://localhost:3000/mcp greet Hello", "server:stdio": "tsx server/stdio-server.ts", "server:sse": "tsx server/sse-server.ts" }, diff --git a/test-utils/build.gradle.kts b/test-utils/build.gradle.kts index ffffc3e..586f0cf 100644 --- a/test-utils/build.gradle.kts +++ b/test-utils/build.gradle.kts @@ -10,6 +10,7 @@ kotlin { api(libs.kotest.assertions.core) api(libs.kotest.assertions.json) api(libs.kotlinx.coroutines.test) + api(libs.ktor.server.core) api(libs.mockk) api(libs.awaitility) api(libs.junit.jupiter.params) diff --git a/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt index cd1d674..3aa1de7 100644 --- a/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt +++ b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt @@ -24,6 +24,7 @@ public object TypeScriptRunner { env: Map = emptyMap(), redirectErrorStream: Boolean = false, logPrefix: String = "TS-RUNNER", + log: Boolean = true, ): Process { val command = mutableListOf("npx", "tsx", scriptPath) command.addAll(arguments) @@ -34,10 +35,12 @@ public object TypeScriptRunner { val proc = pb.start() - if (redirectErrorStream) { - createProcessOutputReader(proc, logPrefix).start() - } else { - createProcessErrorReader(proc, logPrefix).start() + if (log) { + if (redirectErrorStream) { + createProcessOutputReader(proc, logPrefix).start() + } else { + createProcessErrorReader(proc, logPrefix).start() + } } return proc @@ -50,16 +53,91 @@ public object TypeScriptRunner { * @throws RuntimeException if npm install fails */ public fun installDependencies(typescriptDir: File) { - require(typescriptDir.isDirectory()) { "Type script directory does not exist" } + require(typescriptDir.isDirectory()) { "Type script directory does not exist: ${typescriptDir.absolutePath}" } println("Installing TypeScript dependencies in ${typescriptDir.absolutePath}") - val pb = ProcessBuilder("npm", "install") - pb.directory(typescriptDir) - pb.redirectErrorStream(true) - val proc = pb.start() - proc.inputStream.bufferedReader().useLines { lines -> - lines.forEach { println("[NPM] $it") } + + val packageJson = File(typescriptDir, "package.json") + val content = if (packageJson.exists()) packageJson.readText() else "" + val hasCatalog = content.contains("catalog:") + val hasWorkspace = content.contains("workspace:") + val hasPnpmWorkspace = File(typescriptDir, "pnpm-workspace.yaml").exists() + + if (hasCatalog || hasWorkspace || hasPnpmWorkspace) { + println("Detected pnpm-specific protocols or workspace. Attempting pnpm install...") + if (tryCommand(listOf("pnpm", "install"), typescriptDir) || + tryCommand(listOf("npx", "pnpm", "install"), typescriptDir) + ) { + patchPackageJsonRecursively(typescriptDir, patchProtocols = false) + return + } } - val exitCode = proc.waitFor() - require(exitCode == 0) { "npm install failed with exit code $exitCode" } + + // Try standard npm install + if (tryCommand(listOf("npm", "install"), typescriptDir)) { + patchPackageJsonRecursively(typescriptDir, patchProtocols = false) + return + } + + // If npm fails and we have catalog/workspace, try patching and npm again + println("npm install failed. Patching package.json and trying again...") + patchPackageJsonRecursively(typescriptDir, patchProtocols = true) + if (tryCommand(listOf("npm", "install"), typescriptDir)) return + + throw RuntimeException("Failed to install TypeScript dependencies in ${typescriptDir.absolutePath}") + } + + private fun tryCommand(command: List, workingDir: File): Boolean { + val fullCommand = if (isWindows) { + listOf("cmd.exe", "/c") + command + } else { + command + } + println("Executing: ${fullCommand.joinToString(" ")}") + return try { + val pb = ProcessBuilder(fullCommand) + pb.directory(workingDir) + pb.redirectErrorStream(true) + val proc = pb.start() + proc.inputStream.bufferedReader().useLines { lines -> + lines.forEach { println("[TS-INSTALL] $it") } + } + proc.waitFor() == 0 + } catch (e: Exception) { + println("Command failed: ${e.message}") + false + } + } + + private fun patchPackageJsonRecursively(dir: File, patchProtocols: Boolean) { + dir.walkTopDown() + .onEnter { it.name != "node_modules" } + .filter { it.name == "package.json" } + .forEach { file -> + var content = file.readText() + var modified = false + + if (patchProtocols) { + val newContent = content + .replace(Regex(""""catalog:[^"]*""""), "\"*\"") + .replace(Regex(""""workspace:[^"]*""""), "\"*\"") + .replace(Regex(""""link:[^"]*""""), "\"*\"") + if (newContent != content) { + content = newContent + modified = true + } + } + + // Add entry point if missing and src/index.ts exists to help tsx resolution + val srcIndex = File(file.parentFile, "src/index.ts") + if (srcIndex.exists() && !content.contains("\"exports\"") && !content.contains("\"main\"")) { + println("Adding exports to ${file.absolutePath}") + content = content.replace(Regex("""^\{"""), "{\n \"exports\": { \".\": \"./src/index.ts\" },") + modified = true + } + + if (modified) { + file.writeText(content) + } + } } } diff --git a/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/ktor.kt b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/ktor.kt new file mode 100644 index 0000000..cf3e4d1 --- /dev/null +++ b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/ktor.kt @@ -0,0 +1,17 @@ +package io.modelcontextprotocol.kotlin.test.utils + +import io.ktor.server.engine.EmbeddedServer + +/** + * Retrieves the actual port that the embedded server is bound to. + * + * This function resolves the server's connectors and retrieves the port + * from the single resolved connector. It is useful for obtaining the + * port dynamically in cases where the server is bound to a random port. + * + * Note: This method assumes the server has exactly one resolved connector. + * + * @receiver The embedded server instance + * @return The port number the server is bound to + */ +public suspend fun EmbeddedServer<*, *>.actualPort(): Int = engine.resolvedConnectors().single().port diff --git a/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/processUtils.kt b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/processUtils.kt index 43e0a6c..a80c825 100644 --- a/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/processUtils.kt +++ b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/processUtils.kt @@ -4,6 +4,8 @@ import org.awaitility.kotlin.await import java.net.ServerSocket import java.net.Socket import java.util.concurrent.TimeUnit +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds /** * Kills any process currently using the specified port. @@ -52,9 +54,9 @@ public fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Lo * * Attempts graceful shutdown first, then forces termination if necessary. */ -public fun stopProcess(process: Process) { +public fun stopProcess(process: Process, wait: Duration = 1.seconds) { process.destroy() - if (!process.waitFor(1, TimeUnit.SECONDS)) { + if (!process.waitFor(wait.inWholeMilliseconds, TimeUnit.MILLISECONDS)) { process.destroyForcibly() } }