diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e2d28699..d9d1a039 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,11 +13,26 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: - validate-pr: - runs-on: macos-latest-xlarge - name: Validate PR + build: + runs-on: ${{ matrix.os }} + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest-xlarge + name: "macOS" + java-opts: "-Xmx8g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx6g -DexcludeDockerTests=true" + gradle-task: "clean ktlintCheck assemble macosArm64Test macosX64Test jvmTest koverLog koverHtmlReport" + test-type: "macos" + - os: ubuntu-latest + name: "Ubuntu" + java-opts: "-Xmx4g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx3g" + gradle-task: "clean :kotlin-sdk-test:jvmTest" + test-type: "ubuntu" env: - JAVA_OPTS: "-Xmx8g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx6g" + JAVA_OPTS: "${{ matrix.java-opts }}" + TS_SDK_IMAGE: "registry.jetbrains.team/p/grazi/grazie-infra-public/typescript-sdk-mcp@sha256:585bd19d0f2c3e46250da4d128cf3f2e8c155dc387026927e319f556a4fd559f" steps: - uses: actions/checkout@v5 @@ -34,19 +49,24 @@ jobs: cache-read-only: true - name: Build with Gradle - run: |- - ./gradlew clean ktlintCheck build koverLog koverHtmlReport - ./gradlew :kotlin-sdk-core:publishToMavenLocal :kotlin-sdk-client:publishToMavenLocal :kotlin-sdk-server:publishToMavenLocal + run: ./gradlew ${{ matrix.gradle-task }} + + - name: Publish to Maven Local (macOS only) + if: matrix.test-type == 'macos' + run: ./gradlew :kotlin-sdk-core:publishToMavenLocal :kotlin-sdk-client:publishToMavenLocal :kotlin-sdk-server:publishToMavenLocal - name: Build Kotlin-MCP-Client Sample + if: matrix.test-type == 'macos' working-directory: ./samples/kotlin-mcp-client run: ./../../gradlew clean build - name: Build Kotlin-MCP-Server Sample + if: matrix.test-type == 'macos' working-directory: ./samples/kotlin-mcp-server run: ./../../gradlew clean build - name: Build Weather-Stdio-Server Sample + if: matrix.test-type == 'macos' working-directory: ./samples/weather-stdio-server run: ./../../gradlew clean build @@ -54,7 +74,7 @@ jobs: if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 with: - name: reports + name: reports-${{ matrix.test-type }} path: | **/build/reports/ @@ -68,6 +88,7 @@ jobs: include_empty_in_summary: false include_time_in_summary: true annotate_only: true + check_name: Test Report (${{ matrix.test-type }}) - name: Disable Auto-Merge on Fail if: failure() && github.event_name == 'pull_request' diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5bb80c86..bbdd8439 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ logging = "7.0.13" slf4j = "2.0.17" kotest = "6.0.3" awaitility = "4.3.0" +testcontainers = "1.21.3" # Samples mcp-kotlin = "0.7.2" @@ -54,6 +55,7 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" } slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } +testcontainers = { group = "org.testcontainers", name = "testcontainers", version.ref = "testcontainers" } # Samples ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" } diff --git a/kotlin-sdk-test/build.gradle.kts b/kotlin-sdk-test/build.gradle.kts index 012619b9..72d9308c 100644 --- a/kotlin-sdk-test/build.gradle.kts +++ b/kotlin-sdk-test/build.gradle.kts @@ -6,6 +6,11 @@ kotlin { jvm { testRuns["test"].executionTask.configure { useJUnitPlatform() + if (System.getProperty("excludeDockerTests") == "true") { + filter { + excludeTestsMatching("*.typescript.*") + } + } } } sourceSets { @@ -25,6 +30,7 @@ kotlin { dependencies { implementation(kotlin("test-junit5")) implementation(libs.awaitility) + implementation(libs.testcontainers) runtimeOnly(libs.slf4j.simple) } } diff --git a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TsTestBase.kt b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TsTestBase.kt index 801f5820..65cda1b1 100644 --- a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TsTestBase.kt +++ b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TsTestBase.kt @@ -2,453 +2,353 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.sse.SSE +import io.ktor.client.request.get import io.modelcontextprotocol.kotlin.sdk.Implementation import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport import io.modelcontextprotocol.kotlin.sdk.client.mcpStreamableHttp import io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse.KotlinServerForTsClient import io.modelcontextprotocol.kotlin.sdk.integration.utils.Retry -import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout -import kotlinx.io.Sink -import kotlinx.io.Source import kotlinx.io.asSink import kotlinx.io.asSource import kotlinx.io.buffered import org.awaitility.kotlin.await -import org.junit.jupiter.api.BeforeAll -import java.io.BufferedReader -import java.io.File -import java.io.InputStreamReader +import org.testcontainers.containers.BindMode +import org.testcontainers.containers.GenericContainer +import org.testcontainers.images.PullPolicy +import java.io.ByteArrayInputStream +import java.io.OutputStream import java.net.ServerSocket import java.net.Socket import java.util.concurrent.TimeUnit -import kotlin.io.path.createTempDirectory import kotlin.time.Duration.Companion.seconds enum class TransportKind { SSE, STDIO, DEFAULT } @Retry(times = 3) abstract class TsTestBase { - protected open val transportKind: TransportKind = TransportKind.DEFAULT - protected val projectRoot: File get() = File(System.getProperty("user.dir")) - protected val tsClientDir: File - get() { - val base = File( - projectRoot, - "src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript", - ) - - // Allow override via system property for CI: -Dts.transport=stdio|sse - val fromProp = System.getProperty("ts.transport")?.lowercase() - val overrideSubDir = when (fromProp) { - "stdio" -> "stdio" - "sse" -> "sse" - else -> null - } - - val subDirName = overrideSubDir ?: when (transportKind) { - TransportKind.STDIO -> "stdio" - TransportKind.SSE -> "sse" - TransportKind.DEFAULT -> null - } - if (subDirName != null) { - val sub = File(base, subDirName) - if (sub.exists()) return sub - } - return base - } - companion object { - @JvmStatic - private val tempRootDir: File = createTempDirectory("typescript-sdk-").toFile().apply { deleteOnExit() } - - @JvmStatic - protected val sdkDir: File = File(tempRootDir, "typescript-sdk") - - @JvmStatic - @BeforeAll - fun setupTypeScriptSdk() { - println("Cloning TypeScript SDK repository") - - if (!sdkDir.exists()) { - val process = ProcessBuilder( - "git", - "clone", - "--depth", - "1", - "https://github.com/modelcontextprotocol/typescript-sdk.git", - sdkDir.absolutePath, - ) - .redirectErrorStream(true) - .start() - val exitCode = process.waitFor() - if (exitCode != 0) { - throw RuntimeException("Failed to clone TypeScript SDK repository: exit code $exitCode") - } - } + private val isWindows = System.getProperty("os.name").lowercase().contains("windows") - println("Installing TypeScript SDK dependencies") - executeCommand("npm install", sdkDir, allowFailure = false, timeoutSeconds = null) - } + private fun tsDockerImage() = System.getenv("TS_SDK_IMAGE") + ?: throw IllegalStateException("TS_SDK_IMAGE environment variable is not set") - @JvmStatic - protected fun killProcessOnPort(port: Int) { - val isWindows = System.getProperty("os.name").lowercase().contains("windows") - val killCommand = if (isWindows) { - "netstat -ano | findstr :$port | for /f \"tokens=5\" %a in ('more')" + - " do taskkill /F /PID %a 2>nul || echo No process found" - } else { - "lsof -ti:$port | xargs kill -9 2>/dev/null || true" - } - executeCommand(killCommand, File("."), allowFailure = true, timeoutSeconds = null) + private fun getTsFilesPath(subdir: String): String { + val userDir = System.getProperty("user.dir") + return "$userDir/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/$subdir" } - @JvmStatic - protected fun findFreePort(): Int { - ServerSocket(0).use { socket -> - return socket.localPort - } - } + fun findFreePort() = ServerSocket(0).use { it.localPort } - @JvmStatic - protected fun executeCommand( - command: String, - workingDir: File, - allowFailure: Boolean = false, - timeoutSeconds: Long? = null, - ): String { - if (!workingDir.exists()) { - if (!workingDir.mkdirs()) { - throw RuntimeException("Failed to create working directory: ${workingDir.absolutePath}") - } - } - - if (!workingDir.isDirectory || !workingDir.canRead()) { - throw RuntimeException("Working directory is not accessible: ${workingDir.absolutePath}") - } - - val isWindows = System.getProperty("os.name").lowercase().contains("windows") - val processBuilder = if (isWindows) { - ProcessBuilder() - .command("cmd.exe", "/c", "set TYPESCRIPT_SDK_DIR=${sdkDir.absolutePath} && $command") + fun killProcessOnPort(port: Int) { + val command = if (isWindows) { + listOf( + "cmd.exe", + "/c", + "netstat -ano | findstr :$port | for /f \"tokens=5\" %a in ('more') do taskkill /F /PID %a 2>nul || echo No process found", + ) } else { - ProcessBuilder() - .command("bash", "-c", "TYPESCRIPT_SDK_DIR='${sdkDir.absolutePath}' $command") + listOf("bash", "-c", "lsof -ti:$port | xargs kill -9 2>/dev/null || true") } - val process = processBuilder - .directory(workingDir) + val process = ProcessBuilder(command) .redirectErrorStream(true) .start() - val output = StringBuilder() - BufferedReader(InputStreamReader(process.inputStream)).use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - println(line) - output.append(line).append("\n") - } - } - - if (timeoutSeconds == null) { - val exitCode = process.waitFor() - if (!allowFailure && exitCode != 0) { - throw RuntimeException( - "Command execution failed with exit code $exitCode: $command\n" + - "Working dir: ${workingDir.absolutePath}\nOutput:\n$output", - ) - } - } else { - process.waitFor(timeoutSeconds, TimeUnit.SECONDS) - } - - return output.toString() - } - } - - private fun waitForProcessTermination(process: Process, timeoutSeconds: Long): Boolean { - if (process.isAlive && !process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { - process.destroyForcibly() - process.waitFor(2, TimeUnit.SECONDS) - return false + process.inputStream.bufferedReader().use { it.readText() } + process.waitFor() } - return true - } - private fun createProcessOutputReader(process: Process, prefix: String = "TS-SERVER"): Thread { - val outputReader = Thread { - try { - process.inputStream.bufferedReader().useLines { lines -> - for (line in lines) { - println("[$prefix] $line") - } + private fun runDockerCommand( + image: String = tsDockerImage(), + command: List, + interactive: Boolean = true, + environmentVariables: Map = emptyMap(), + extraArgs: List = emptyList(), + allowFailure: Boolean = false, + ): Process { + val dockerArgs = buildList { + add("docker") + add("run") + add("--rm") + if (interactive) add("-i") + add("-v") + add("${getTsFilesPath("sse")}:/app/sse") + add("-v") + add("${getTsFilesPath("stdio")}:/app/stdio") + addAll(extraArgs) + environmentVariables.forEach { (key, value) -> + add("-e") + add("$key=$value") } - } catch (e: Exception) { - println("Warning: Error reading process output: ${e.message}") + add(image) + addAll(command) } + + return ProcessBuilder(dockerArgs) + .redirectErrorStream(false) + .start() + .also { if (!allowFailure) it.startErrorLogger() } } - outputReader.isDaemon = true - return outputReader - } - private fun createProcessErrorReader(process: Process, prefix: String = "TS-SERVER"): Thread { - val errorReader = Thread { - try { - process.errorStream.bufferedReader().useLines { lines -> - for (line in lines) { - println("[$prefix][err] $line") - } - } - } catch (e: Exception) { - println("Warning: Error reading process error stream: ${e.message}") + private fun Process.startErrorLogger() = Thread { + errorStream.bufferedReader().useLines { lines -> + lines.forEach { println("[ERR] $it") } } + }.apply { + isDaemon = true + start() } - errorReader.isDaemon = true - return errorReader } - protected fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Long = 10): Boolean = try { + // ===== Wait utilities ===== + protected fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Long = 10) = runCatching { await.atMost(timeoutSeconds, TimeUnit.SECONDS) .pollDelay(200, TimeUnit.MILLISECONDS) .pollInterval(100, TimeUnit.MILLISECONDS) .until { - try { - Socket(host, port).use { true } - } catch (_: Exception) { - false - } + runCatching { Socket(host, port).close() }.isSuccess } true - } catch (_: Exception) { - false - } - - protected fun executeCommandAllowingFailure(command: String, workingDir: File, timeoutSeconds: Long = 20): String = - executeCommand(command, workingDir, allowFailure = true, timeoutSeconds = timeoutSeconds) - - protected fun startTypeScriptServer(port: Int): Process { - killProcessOnPort(port) + }.getOrDefault(false) - if (!sdkDir.exists() || !sdkDir.isDirectory) { - throw IllegalStateException( - "TypeScript SDK directory does not exist or is not accessible: ${sdkDir.absolutePath}", - ) + protected fun waitForHttpReady(url: String, timeoutSeconds: Long = 10) = runCatching { + await.atMost(timeoutSeconds, TimeUnit.SECONDS) + .pollDelay(200, TimeUnit.MILLISECONDS) + .pollInterval(150, TimeUnit.MILLISECONDS) + .until { + runCatching { + HttpClient(CIO) { + install(HttpTimeout) { + requestTimeoutMillis = 1000 + connectTimeoutMillis = 1000 + socketTimeoutMillis = 1000 + } + followRedirects = false + }.use { client -> + runBlocking { client.get(url).status.value in 200..499 } + } + }.getOrDefault(false) + } + true + }.getOrDefault(false) + + // ===== TypeScript Server (SSE/HTTP) ===== + protected fun startTypeScriptServer(port: Int): ContainerProcess { + val container = GenericContainer(tsDockerImage()).apply { + withImagePullPolicy(PullPolicy.defaultPolicy()) + withExposedPorts(port) + mapOf( + "MCP_HOST" to "0.0.0.0", + "MCP_PORT" to port.toString(), + "MCP_PATH" to "/mcp", + ).forEach { (k, v) -> withEnv(k, v) } + withFileSystemBind(getTsFilesPath("sse"), "/app/sse", BindMode.READ_ONLY) + withFileSystemBind(getTsFilesPath("stdio"), "/app/stdio", BindMode.READ_ONLY) + withCommand("npx", "--prefix", "/opt/typescript-sdk", "tsx", "/app/sse/simpleStreamableHttp.ts") + withReuse(false) } - val isWindows = System.getProperty("os.name").lowercase().contains("windows") - val localServerPath = File(tsClientDir, "simpleStreamableHttp.ts").absolutePath - val processBuilder = if (isWindows) { - ProcessBuilder() - .command( - "cmd.exe", - "/c", - "set MCP_PORT=$port && set NODE_PATH=${sdkDir.absolutePath}\\node_modules && npx --prefix \"${sdkDir.absolutePath}\" tsx \"$localServerPath\"", - ) - } else { - ProcessBuilder() - .command( - "bash", - "-c", - "MCP_PORT=$port NODE_PATH='${sdkDir.absolutePath}/node_modules' npx --prefix '${sdkDir.absolutePath}' tsx \"$localServerPath\"", - ) + runCatching { container.start() }.onFailure { + runCatching { container.stop() } + throw it } - processBuilder.environment()["TYPESCRIPT_SDK_DIR"] = sdkDir.absolutePath + val host = container.host + val mappedPort = container.getMappedPort(port) + require(waitForPort(host, mappedPort, 60)) { + runCatching { container.stop() } + "TypeScript server did not become ready on $host:$mappedPort" + } + require(waitForHttpReady("http://$host:$mappedPort/mcp")) { + runCatching { container.stop() } + "TypeScript server HTTP endpoint /mcp not ready on $host:$mappedPort" + } - val process = processBuilder - .directory(tsClientDir) - .redirectErrorStream(true) - .start() + println("TypeScript server started on $host:$mappedPort (container port: $port)") + Thread.sleep(300) - createProcessOutputReader(process).start() + return ContainerProcess(container, mappedPort) + } - if (!waitForPort(port = port, timeoutSeconds = 20)) { - throw IllegalStateException("TypeScript server did not become ready on localhost:$port within timeout") + protected class ContainerProcess(private val container: GenericContainer<*>, val mappedPort: Int) : Process() { + override fun destroy() = runCatching { container.stop() }.let { } + override fun destroyForcibly() = apply { destroy() } + override fun exitValue() = if (container.isRunning) throw IllegalThreadStateException() else 0 + override fun isAlive() = container.isRunning + override fun waitFor(): Int { + while (container.isRunning) { + runCatching { Thread.sleep(50) } + } + return 0 + } + override fun getInputStream() = ByteArrayInputStream(ByteArray(0)) + override fun getErrorStream() = ByteArrayInputStream(ByteArray(0)) + override fun getOutputStream() = object : OutputStream() { + override fun write(b: Int) {} } - return process } - protected fun stopProcess(process: Process, waitSeconds: Long = 3, name: String = "TypeScript server") { + protected fun stopProcess(process: Process, waitSeconds: Long = 3, name: String = "Process") { process.destroy() - if (waitForProcessTermination(process, waitSeconds)) { - println("$name stopped gracefully") - } else { - println("$name did not stop gracefully, forced termination") - } + val stopped = process.waitFor(waitSeconds, TimeUnit.SECONDS) + if (!stopped) process.destroyForcibly() + println("$name stopped ${if (stopped) "gracefully" else "forcibly"}") } - // ===== SSE client helpers ===== - protected suspend fun newClient(serverUrl: String): Client = - HttpClient(CIO) { install(SSE) }.mcpStreamableHttp(serverUrl) + // ===== SSE Client ===== + protected suspend fun newClient(serverUrl: String) = HttpClient(CIO) { install(SSE) }.mcpStreamableHttp(serverUrl) protected suspend fun withClient(serverUrl: String, block: suspend (Client) -> T): T { val client = newClient(serverUrl) return try { withTimeout(20.seconds) { block(client) } } finally { - try { - withTimeout(3.seconds) { client.close() } - } catch (_: Exception) { - // ignore errors - } + runCatching { withTimeout(3.seconds) { client.close() } } } } - // ===== STDIO client + server helpers ===== + // ===== STDIO Client ===== protected fun startTypeScriptServerStdio(): Process { - if (!sdkDir.exists() || !sdkDir.isDirectory) { - throw IllegalStateException( - "TypeScript SDK directory does not exist or is not accessible: ${sdkDir.absolutePath}", - ) - } - val isWindows = System.getProperty("os.name").lowercase().contains("windows") - val localServerPath = File(tsClientDir, "simpleStdio.ts").absolutePath - val processBuilder = if (isWindows) { - ProcessBuilder() - .command( - "cmd.exe", - "/c", - "set NODE_PATH=${sdkDir.absolutePath}\\node_modules && npx --prefix \"${sdkDir.absolutePath}\" tsx \"$localServerPath\"", - ) - } else { - ProcessBuilder() - .command( - "bash", - "-c", - "NODE_PATH='${sdkDir.absolutePath}/node_modules' npx --prefix '${sdkDir.absolutePath}' tsx \"$localServerPath\"", - ) - } - processBuilder.environment()["TYPESCRIPT_SDK_DIR"] = sdkDir.absolutePath - val process = processBuilder - .directory(tsClientDir) - .redirectErrorStream(false) - .start() - // For stdio transports, do NOT read from stdout (it's used for protocol). Read stderr for logs only. - createProcessErrorReader(process, prefix = "TS-SERVER-STDIO").start() - // Give the process a moment to start + val process = runDockerCommand( + command = listOf("npx", "--prefix", "/opt/typescript-sdk", "tsx", "/app/stdio/simpleStdio.ts"), + ) + await.atMost(2, TimeUnit.SECONDS) .pollDelay(200, TimeUnit.MILLISECONDS) .pollInterval(100, TimeUnit.MILLISECONDS) .until { process.isAlive } + return process } protected suspend fun newClientStdio(process: Process): Client { - val input: Source = process.inputStream.asSource().buffered() - val output: Sink = process.outputStream.asSink().buffered() - val transport = StdioClientTransport(input = input, output = output) - val client = Client(Implementation("test", "1.0")) - client.connect(transport) - return client + val transport = StdioClientTransport( + input = process.inputStream.asSource().buffered(), + output = process.outputStream.asSink().buffered(), + ) + return Client(Implementation("test", "1.0")).apply { connect(transport) } } protected suspend fun withClientStdio(block: suspend (Client, Process) -> T): T { val proc = startTypeScriptServerStdio() val client = newClientStdio(proc) return try { - withTimeout(20.seconds) { block(client, proc) } + withTimeout(5.seconds) { block(client, proc) } } finally { - try { - withTimeout(3.seconds) { client.close() } - } catch (_: Exception) { - } - try { - stopProcess(proc, name = "TypeScript stdio server") - } catch (_: Exception) { - } + runCatching { withTimeout(3.seconds) { client.close() } } + runCatching { stopProcess(proc, name = "TypeScript stdio server") } } } - // ===== Helpers to run TypeScript client over STDIO against Kotlin server over STDIO ===== - protected fun runStdioClient(vararg args: String): String { - // Start Node stdio client (it will speak MCP over its stdout/stdin) - val isWindows = System.getProperty("os.name").lowercase().contains("windows") - val clientPath = File(tsClientDir, "myClient.ts").absolutePath + // ===== HTTP Client (TypeScript → Kotlin Server) ===== + protected fun runHttpClient(serverUrl: String, vararg args: String) = runTsClient(serverUrl, args.toList()) + + protected fun runHttpClientAllowingFailure(serverUrl: String, vararg args: String) = + runTsClient(serverUrl, args.toList(), allowFailure = true) + + private fun runTsClient(serverUrl: String, args: List, allowFailure: Boolean = false): String { + val containerUrl = serverUrl.replace("localhost", "host.docker.internal") + val command = buildList { + add("npx") + add("--prefix") + add("/opt/typescript-sdk") + add("tsx") + add("/app/sse/myClient.ts") + add(containerUrl) + addAll(args) + } - val process = if (isWindows) { - ProcessBuilder() - .command( - "cmd.exe", - "/c", - ( - "set TYPESCRIPT_SDK_DIR=${sdkDir.absolutePath} && " + - "set NODE_PATH=${sdkDir.absolutePath}\\node_modules && " + - "npx --prefix \"${sdkDir.absolutePath}\" tsx \"$clientPath\" " + - args.joinToString(" ") - ), - ) - .directory(tsClientDir) - .redirectErrorStream(false) - .start() + val process = runDockerCommand( + command = command, + environmentVariables = mapOf("NODE_PATH" to "/opt/typescript-sdk/node_modules"), + extraArgs = listOf("--add-host=host.docker.internal:host-gateway"), + allowFailure = allowFailure, + ) + + // Capture both stdout and stderr from the TS client to ensure error messages are returned to tests + val output = StringBuilder() + + fun captureStream(stream: java.io.InputStream, prefix: String = ""): Thread = Thread { + stream.bufferedReader().useLines { lines -> + lines.forEach { line -> + val msg = if (prefix.isEmpty()) line else "$prefix$line" + println(msg) + output.append(line).append('\n') + } + } + }.apply { + isDaemon = true + start() + } + + val stdoutThread = captureStream(process.inputStream) + val stderrThread = captureStream(process.errorStream, "[TS-CLIENT][err] ") + + val finished = if (allowFailure) { + process.waitFor(25, TimeUnit.SECONDS) } else { - ProcessBuilder() - .command( - "bash", - "-c", - ( - "TYPESCRIPT_SDK_DIR='${sdkDir.absolutePath}' " + - "NODE_PATH='${sdkDir.absolutePath}/node_modules' " + - "npx --prefix '${sdkDir.absolutePath}' tsx \"$clientPath\" " + - args.joinToString(" ") - ), - ) - .directory(tsClientDir) - .redirectErrorStream(false) - .start() + process.waitFor() + true } - // Create Kotlin server and attach stdio transport to the process streams - val server: Server = KotlinServerForTsClient().createMcpServer() + if (!finished) { + process.destroyForcibly() + } + + stdoutThread.join(1000) + stderrThread.join(1000) + + return output.toString() + } + + // ===== STDIO Client (TypeScript → Kotlin Server) ===== + protected fun runStdioClient(vararg args: String): String { + val process = runDockerCommand( + command = listOf("npx", "--prefix", "/opt/typescript-sdk", "tsx", "/app/stdio/myClient.ts") + args, + allowFailure = true, + ) + + val server = KotlinServerForTsClient().createMcpServer() val transport = StdioServerTransport( inputStream = process.inputStream.asSource().buffered(), outputStream = process.outputStream.asSink().buffered(), ) - // Connect server in a background thread to avoid blocking - val serverThread = Thread { - try { - kotlinx.coroutines.runBlocking { server.connect(transport) } - } catch (e: Exception) { - println("[STDIO-SERVER] Error connecting: ${e.message}") - } + Thread { + runCatching { runBlocking { server.connect(transport) } } + .onFailure { println("[STDIO-SERVER] Error: ${it.message}") } + }.apply { + isDaemon = true + start() } - serverThread.isDaemon = true - serverThread.start() - // Read ONLY stderr from client for human-readable output val output = StringBuilder() - val errReader = Thread { - try { - process.errorStream.bufferedReader().useLines { lines -> - lines.forEach { line -> - println("[TS-CLIENT-STDIO][err] $line") - output.append(line).append('\n') - } + Thread { + process.errorStream.bufferedReader().useLines { lines -> + lines.forEach { + println("[TS-CLIENT-STDIO][err] $it") + output.append(it).append('\n') } - } catch (e: Exception) { - println("Warning: Error reading stdio client stderr: ${e.message}") } + }.apply { + isDaemon = true + start() } - errReader.isDaemon = true - errReader.start() - // Wait up to 25s for client to exit - val finished = process.waitFor(25, TimeUnit.SECONDS) - if (!finished) { - println("Stdio client did not finish in time; destroying") + if (!process.waitFor(25, TimeUnit.SECONDS)) { + println("Stdio client timeout; destroying") process.destroyForcibly() } - try { - kotlinx.coroutines.runBlocking { transport.close() } - } catch (_: Exception) { - } - + runCatching { runBlocking { transport.close() } } return output.toString() } } diff --git a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerEdgeCasesTestSse.kt b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerEdgeCasesTestSse.kt index 1585aa5e..57b9c507 100644 --- a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerEdgeCasesTestSse.kt +++ b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerEdgeCasesTestSse.kt @@ -33,14 +33,14 @@ class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() { private lateinit var serverUrl: String private lateinit var client: Client - private lateinit var tsServerProcess: Process + private lateinit var tsServerProcess: ContainerProcess @BeforeEach fun setUp() { port = findFreePort() - serverUrl = "http://$host:$port/mcp" tsServerProcess = startTypeScriptServer(port) - println("TypeScript server started on port $port") + serverUrl = "http://$host:${tsServerProcess.mappedPort}/mcp" + println("TypeScript server started on port ${tsServerProcess.mappedPort}") } @AfterEach diff --git a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerTestSse.kt b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerTestSse.kt index 95a80f0b..aa650245 100644 --- a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerTestSse.kt +++ b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerTestSse.kt @@ -15,14 +15,14 @@ class KotlinClientTsServerTestSse : AbstractKotlinClientTsServerTest() { private var port: Int = 0 private val host = "localhost" private lateinit var serverUrl: String - private lateinit var tsServerProcess: Process + private lateinit var tsServerProcess: ContainerProcess @BeforeEach fun setUpSse() { port = findFreePort() - serverUrl = "http://$host:$port/mcp" tsServerProcess = startTypeScriptServer(port) - println("TypeScript server started on port $port") + serverUrl = "http://$host:${tsServerProcess.mappedPort}/mcp" + println("TypeScript server started on port ${tsServerProcess.mappedPort}") } @AfterEach diff --git a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinServerForTsClientSse.kt b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinServerForTsClientSse.kt index 5cb0f61b..d9cc5ace 100644 --- a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinServerForTsClientSse.kt +++ b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinServerForTsClientSse.kt @@ -53,7 +53,6 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonPrimitive -import org.awaitility.Awaitility.await import java.util.UUID import java.util.concurrent.ConcurrentHashMap diff --git a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsClientKotlinServerTestSse.kt b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsClientKotlinServerTestSse.kt index edee4279..5355ecca 100644 --- a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsClientKotlinServerTestSse.kt +++ b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsClientKotlinServerTestSse.kt @@ -36,15 +36,5 @@ 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(" ")) - } - } - return executeCommand(cmd, tsClientDir) - } + override fun runClient(vararg args: String): String = runHttpClient(serverUrl, *args) } diff --git a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsEdgeCasesTestSse.kt b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsEdgeCasesTestSse.kt index 65b003df..603f1560 100644 --- a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsEdgeCasesTestSse.kt +++ b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsEdgeCasesTestSse.kt @@ -52,16 +52,15 @@ 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 nonExistentToolOutput = runHttpClientAllowingFailure(serverUrl, "non-existent-tool") 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 invalidUrl = "http://localhost:${port + 1000}/mcp" + val invalidUrlOutput = runHttpClientAllowingFailure(invalidUrl, "greet", "TestUser") assertTrue( invalidUrlOutput.contains("Invalid URL") || @@ -81,8 +80,7 @@ class TsEdgeCasesTestSse : TsTestBase() { tempFile.deleteOnExit() val specialCharsContent = tempFile.readText() - val specialCharsCommand = "npx tsx myClient.ts $serverUrl greet \"$specialCharsContent\"" - val specialCharsOutput = executeCommand(specialCharsCommand, tsClientDir) + val specialCharsOutput = runHttpClient(serverUrl, "greet", specialCharsContent) assertTrue( specialCharsOutput.contains("Hello, $specialChars!"), @@ -106,8 +104,7 @@ class TsEdgeCasesTestSse : TsTestBase() { tempFile.deleteOnExit() val largeNameContent = tempFile.readText() - val largePayloadCommand = "npx tsx myClient.ts $serverUrl greet \"$largeNameContent\"" - val largePayloadOutput = executeCommand(largePayloadCommand, tsClientDir) + val largePayloadOutput = runHttpClient(serverUrl, "greet", largeNameContent) tempFile.delete() @@ -165,7 +162,21 @@ class TsEdgeCasesTestSse : TsTestBase() { val jobs = commands.mapIndexed { index, command -> async(kotlinx.coroutines.Dispatchers.IO) { println("Starting client $index") - val output = executeCommand(command, tsClientDir) + val output = when { + command.contains("greet \"Client1\"") -> runHttpClient(serverUrl, "greet", "Client1") + + command.contains( + "multi-greet \"Client2\"", + ) -> runHttpClient(serverUrl, "multi-greet", "Client2") + + command.contains("greet \"Client3\"") -> runHttpClient(serverUrl, "greet", "Client3") + + command.contains( + "multi-greet \"Client5\"", + ) -> runHttpClient(serverUrl, "multi-greet", "Client5") + + else -> runHttpClient(serverUrl) + } println("Client $index completed") assertContains( @@ -239,8 +250,7 @@ class TsEdgeCasesTestSse : TsTestBase() { @Timeout(120, unit = TimeUnit.SECONDS) fun testRapidSequentialRequests() = runTest { val outputs = (1..10).map { i -> - val command = "npx tsx myClient.ts $serverUrl greet \"RapidClient$i\"" - val output = executeCommand(command, tsClientDir) + val output = runHttpClient(serverUrl, "greet", "RapidClient$i") assertTrue( output.contains("Connected to server"), diff --git a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/simpleStdio.ts b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/simpleStdio.ts index 29863091..8e5fc799 100644 --- a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/simpleStdio.ts +++ b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/simpleStdio.ts @@ -1,7 +1,7 @@ // @ts-nocheck -import { z } from 'zod'; +import {z} from 'zod'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import {pathToFileURL} from 'node:url'; const SDK_DIR = process.env.TYPESCRIPT_SDK_DIR; if (!SDK_DIR) { diff --git a/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/utils/Dockerfile b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/utils/Dockerfile new file mode 100644 index 00000000..c979326f --- /dev/null +++ b/kotlin-sdk-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/utils/Dockerfile @@ -0,0 +1,18 @@ +# Docker image for running TypeScript SDK test servers/clients with SSE over HTTP and STDIO + +FROM --platform=linux/amd64 node:20-alpine AS base + +# 1) System deps +RUN apk add --no-cache git + +# 2) Fetch the official MCP TypeScript SDK and install its dependencies +WORKDIR /opt/typescript-sdk +RUN git clone --depth 1 https://github.com/modelcontextprotocol/typescript-sdk.git . \ + && npm ci || npm install + +# 3) Set working directory +WORKDIR /app + +# 4) Runtime configuration +ENV NODE_PATH=/opt/typescript-sdk/node_modules +ENV TYPESCRIPT_SDK_DIR=/opt/typescript-sdk