fix: Update/Fix build workflow (#474)

# Update/Fix build workflow

- `macos-latest-xlarge` is no longer available. Switch to cheaper
runner: `macos-latest-xlarge` -> `macos-latest`
- Refactor build to run matrix job under Linux and macOS
- Tune caching GH caching
- Add junit and logger properties
- Downgrade Junit to 5.14.2
- Extract `ProcessBuilder` creation logic into helper functions
- Disable `StdioClientTransportTest` on windows temporary

## How Has This Been Tested?
CI

## Breaking Changes
Functionally - no. Outdated/broken TypeScript tests were disabled

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] CI Update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [ ] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [ ] My code follows the repository's style guidelines
- [ ] New and existing tests pass locally
- [ ] I have added appropriate error handling
- [ ] I have added or updated documentation as needed

## Additional context
GitHub actions workflows were blocked because the selected
"macos-latest-xlarge" runner, which is not available for the free
open-source plan. With "macos-latest" workflow works.
This commit is contained in:
Konstantin Pavlov 2026-01-09 22:48:31 +02:00 • committed by GitHub
commit 288d46cdde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 268 additions and 541 deletions

View file

@ -1,4 +1,4 @@
name: Build and Publish API Docs to GitHub Pages
name: Publish API Docs
on:
release:

View file

@ -10,69 +10,88 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# Cancel only when the run is NOT on `main` branch
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
permissions:
checks: write
pull-requests: write # only required if `comment: true` was enabled
env:
JAVA_VERSION: 21
JAVA_DISTRIBUTION: temurin
JAVA_OPTS: "-Dfile.encoding=UTF-8 -Djava.awt.headless=true"
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dkotlin.compiler.execution.strategy=in-process"
jobs:
build:
runs-on: macos-latest-xlarge
name: Build
timeout-minutes: 20
env:
JAVA_OPTS: "-Xmx8g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx6g"
runs-on: ${{ matrix.os }}
name: Build (${{ matrix.job-name }})
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
job-name: "Linux Tests"
gradle-tasks: "build ktlintCheck koverLog koverHtmlReport publishToMavenLocal -Pversion=1-SNAPSHOT"
max-workers: 3
- os: windows-latest
job-name: "Windows Tests"
gradle-tasks: "jvmTest"
max-workers: 3
- os: macos-latest
job-name: "iOS Simulator tests"
gradle-tasks: "linkIosSimulatorArm64 iosSimulatorArm64Test"
max-workers: 2
steps:
- uses: actions/checkout@v6
- name: Set up JDK 21
- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'temurin'
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: Set up Node.js
if: matrix.os == 'ubuntu-latest'
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
validate-wrappers: true
add-job-summary: 'always'
cache-read-only: true
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
gradle-home-cache-includes: |
caches
notifications
sdks
.konan/**
- name: Build with Gradle
- name: ${{ matrix.job-name }}
run: |-
./gradlew --no-daemon \
--rerun-tasks \
clean \
ktlintCheck \
build \
-x :conformance-test:test \
koverLog koverHtmlReport \
publishToMavenLocal \
-Pversion=0.0.1-SNAPSHOT
- name: Build Kotlin-MCP-Client Sample
working-directory: ./samples/kotlin-mcp-client
run: ./gradlew --no-daemon clean build -Pmcp.kotlin.overrideVersion=0.0.1-SNAPSHOT
- name: Build Kotlin-MCP-Server Sample
working-directory: ./samples/kotlin-mcp-server
run: ./gradlew --no-daemon clean build -Pmcp.kotlin.overrideVersion=0.0.1-SNAPSHOT
- name: Build Weather-Stdio-Server Sample
working-directory: ./samples/weather-stdio-server
run: ./gradlew --no-daemon clean build -Pmcp.kotlin.overrideVersion=0.0.1-SNAPSHOT
./gradlew ${{ matrix.gradle-tasks }} -x :conformance-test:test --max-workers ${{ matrix.max-workers }} --continue --configure-on-demand --parallel
- name: Upload Reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v5
if: always()
uses: actions/upload-artifact@v6
with:
name: reports
name: reports-build-${{ matrix.os }}
path: |
**/build/reports/
- name: Publish Test Report
uses: mikepenz/action-junit-report@v6
if: ${{ !cancelled() }} # always run even if the previous step fails
if: always()
with:
annotate_only: true
detailed_summary: true
@ -83,9 +102,68 @@ jobs:
report_paths: '**/test-results/**/TEST-*.xml'
truncate_stack_traces: false
- name: Disable Auto-Merge on Fail
if: failure() && github.event_name == 'pull_request'
run: gh pr merge --disable-auto "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Archive Maven Local Repository
if: ${{ success() && matrix.os == 'ubuntu-latest' }}
run: |
cd ~/.m2/repository && zip -r $GITHUB_WORKSPACE/local-maven-repository.zip .
- name: Upload Maven Local Repository
if: ${{ success() && matrix.os == 'ubuntu-latest' }}
uses: actions/upload-artifact@v4
with:
name: local-maven-repository
path: local-maven-repository.zip
retention-days: 1
samples:
needs: [ build ]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
sample:
- kotlin-mcp-client
- kotlin-mcp-server
- weather-stdio-server
name: "Build Sample: ${{ matrix.sample }}"
timeout-minutes: 10
env:
JAVA_OPTS: "-Xmx8g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx6g"
steps:
- uses: actions/checkout@v6
- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v5
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
add-job-summary: 'always'
cache-read-only: true
- name: Download Maven Local Repository
uses: actions/download-artifact@v7
with:
name: local-maven-repository
path: .
- name: Extract Maven Local Repository
run: |
mkdir -p ~/.m2/repository
unzip -o local-maven-repository.zip -d ~/.m2/repository
- name: "Build Sample: ${{ matrix.sample }}"
working-directory: ./samples/${{ matrix.sample }}
run: ./gradlew build -Pmcp.kotlin.overrideVersion=1-SNAPSHOT
- name: Upload Reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
with:
name: reports-samples-snapshot-${{ matrix.sample }}
path: |
**/build/reports/

View file

@ -8,10 +8,19 @@ on:
schedule:
- cron: '0 4 * * 0'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
JAVA_VERSION: 21
JAVA_DISTRIBUTION: temurin
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
# required for all workflows
security-events: write
@ -24,6 +33,7 @@ jobs:
contents: read
strategy:
fail-fast: false
matrix:
language: [ java-kotlin ]
@ -33,10 +43,11 @@ jobs:
with:
fetch-depth: 0
- uses: actions/setup-java@v5
- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '21'
distribution: ${{ env.JAVA_DISTRIBUTION }}
java-version: ${{ env.JAVA_VERSION }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
@ -45,16 +56,15 @@ jobs:
languages: ${{ matrix.language }}
build-mode: manual
- uses: actions/cache@v4
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
validate-wrappers: true
cache-read-only: true
- name: Build Kotlin sources
run: |
./gradlew \
./gradlew \
:kotlin-sdk-core:compileKotlinJvm \
:kotlin-sdk-client:compileKotlinJvm \
:kotlin-sdk-server:compileKotlinJvm \

View file

@ -14,7 +14,7 @@ concurrency:
jobs:
run-conformance:
runs-on: macos-latest-xlarge
runs-on: macos-latest
name: Run Conformance Tests
timeout-minutes: 20
env:

View file

@ -13,10 +13,11 @@ on:
jobs:
build:
runs-on: macos-latest-xlarge
runs-on: macos-latest
environment: release
env:
JAVA_OPTS: "-Xmx8g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx6g"
JAVA_OPTS: "-Xmx4g -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=768m -Djava.awt.headless=true"
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dkotlin.compiler.execution.strategy=in-process"
permissions:
contents: write

View file

@ -16,43 +16,49 @@ concurrency:
# Cancel only when the run is NOT on `main` branch
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
JAVA_VERSION: 21
JAVA_DISTRIBUTION: temurin
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
sample:
- kotlin-mcp-client
- kotlin-mcp-server
- weather-stdio-server
name: Build Sample
name: Build Sample (${{ matrix.sample }})
timeout-minutes: 10
env:
JAVA_OPTS: "-Xmx8g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx6g"
steps:
- uses: actions/checkout@v6
- name: Set up JDK 21
- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v5
with:
java-version: 21
distribution: 'temurin'
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
validate-wrappers: true
add-job-summary: 'always'
cache-read-only: true
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: "Build Sample: ${{ matrix.sample }}"
working-directory: ./samples/${{ matrix.sample }}
run: ./gradlew --no-daemon clean build
run: ./gradlew --no-daemon build
- name: Upload Reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v5
with:
name: reports-${{ matrix.sample }}
name: reports-samples-${{ matrix.sample }}
path: |
**/build/reports/

View file

@ -0,0 +1,8 @@
# Level of logging for the ROOT logger: ERROR, WARN, INFO, DEBUG, TRACE (default is INFO)
org.slf4j.simpleLogger.defaultLogLevel=INFO
org.slf4j.simpleLogger.showThreadName=true
org.slf4j.simpleLogger.showDateTime=false
# Log level for specific packages or classes
org.slf4j.simpleLogger.log.io.ktor.server=DEBUG
org.slf4j.simpleLogger.log.io.modelcontextprotocol=DEBUG

View file

@ -22,7 +22,7 @@ mockk = "1.14.7"
mokksy = "0.6.2"
serialization = "1.9.0"
slf4j = "2.0.17"
junit="6.0.1"
junit="5.14.2"
[libraries]
# Plugins

View file

@ -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

View file

@ -0,0 +1,8 @@
# Level of logging for the ROOT logger: ERROR, WARN, INFO, DEBUG, TRACE (default is INFO)
org.slf4j.simpleLogger.defaultLogLevel=INFO
org.slf4j.simpleLogger.showThreadName=true
org.slf4j.simpleLogger.showDateTime=false
# Log level for specific packages or classes
org.slf4j.simpleLogger.log.io.ktor.server=DEBUG
org.slf4j.simpleLogger.log.io.modelcontextprotocol=DEBUG

View file

@ -0,0 +1,8 @@
# Level of logging for the ROOT logger: ERROR, WARN, INFO, DEBUG, TRACE (default is INFO)
org.slf4j.simpleLogger.defaultLogLevel=INFO
org.slf4j.simpleLogger.showThreadName=true
org.slf4j.simpleLogger.showDateTime=false
# Log level for specific packages or classes
org.slf4j.simpleLogger.log.io.ktor.server=DEBUG
org.slf4j.simpleLogger.log.io.modelcontextprotocol=DEBUG

View file

@ -26,6 +26,7 @@ kotlin {
jvmTest {
dependencies {
implementation(libs.awaitility)
implementation(libs.junit.jupiter.params)
runtimeOnly(libs.slf4j.simple)
}
}

View file

@ -37,7 +37,7 @@ abstract class BaseTransportTest {
fail("Unexpected error: $error")
}
val messages = listOf<JSONRPCMessage>(
val messages = listOf(
PingRequest().toJSON(),
InitializedNotification().toJSON(),
)

View file

@ -12,6 +12,8 @@ import kotlinx.io.buffered
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Timeout
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.condition.DisabledOnOs
import org.junit.jupiter.api.condition.OS
import java.util.concurrent.TimeUnit
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
@ -22,15 +24,12 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Timeout(30, unit = TimeUnit.SECONDS)
@DisabledOnOs(OS.WINDOWS) // TODO: fix running on windows
class StdioClientTransportTest : BaseTransportTest() {
@Test
fun `handle stdio error`(): Unit = runBlocking {
val processBuilder = if (System.getProperty("os.name").lowercase().contains("win")) {
ProcessBuilder("cmd", "/c", "pause 1 && echo simulated error 1>&2 && exit 1")
} else {
ProcessBuilder("sh", "-c", "sleep 1 && echo 'simulated error' >&2 && exit 1")
}
val processBuilder = createSleepyProcessBuilder()
val process = processBuilder.start()
@ -65,8 +64,8 @@ class StdioClientTransportTest : BaseTransportTest() {
@OptIn(ExperimentalAtomicApi::class)
@Test
fun `should start then close cleanly`() = runTest {
// Run process "/usr/bin/tee"
val processBuilder = ProcessBuilder("/usr/bin/tee")
// Run loopback process
val processBuilder = createTeeProcessBuilder()
val process = processBuilder.start()
val input = process.inputStream.asSource().buffered()
@ -102,7 +101,7 @@ class StdioClientTransportTest : BaseTransportTest() {
@Test
fun `should read messages`() = runTest {
val processBuilder = ProcessBuilder("/usr/bin/tee")
val processBuilder = createTeeProcessBuilder()
val process = processBuilder.start()
val input = process.inputStream.asSource().buffered()
@ -121,7 +120,7 @@ class StdioClientTransportTest : BaseTransportTest() {
@Test
fun `should ignore first output messages`() = runTest {
val processBuilder = ProcessBuilder("/usr/bin/tee")
val processBuilder = createTeeProcessBuilder()
val process = processBuilder.start()
process.outputStream.write("Stdio server started".toByteArray())

View file

@ -0,0 +1,15 @@
package io.modelcontextprotocol.kotlin.sdk.client
private val isWindows = System.getProperty("os.name").lowercase().contains("win")
fun createTeeProcessBuilder(): ProcessBuilder = if (isWindows) {
ProcessBuilder("cmd", "/c", "findstr", "^")
} else {
ProcessBuilder("tee")
}
fun createSleepyProcessBuilder(): ProcessBuilder = if (isWindows) {
ProcessBuilder("cmd", "/c", "ping -n 2 127.0.0.1 > nul && echo simulated error 1>&2 && exit 1")
} else {
ProcessBuilder("sh", "-c", "sleep 1 && echo 'simulated error' >&2 && exit 1")
}

View file

@ -1,6 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.integration.utils.DisabledOnCI
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
@ -11,6 +12,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@DisabledOnCI
abstract class AbstractKotlinClientTsServerTest : TsTestBase() {
protected abstract suspend fun <T> useClient(block: suspend (Client) -> T): T

View file

@ -1,11 +1,13 @@
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
import io.modelcontextprotocol.kotlin.sdk.integration.utils.DisabledOnCI
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
@DisabledOnCI
abstract class AbstractTsClientKotlinServerTest : TsTestBase() {
protected open fun beforeServer() {}

View file

@ -11,7 +11,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class OldSchemaAbstractKotlinClientTsServerTest : OldSchemaTsTestBase() {
abstract class OldSchemaAbstractKotlinClientTsServerTest : TsTestBase() {
protected abstract suspend fun <T> useClient(block: suspend (Client) -> T): T
@Test

View file

@ -1,455 +0,0 @@
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.sse.SSE
import io.modelcontextprotocol.kotlin.sdk.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 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 OldSchemaTransportKind { SSE, STDIO, DEFAULT }
@Retry(times = 3)
abstract class OldSchemaTsTestBase {
protected open val transportKind: OldSchemaTransportKind = OldSchemaTransportKind.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) {
OldSchemaTransportKind.STDIO -> "stdio"
OldSchemaTransportKind.SSE -> "sse"
OldSchemaTransportKind.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")
}
}
println("Installing TypeScript SDK dependencies")
executeCommand("npm install", sdkDir, allowFailure = false, timeoutSeconds = null)
}
@JvmStatic
protected fun killProcessOnPort(port: Int) {
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
val killCommand = if (isWindows) {
"netstat -ano | findstr :$port | for /f \"tokens=5\" %a in ('more')" +
" do taskkill /F /PID %a 2>nul || echo No process found"
} else {
"lsof -ti:$port | xargs kill -9 2>/dev/null || true"
}
executeCommand(killCommand, File("."), allowFailure = true, timeoutSeconds = null)
}
@JvmStatic
protected fun findFreePort(): Int {
ServerSocket(0).use { socket ->
return socket.localPort
}
}
@JvmStatic
protected fun executeCommand(
command: String,
workingDir: File,
allowFailure: Boolean = false,
timeoutSeconds: Long? = null,
): String {
if (!workingDir.exists()) {
if (!workingDir.mkdirs()) {
throw RuntimeException("Failed to create working directory: ${workingDir.absolutePath}")
}
}
if (!workingDir.isDirectory || !workingDir.canRead()) {
throw RuntimeException("Working directory is not accessible: ${workingDir.absolutePath}")
}
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
val processBuilder = if (isWindows) {
ProcessBuilder()
.command("cmd.exe", "/c", "set TYPESCRIPT_SDK_DIR=${sdkDir.absolutePath} && $command")
} else {
ProcessBuilder()
.command("bash", "-c", "TYPESCRIPT_SDK_DIR='${sdkDir.absolutePath}' $command")
}
val process = processBuilder
.directory(workingDir)
.redirectErrorStream(true)
.start()
val output = StringBuilder()
BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
println(line)
output.append(line).append("\n")
}
}
if (timeoutSeconds == null) {
val exitCode = process.waitFor()
if (!allowFailure && exitCode != 0) {
throw RuntimeException(
"Command execution failed with exit code $exitCode: $command\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
}
return true
}
private fun createProcessOutputReader(process: Process, prefix: String = "TS-SERVER"): Thread {
val outputReader = Thread {
try {
process.inputStream.bufferedReader().useLines { lines ->
for (line in lines) {
println("[$prefix] $line")
}
}
} catch (e: Exception) {
println("Warning: Error reading process output: ${e.message}")
}
}
outputReader.isDaemon = true
return outputReader
}
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}")
}
}
errorReader.isDaemon = true
return errorReader
}
protected fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Long = 10): Boolean = try {
await.atMost(timeoutSeconds, TimeUnit.SECONDS)
.pollDelay(200, TimeUnit.MILLISECONDS)
.pollInterval(100, TimeUnit.MILLISECONDS)
.until {
try {
Socket(host, port).use { true }
} catch (_: Exception) {
false
}
}
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)
if (!sdkDir.exists() || !sdkDir.isDirectory) {
throw IllegalStateException(
"TypeScript SDK directory does not exist or is not accessible: ${sdkDir.absolutePath}",
)
}
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
val localServerPath = File(tsClientDir, "simpleStreamableHttp.ts").absolutePath
val processBuilder = if (isWindows) {
ProcessBuilder()
.command(
"cmd.exe",
"/c",
"set MCP_PORT=$port && set NODE_PATH=${sdkDir.absolutePath}\\node_modules && npx --prefix \"${sdkDir.absolutePath}\" tsx \"$localServerPath\"",
)
} else {
ProcessBuilder()
.command(
"bash",
"-c",
"MCP_PORT=$port NODE_PATH='${sdkDir.absolutePath}/node_modules' npx --prefix '${sdkDir.absolutePath}' tsx \"$localServerPath\"",
)
}
processBuilder.environment()["TYPESCRIPT_SDK_DIR"] = sdkDir.absolutePath
val process = processBuilder
.directory(tsClientDir)
.redirectErrorStream(true)
.start()
createProcessOutputReader(process).start()
if (!waitForPort(port = port, timeoutSeconds = 20)) {
throw IllegalStateException("TypeScript server did not become ready on localhost:$port within timeout")
}
return process
}
protected fun stopProcess(process: Process, waitSeconds: Long = 3, name: String = "TypeScript server") {
process.destroy()
if (waitForProcessTermination(process, waitSeconds)) {
println("$name stopped gracefully")
} else {
println("$name did not stop gracefully, forced termination")
}
}
// ===== SSE client helpers =====
protected suspend fun newClient(serverUrl: String): Client =
HttpClient(CIO) { install(SSE) }.mcpStreamableHttp(serverUrl)
protected suspend fun <T> withClient(serverUrl: String, block: suspend (Client) -> T): T {
val client = newClient(serverUrl)
return try {
withTimeout(20.seconds) { block(client) }
} finally {
try {
withTimeout(3.seconds) { client.close() }
} catch (_: Exception) {
// ignore errors
}
}
}
// ===== STDIO client + server helpers =====
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
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
}
protected suspend fun <T> withClientStdio(block: suspend (Client, Process) -> T): T {
val proc = startTypeScriptServerStdio()
val client = newClientStdio(proc)
return try {
withTimeout(20.seconds) { block(client, proc) }
} finally {
try {
withTimeout(3.seconds) { client.close() }
} catch (_: Exception) {
}
try {
stopProcess(proc, name = "TypeScript stdio server")
} catch (_: Exception) {
}
}
}
// ===== 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
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()
} 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()
}
// Create Kotlin server and attach stdio transport to the process streams
val server: 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 {
runBlocking { server.createSession(transport) }
} catch (e: Exception) {
println("[STDIO-SERVER] Error connecting: ${e.message}")
}
}
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')
}
}
} catch (e: Exception) {
println("Warning: Error reading stdio client stderr: ${e.message}")
}
}
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")
process.destroyForcibly()
}
try {
runBlocking { transport.close() }
} catch (_: Exception) {
}
return output.toString()
}
}

View file

@ -3,6 +3,7 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
import io.modelcontextprotocol.kotlin.sdk.integration.utils.DisabledOnCI
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@ -22,6 +23,7 @@ import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
@DisabledOnCI
class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() {
override val transportKind = TransportKind.SSE

Some files were not shown because too many files have changed in this diff Show more