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:
parent
0ba61060c2
commit
288d46cdde
33 changed files with 268 additions and 541 deletions
2
.github/workflows/apidocs.yaml
vendored
2
.github/workflows/apidocs.yaml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
name: Build and Publish API Docs to GitHub Pages
|
||||
name: Publish API Docs
|
||||
|
||||
on:
|
||||
release:
|
||||
|
|
|
|||
162
.github/workflows/build.yml
vendored
162
.github/workflows/build.yml
vendored
|
|
@ -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/
|
||||
|
|
|
|||
28
.github/workflows/codeql.yml
vendored
28
.github/workflows/codeql.yml
vendored
|
|
@ -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 \
|
||||
|
|
|
|||
2
.github/workflows/conformance.yml
vendored
2
.github/workflows/conformance.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
5
.github/workflows/gradle-publish.yml
vendored
5
.github/workflows/gradle-publish.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
20
.github/workflows/samples.yml
vendored
20
.github/workflows/samples.yml
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -26,6 +26,7 @@ kotlin {
|
|||
jvmTest {
|
||||
dependencies {
|
||||
implementation(libs.awaitility)
|
||||
implementation(libs.junit.jupiter.params)
|
||||
runtimeOnly(libs.slf4j.simple)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ abstract class BaseTransportTest {
|
|||
fail("Unexpected error: $error")
|
||||
}
|
||||
|
||||
val messages = listOf<JSONRPCMessage>(
|
||||
val messages = listOf(
|
||||
PingRequest().toJSON(),
|
||||
InitializedNotification().toJSON(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue