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