diff --git a/.github/workflows/apidocs.yaml b/.github/workflows/apidocs.yaml index 995f7d8..67770a7 100644 --- a/.github/workflows/apidocs.yaml +++ b/.github/workflows/apidocs.yaml @@ -24,17 +24,17 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} cache: gradle - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 - name: Generate Dokka Site run: |- diff --git a/.github/workflows/automations.yml b/.github/workflows/automations.yml index 4f88d37..60a9d3b 100644 --- a/.github/workflows/automations.yml +++ b/.github/workflows/automations.yml @@ -27,7 +27,7 @@ jobs: name: Mark/unmark stale PRs and issues runs-on: ubuntu-latest steps: - - uses: actions/stale@v10 + - uses: actions/stale@v11 with: debug-only: ${{ env.DRY_RUN }} exempt-draft-pr: true @@ -66,7 +66,7 @@ jobs: name: Close stale PRs and issues runs-on: ubuntu-latest steps: - - uses: actions/stale@v10 + - uses: actions/stale@v11 with: debug-only: ${{ env.DRY_RUN }} only-labels: 'stale' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ad4cc7a..ff99581 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,17 +43,17 @@ jobs: max-workers: 2 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Set up Node.js if: matrix.os == 'ubuntu-latest' - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed @@ -63,7 +63,7 @@ jobs: run: npm ci - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: validate-wrappers: true add-job-summary: 'always' @@ -126,16 +126,16 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: validate-wrappers: true add-job-summary: 'always' @@ -168,16 +168,16 @@ jobs: name: "Build Sample: ${{ matrix.sample }}" timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: add-job-summary: 'always' cache-read-only: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5697b61..2e7c58f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,22 +25,22 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.JAVA_VERSION }} - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v4.38.0 with: languages: java-kotlin build-mode: manual - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: validate-wrappers: true cache-read-only: true @@ -52,6 +52,6 @@ jobs: --no-daemon --rerun-tasks --stacktrace - name: Analyze - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v4.38.0 with: category: '/language:java-kotlin' diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 4058d02..b3e3935 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -25,16 +25,16 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -76,16 +76,16 @@ jobs: - sse-retry steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -107,16 +107,16 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -146,16 +146,16 @@ jobs: - auth/cross-app-access-complete-flow steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml index 6ed0756..f01f9c8 100644 --- a/.github/workflows/gradle-publish.yml +++ b/.github/workflows/gradle-publish.yml @@ -17,20 +17,20 @@ jobs: packages: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: '21' distribution: 'temurin' - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 - name: Check run: ./gradlew check --no-configuration-cache diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 2c910fd..7463a9e 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -36,16 +36,16 @@ jobs: name: Build Sample (${{ matrix.sample }}) timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: validate-wrappers: true add-job-summary: 'always' diff --git a/.gitignore b/.gitignore index d9f0fde..805579c 100644 --- a/.gitignore +++ b/.gitignore @@ -56,7 +56,7 @@ node_modules dist ### SWE agents ### -.claude/settings.local.json +.claude/ .junie/ ### Conformance test results ### diff --git a/README.md b/README.md index 58a2cad..aefadcf 100644 --- a/README.md +++ b/README.md @@ -729,7 +729,7 @@ val client = Client( ) client.setRequestHandler(Method.Defined.SamplingCreateMessage) { request, _ -> - val content = request.messages.lastOrNull()?.content + val content = request.messages.lastOrNull()?.content?.firstOrNull() val prompt = if (content is TextContent) content.text else "your topic" CreateMessageResult( model = "gpt-4o-mini", diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 98fce0e..a82b404 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -416,7 +416,6 @@ potential-bugs: Deprecation: active: false aliases: ['DEPRECATION'] - excludeImportStatements: false DontDowncastCollectionTypes: active: false DoubleMutabilityForCollection: diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt index 03d5ee3..8a18957 100644 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt +++ b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt @@ -12,8 +12,6 @@ fun main() { val port = System.getenv("MCP_PORT")?.toIntOrNull() ?: 3001 embeddedServer(CIO, port = port) { mcpStreamableHttp( - enableDnsRebindingProtection = true, - allowedHosts = listOf("localhost", "127.0.0.1", "localhost:$port", "127.0.0.1:$port"), eventStore = InMemoryEventStore(), ) { createConformanceServer() diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTools.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTools.kt index e288272..c81c98c 100644 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTools.kt +++ b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTools.kt @@ -179,7 +179,13 @@ fun Server.registerConformanceTools() { ), ), ) - CallToolResult(listOf(TextContent(result.content.joinToString("\n") { it.toString() }))) + val sampledText = result.content.joinToString("\n") { content -> + when (content) { + is TextContent -> content.text + else -> "Non-text sampling content: ${content::class.simpleName}" + } + } + CallToolResult(listOf(TextContent(sampledText))) } // 9. Elicitation diff --git a/gradle.properties b/gradle.properties index e145506..a5655c3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,4 +8,4 @@ kotlin.daemon.jvmargs=-Xmx4G kotlin.native.ignoreDisabledTargets=true group=io.modelcontextprotocol -version=0.12.0 +version=0.15.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 360a468..a8d034f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,30 +1,29 @@ [versions] # plugins version -atomicfu = "0.32.1" -binaryCompatibilityValidatorPlugin = "0.18.1" -detekt = "2.0.0-alpha.3" +atomicfu = "0.33.0" +binaryCompatibilityValidatorPlugin = "0.18.2" +detekt = "2.0.0-alpha.6" dokka = "2.2.0" -kotlin = "2.3.21" -kover = "0.9.8" +kotlin = "2.4.10" +kover = "0.9.9" ktlint = "14.2.0" knit = "0.5.1" -mavenPublish = "0.36.0" -netty = "4.2.12.Final" +mavenPublish = "0.37.0" +netty = "4.2.18.Final" # libraries version awaitility = "4.3.0" -collections-immutable = "0.4.0" -coroutines = "1.10.2" -kotest = "6.1.11" -kotlinx-io = "0.9.0" -ktor = "3.3.3" -logging = "8.0.01" -mockk = "1.14.9" -mokksy = "0.8.1" +collections-immutable = "0.5.2" +coroutines = "1.11.0" +kotest = "6.2.5" +kotlinx-io = "0.9.1" +ktor = "3.5.2" +logging = "8.0.4" +mockk = "1.14.11" serialization = "1.11.0" -slf4j = "2.0.17" -logback = "1.5.32" -junit = "5.14.3" +slf4j = "2.0.19" +logback = "1.6.3" +junit = "5.14.4" [libraries] # Plugins @@ -65,7 +64,6 @@ 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" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } -mokksy = { group = "dev.mokksy", name = "mokksy", version.ref = "mokksy" } netty-bom = { group = "io.netty", name = "netty-bom", version.ref = "netty" } slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d997cfc..eddabd2 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c61a118..ad7845b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 739907d..249efbb 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/gradlew.bat b/gradlew.bat index c4bdd3a..8508ef6 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,93 +1,82 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt index e5f6638..099329d 100644 --- a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt @@ -14,13 +14,17 @@ import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult +import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification +import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotificationParams import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION @@ -46,8 +50,10 @@ import io.modelcontextprotocol.kotlin.sdk.types.Tool import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UrlElicitationRequiredException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.joinAll @@ -67,6 +73,7 @@ import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds class ClientTest { @Test @@ -539,6 +546,15 @@ class ClientTest { val serverSession = serverSessionResult.await() + // Register the tap after createSession so it chains onto the session's subscription instead + // of pre-empting the handshake. + val cancelledOnWire = CompletableDeferred() + serverTransport.onMessage { message -> + if (message is JSONRPCNotification && message.method == Method.Defined.NotificationsCancelled.value) { + cancelledOnWire.complete(Unit) + } + } + serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> // Simulate delay def.complete(Unit) @@ -564,6 +580,7 @@ class ClientTest { def.await() runCatching { job.cancel("Cancelled by test") } defCancel.await() + cancelledOnWire.await() defTimeOut.await() } @@ -602,17 +619,9 @@ class ClientTest { val serverSession = serverSessionResult.await() serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> - // Simulate a delayed response - // Wait ~100ms unless canceled - try { - withTimeout(100L) { - // Just delay here, if timeout is 0 on the client side, this won't return in time - delay(100) - } - } catch (_: Exception) { - // If aborted, just rethrow or return early - } - ListResourcesResult(resources = emptyList()) + // Never respond: the handler runs on a real dispatcher while the 1 ms timeout runs on the + // virtual test clock, so a real-time delay could race it. Parking keeps the test deterministic. + awaitCancellation() } // Request with 1 msec timeout should fail immediately @@ -891,8 +900,8 @@ class ClientTest { ), ) - // Track notifications - var rootListChangedNotificationReceived = false + // The handler runs on Dispatchers.Default after concurrent dispatch is enabled; await the deferred. + val rootListChangedReceived = CompletableDeferred() val serverSessionResult = CompletableDeferred() @@ -911,16 +920,13 @@ class ClientTest { serverSession.setNotificationHandler( Method.Defined.NotificationsRootsListChanged, ) { - rootListChangedNotificationReceived = true + rootListChangedReceived.complete(Unit) CompletableDeferred(Unit) } client.sendRootsListChanged() - assertTrue( - rootListChangedNotificationReceived, - "Notification should be sent when sendRootsListChanged is called", - ) + withTimeout(5.seconds) { rootListChangedReceived.await() } } @Test @@ -1058,7 +1064,7 @@ class ClientTest { Implementation(name = "test client", version = "1.0"), ClientOptions( capabilities = ClientCapabilities( - elicitation = EmptyJsonObject, + elicitation = ClientCapabilities.Elicitation(), ), ), ) @@ -1118,6 +1124,103 @@ class ClientTest { assertEquals(elicitationResultContent, result.content) } + @Test + fun `should reject accepted elicitation content that does not match requested schema`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + required = listOf("name"), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = buildJsonObject { put("name", 42) }) + } + + val exception = assertFailsWith { + serverSession.createElicitation(message = "mismatch", requestedSchema = requestedSchema) + } + assertTrue( + exception.message.orEmpty() + .startsWith("Elicitation response content does not match requested schema: "), + "Unexpected message: ${exception.message}", + ) + assertTrue("'name' must be string" in exception.message.orEmpty(), "Unexpected message: ${exception.message}") + + client.close() + } + + @Test + fun `should reject accepted elicitation without content when schema has required properties`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + required = listOf("name"), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = null) + } + + val exception = assertFailsWith { + serverSession.createElicitation(message = "no content", requestedSchema = requestedSchema) + } + assertTrue( + "must have required property 'name'" in exception.message.orEmpty(), + "Unexpected message: ${exception.message}", + ) + + client.close() + } + + @Test + fun `should accept elicitation without content when schema has no required properties`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = null) + } + + val result = serverSession.createElicitation(message = "no required", requestedSchema = requestedSchema) + + assertEquals(ElicitResult.Action.Accept, result.action) + assertNull(result.content) + + client.close() + } + + @Test + fun `should pass through declined elicitation without validation`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + required = listOf("name"), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Decline) + } + + val result = serverSession.createElicitation(message = "decline", requestedSchema = requestedSchema) + + assertEquals(ElicitResult.Action.Decline, result.action) + assertNull(result.content) + + client.close() + } + + @Test + fun `should pass through cancelled elicitation without validation`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + required = listOf("name"), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Cancel) + } + + val result = serverSession.createElicitation(message = "cancel", requestedSchema = requestedSchema) + + assertEquals(ElicitResult.Action.Cancel, result.action) + assertNull(result.content) + + client.close() + } + @Test fun `should apply elicitation defaults for missing fields in empty content`() = runTest { val schema = defaultsTestSchema() @@ -1251,6 +1354,189 @@ class ClientTest { client.close() } + // ── URL-mode elicitation (SEP-1036) ───────────────────────────────── + + @Test + fun `should handle URL mode elicitation end-to-end`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), + ), + ), + ) + + val elicitationId = "550e8400-e29b-41d4-a716-446655440000" + val url = "https://oauth.example.com/authorize" + + client.setElicitationHandler { request -> + val params = assertIs(request.params) + assertEquals(elicitationId, params.elicitationId) + assertEquals(url, params.url) + ElicitResult(action = ElicitResult.Action.Accept) + } + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + val server = Server( + serverInfo = Implementation(name = "test server", version = "1.0"), + options = ServerOptions(capabilities = ServerCapabilities()), + ) + + val serverSessionResult = CompletableDeferred() + listOf( + launch { client.connect(clientTransport) }, + launch { serverSessionResult.complete(server.createSession(serverTransport)) }, + ).joinAll() + val serverSession = serverSessionResult.await() + + val result = serverSession.createElicitation( + message = "Authorize access to continue", + elicitationId = elicitationId, + url = url, + ) + + assertEquals(ElicitResult.Action.Accept, result.action) + assertNull(result.content) + + client.close() + } + + @Test + fun `should reject URL mode elicitation when client supports only form mode`() = runTest { + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept) + } + + val exception = assertFailsWith { + serverSession.createElicitation( + message = "Authorize", + elicitationId = "id-1", + url = "https://example.com/auth", + ) + } + assertTrue(exception.message!!.contains("elicitation.url")) + + client.close() + } + + @Test + fun `should deliver elicitation complete notification to client`() = runTest { + val received = CompletableDeferred() + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), + ), + ), + ) + client.setElicitationCompleteHandler { received.complete(it) } + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + val server = Server( + serverInfo = Implementation(name = "test server", version = "1.0"), + options = ServerOptions(capabilities = ServerCapabilities()), + ) + + val serverSessionResult = CompletableDeferred() + listOf( + launch { client.connect(clientTransport) }, + launch { serverSessionResult.complete(server.createSession(serverTransport)) }, + ).joinAll() + val serverSession = serverSessionResult.await() + + val elicitationId = "complete-id-1" + serverSession.sendElicitationComplete( + ElicitationCompleteNotification(ElicitationCompleteNotificationParams(elicitationId = elicitationId)), + ) + + val notification = received.await() + assertEquals(elicitationId, notification.params.elicitationId) + + client.close() + } + + @Test + fun `should reject elicitation complete when client supports only form mode`() = runTest { + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept) + } + + val exception = assertFailsWith { + serverSession.sendElicitationComplete( + ElicitationCompleteNotification(ElicitationCompleteNotificationParams(elicitationId = "id-1")), + ) + } + assertTrue(exception.message!!.contains("elicitation.url")) + + client.close() + } + + @Test + fun `setElicitationCompleteHandler should require url capability`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + elicitation = ClientCapabilities.Elicitation(), + ), + ), + ) + + assertFailsWith { + client.setElicitationCompleteHandler { } + } + } + + @Test + fun `should surface URL elicitation required error to client as typed exception`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), + ), + ), + ) + + val elicitationId = "auth-required-1" + val url = "https://oauth.example.com/authorize" + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + val server = Server( + serverInfo = Implementation(name = "test server", version = "1.0"), + options = ServerOptions(capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(true))), + ) + server.addTool("needs-auth", "Requires URL elicitation") { + throw UrlElicitationRequiredException( + listOf( + ElicitRequestURLParams( + message = "Authorize to continue", + elicitationId = elicitationId, + url = url, + ), + ), + ) + } + + val serverSessionResult = CompletableDeferred() + listOf( + launch { client.connect(clientTransport) }, + launch { serverSessionResult.complete(server.createSession(serverTransport)) }, + ).joinAll() + serverSessionResult.await() + + val exception = assertFailsWith { + client.callTool(name = "needs-auth", arguments = emptyMap()) + } + val elicitation = exception.elicitations.single() + assertEquals(elicitationId, elicitation.elicitationId) + assertEquals(url, elicitation.url) + + client.close() + } + private fun defaultsTestSchema(): ElicitRequestParams.RequestedSchema = ElicitRequestParams.RequestedSchema( properties = mapOf( "name" to StringSchema(description = "User name", default = "John Doe"), @@ -1270,7 +1556,7 @@ class ClientTest { ): Pair = kotlinx.coroutines.coroutineScope { val client = Client( Implementation(name = "test client", version = "1.0"), - ClientOptions(capabilities = ClientCapabilities(elicitation = EmptyJsonObject)), + ClientOptions(capabilities = ClientCapabilities(elicitation = ClientCapabilities.Elicitation())), ) client.setElicitationHandler(handler) diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt index 585af3f..fc3c1d3 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt @@ -7,9 +7,13 @@ import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.test.utils.createSleepyProcessBuilder import io.modelcontextprotocol.kotlin.test.utils.createTeeProcessBuilder import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.io.Buffer +import kotlinx.io.RawSource import kotlinx.io.asSink import kotlinx.io.asSource import kotlinx.io.buffered @@ -18,10 +22,12 @@ 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.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail import kotlin.time.Duration.Companion.seconds @@ -109,6 +115,43 @@ class StdioClientTransportTest : BaseTransportTest() { } } + @OptIn(ExperimentalAtomicApi::class) + @Test + fun `should close cleanly while stdin read is blocked`(): Unit = runBlocking(Dispatchers.IO) { + val input = BlockingRawSource() + + val transport = StdioClientTransport( + input = input.buffered(), + output = Buffer(), + ) + + val didClose = AtomicBoolean(false) + transport.onClose { didClose.store(true) } + transport.onError { error -> + fail("Unexpected error while closing transport: $error") + } + + transport.start() + + eventually(2.seconds) { + assertTrue(input.readStarted, "Transport should start reading stdin before close") + } + + val closeJob = async { + transport.close() + } + + val closed = withTimeoutOrNull(1.seconds) { + closeJob.await() + } + + input.close() + closeJob.await() + + assertNotNull(closed, "Transport.close() should not wait for stdin to produce data or EOF") + assertTrue(didClose.load(), "Transport should be closed after close() call") + } + @Test fun `should read messages`() = runTest { val processBuilder = createTeeProcessBuilder() @@ -147,4 +190,22 @@ class StdioClientTransportTest : BaseTransportTest() { process.waitFor() process.destroyForcibly() } + + private class BlockingRawSource : RawSource { + private val closed = CountDownLatch(1) + + @Volatile + var readStarted: Boolean = false + private set + + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + readStarted = true + closed.await() + return -1L + } + + override fun close() { + closed.countDown() + } + } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/ProtocolConcurrencyIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/ProtocolConcurrencyIntegrationTest.kt new file mode 100644 index 0000000..d8d64ec --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/ProtocolConcurrencyIntegrationTest.kt @@ -0,0 +1,306 @@ +package io.modelcontextprotocol.kotlin.sdk.integration + +import io.kotest.matchers.shouldBe +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.ServerSession +import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport +import io.modelcontextprotocol.kotlin.sdk.shared.currentRequestHandlerExtra +import io.modelcontextprotocol.kotlin.sdk.testing.ChannelTransport +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest +import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest +import io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult +import io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest +import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import io.modelcontextprotocol.kotlin.sdk.types.Role +import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.toJSON +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.time.Duration.Companion.seconds + +/** + * End-to-end tests for concurrent inbound dispatch enabled after the MCP handshake. + * + * Handlers run on a real [kotlinx.coroutines.Dispatchers.Default], so these use [runBlocking] with + * explicit [withTimeout] guards. A virtual test clock would false-fire timeouts against cross-thread + * real dispatchers. + */ +@OptIn(ExperimentalMcpApi::class) +class ProtocolConcurrencyIntegrationTest { + + private fun rootsClient(): Client = Client( + clientInfo = Implementation(name = "test client", version = "1.0"), + options = ClientOptions( + capabilities = ClientCapabilities(roots = ClientCapabilities.Roots(listChanged = null)), + ), + ) + + private fun toolsServer(): Server = Server( + serverInfo = Implementation(name = "test server", version = "1.0"), + options = ServerOptions( + capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = null)), + ), + ) + + // ChannelTransport delivers inbound messages as soon as its read loop starts, which is before + // start() marks it operational. Creating the session first keeps the client's initialize from + // landing on a transport that would reject the session's response. + private suspend fun connect( + client: Client, + clientTransport: ChannelTransport, + server: Server, + serverTransport: ChannelTransport, + ): ServerSession { + val serverSession = server.createSession(serverTransport) + client.connect(clientTransport) + return serverSession + } + + // The motivating deadlock over real serial read loops: a server tool handler makes a + // server-to-client request (roots/list) while still handling the client's tools/call. + @Test + fun `server tool handler can call roots list mid-handling without deadlock`() = runBlocking { + val client = rootsClient() + val server = toolsServer() + val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() + + val serverSession = connect(client, clientTransport, server, serverTransport) + + // Register the tools/call handler directly on the session so it can call back to the client + // mid-handling. Before concurrent dispatch this would deadlock: the server read loop is + // blocked by the tool handler and can never deliver the roots/list response. + serverSession.setRequestHandler(Method.Defined.ToolsCall) { _, _ -> + val roots = serverSession.request(ListRootsRequest()) + CallToolResult(content = listOf(TextContent(text = "roots=${roots.roots.size}"))) + } + + val result = withTimeout(10.seconds) { + client.callTool(CallToolRequest(CallToolRequestParams("needs-roots"))) + } + val text = (result as CallToolResult).content.filterIsInstance().single().text + text shouldBe "roots=0" + + client.close() + server.close() + } + + // Client-side mirror: a client sampling handler makes a client-to-server request (tools/list) + // while still handling the server's sampling/createMessage. + @Test + fun `client sampling handler can call the server mid-handling without deadlock`() = runBlocking { + val client = Client( + clientInfo = Implementation(name = "test client", version = "1.0"), + options = ClientOptions( + capabilities = ClientCapabilities(sampling = ClientCapabilities.Sampling()), + ), + ) + val server = toolsServer() + val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() + + client.setRequestHandler(Method.Defined.SamplingCreateMessage) { _, _ -> + val tools = client.request(ListToolsRequest()) + CreateMessageResult( + role = Role.Assistant, + content = TextContent(text = "tools=${tools.tools.size}"), + model = "test-model", + ) + } + + val serverSession = connect(client, clientTransport, server, serverTransport) + + val result = withTimeout(10.seconds) { + serverSession.request( + CreateMessageRequest( + CreateMessageRequestParams( + maxTokens = 100, + messages = listOf(SamplingMessage(role = Role.User, content = TextContent(text = "hi"))), + ), + ), + ) + } + val text = result.content.filterIsInstance().single().text + text shouldBe "tools=0" + + client.close() + server.close() + } + + // Nested-cancel cascade: cancelling the client's tools/call must reach the server tool handler + // over the wire and cascade into its nested roots/list request, cancelling the parked + // client-side roots handler. No tools/call response is ever sent. + @Test + fun `cancelling a tool call cascades into its nested roots list request`() = runBlocking { + val client = rootsClient() + val server = toolsServer() + val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() + + val capturedToolCallId = AtomicReference(null) + val toolHandlerCancelled = CompletableDeferred() + val rootsHandlerEntered = CompletableDeferred() + val rootsHandlerCancelled = CompletableDeferred() + val parkForever = CompletableDeferred() + val unexpectedToolCallResponse = CompletableDeferred() + + // Tap the server->client direction (client transport inbound) to prove no tools/call + // response is ever delivered once the call is cancelled. + clientTransport.onMessage { message -> + if (message is JSONRPCResponse && message.id == capturedToolCallId.get()) { + unexpectedToolCallResponse.complete(Unit) + } + } + + val serverSession = connect(client, clientTransport, server, serverTransport) + + // Client roots handler parks until cancelled and records the cancellation. + client.setRequestHandler(Method.Defined.RootsList) { _, _ -> + rootsHandlerEntered.complete(Unit) + try { + parkForever.await() + } catch (e: CancellationException) { + rootsHandlerCancelled.complete(e) + throw e + } + } + + // Tool handler makes a nested server->client request and records its own cancellation. + serverSession.setRequestHandler(Method.Defined.ToolsCall) { _, _ -> + val extra = currentRequestHandlerExtra()!! + capturedToolCallId.set(extra.requestId) + try { + extra.sendRequest(ListRootsRequest()) + } catch (e: CancellationException) { + toolHandlerCancelled.complete(e) + throw e + } + CallToolResult(content = listOf(TextContent(text = "unreachable"))) + } + + val call = launch { client.callTool(CallToolRequest(CallToolRequestParams("nested"))) } + + withTimeout(10.seconds) { rootsHandlerEntered.await() } + call.cancel(CancellationException("user aborted")) + + withTimeout(10.seconds) { toolHandlerCancelled.await() } + withTimeout(10.seconds) { rootsHandlerCancelled.await() } + + // The server suppresses the response for a cancelled request, so none is ever sent. + assertFalse( + unexpectedToolCallResponse.isCompleted, + "no tools/call response should be delivered after the call was cancelled", + ) + + client.close() + server.close() + } + + // Init gate plus context element in a high-level handler: initialize and tools/list are + // pipelined before notifications/initialized and answered serially in order. After the + // handshake a high-level addTool handler can observe currentRequestHandlerExtra(). + @Test + fun `pipelined initialize and tools list are processed serially then context element is available`(): Unit = + runBlocking { + val server = toolsServer() + server.addTool(name = "ctx-tool", description = "returns its request id") { + val extra = currentRequestHandlerExtra() + checkNotNull(extra) { "currentRequestHandlerExtra() must be non-null inside a tool handler" } + CallToolResult(content = listOf(TextContent(text = extra.requestId.asText()))) + } + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + val arrivalOrder = CopyOnWriteArrayList() + val pending = ConcurrentHashMap>() + clientTransport.onMessage { message -> + val id = when (message) { + is JSONRPCResponse -> message.id + is JSONRPCError -> message.id + else -> null + } + if (id != null) { + arrivalOrder.add(id) + pending[id]?.complete(message) + } + } + + // Drive the client side manually over the raw transport. + server.createSession(serverTransport) + + val initRequest = InitializeRequest( + InitializeRequestParams( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = ClientCapabilities(), + clientInfo = Implementation(name = "raw-client", version = "1.0"), + ), + ).toJSON() + val toolsListRequest = ListToolsRequest().toJSON() + + val initAnswered = pending.expect(initRequest.id) + val toolsListAnswered = pending.expect(toolsListRequest.id) + + // Pipelined before notifications/initialized: both handled in the serial dispatch phase. + clientTransport.send(initRequest) + clientTransport.send(toolsListRequest) + + withTimeout(10.seconds) { initAnswered.await() } + withTimeout(10.seconds) { toolsListAnswered.await() } + + // Deterministic serial ordering: initialize answered before tools/list. + assertEquals( + listOf(initRequest.id, toolsListRequest.id), + arrivalOrder.toList(), + "pipelined requests must be answered in arrival order", + ) + + // Complete the handshake, then call the high-level tool. + clientTransport.send(InitializedNotification().toJSON()) + + val toolCallRequest = CallToolRequest(CallToolRequestParams("ctx-tool")).toJSON() + val toolCallAnswered = pending.expect(toolCallRequest.id) + clientTransport.send(toolCallRequest) + + val toolCallResponse = withTimeout(10.seconds) { toolCallAnswered.await() } as JSONRPCResponse + val text = (toolCallResponse.result as CallToolResult).content.filterIsInstance().single().text + // The handler echoed its request id, proving currentRequestHandlerExtra() was available. + text shouldBe toolCallRequest.id.asText() + } + + private fun ConcurrentHashMap>.expect( + id: RequestId, + ): CompletableDeferred = CompletableDeferred().also { put(id, it) } + + private fun RequestId.asText(): String = when (this) { + is RequestId.StringId -> value + is RequestId.NumberId -> value.toString() + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt index eaa51ad..fb97782 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt @@ -698,8 +698,8 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() { val expectedMessage = "Prompt not found: non-existent-prompt" - withClue("Exception code should be INTERNAL_ERROR: -32603") { - exception.code shouldBe -32603 + withClue("Exception code should be INVALID_PARAMS: ${RPCError.ErrorCode.INVALID_PARAMS}") { + exception.code shouldBe RPCError.ErrorCode.INVALID_PARAMS } withClue("Unexpected error message for non-existent prompt") { exception.message shouldBe expectedMessage diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt index da7d85b..ab2a854 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt @@ -77,23 +77,6 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() { ) } - server.addResource( - uri = testResourceUri, - name = testResourceName, - description = testResourceDescription, - mimeType = "text/plain", - ) { request -> - ReadResourceResult( - contents = listOf( - TextResourceContents( - text = testResourceContent, - uri = request.params.uri, - mimeType = "text/plain", - ), - ), - ) - } - server.addResource( uri = binaryResourceUri, name = binaryResourceName, diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt index 9bc0c17..535393e 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt @@ -3,6 +3,7 @@ package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer +import io.ktor.server.sse.Heartbeat import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp @@ -17,7 +18,10 @@ import io.ktor.server.cio.CIO as ServerCIO open class AbstractStreamableHttpIntegrationTest { - suspend fun initTestServer(name: String? = null): StreamableHttpTestServer { + suspend fun initTestServer( + name: String? = null, + sseHeartbeatConfig: (Heartbeat.() -> Unit)? = null, + ): StreamableHttpTestServer { val mcpServer = Server( Implementation(name = name ?: DEFAULT_SERVER_NAME, version = VERSION), ServerOptions( @@ -52,7 +56,7 @@ open class AbstractStreamableHttpIntegrationTest { host = URL, port = PORT, ) { - mcpStreamableHttp { mcpServer } + mcpStreamableHttp(sseHeartbeatConfig = sseHeartbeatConfig) { mcpServer } } return StreamableHttpTestServer( diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpClientTransportIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpClientTransportIntegrationTest.kt new file mode 100644 index 0000000..598825f --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpClientTransportIntegrationTest.kt @@ -0,0 +1,270 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp + +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import io.ktor.client.HttpClient +import io.ktor.client.plugins.sse.SSE +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.engine.embeddedServer +import io.ktor.server.request.receiveText +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.response.respondText +import io.ktor.server.response.respondTextWriter +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions +import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.Tool +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import io.modelcontextprotocol.kotlin.test.utils.actualPort +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import kotlin.test.Test +import io.ktor.client.engine.cio.CIO as ClientCIO +import io.ktor.server.cio.CIO as ServerCIO + +private const val SESSION_ID_HEADER = "mcp-session-id" +private const val HOST = "127.0.0.1" +private const val MCP_PATH = "/mcp" + +private val INITIALIZE_RESULT = """ + { + "capabilities": {"tools": {"listChanged": false}}, + "protocolVersion": "2025-03-26", + "serverInfo": {"name": "test-server", "version": "1.0.0"} + } +""".trimIndent() + +private val TOOLS_LIST_RESULT = """ + { + "tools": [ + { + "name": "get_weather", + "title": "Weather Information Provider", + "description": "Get current weather information for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "City name or zip code"} + }, + "required": ["location"] + }, + "outputSchema": { + "type": "object", + "properties": { + "temperature": {"type": "number", "description": "Temperature, Celsius"} + }, + "required": ["temperature"] + }, + "_meta": {} + } + ] + } +""".trimIndent() + +private const val PROGRESS_NOTIFICATION_1 = + """{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""" + +private const val PROGRESS_NOTIFICATION_2 = + """{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":75,"total":100}}""" + +internal class StreamableHttpClientTransportIntegrationTest { + + @Test + fun `client receives progress notifications via GET SSE and lists tools`(): Unit = runBlocking(Dispatchers.IO) { + val sessionId = "sid-progress" + runWithServer({ + mcpPostRoute(sessionId, extraResults = mapOf("tools/list" to TOOLS_LIST_RESULT)) + get(MCP_PATH) { + call.response.header(SESSION_ID_HEADER, sessionId) + call.respondTextWriter(contentType = ContentType.Text.EventStream) { + write(sseEvent(id = "1", data = PROGRESS_NOTIFICATION_1)) + flush() + delay(200) + write(sseEvent(id = "2", data = PROGRESS_NOTIFICATION_2)) + flush() + awaitCancellation() + } + } + }) { url -> + val client = newClient() + client.connect(StreamableHttpClientTransport(client = newHttpClient(), url = url)) + + val listToolsResult = client.listTools() + + listToolsResult.tools shouldContain Tool( + name = "get_weather", + title = "Weather Information Provider", + description = "Get current weather information for a location", + inputSchema = ToolSchema( + properties = buildJsonObject { + putJsonObject("location") { + put("type", "string") + put("description", "City name or zip code") + } + }, + required = listOf("location"), + ), + outputSchema = ToolSchema( + properties = buildJsonObject { + putJsonObject("temperature") { + put("type", "number") + put("description", "Temperature, Celsius") + } + }, + required = listOf("temperature"), + ), + annotations = null, + meta = EmptyJsonObject, + ) + + client.close() + } + } + + @Test + fun `terminateSession sends DELETE request and clears sessionId`(): Unit = runBlocking(Dispatchers.IO) { + val sessionId = "sid-terminate" + runWithServer({ + mcpPostRoute(sessionId) + get(MCP_PATH) { + call.response.header(SESSION_ID_HEADER, sessionId) + call.respondTextWriter(contentType = ContentType.Text.EventStream) { + awaitCancellation() + } + } + delete(MCP_PATH) { + call.respond(HttpStatusCode.OK) + } + }) { url -> + val client = newClient() + val transport = StreamableHttpClientTransport(client = newHttpClient(), url = url) + client.connect(transport) + + transport.sessionId shouldBe sessionId + transport.terminateSession() + transport.sessionId shouldBe null + + client.close() + } + } + + @Test + fun `client survives 405 on GET SSE`(): Unit = runBlocking(Dispatchers.IO) { + val sessionId = "sid-405" + runWithServer({ + mcpPostRoute(sessionId, extraResults = mapOf("ping" to "{}")) + get(MCP_PATH) { + call.respondText( + text = "", + contentType = ContentType.Text.EventStream, + status = HttpStatusCode.MethodNotAllowed, + ) + } + }) { url -> + val client = newClient() + client.connect(StreamableHttpClientTransport(client = newHttpClient(), url = url)) + + client.ping() + + client.close() + } + } + + @Test + fun `client survives non streaming JSON response on GET SSE`(): Unit = runBlocking(Dispatchers.IO) { + val sessionId = "sid-json" + runWithServer({ + mcpPostRoute(sessionId, extraResults = mapOf("ping" to "{}")) + get(MCP_PATH) { + call.response.header(SESSION_ID_HEADER, sessionId) + call.respondText( + text = "", + contentType = ContentType.Application.Json, + status = HttpStatusCode.OK, + ) + } + }) { url -> + val client = newClient() + client.connect(StreamableHttpClientTransport(client = newHttpClient(), url = url)) + + client.ping() + + client.close() + } + } + + private fun Route.mcpPostRoute(sessionId: String, extraResults: Map = emptyMap()) { + post(MCP_PATH) { + val body = call.receiveText() + val parsed = Json.parseToJsonElement(body).jsonObject + val method = parsed["method"]?.jsonPrimitive?.content + val idJson = parsed["id"]?.toString() ?: "null" + call.response.header(SESSION_ID_HEADER, sessionId) + when { + method == "initialize" -> call.respondText( + text = jsonRpcResult(idJson, INITIALIZE_RESULT), + contentType = ContentType.Application.Json, + status = HttpStatusCode.OK, + ) + + method == "notifications/initialized" -> call.respond(HttpStatusCode.Accepted) + + method != null && method in extraResults -> call.respondText( + text = jsonRpcResult(idJson, extraResults.getValue(method)), + contentType = ContentType.Application.Json, + status = HttpStatusCode.OK, + ) + + else -> call.respond(HttpStatusCode.NotFound) + } + } + } + + private suspend fun runWithServer(routes: Route.() -> Unit, block: suspend (url: String) -> Unit) { + val server = embeddedServer(ServerCIO, host = HOST, port = 0) { + routing { routes() } + }.startSuspend(wait = false) + try { + val port = server.actualPort() + block("http://$HOST:$port$MCP_PATH") + } finally { + server.stopSuspend(1000, 2000) + } + } + + private fun newClient(): Client = Client( + clientInfo = Implementation(name = "client1", version = "1.0.0"), + options = ClientOptions(capabilities = ClientCapabilities()), + ) + + private fun newHttpClient(): HttpClient = HttpClient(ClientCIO) { + install(SSE) + } + + private fun jsonRpcResult(idJson: String, resultJson: String): String = + """{"jsonrpc":"2.0","id":$idJson,"result":$resultJson}""" + + private fun sseEvent(id: String, data: String): String = buildString { + append("event: message\n") + append("id: $id\n") + append("data: $data\n\n") + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpHeartbeatIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpHeartbeatIntegrationTest.kt new file mode 100644 index 0000000..8019557 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpHeartbeatIntegrationTest.kt @@ -0,0 +1,174 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp + +import io.kotest.matchers.shouldBe +import io.ktor.client.HttpClient +import io.ktor.client.plugins.sse.SSE +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.prepareGet +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.sse.ServerSentEvent +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readUTF8Line +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.toJSON +import io.modelcontextprotocol.kotlin.test.utils.actualPort +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.milliseconds +import io.ktor.client.engine.cio.CIO as ClientCIO + +private const val SESSION_ID_HEADER = "mcp-session-id" +private const val PROTOCOL_VERSION_HEADER = "mcp-protocol-version" +private const val DEFAULT_HEARTBEAT_LINE = ": heartbeat" +private const val CUSTOM_HEARTBEAT_LINE = ": mcp-heartbeat" + +class StreamableHttpHeartbeatIntegrationTest : AbstractStreamableHttpIntegrationTest() { + + @Test + fun `GET SSE stream emits configured heartbeat`(): Unit = runBlocking(Dispatchers.IO) { + var server: StreamableHttpTestServer? = null + var httpClient: HttpClient? = null + + try { + server = initTestServer("heartbeat-test") { + period = 50.milliseconds + event = ServerSentEvent(comments = "mcp-heartbeat") + } + val mcpUrl = "http://$URL:${server.ktorServer.actualPort()}/mcp" + httpClient = HttpClient(ClientCIO) { install(SSE) } + val sessionId = initializeSession(httpClient, mcpUrl) + + httpClient.prepareGet(mcpUrl) { + addSseHeaders(sessionId) + }.execute { response -> + response.status shouldBe HttpStatusCode.OK + response.headers[SESSION_ID_HEADER] shouldBe sessionId + + response.bodyAsChannel().readLineMatching(CUSTOM_HEARTBEAT_LINE) shouldBe CUSTOM_HEARTBEAT_LINE + } + } finally { + httpClient?.close() + server?.ktorServer?.stopSuspend(1000, 2000) + } + } + + @Test + fun `GET SSE stream does not emit heartbeat by default`(): Unit = runBlocking(Dispatchers.IO) { + var server: StreamableHttpTestServer? = null + var httpClient: HttpClient? = null + + try { + server = initTestServer("no-heartbeat-test") + val mcpUrl = "http://$URL:${server.ktorServer.actualPort()}/mcp" + httpClient = HttpClient(ClientCIO) { install(SSE) } + val sessionId = initializeSession(httpClient, mcpUrl) + + httpClient.prepareGet(mcpUrl) { + addSseHeaders(sessionId) + }.execute { response -> + response.status shouldBe HttpStatusCode.OK + response.headers[SESSION_ID_HEADER] shouldBe sessionId + + response.bodyAsChannel().readLineMatching(timeoutMillis = 150) { line -> + line.isHeartbeatSseLine() + } shouldBe null + } + } finally { + httpClient?.close() + server?.ktorServer?.stopSuspend(1000, 2000) + } + } + + @Test + fun `GET SSE stream emits configured heartbeat repeatedly`(): Unit = runBlocking(Dispatchers.IO) { + var server: StreamableHttpTestServer? = null + var httpClient: HttpClient? = null + + try { + server = initTestServer("repeating-heartbeat-test") { + period = 50.milliseconds + event = ServerSentEvent(comments = "mcp-heartbeat") + } + val mcpUrl = "http://$URL:${server.ktorServer.actualPort()}/mcp" + httpClient = HttpClient(ClientCIO) { install(SSE) } + val sessionId = initializeSession(httpClient, mcpUrl) + + httpClient.prepareGet(mcpUrl) { + addSseHeaders(sessionId) + }.execute { response -> + response.status shouldBe HttpStatusCode.OK + response.headers[SESSION_ID_HEADER] shouldBe sessionId + + val channel = response.bodyAsChannel() + channel.readLineMatching(CUSTOM_HEARTBEAT_LINE) shouldBe CUSTOM_HEARTBEAT_LINE + channel.readLineMatching(CUSTOM_HEARTBEAT_LINE) shouldBe CUSTOM_HEARTBEAT_LINE + } + } finally { + httpClient?.close() + server?.ktorServer?.stopSuspend(1000, 2000) + } + } + + private suspend fun initializeSession(client: HttpClient, mcpUrl: String): String { + val response = client.post(mcpUrl) { + contentType(ContentType.Application.Json) + header( + HttpHeaders.Accept, + "${ContentType.Application.Json}, ${ContentType.Text.EventStream}", + ) + setBody(Json.encodeToString(buildInitPayload())) + } + + response.status shouldBe HttpStatusCode.OK + return assertNotNull(response.headers[SESSION_ID_HEADER]) + } + + private fun buildInitPayload(): JSONRPCRequest = InitializeRequest( + InitializeRequestParams( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = ClientCapabilities(), + clientInfo = Implementation(name = "heartbeat-test-client", version = "1.0.0"), + ), + ).toJSON() + + private fun io.ktor.client.request.HttpRequestBuilder.addSseHeaders(sessionId: String) { + header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) + header(SESSION_ID_HEADER, sessionId) + header(PROTOCOL_VERSION_HEADER, LATEST_PROTOCOL_VERSION) + } + + private suspend fun ByteReadChannel.readLineMatching(expectedLine: String, timeoutMillis: Long = 2_000): String? = + readLineMatching(timeoutMillis) { line -> line == expectedLine } + + private suspend fun ByteReadChannel.readLineMatching( + timeoutMillis: Long = 2_000, + matches: (String) -> Boolean, + ): String? = withTimeoutOrNull(timeoutMillis.milliseconds) { + var line = readUTF8Line() + while (line != null) { + if (matches(line)) return@withTimeoutOrNull line + line = readUTF8Line() + } + null + } + + private fun String.isHeartbeatSseLine(): Boolean = this == DEFAULT_HEARTBEAT_LINE || + this == CUSTOM_HEARTBEAT_LINE || + (startsWith(":") && contains("heartbeat", ignoreCase = true)) || + (startsWith("event:") && contains("heartbeat", ignoreCase = true)) +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionLoggingTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionLoggingTest.kt index 81a3b41..a1cf797 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionLoggingTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionLoggingTest.kt @@ -1,5 +1,7 @@ package io.modelcontextprotocol.kotlin.sdk.server +import io.kotest.assertions.nondeterministic.continually +import io.kotest.assertions.nondeterministic.eventually import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe @@ -17,6 +19,9 @@ import kotlinx.serialization.json.JsonPrimitive import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { @@ -56,7 +61,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { @ParameterizedTest @EnumSource(LoggingLevel::class) fun `notification should filter logging messages below level`(minLevel: LoggingLevel): Unit = runBlocking { - val receivedMessages = mutableListOf() + val receivedMessages = CopyOnWriteArrayList() client.setNotificationHandler(Method.Defined.NotificationsMessage) { receivedMessages.add(it) CompletableDeferred(Unit) @@ -79,8 +84,10 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { client.callTool(CallToolRequest(CallToolRequestParams("test-notification-filtered"))) + // Handlers dispatch on Dispatchers.Default after the handshake, so await the expected count + // before asserting order. val expectedLevels = LoggingLevel.entries.filter { it >= minLevel } - receivedMessages shouldHaveSize expectedLevels.size + eventually(5.seconds) { receivedMessages shouldHaveSize expectedLevels.size } receivedMessages.map { it.params.level } shouldBe expectedLevels } @@ -115,7 +122,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { @ParameterizedTest @EnumSource(LoggingLevel::class) fun `sendLoggingMessage should filter messages below level`(minLevel: LoggingLevel): Unit = runBlocking { - val receivedMessages = mutableListOf() + val receivedMessages = CopyOnWriteArrayList() client.setNotificationHandler(Method.Defined.NotificationsMessage) { receivedMessages.add(it) CompletableDeferred(Unit) @@ -138,14 +145,16 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { client.callTool(CallToolRequest(CallToolRequestParams("test-logging-level"))) + // Handlers dispatch on Dispatchers.Default after the handshake, so await the expected count + // before asserting order. val expectedLevels = LoggingLevel.entries.filter { it >= minLevel } - receivedMessages shouldHaveSize expectedLevels.size + eventually(5.seconds) { receivedMessages shouldHaveSize expectedLevels.size } receivedMessages.map { it.params.level } shouldBe expectedLevels } @Test fun `sendLoggingMessage should send no messages when level is set to highest`(): Unit = runBlocking { - val receivedMessages = mutableListOf() + val receivedMessages = CopyOnWriteArrayList() client.setNotificationHandler(Method.Defined.NotificationsMessage) { receivedMessages.add(it) CompletableDeferred(Unit) @@ -167,6 +176,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { } client.callTool(CallToolRequest(CallToolRequestParams("test-logging-highest"))) - receivedMessages.shouldBeEmpty() + // Handlers dispatch asynchronously, so hold for a window to confirm nothing ever arrives. + continually(500.milliseconds) { receivedMessages.shouldBeEmpty() } } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt index ee8953a..581e5ae 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt @@ -9,6 +9,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotificationParams +import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest @@ -61,6 +62,7 @@ class ClientConnectionTest : AbstractServerFeaturesTest() { override fun getClientCapabilities(): ClientCapabilities = ClientCapabilities( roots = ClientCapabilities.Roots(listChanged = true), + elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), ) private val sampleRoots = listOf(Root("file:///project", "Project Root")) diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerBulkFeaturesTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerBulkFeaturesTest.kt index 1eb828f..52f6908 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerBulkFeaturesTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerBulkFeaturesTest.kt @@ -1,5 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.server +import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.collections.shouldHaveSize @@ -57,6 +58,39 @@ class ServerBulkFeaturesTest : AbstractServerFeaturesTest() { tools.map { it.name } shouldContainExactlyInAnyOrder listOf("existing", "new-a", "new-b") } + @Test + fun `addTools should throw and register nothing when a tool is already registered`() = runTest { + server.addTool("existing", "Existing") { CallToolResult(emptyList()) } + + shouldThrow { + server.addTools( + listOf( + RegisteredTool(Tool("new-a", ToolSchema())) { CallToolResult(emptyList()) }, + RegisteredTool(Tool("existing", ToolSchema())) { CallToolResult(emptyList()) }, + ), + ) + } + + val tools = client.listTools().tools + + tools shouldHaveSize 1 + tools.map { it.name } shouldContainExactlyInAnyOrder listOf("existing") + } + + @Test + fun `addTools should throw when batch contains duplicate names`() = runTest { + shouldThrow { + server.addTools( + listOf( + RegisteredTool(Tool("dup", ToolSchema())) { CallToolResult(emptyList()) }, + RegisteredTool(Tool("dup", ToolSchema())) { CallToolResult(emptyList()) }, + ), + ) + } + + client.listTools().tools.shouldBeEmpty() + } + // ── addPrompts ───────────────────────────────────────────────────────────── @Test @@ -89,6 +123,25 @@ class ServerBulkFeaturesTest : AbstractServerFeaturesTest() { prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("existing-prompt", "new-p") } + @Test + fun `addPrompts should throw and register nothing when a prompt is already registered`() = runTest { + server.addPrompt("existing-prompt", "Existing") { GetPromptResult(messages = emptyList()) } + + shouldThrow { + server.addPrompts( + listOf( + RegisteredPrompt(Prompt("new-p")) { GetPromptResult(messages = emptyList()) }, + RegisteredPrompt(Prompt("existing-prompt")) { GetPromptResult(messages = emptyList()) }, + ), + ) + } + + val prompts = client.listPrompts().prompts + + prompts shouldHaveSize 1 + prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("existing-prompt") + } + // ── addResources ─────────────────────────────────────────────────────────── @Test @@ -123,6 +176,27 @@ class ServerBulkFeaturesTest : AbstractServerFeaturesTest() { resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://existing-res", "test://new-res") } + @Test + fun `addResources should throw and register nothing when a resource is already registered`() = runTest { + server.addResource("test://existing-res", "Existing", "Existing resource") { + ReadResourceResult(emptyList()) + } + + shouldThrow { + server.addResources( + listOf( + RegisteredResource(Resource("test://new-res", "New")) { ReadResourceResult(emptyList()) }, + RegisteredResource(Resource("test://existing-res", "Dup")) { ReadResourceResult(emptyList()) }, + ), + ) + } + + val resources = client.listResources().resources + + resources shouldHaveSize 1 + resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://existing-res") + } + // ── Partial-batch remove ─────────────────────────────────────────────────── @Test diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsTest.kt index d7ae46d..44b1e42 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsTest.kt @@ -1,5 +1,8 @@ package io.modelcontextprotocol.kotlin.sdk.server +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest +import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.Method @@ -110,6 +113,26 @@ class ServerPromptsTest : AbstractServerFeaturesTest() { assertFalse(promptListChangedNotificationReceived, "No notification should be sent when prompt doesn't exist") } + @Test + fun `addPrompt should throw when prompt with same name is already registered`() = runTest { + server.addPrompt(Prompt("test-prompt", "Original Prompt", null)) { + GetPromptResult(description = "original", messages = emptyList()) + } + + val exception = assertThrows { + server.addPrompt(Prompt("test-prompt", "Duplicate Prompt", null)) { + GetPromptResult(description = "duplicate", messages = emptyList()) + } + } + exception.message.orEmpty() shouldContain "Prompt \"test-prompt\" is already registered" + + // The original registration is intact + val prompts = client.listPrompts().prompts + assertEquals(1, prompts.size, "Only the original prompt should be registered") + val result = client.getPrompt(GetPromptRequest(GetPromptRequestParams("test-prompt"))) + assertEquals("original", result.description) + } + @Test fun `removePrompt should throw when prompts capability is not supported`() = runTest { var promptListChangedNotificationReceived = false diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt index c8ace19..8405469 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt @@ -5,6 +5,7 @@ import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.maps.shouldContainKey import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesRequest import io.modelcontextprotocol.kotlin.sdk.types.McpException @@ -162,6 +163,24 @@ class ServerResourceTemplateTest : AbstractServerFeaturesTest() { removed shouldBe false } + @Test + fun `addResourceTemplate should throw when template with same uriTemplate is already registered`() { + server.addResourceTemplate("test://items/{id}", "Item") { _, _ -> + ReadResourceResult(emptyList()) + } + + val exception = assertThrows { + server.addResourceTemplate("test://items/{id}", "Duplicate Item") { _, _ -> + ReadResourceResult(emptyList()) + } + } + exception.message.orEmpty() shouldContain "ResourceTemplate \"test://items/{id}\" is already registered" + + // The original registration is intact + server.resourceTemplates shouldHaveSize 1 + server.resourceTemplates[0].name shouldBe "Item" + } + @Test fun `addResourceTemplate should throw when resources capability is not supported`() { val noResourcesServer = Server( diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesTest.kt index fafc3c8..a830788 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesTest.kt @@ -1,7 +1,10 @@ package io.modelcontextprotocol.kotlin.sdk.server +import io.kotest.matchers.string.shouldContain import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest +import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities @@ -115,6 +118,24 @@ class ServerResourcesTest : AbstractServerFeaturesTest() { ) } + @Test + fun `addResource should throw when resource with same uri is already registered`() = runTest { + server.addResource("test://resource", "Original", "Original resource") { + ReadResourceResult(listOf(TextResourceContents(text = "original", uri = "test://resource"))) + } + + val exception = assertThrows { + server.addResource("test://resource", "Duplicate", "Duplicate resource") { + ReadResourceResult(listOf(TextResourceContents(text = "duplicate", uri = "test://resource"))) + } + } + exception.message.orEmpty() shouldContain "Resource \"test://resource\" is already registered" + + // The original registration is intact + val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://resource"))) + assertEquals("original", (result.contents.single() as TextResourceContents).text) + } + @Test fun `removeResource should throw when resources capability is not supported`() = runTest { // Create server without resources capability diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt index 5031a9c..38b169a 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt @@ -7,9 +7,11 @@ import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.RequestId import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.toJSON import kotlinx.coroutines.CompletableDeferred @@ -18,9 +20,13 @@ import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject import org.junit.jupiter.api.Test import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -41,6 +47,18 @@ class ServerSessionInitializeTest { ), ) + private fun createMalformedInitializeRequest(): JSONRPCRequest = JSONRPCRequest( + id = RequestId(1), + method = "initialize", + params = buildJsonObject { + putJsonObject("capabilities") {} + putJsonObject("clientInfo") { + put("name", "repro") + put("version", "0.1.0") + } + }, + ) + @Test fun `should handle first initialize request successfully`() = runTest { val session = createSession() @@ -65,6 +83,30 @@ class ServerSessionInitializeTest { assertEquals("test-client", session.clientVersion?.name) } + // Both initialize requests arrive before notifications/initialized, in the serial dispatch + // phase, so processing and response order stay deterministic. + @Test + fun `should classify malformed initialize params as invalid params`() = runTest { + val session = createSession() + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + val responseDone = CompletableDeferred() + clientTransport.onMessage { message -> + if (message is JSONRPCError) { + responseDone.complete(message) + } + } + + session.connect(serverTransport) + clientTransport.send(createMalformedInitializeRequest()) + + val response = responseDone.await() + assertEquals(RPCError.ErrorCode.INVALID_PARAMS, response.error.code) + assertFalse(response.error.message.contains("kotlinx.serialization")) + assertNull(session.clientCapabilities) + assertNull(session.clientVersion) + } + @Test fun `should reject duplicate initialize request`() = runTest { val session = createSession() diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsNotificationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsNotificationTest.kt index 3572082..9ac744b 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsNotificationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsNotificationTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.test.runTest import org.awaitility.kotlin.await import org.awaitility.kotlin.untilAsserted import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -81,6 +82,27 @@ class ServerToolsNotificationTest : AbstractServerFeaturesTest() { } } + @Test + fun `addTool should not send notification when duplicate is rejected`() = runTest { + // Track notifications + val notifications = mutableListOf() + client.setNotificationHandler( + Method.Defined.NotificationsToolsListChanged, + ) { notification -> + notifications.add(notification) + CompletableDeferred(Unit) + } + + server.addTool("test-tool", "Test Tool") { CallToolResult(emptyList()) } + assertThrows { + server.addTool("test-tool", "Duplicate Tool") { CallToolResult(emptyList()) } + } + // Close the server to stop processing further events and flush notifications + server.close() + + assertEquals(1, notifications.size, "Only the first registration should send a notification") + } + @Test fun `notification should not be send when removed tool does not exists`() = runTest { // Track notifications diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt index 54c998e..1b3df91 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt @@ -1,5 +1,8 @@ package io.modelcontextprotocol.kotlin.sdk.server +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.Method @@ -108,6 +111,42 @@ class ServerToolsTest : AbstractServerFeaturesTest() { assertTrue(result, "Tool with non-conforming name should have been registered and removable") } + @Test + fun `addTool should throw when tool with same name is already registered`() = runTest { + server.addTool("test-tool", "Original Tool") { + CallToolResult(listOf(TextContent("Original result"))) + } + + val exception = assertThrows { + server.addTool("test-tool", "Duplicate Tool") { + CallToolResult(listOf(TextContent("Duplicate result"))) + } + } + exception.message.orEmpty() shouldContain "Tool \"test-tool\" is already registered" + + // The original registration is intact + val tools = client.listTools().tools + assertEquals(1, tools.size, "Only the original tool should be registered") + val result = client.callTool(CallToolRequest(CallToolRequestParams("test-tool"))) + assertEquals("Original result", (result.content.single() as TextContent).text) + } + + @Test + fun `addTool should succeed after removing existing tool`() = runTest { + server.addTool("test-tool", "Original Tool") { + CallToolResult(listOf(TextContent("Original result"))) + } + + server.removeTool("test-tool") + server.addTool("test-tool", "Replacement Tool") { + CallToolResult(listOf(TextContent("Replacement result"))) + } + + assertEquals(1, client.listTools().tools.size, "Re-adding after removal should not duplicate the tool") + val result = client.callTool(CallToolRequest(CallToolRequestParams("test-tool"))) + assertEquals("Replacement result", (result.content.single() as TextContent).text) + } + @Test fun `removeTool should throw when tools capability is not supported`() = runTest { var toolListChangedNotificationReceived = false diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt index ea246db..4d798ce 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt @@ -6,7 +6,6 @@ import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions -import io.modelcontextprotocol.kotlin.sdk.server.ServerSession import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult @@ -16,9 +15,6 @@ import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult import io.modelcontextprotocol.kotlin.sdk.types.Method import io.modelcontextprotocol.kotlin.sdk.types.Resource import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlin.test.Test @@ -43,18 +39,9 @@ class ChannelTransportTest { clientInfo = Implementation(name = "test client", version = "1.0"), ) - val serverSessionResult = CompletableDeferred() + val serverSession = server.createSession(serverTransport) + client.connect(clientTransport) - listOf( - launch { - client.connect(clientTransport) - }, - launch { - serverSessionResult.complete(server.createSession(serverTransport)) - }, - ).joinAll() - - val serverSession = serverSessionResult.await() serverSession.setRequestHandler(Method.Defined.Initialize) { _, _ -> InitializeResult( protocolVersion = LATEST_PROTOCOL_VERSION, diff --git a/integration-test/src/jvmTest/typescript/package-lock.json b/integration-test/src/jvmTest/typescript/package-lock.json index 34e6bf4..ffb53fc 100644 --- a/integration-test/src/jvmTest/typescript/package-lock.json +++ b/integration-test/src/jvmTest/typescript/package-lock.json @@ -8,23 +8,23 @@ "name": "mcp-typescript-test-sdk", "version": "1.0.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.30.0", "express": "^5.2.1", - "zod": "^4.3.6" + "zod": "^4.6.2" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^26.5.1", "cors": "^2.8.6", - "tsx": "^4.21.0", - "typescript": "^6.0.3" + "tsx": "^4.23.13", + "typescript": "^7.0.2" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -39,9 +39,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -56,9 +56,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -73,9 +73,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -90,9 +90,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -107,9 +107,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -124,9 +124,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -141,9 +141,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -158,9 +158,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -175,9 +175,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -192,9 +192,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -209,9 +209,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -226,9 +226,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -243,9 +243,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -260,9 +260,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -277,9 +277,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -294,9 +294,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -311,9 +311,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -328,9 +328,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -345,9 +345,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -362,9 +362,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -379,9 +379,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -396,9 +396,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -413,9 +413,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -430,9 +430,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -447,9 +447,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -464,24 +464,24 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -579,13 +579,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/qs": { @@ -623,6 +623,346 @@ "@types/node": "*" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -670,20 +1010,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -693,6 +1033,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -876,9 +1229,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -888,9 +1241,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -901,32 +1254,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escape-html": { @@ -1009,12 +1362,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -1033,9 +1387,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -1147,19 +1501,6 @@ "node": ">= 0.4" } }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1185,9 +1526,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1197,9 +1538,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1248,9 +1589,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -1461,12 +1802,13 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -1508,16 +1850,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1613,14 +1945,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1632,13 +1964,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1703,14 +2035,13 @@ } }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -1723,37 +2054,75 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" }, @@ -1797,9 +2166,9 @@ "license": "ISC" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz", + "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/integration-test/src/jvmTest/typescript/package.json b/integration-test/src/jvmTest/typescript/package.json index 030c79c..b0147e4 100644 --- a/integration-test/src/jvmTest/typescript/package.json +++ b/integration-test/src/jvmTest/typescript/package.json @@ -9,16 +9,16 @@ "server:http": "tsx server/http-server.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.30.0", "express": "^5.2.1", - "zod": "^4.3.6" + "zod": "^4.6.2" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^26.5.1", "cors": "^2.8.6", - "tsx": "^4.21.0", - "typescript": "^6.0.3" + "tsx": "^4.23.13", + "typescript": "^7.0.2" } } diff --git a/kotlin-sdk-client/api/kotlin-sdk-client.api b/kotlin-sdk-client/api/kotlin-sdk-client.api index 8b6fb79..d856f5b 100644 --- a/kotlin-sdk-client/api/kotlin-sdk-client.api +++ b/kotlin-sdk-client/api/kotlin-sdk-client.api @@ -34,6 +34,7 @@ public class io/modelcontextprotocol/kotlin/sdk/client/Client : io/modelcontextp public final fun removeRoot (Ljava/lang/String;)Z public final fun removeRoots (Ljava/util/List;)I public final fun sendRootsListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun setElicitationCompleteHandler (Lkotlin/jvm/functions/Function1;)V public final fun setElicitationHandler (Lkotlin/jvm/functions/Function1;)V public final fun setLoggingLevel (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun setLoggingLevel$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; @@ -51,8 +52,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/client/ClientKt { public final class io/modelcontextprotocol/kotlin/sdk/client/ClientOptions : io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions { public fun ()V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;Z)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZLkotlin/coroutines/CoroutineContext;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZLkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; } @@ -101,8 +102,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/client/StdioClientTranspor } public final class io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport { - public fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;ILkotlin/jvm/functions/Function1;)V + public synthetic fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;ILkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getProtocolVersion ()Ljava/lang/String; diff --git a/kotlin-sdk-client/build.gradle.kts b/kotlin-sdk-client/build.gradle.kts index ae7a314..fbb1300 100644 --- a/kotlin-sdk-client/build.gradle.kts +++ b/kotlin-sdk-client/build.gradle.kts @@ -55,7 +55,6 @@ kotlin { implementation(libs.ktor.client.apache5) implementation(libs.mockk) implementation(libs.junit.jupiter.params) - implementation(libs.mokksy) implementation(dependencies.platform(libs.netty.bom)) runtimeOnly(libs.slf4j.simple) } diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt index d71c091..3352104 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt @@ -19,6 +19,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequest import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult +import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult @@ -63,6 +64,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.supportsUrl import io.modelcontextprotocol.kotlin.sdk.types.toJson import kotlinx.atomicfu.atomic import kotlinx.atomicfu.getAndUpdate @@ -70,11 +72,14 @@ import kotlinx.atomicfu.update import kotlinx.collections.immutable.minus import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray +import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException private val logger = KotlinLogging.logger {} @@ -84,11 +89,16 @@ private val logger = KotlinLogging.logger {} * * @property capabilities The capabilities this client supports. * @param enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with the server. + * @param handlerCoroutineContext Coroutine context for inbound handlers. See [ProtocolOptions.handlerCoroutineContext]. */ public class ClientOptions( public val capabilities: ClientCapabilities = ClientCapabilities(), enforceStrictCapabilities: Boolean = true, -) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) + handlerCoroutineContext: CoroutineContext = Dispatchers.Default, +) : ProtocolOptions( + enforceStrictCapabilities = enforceStrictCapabilities, + handlerCoroutineContext = handlerCoroutineContext, +) /** * Initializes and connects an MCP client using the provided clientInfo [Implementation], client options, @@ -174,6 +184,7 @@ public open class Client(private val clientInfo: Implementation, options: Client "prompts" -> caps?.prompts != null "resources" -> caps?.resources != null "tools" -> caps?.tools != null + "tasks" -> caps?.tasks != null else -> true } @@ -212,6 +223,7 @@ public open class Client(private val clientInfo: Implementation, options: Client serverInstructions = result.instructions notification(InitializedNotification()) + enableConcurrentDispatch() } catch (error: Throwable) { logger.error(error) { "Failed to initialize client: ${error.message}" } close() @@ -238,13 +250,18 @@ public open class Client(private val clientInfo: Implementation, options: Client Method.Defined.PromptsGet, Method.Defined.PromptsList, - Method.Defined.CompletionComplete, -> { checkNotNull(serverCapabilities?.prompts) { "Server does not support prompts (required for $method)" } } + Method.Defined.CompletionComplete -> { + checkNotNull(serverCapabilities?.completions) { + "Server does not support completions (required for $method)" + } + } + Method.Defined.ResourcesList, Method.Defined.ResourcesTemplatesList, Method.Defined.ResourcesRead, @@ -267,6 +284,12 @@ public open class Client(private val clientInfo: Implementation, options: Client } } + Method.Defined.TasksGet, + Method.Defined.TasksResult, + Method.Defined.TasksList, + Method.Defined.TasksCancel, + -> assertTasksCapabilityForMethod(method) + Method.Defined.Initialize, Method.Defined.Ping -> { // No specific capability required } @@ -277,6 +300,24 @@ public open class Client(private val clientInfo: Implementation, options: Client } } + private fun assertTasksCapabilityForMethod(method: Method) { + val tasks = serverCapabilities?.tasks + ?: error("Server does not support tasks (required for $method)") + when (method) { + Method.Defined.TasksList -> checkNotNull(tasks.list) { + "Server does not support listing tasks (required for $method)" + } + + Method.Defined.TasksCancel -> checkNotNull(tasks.cancel) { + "Server does not support cancelling tasks (required for $method)" + } + + else -> { + // TasksGet, TasksResult: base tasks capability suffices. + } + } + } + override fun assertNotificationCapability(method: Method) { when (method) { Method.Defined.NotificationsRootsListChanged -> { @@ -285,6 +326,12 @@ public open class Client(private val clientInfo: Implementation, options: Client } } + Method.Defined.NotificationsTasksStatus -> { + checkNotNull(capabilities.tasks) { + "Client does not support tasks (required for $method)" + } + } + Method.Defined.NotificationsInitialized, Method.Defined.NotificationsCancelled, Method.Defined.NotificationsProgress, @@ -360,7 +407,7 @@ public open class Client(private val clientInfo: Implementation, options: Client * @param params The completion request parameters. * @param options Optional request options. * @return The completion result returned by the server, or `null` if none. - * @throws IllegalStateException If the server does not support prompts or completion. + * @throws IllegalStateException If the server does not support completions. */ public suspend fun complete(params: CompleteRequest, options: RequestOptions? = null): CompleteResult = request(params, options) @@ -612,9 +659,18 @@ public open class Client(private val clientInfo: Implementation, options: Client /** * Sets the elicitation handler. * - * When the handler returns [ElicitResult.Action.Accept], any properties missing from + * The handler receives both form-mode ([ElicitRequestFormParams]) and URL-mode + * ([io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams]) requests; + * branch on `request.params` to tell them apart. For URL mode, + * the host application must obtain explicit user consent and display the target domain before + * navigating — the SDK never opens or validates the URL — and should return + * [ElicitResult.Action.Decline] or [ElicitResult.Action.Cancel] when it cannot or will not proceed. + * A URL-mode [ElicitResult.Action.Accept] only signals consent; the outcome arrives out-of-band via + * [setElicitationCompleteHandler]. + * + * When a form-mode handler returns [ElicitResult.Action.Accept], any properties missing from * [ElicitResult.content] are automatically populated with default values defined in the - * elicitation schema. + * elicitation schema. URL-mode responses carry no content. * * @param handler The elicitation handler. * @throws IllegalStateException if the client does not support elicitation. @@ -632,6 +688,35 @@ public open class Client(private val clientInfo: Implementation, options: Client } } + /** + * Sets the handler invoked when the server reports that a URL-mode elicitation has completed. + * + * The handler is called for every `notifications/elicitation/complete` notification. Because the + * server only sends this for an out-of-band (URL-mode) interaction, the client must support url-mode + * elicitation. The client is responsible for correlating the notification's `elicitationId` with a + * pending elicitation, ignoring unknown or already-completed identifiers, and providing a manual way + * to continue if a notification never arrives. + * + * @param handler Invoked with each completion notification. + * @throws IllegalStateException if the client does not support url-mode elicitation. + */ + public fun setElicitationCompleteHandler(handler: (ElicitationCompleteNotification) -> Unit) { + check(capabilities.elicitation.supportsUrl) { + logger.error { + "Failed to set elicitation-complete handler: client does not support url-mode elicitation" + } + "Client does not support url-mode elicitation." + } + logger.info { "Setting the elicitation-complete handler" } + + setNotificationHandler( + Method.Defined.NotificationsElicitationComplete, + ) { notification -> + handler(notification) + CompletableDeferred(Unit) + } + } + // --- Internal Handlers --- private fun applyElicitationDefaults(request: ElicitRequest, result: ElicitResult): ElicitResult { diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt index a48db60..0295eb9 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt @@ -136,6 +136,7 @@ public class SseClientTransport( throw e } finally { closeResources() + invokeOnCloseCallback() } } diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt index 63f681c..5e990ee 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt @@ -244,10 +244,20 @@ public class StdioClientTransport @JvmOverloads public constructor( override suspend fun closeResources() { withContext(NonCancellable) { scope.stopProcessing("Closed") + closeReadSources() scope.coroutineContext[Job]?.join() // Wait for all coroutines to complete } } + private fun closeReadSources() { + runCatching { input.close() } + .onFailure { logger.debug(it) { "Error closing stdin source" } } + error?.let { source -> + runCatching { source.close() } + .onFailure { logger.debug(it) { "Error closing stderr source" } } + } + } + private fun sendOutboundMessage(message: JSONRPCMessage, sink: Sink, mainScope: CoroutineScope) { try { val json = serializeMessage(message) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt index c27ed79..6df352a 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt @@ -21,8 +21,10 @@ import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.http.isSuccess +import io.ktor.utils.io.charsets.TooLongLineException import io.ktor.utils.io.readUTF8Line import io.modelcontextprotocol.kotlin.sdk.shared.AbstractClientTransport +import io.modelcontextprotocol.kotlin.sdk.shared.TooLongFrameException import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification @@ -41,6 +43,10 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.pow import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -49,6 +55,18 @@ import kotlin.time.Duration.Companion.seconds private const val MCP_SESSION_ID_HEADER = "mcp-session-id" private const val MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version" private const val MCP_RESUMPTION_TOKEN_HEADER = "Last-Event-ID" +private const val MCP_METHOD_HEADER = "Mcp-Method" +private const val MCP_NAME_HEADER = "Mcp-Name" +private const val MCP_BASE64_PREFIX = "=?base64?" +private const val MCP_BASE64_SUFFIX = "?=" + +/** + * Default maximum size, in characters, of a single inline SSE event assembled from a POST response. + * + * Mirrors the stdio transport's 16 MiB frame cap: a server that streams `data:` lines without ever + * terminating the event cannot grow the client's buffer without bound. + */ +private const val DEFAULT_MAX_INLINE_SSE_EVENT_SIZE: Int = 16 * 1024 * 1024 /** * Represents an error from the Streamable HTTP transport. @@ -75,15 +93,23 @@ private sealed interface ConnectResult { * @param client Ktor HTTP client used for all requests * @param url MCP endpoint URL * @param reconnectionOptions reconnection backoff and retry-limit settings for the SSE stream + * @param maxInlineSseEventSize maximum size, in characters, of a single inline SSE event parsed from a + * POST response; a server that exceeds it (including by never terminating an event) fails the send + * with [io.modelcontextprotocol.kotlin.sdk.shared.TooLongFrameException]. Defaults to 16 MiB. * @param requestBuilder builder applied to every outgoing HTTP request, e.g. for adding auth headers */ public class StreamableHttpClientTransport( private val client: HttpClient, private val url: String, private val reconnectionOptions: ReconnectionOptions = ReconnectionOptions(), + private val maxInlineSseEventSize: Int = DEFAULT_MAX_INLINE_SSE_EVENT_SIZE, private val requestBuilder: HttpRequestBuilder.() -> Unit = {}, ) : AbstractClientTransport() { + init { + require(maxInlineSseEventSize > 0) { "maxInlineSseEventSize must be greater than 0" } + } + @Deprecated( "Use constructor with ReconnectionOptions", replaceWith = ReplaceWith( @@ -98,7 +124,12 @@ public class StreamableHttpClientTransport( url: String, reconnectionTime: Duration?, requestBuilder: HttpRequestBuilder.() -> Unit = {}, - ) : this(client, url, ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), requestBuilder) + ) : this( + client, + url, + ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), + requestBuilder = requestBuilder, + ) override val logger: KLogger = KotlinLogging.logger {} @@ -144,6 +175,7 @@ public class StreamableHttpClientTransport( val jsonBody = McpJson.encodeToString(message) val response = client.post(url) { applyCommonHeaders(this) + applyStandardPostHeaders(this, message) headers.append(HttpHeaders.Accept, "${ContentType.Application.Json}, ${ContentType.Text.EventStream}") contentType(ContentType.Application.Json) setBody(jsonBody) @@ -367,6 +399,36 @@ public class StreamableHttpClientTransport( } } + private fun applyStandardPostHeaders(builder: HttpRequestBuilder, message: JSONRPCMessage) { + val (method, params) = when (message) { + is JSONRPCRequest -> message.method to message.params + is JSONRPCNotification -> message.method to message.params + else -> return + } + + builder.headers { + append(MCP_METHOD_HEADER, method) + + val paramsObject = params as? JsonObject ?: return@headers + val mcpName = paramsObject.stringValue("name") ?: paramsObject.stringValue("uri") + mcpName?.let { append(MCP_NAME_HEADER, it.encodeMcpHeaderValue()) } + } + } + + private fun JsonObject.stringValue(key: String): String? = + (get(key) as? JsonPrimitive)?.takeIf { it.isString }?.content + + @OptIn(ExperimentalEncodingApi::class) + private fun String.encodeMcpHeaderValue(): String { + val containsUnsafeCharacters = any { it != '\t' && it.code !in 0x20..0x7e } + val hasEdgeWhitespace = firstOrNull()?.isWhitespace() == true || lastOrNull()?.isWhitespace() == true + val matchesBase64Sentinel = startsWith(MCP_BASE64_PREFIX) && endsWith(MCP_BASE64_SUFFIX) + + if (!containsUnsafeCharacters && !hasEdgeWhitespace && !matchesBase64Sentinel) return this + + return "$MCP_BASE64_PREFIX${Base64.Default.encode(encodeToByteArray())}$MCP_BASE64_SUFFIX" + } + private suspend fun collectSse( session: ClientSSESession, replayMessageId: RequestId?, @@ -458,7 +520,14 @@ public class StreamableHttpClientTransport( } while (!channel.isClosedForRead) { - val line = channel.readUTF8Line() ?: break + // Bound each line so a server that streams a line without ever terminating it cannot + // exhaust client memory; readUTF8Line returns null at the end of the stream. + val line = try { + channel.readUTF8Line(maxInlineSseEventSize) + } catch (_: TooLongLineException) { + throw TooLongFrameException(maxInlineSseEventSize.toLong() + 1, maxInlineSseEventSize) + } + if (line == null) break if (line.isEmpty()) { dispatch(id = id, eventName = eventName, data = sb.toString()) // reset @@ -472,7 +541,13 @@ public class StreamableHttpClientTransport( line.startsWith("event:") -> eventName = line.substringAfter("event:").trim() - line.startsWith("data:") -> sb.append(line.substringAfter("data:").trim()) + line.startsWith("data:") -> { + sb.append(line.substringAfter("data:").trim()) + // Cap an event assembled from many data: lines that never sees a terminating blank line. + if (sb.length > maxInlineSseEventSize) { + throw TooLongFrameException(sb.length.toLong(), maxInlineSseEventSize) + } + } line.startsWith("retry:") -> line.substringAfter("retry:").trim().toLongOrNull()?.let { localServerRetryDelay = it.milliseconds diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientAssertCapabilityTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientAssertCapabilityTest.kt new file mode 100644 index 0000000..e9379a7 --- /dev/null +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientAssertCapabilityTest.kt @@ -0,0 +1,171 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.shared.Transport +import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFailsWith + +/** + * Tests for the protected helper [Client.assertCapability]. + * + * `assertCapability(capability, method)` is `protected`, so this suite uses + * a small subclass [TestClient] that re-exports it via [TestClient.exposedAssertCapability]. + * Server capabilities are seeded by completing the initialization handshake through + * [CapabilitiesTransport], which replays a configurable [ServerCapabilities] in its + * `initialize` response. + */ +class ClientAssertCapabilityTest { + + @Test + fun `assertCapability tasks throws when server has no tasks capability`() = runTest { + val client = newTestClient(serverCapabilities = ServerCapabilities()) + val ex = assertFailsWith { + client.exposedAssertCapability("tasks", "tasks/list") + } + ex.message.orEmpty() shouldContain "Server does not support tasks" + } + + @Test + fun `TasksGet throws when server has no tasks capability`() = runTest { + val client = newTestClient(serverCapabilities = ServerCapabilities()) + val ex = assertFailsWith { + client.exposedAssertCapabilityForMethod(Method.Defined.TasksGet) + } + ex.message.orEmpty() shouldContain "Server does not support tasks" + } + + @Test + fun `TasksGet does not throw when server declared tasks`() = runTest { + val client = newTestClient( + serverCapabilities = ServerCapabilities(tasks = ServerCapabilities.Tasks()), + ) + client.exposedAssertCapabilityForMethod(Method.Defined.TasksGet) + } + + @Test + fun `TasksList throws when server tasks list is null`() = runTest { + val client = newTestClient( + serverCapabilities = ServerCapabilities(tasks = ServerCapabilities.Tasks()), + ) + val ex = assertFailsWith { + client.exposedAssertCapabilityForMethod(Method.Defined.TasksList) + } + ex.message.orEmpty() shouldContain "Server does not support listing tasks" + } + + @Test + fun `TasksCancel throws when server tasks cancel is null`() = runTest { + val client = newTestClient( + serverCapabilities = ServerCapabilities(tasks = ServerCapabilities.Tasks()), + ) + val ex = assertFailsWith { + client.exposedAssertCapabilityForMethod(Method.Defined.TasksCancel) + } + ex.message.orEmpty() shouldContain "Server does not support cancelling tasks" + } + + @Test + fun `NotificationsTasksStatus throws when client has no tasks capability`() = runTest { + val client = newTestClient(clientCapabilities = ClientCapabilities()) + val ex = assertFailsWith { + client.exposedAssertNotificationCapability(Method.Defined.NotificationsTasksStatus) + } + ex.message.orEmpty() shouldContain "Client does not support tasks" + } + + @Test + fun `CompletionComplete does not throw when server declared completions`() = runTest { + val client = newTestClient( + serverCapabilities = ServerCapabilities(completions = ServerCapabilities.Completions), + ) + client.exposedAssertCapabilityForMethod(Method.Defined.CompletionComplete) + } + + @Test + fun `CompletionComplete throws when server has no completions capability`() = runTest { + val client = newTestClient( + serverCapabilities = ServerCapabilities(prompts = ServerCapabilities.Prompts()), + ) + val ex = assertFailsWith { + client.exposedAssertCapabilityForMethod(Method.Defined.CompletionComplete) + } + ex.message.orEmpty() shouldContain "Server does not support completions" + } + + private suspend fun newTestClient( + serverCapabilities: ServerCapabilities = ServerCapabilities(), + clientCapabilities: ClientCapabilities = ClientCapabilities(), + ): TestClient { + val client = TestClient( + Implementation("test-client", "1.0.0"), + ClientOptions(capabilities = clientCapabilities), + ) + val transport = CapabilitiesTransport(serverCapabilities) + client.connect(transport) + return client + } + + /** + * Test-only [Client] subclass that exposes the protected + * [Client.assertCapability], [Client.assertCapabilityForMethod], and + * [Client.assertNotificationCapability] helpers to the test code. + */ + private class TestClient(clientInfo: Implementation, options: ClientOptions = ClientOptions()) : + Client(clientInfo, options) { + fun exposedAssertCapability(capability: String, method: String): Unit = assertCapability(capability, method) + fun exposedAssertCapabilityForMethod(method: Method): Unit = assertCapabilityForMethod(method) + fun exposedAssertNotificationCapability(method: Method): Unit = assertNotificationCapability(method) + } + + /** + * Minimal in-memory [Transport] that responds to `initialize` with a configurable + * [ServerCapabilities]. Other JSON-RPC traffic (e.g. the `notifications/initialized` + * sent by [Client.connect]) is silently consumed. + */ + private class CapabilitiesTransport(private val serverCapabilities: ServerCapabilities) : Transport { + private var onMessageBlock: (suspend (JSONRPCMessage) -> Unit)? = null + private var onCloseBlock: (() -> Unit)? = null + + override suspend fun start() = Unit + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + if (message is JSONRPCRequest && message.method == "initialize") { + onMessageBlock?.invoke( + JSONRPCResponse( + id = message.id, + result = InitializeResult( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = serverCapabilities, + serverInfo = Implementation("mock-server", "1.0.0"), + ), + ), + ) + } + } + + override suspend fun close() { + onCloseBlock?.invoke() + } + + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { + onMessageBlock = block + } + + override fun onClose(block: () -> Unit) { + onCloseBlock = block + } + + override fun onError(block: (Throwable) -> Unit) = Unit + } +} diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/MockSseClientEngine.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/MockSseClientEngine.kt index 3627b6c..14af546 100644 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/MockSseClientEngine.kt +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/MockSseClientEngine.kt @@ -53,6 +53,14 @@ internal open class MockSseClientEngine( job.cancel() } + /** + * Closes the SSE byte channel without disposing the engine, simulating a server-side + * disconnect of the SSE stream. + */ + fun disconnectSseStream() { + channel.close() + } + private fun responseContext(): CoroutineContext = dispatcher + Job() private suspend fun respondWithSseEndpointEvent(data: HttpRequestData): HttpResponseData { diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt index 6cc7f6e..319ba2a 100644 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt @@ -1,5 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.client.sse +import io.kotest.assertions.nondeterministic.eventually import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.ktor.client.HttpClient @@ -9,6 +10,7 @@ import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds class SseClientTransportTest { @@ -90,6 +92,31 @@ class SseClientTransportTest { engine.close() } + @Test + fun `onClose callback fires when the SSE stream is disconnected by the server`() = runTest { + // Given + val sseUrl = "http://example.com/api/mcp/sse" + + // And + val engine = CapturingSseClientEngine(endpoint = "/messages?sessionId=abc") + val transport = sseTransport(sseUrl, engine) + var onCloseFired = false + transport.onClose { onCloseFired = true } + + // When + transport.start() + engine.disconnectSseStream() + + // Then + eventually(2.seconds) { + onCloseFired shouldBe true + } + + // Cleanup + transport.close() + engine.close() + } + private fun sseTransport(sseUrl: String, engine: CapturingSseClientEngine) = SseClientTransport(HttpClient(engine) { install(SSE) }, sseUrl) diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/streamable/http/StreamableHttpClientTransportTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/streamable/http/StreamableHttpClientTransportTest.kt index 8810552..d9aaf7a 100644 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/streamable/http/StreamableHttpClientTransportTest.kt +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/streamable/http/StreamableHttpClientTransportTest.kt @@ -19,6 +19,7 @@ import io.ktor.http.headersOf import io.ktor.utils.io.ByteReadChannel import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport +import io.modelcontextprotocol.kotlin.sdk.shared.TooLongFrameException import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification @@ -43,11 +44,15 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class StreamableHttpClientTransportTest { - private fun createTransport(handler: MockRequestHandler): StreamableHttpClientTransport { + private fun createTransport( + maxInlineSseEventSize: Int = 16 * 1024 * 1024, + handler: MockRequestHandler, + ): StreamableHttpClientTransport { val mockEngine = MockEngine(handler) val httpClient = HttpClient(mockEngine) { install(SSE) { @@ -55,7 +60,11 @@ class StreamableHttpClientTransportTest { } } - return StreamableHttpClientTransport(httpClient, url = "http://localhost:8080/mcp") + return StreamableHttpClientTransport( + httpClient, + url = "http://localhost:8080/mcp", + maxInlineSseEventSize = maxInlineSseEventSize, + ) } private fun buildSseMessage(id: String, method: String, params: String): String = buildString { @@ -237,6 +246,100 @@ class StreamableHttpClientTransportTest { transport.close() } + @Test + fun `should send MCP method and name headers from name`() = runTest { + val transport = createTransport { request -> + assertEquals("tools/call", request.headers["Mcp-Method"]) + assertEquals("read_file", request.headers["Mcp-Name"]) + respond(content = "", status = HttpStatusCode.Accepted) + } + + transport.start() + transport.send( + JSONRPCRequest( + id = "test-id", + method = "tools/call", + params = buildJsonObject { + put("name", JsonPrimitive("read_file")) + put("arguments", buildJsonObject { put("path", JsonPrimitive("README.md")) }) + }, + ), + ) + transport.close() + } + + @Test + fun `should send MCP name header from URI`() = runTest { + val transport = createTransport { request -> + assertEquals("resources/read", request.headers["Mcp-Method"]) + assertEquals("file:///README.md", request.headers["Mcp-Name"]) + respond(content = "", status = HttpStatusCode.Accepted) + } + + transport.start() + transport.send( + JSONRPCRequest( + id = "test-id", + method = "resources/read", + params = buildJsonObject { put("uri", JsonPrimitive("file:///README.md")) }, + ), + ) + transport.close() + } + + @Test + fun `should encode MCP name header values when required`() = runTest { + val cases = listOf( + "Hello, 世界" to "=?base64?SGVsbG8sIOS4lueVjA==?=", + " padded " to "=?base64?IHBhZGRlZCA=?=", + "line1\nline2" to "=?base64?bGluZTEKbGluZTI=?=", + "=?base64?literal?=" to "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=", + ) + + cases.forEachIndexed { index, (name, expectedHeader) -> + val transport = createTransport { request -> + assertEquals(expectedHeader, request.headers["Mcp-Name"]) + respond(content = "", status = HttpStatusCode.Accepted) + } + + transport.start() + transport.send( + JSONRPCRequest( + id = "test-id-$index", + method = "tools/call", + params = buildJsonObject { put("name", JsonPrimitive(name)) }, + ), + ) + transport.close() + } + } + + @Test + fun `should send MCP method header for notifications`() = runTest { + val transport = createTransport { request -> + assertEquals("notifications/tools/list_changed", request.headers["Mcp-Method"]) + assertNull(request.headers["Mcp-Name"]) + respond(content = "", status = HttpStatusCode.Accepted) + } + + transport.start() + transport.send(JSONRPCNotification(method = "notifications/tools/list_changed")) + transport.close() + } + + @Test + fun `should omit MCP standard headers for responses`() = runTest { + val transport = createTransport { request -> + assertNull(request.headers["Mcp-Method"]) + assertNull(request.headers["Mcp-Name"]) + respond(content = "", status = HttpStatusCode.Accepted) + } + + transport.start() + transport.send(JSONRPCResponse(id = RequestId.StringId("test-id"))) + transport.close() + } + // Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support @Ignore @Test @@ -478,6 +581,170 @@ class StreamableHttpClientTransportTest { } } + @Test + fun testSkipEmptySSE() = runTest { + val transport = createTransport { request -> + if (request.method == HttpMethod.Post) { + val sseContent = buildString { + // Event 1: empty data — must be skipped without error + appendLine("id: a") + appendLine("data:") + appendLine() + // Event 2: whitespace-only data — must be skipped without error + appendLine("id: b") + appendLine("data: \t ") + appendLine() + // Event 3: multi-line data lines that together form a valid JSON-RPC response + appendLine("id: c") + appendLine("event: message") + appendLine("data: {") + appendLine("""data: "jsonrpc":"2.0",""") + appendLine("""data: "id":"test-1",""") + appendLine("""data: "result":{""") + appendLine("""data: "protocolVersion":"2025-06-18",""") + appendLine("""data: "capabilities":{},""") + appendLine("""data: "serverInfo":{"name":"server","version":"1.0.0"}""") + appendLine("data: }") + appendLine("data: }") + appendLine() + } + + respond( + content = ByteReadChannel(sseContent), + status = HttpStatusCode.OK, + headers = headersOf( + HttpHeaders.ContentType, + ContentType.Text.EventStream.toString(), + ), + ) + } else { + respond("", HttpStatusCode.OK) + } + } + + val receivedMessages = mutableListOf() + val messageReceived = CompletableDeferred() + + transport.onMessage { message -> + receivedMessages.add(message) + if (!messageReceived.isCompleted) { + messageReceived.complete(Unit) + } + } + + transport.start() + + transport.send( + JSONRPCRequest( + id = "test-1", + method = "initialize", + params = buildJsonObject { }, + ), + ) + + eventually { + messageReceived.await() + } + + receivedMessages shouldHaveSize 1 + val response = receivedMessages[0] + response.shouldBeInstanceOf() + response.id shouldBe RequestId.StringId("test-1") + + transport.close() + } + + @Test + fun testInlineSseRejectsEventExceedingMaxSize() = runTest { + // A malicious server streams an endless single event: many `data:` lines that accumulate + // past the cap and never send the blank-line terminator that would flush the buffer. + val maxInlineSseEventSize = 64 + val transport = createTransport(maxInlineSseEventSize) { request -> + if (request.method == HttpMethod.Post) { + val sseContent = buildString { + appendLine("event: message") + // 20 lines × 16 chars = 320 chars of accumulated data, no terminating blank line. + repeat(20) { appendLine("data: ${"A".repeat(16)}") } + } + respond( + content = ByteReadChannel(sseContent), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.EventStream.toString()), + ) + } else { + respond("", HttpStatusCode.OK) + } + } + + val receivedMessages = mutableListOf() + val receivedErrors = mutableListOf() + transport.onMessage { receivedMessages.add(it) } + transport.onError { receivedErrors.add(it) } + transport.start() + + val error = assertFailsWith { + transport.send(JSONRPCRequest(id = "req-1", method = "test", params = buildJsonObject { })) + } + + error.cause.shouldBeInstanceOf() + receivedErrors.filterIsInstance() shouldHaveSize 1 + receivedMessages shouldHaveSize 0 + transport.close() + } + + @Test + fun testInlineSseEventExactlyAtMaxSizeIsAccepted() = runTest { + // An event whose assembled data length equals the cap must still be accepted and dispatched: + // the guard rejects only sizes strictly greater than the cap (parity with ReadBuffer). + val part1 = """{"jsonrpc":"2.0",""" + val part2 = """"method":"notifications/tools/list_changed"}""" + val maxInlineSseEventSize = (part1 + part2).length + + val transport = createTransport(maxInlineSseEventSize) { request -> + if (request.method == HttpMethod.Post) { + val sseContent = buildString { + appendLine("event: message") + appendLine("data: $part1") + appendLine("data: $part2") + appendLine() + } + respond( + content = ByteReadChannel(sseContent), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.EventStream.toString()), + ) + } else { + respond("", HttpStatusCode.OK) + } + } + + val receivedMessages = mutableListOf() + val receivedErrors = mutableListOf() + val messageReceived = CompletableDeferred() + transport.onMessage { + receivedMessages.add(it) + if (!messageReceived.isCompleted) messageReceived.complete(Unit) + } + transport.onError { receivedErrors.add(it) } + transport.start() + + transport.send(JSONRPCRequest(id = "req-1", method = "test", params = buildJsonObject { })) + + eventually { messageReceived.await() } + + receivedMessages shouldHaveSize 1 + (receivedMessages[0] as JSONRPCNotification).method shouldBe "notifications/tools/list_changed" + receivedErrors shouldHaveSize 0 + transport.close() + } + + @Test + fun testNonPositiveMaxInlineSseEventSizeThrows() { + assertFailsWith { + createTransport(maxInlineSseEventSize = 0) { respond("", HttpStatusCode.OK) } + } + } + @Test fun testInlineSSEInResponse() = runTest { val transport = createTransport { request -> @@ -643,7 +910,7 @@ class StreamableHttpClientTransportTest { eventually { while (receivedMessages.isEmpty()) { - delay(10) + delay(10.milliseconds) } } diff --git a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractStreamableHttpClientTest.kt b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractStreamableHttpClientTest.kt deleted file mode 100644 index 4a04e81..0000000 --- a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractStreamableHttpClientTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.client - -import io.ktor.client.HttpClient -import io.ktor.client.engine.apache5.Apache5 -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logging -import io.ktor.client.plugins.sse.SSE -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.TestInstance - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -internal abstract class AbstractStreamableHttpClientTest { - - // start mokksy on random port - protected val mockMcp: MockMcp = MockMcp(verbose = true) - - @AfterEach - fun afterEach() { - mockMcp.verifyNoUnexpectedRequests() - } - - @AfterAll - fun afterAll() { - mockMcp.verifyNoUnmatchedStubs() - mockMcp.close() - } - - protected suspend fun connect(client: Client) { - client.connect( - StreamableHttpClientTransport( - url = mockMcp.url, - client = HttpClient(Apache5) { - install(SSE) - install(Logging) { - level = LogLevel.ALL - } - }, - ), - ) - } -} diff --git a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockMcp.kt b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockMcp.kt deleted file mode 100644 index de9d2b3..0000000 --- a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockMcp.kt +++ /dev/null @@ -1,237 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.client - -import dev.mokksy.mokksy.BuildingStep -import dev.mokksy.mokksy.Mokksy -import dev.mokksy.mokksy.StubConfiguration -import dev.mokksy.mokksy.shutdown -import dev.mokksy.mokksy.start -import io.ktor.http.ContentType -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.sse.ServerSentEvent -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest -import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import kotlinx.coroutines.flow.Flow -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.putJsonObject - -const val MCP_SESSION_ID_HEADER = "MCP-Session-Id" - -internal class MockMcp(verbose: Boolean = false) : AutoCloseable { - - private val mokksy: Mokksy = Mokksy(verbose = verbose).apply { - start() - } - - fun verifyNoUnexpectedRequests() { - mokksy.verifyNoUnexpectedRequests() - } - - fun verifyNoUnmatchedStubs() { - mokksy.verifyNoUnmatchedStubs() - } - - val url = "${mokksy.baseUrl()}/mcp" - - fun onInitialize( - clientName: String? = null, - sessionId: String, - protocolVersion: String = "2025-03-26", - serverName: String = "Mock MCP Server", - serverVersion: String = "1.0.0", - capabilities: JsonObject = buildJsonObject { - putJsonObject("tools") { - put("listChanged", JsonPrimitive(false)) - } - }, - ) { - val predicates = if (clientName != null) { - arrayOf<(JSONRPCRequest?) -> Boolean>({ - it?.params?.jsonObject - ?.get("clientInfo")?.jsonObject - ?.get("name")?.jsonPrimitive - ?.contentOrNull == clientName - }) - } else { - emptyArray() - } - - handleWithResult( - jsonRpcMethod = "initialize", - sessionId = sessionId, - bodyPredicates = predicates, - // language=json - result = """ - { - "capabilities": $capabilities, - "protocolVersion": "$protocolVersion", - "serverInfo": { - "name": "$serverName", - "version": "$serverVersion" - }, - "_meta": { - "foo": "bar" - } - } - """.trimIndent(), - ) - } - - fun onJSONRPCRequest( - httpMethod: HttpMethod = HttpMethod.Post, - jsonRpcMethod: String, - expectedSessionId: String? = null, - vararg bodyPredicates: (JSONRPCRequest) -> Boolean, - ): BuildingStep = mokksy.method( - configuration = StubConfiguration(removeAfterMatch = true), - httpMethod = httpMethod, - requestType = JSONRPCRequest::class, - ) { - path("/mcp") - expectedSessionId?.let { - containsHeader(MCP_SESSION_ID_HEADER, it) - } - bodyMatchesPredicate( - description = "JSON-RPC version is '2.0'", - predicate = - { - it!!.jsonrpc == "2.0" - }, - ) - bodyMatchesPredicate( - description = "JSON-RPC Method should be '$jsonRpcMethod'", - predicate = - { - it!!.method == jsonRpcMethod - }, - ) - bodyPredicates.forEach { predicate -> - bodyMatchesPredicate(predicate = { predicate.invoke(it!!) }) - } - } - - fun handleWithResult( - httpMethod: HttpMethod = HttpMethod.Post, - jsonRpcMethod: String, - expectedSessionId: String? = null, - sessionId: String, - contentType: ContentType = ContentType.Application.Json, - statusCode: HttpStatusCode = HttpStatusCode.OK, - vararg bodyPredicates: (JSONRPCRequest) -> Boolean, - result: () -> JsonObject, - ) { - onJSONRPCRequest( - httpMethod = httpMethod, - jsonRpcMethod = jsonRpcMethod, - expectedSessionId = expectedSessionId, - bodyPredicates = bodyPredicates, - ) respondsWith { - val requestId = when (val id = request.body().id) { - is RequestId.NumberId -> id.value.toString() - is RequestId.StringId -> "\"${id.value}\"" - } - val resultObject = result.invoke() - // language=json - body = """ - { - "jsonrpc": "2.0", - "id": $requestId, - "result": $resultObject - } - """.trimIndent() - this.contentType = contentType - headers += MCP_SESSION_ID_HEADER to sessionId - httpStatus = statusCode - } - } - - fun handleWithResult( - httpMethod: HttpMethod = HttpMethod.Post, - jsonRpcMethod: String, - expectedSessionId: String? = null, - sessionId: String, - contentType: ContentType = ContentType.Application.Json, - statusCode: HttpStatusCode = HttpStatusCode.OK, - vararg bodyPredicates: (JSONRPCRequest) -> Boolean, - result: String, - ) { - handleWithResult( - httpMethod = httpMethod, - jsonRpcMethod = jsonRpcMethod, - expectedSessionId = expectedSessionId, - sessionId = sessionId, - contentType = contentType, - statusCode = statusCode, - bodyPredicates = bodyPredicates, - result = { - Json.parseToJsonElement(result).jsonObject - }, - ) - } - - fun handleJSONRPCRequest( - httpMethod: HttpMethod = HttpMethod.Post, - jsonRpcMethod: String, - expectedSessionId: String? = null, - sessionId: String, - contentType: ContentType = ContentType.Application.Json, - statusCode: HttpStatusCode = HttpStatusCode.OK, - vararg bodyPredicates: (JSONRPCRequest?) -> Boolean, - bodyBuilder: () -> String = { "" }, - ) { - onJSONRPCRequest( - httpMethod = httpMethod, - jsonRpcMethod = jsonRpcMethod, - expectedSessionId = expectedSessionId, - bodyPredicates = bodyPredicates, - ) respondsWith { - body = bodyBuilder.invoke() - this.contentType = contentType - headers += MCP_SESSION_ID_HEADER to sessionId - httpStatus = statusCode - } - } - - fun onSubscribe(httpMethod: HttpMethod = HttpMethod.Post, sessionId: String): BuildingStep = mokksy.method( - httpMethod = httpMethod, - name = "MCP GETs", - requestType = Any::class, - ) { - path("/mcp") - containsHeader(MCP_SESSION_ID_HEADER, sessionId) - containsHeader("Accept", "application/json,text/event-stream") - containsHeader("Cache-Control", "no-store") - } - - fun handleSubscribeWithGet(sessionId: String, block: () -> Flow) { - onSubscribe( - httpMethod = HttpMethod.Get, - sessionId = sessionId, - ) respondsWithSseStream { - headers += MCP_SESSION_ID_HEADER to sessionId - this.flow = block.invoke() - } - } - - fun mockUnsubscribeRequest(sessionId: String) { - mokksy.delete( - configuration = StubConfiguration(removeAfterMatch = true), - requestType = JSONRPCRequest::class, - ) { - path("/mcp") - containsHeader(MCP_SESSION_ID_HEADER, sessionId) - } respondsWith { - body = null - } - } - - override fun close() { - mokksy.shutdown() - } -} diff --git a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt deleted file mode 100644 index 43be8c9..0000000 --- a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt +++ /dev/null @@ -1,293 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.client - -import io.kotest.matchers.collections.shouldContain -import io.kotest.matchers.shouldBe -import io.ktor.http.ContentType -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.sse.ServerSentEvent -import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.Tool -import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonObject -import org.junit.jupiter.api.TestInstance -import kotlin.test.Test -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -@OptIn(ExperimentalUuidApi::class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -internal class StreamableHttpClientTest : AbstractStreamableHttpClientTest() { - - @Test - fun `Should skip empty SSE`(): Unit = runBlocking { - val client = Client( - clientInfo = Implementation( - name = "client1", - version = "1.0.0", - ), - options = ClientOptions( - capabilities = ClientCapabilities(), - ), - ) - val sessionId = Uuid.random().toString() - - mockMcp.onJSONRPCRequest( - httpMethod = HttpMethod.Post, - jsonRpcMethod = "initialize", - ).respondsWithStream { - headers += MCP_SESSION_ID_HEADER to sessionId - flow = flowOf( - "id: ${Uuid.random()}\n", - "data:\n", // empty data - "\n", - "id: ${Uuid.random()}\n", - "data: \t \n", // tabs and spaces - "\n", - "id: ${Uuid.random()}\n", - "event: message\n", - // multiline data - "data: {\n", - "data: \"result\":{\n" + - "data: \"protocolVersion\":\"2025-06-18\",\n" + - "data: \"capabilities\":{},\n" + - "data: \"serverInfo\":{\"name\":\"simple-streamable-http-server\",\"version\":\"1.0.0\"}\n" + - "data: },\n" + - "data: \"jsonrpc\":\"2.0\",\n" + - "data: \"id\":\"7ce065b0678f49e5b04ce5a0fcc7d518\"\n" + - "data: }\n", - "\n", - ) - } - - mockMcp.handleJSONRPCRequest( - jsonRpcMethod = "notifications/initialized", - expectedSessionId = sessionId, - sessionId = sessionId, - statusCode = HttpStatusCode.Accepted, - ) - - mockMcp.handleSubscribeWithGet(sessionId) { - emptyFlow() - } - - connect(client) - } - - @Test - fun `test streamableHttpClient`() = runBlocking { - val client = Client( - clientInfo = Implementation( - name = "client1", - version = "1.0.0", - ), - options = ClientOptions( - capabilities = ClientCapabilities(), - ), - ) - - val sessionId = Uuid.random().toString() - - mockMcp.onInitialize( - clientName = "client1", - sessionId = sessionId, - ) - - mockMcp.handleJSONRPCRequest( - jsonRpcMethod = "notifications/initialized", - expectedSessionId = sessionId, - sessionId = sessionId, - statusCode = HttpStatusCode.Accepted, - ) - - mockMcp.handleSubscribeWithGet(sessionId) { - flow { - delay(500.milliseconds) - emit( - ServerSentEvent( - event = "message", - id = "1", - //language=json - data = - """{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""", - ), - ) - delay(200.milliseconds) - emit( - ServerSentEvent( - //language=json - data = - """{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""", - ), - ) - } - } - - // TODO: how to get notifications via Client API? - - mockMcp.handleWithResult( - jsonRpcMethod = "tools/list", - sessionId = sessionId, - // language=json - result = """ - { - "tools": [ - { - "name": "get_weather", - "title": "Weather Information Provider", - "description": "Get current weather information for a location", - "inputSchema": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "City name or zip code" - } - }, - "required": ["location"] - }, - "outputSchema": { - "type": "object", - "properties": { - "temperature": { - "type": "number", - "description": "Temperature, Celsius" - } - }, - "required": ["temperature"] - }, - "_meta": {} - } - ] - } - """.trimIndent(), - ) - - connect(client) - - val listToolsResult = client.listTools() - - listToolsResult.tools shouldContain Tool( - name = "get_weather", - title = "Weather Information Provider", - description = "Get current weather information for a location", - inputSchema = ToolSchema( - properties = buildJsonObject { - putJsonObject("location") { - put("type", "string") - put("description", "City name or zip code") - } - }, - required = listOf("location"), - ), - outputSchema = ToolSchema( - properties = buildJsonObject { - putJsonObject("temperature") { - put("type", "number") - put("description", "Temperature, Celsius") - } - }, - required = listOf("temperature"), - ), - annotations = null, - meta = EmptyJsonObject, - ) - - client.close() - } - - @Test - fun `terminateSession sends DELETE request`() = runBlocking { - val client = Client( - clientInfo = Implementation(name = "client1", version = "1.0.0"), - options = ClientOptions(capabilities = ClientCapabilities()), - ) - val sessionId = Uuid.random().toString() - - mockMcp.onInitialize(clientName = "client1", sessionId = sessionId) - mockMcp.handleJSONRPCRequest( - jsonRpcMethod = "notifications/initialized", - expectedSessionId = sessionId, - sessionId = sessionId, - statusCode = HttpStatusCode.Accepted, - ) - mockMcp.handleSubscribeWithGet(sessionId) { emptyFlow() } - - connect(client) - - mockMcp.mockUnsubscribeRequest(sessionId = sessionId) - (client.transport as StreamableHttpClientTransport).terminateSession() - (client.transport as StreamableHttpClientTransport).sessionId shouldBe null - - client.close() - } - - @Test - fun `handle MethodNotAllowed`() = runBlocking { - checkSupportNonStreamingResponse( - ContentType.Text.EventStream, - HttpStatusCode.MethodNotAllowed, - ) - } - - @Test - fun `handle non-streaming response`() = runBlocking { - checkSupportNonStreamingResponse( - ContentType.Application.Json, - HttpStatusCode.OK, - ) - } - - private suspend fun checkSupportNonStreamingResponse(contentType: ContentType, statusCode: HttpStatusCode) { - val sessionId = "SID_${Uuid.random().toHexString()}" - val clientName = "client-${Uuid.random().toHexString()}" - val client = Client( - clientInfo = Implementation(name = clientName, version = "1.0.0"), - options = ClientOptions( - capabilities = ClientCapabilities(), - ), - ) - - mockMcp.onInitialize(clientName = clientName, sessionId = sessionId) - - mockMcp.handleJSONRPCRequest( - jsonRpcMethod = "notifications/initialized", - expectedSessionId = sessionId, - sessionId = sessionId, - statusCode = HttpStatusCode.Accepted, - ) - - mockMcp.onSubscribe( - httpMethod = HttpMethod.Get, - sessionId = sessionId, - ) respondsWith { - headers += MCP_SESSION_ID_HEADER to sessionId - body = null - httpStatus = statusCode - this.contentType = contentType - } - - mockMcp.handleWithResult(jsonRpcMethod = "ping", sessionId = sessionId) { - buildJsonObject {} - } - - connect(client) - - delay(1.seconds) - - client.ping() // connection is still alive - - client.close() - } -} diff --git a/kotlin-sdk-core/api/kotlin-sdk-core.api b/kotlin-sdk-core/api/kotlin-sdk-core.api index c843aea..b3e88d8 100644 --- a/kotlin-sdk-core/api/kotlin-sdk-core.api +++ b/kotlin-sdk-core/api/kotlin-sdk-core.api @@ -59,6 +59,7 @@ public abstract class io/modelcontextprotocol/kotlin/sdk/shared/Protocol { public abstract fun assertRequestHandlerCapability (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun connect (Lio/modelcontextprotocol/kotlin/sdk/shared/Transport;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + protected final fun enableConcurrentDispatch ()V public final fun getFallbackNotificationHandler ()Lkotlin/jvm/functions/Function2; public final fun getFallbackRequestHandler ()Lkotlin/jvm/functions/Function3; public final fun getNotificationHandlers ()Ljava/util/Map; @@ -71,6 +72,7 @@ public abstract class io/modelcontextprotocol/kotlin/sdk/shared/Protocol { public static synthetic fun notification$default (Lio/modelcontextprotocol/kotlin/sdk/shared/Protocol;Lio/modelcontextprotocol/kotlin/sdk/types/Notification;Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun onClose ()V public fun onError (Ljava/lang/Throwable;)V + protected fun onInitializedNotification ()V public final fun removeNotificationHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public final fun removeRequestHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public final fun request (Lio/modelcontextprotocol/kotlin/sdk/types/Request;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -84,15 +86,18 @@ public abstract class io/modelcontextprotocol/kotlin/sdk/shared/Protocol { public final class io/modelcontextprotocol/kotlin/sdk/shared/ProtocolKt { public static final field IMPLEMENTATION_NAME Ljava/lang/String; + public static final fun currentRequestHandlerExtra (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun getDEFAULT_REQUEST_TIMEOUT ()J } public class io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions { - public synthetic fun (ZJILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (ZJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZJLkotlin/coroutines/CoroutineContext;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZJLkotlin/coroutines/CoroutineContext;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getEnforceStrictCapabilities ()Z + public final fun getHandlerCoroutineContext ()Lkotlin/coroutines/CoroutineContext; public final fun getTimeout-UwyO8pc ()J public final fun setEnforceStrictCapabilities (Z)V + public final fun setHandlerCoroutineContext (Lkotlin/coroutines/CoroutineContext;)V public final fun setTimeout-LRDsOJo (J)V } @@ -107,8 +112,16 @@ public final class io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferKt { public static final fun serializeMessage (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;)Ljava/lang/String; } -public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra { - public fun ()V +public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra : kotlin/coroutines/AbstractCoroutineContextElement { + public static final field Key Lio/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra$Key; + public final fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; + public final fun getRequestId ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestId; + public final fun sendNotification (Lio/modelcontextprotocol/kotlin/sdk/types/Notification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun sendRequest (Lio/modelcontextprotocol/kotlin/sdk/types/Request;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun sendRequest$default (Lio/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra;Lio/modelcontextprotocol/kotlin/sdk/types/Request;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra$Key : kotlin/coroutines/CoroutineContext$Key { } public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestOptions : io/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions { @@ -125,6 +138,12 @@ public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestOptions : io public fun toString ()Ljava/lang/String; } +public final class io/modelcontextprotocol/kotlin/sdk/shared/TooLongFrameException : java/io/IOException { + public fun (JI)V + public final fun getFrameSize ()J + public final fun getMaxFrameSize ()I +} + public abstract interface class io/modelcontextprotocol/kotlin/sdk/shared/Transport { public abstract fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onClose (Lkotlin/jvm/functions/Function0;)V @@ -371,6 +390,19 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/BooleanSchema$Compan public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/CacheScope : java/lang/Enum { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope$Companion; + public static final field Private Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; + public static final field Public Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; + public static fun values ()[Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/CacheScope$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequest$Companion; public fun (Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams;)V @@ -384,6 +416,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolRequest : io public final fun getName ()Ljava/lang/String; public fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestParams; + public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -408,24 +441,26 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolRequestBuild public final fun arguments (Lkotlin/jvm/functions/Function1;)V public final fun arguments (Lkotlinx/serialization/json/JsonObject;)V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getName ()Ljava/lang/String; public final fun setName (Ljava/lang/String;)V } public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams$Companion; - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Lkotlinx/serialization/json/JsonObject; - public final fun component3-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun copy-EDEVuBg (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; - public static synthetic fun copy-EDEVuBg$default (Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; + public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; + public final fun component4-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; + public final fun copy-6R815Ik (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; + public static synthetic fun copy-6R815Ik$default (Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; public fun equals (Ljava/lang/Object;)Z public final fun getArguments ()Lkotlinx/serialization/json/JsonObject; public fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; public final fun getName ()Ljava/lang/String; + public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -604,26 +639,32 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CancelledNotificatio public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesKt { + public static final fun getSupportsUrl (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;)Z +} + public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Companion; public fun ()V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)V public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling; public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots; - public final fun component3 ()Lkotlinx/serialization/json/JsonObject; - public final fun component4 ()Lkotlinx/serialization/json/JsonObject; - public final fun component5 ()Ljava/util/Map; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; + public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; + public final fun component4 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks; + public final fun component5 ()Lkotlinx/serialization/json/JsonObject; + public final fun component6 ()Ljava/util/Map; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; public fun equals (Ljava/lang/Object;)Z - public final fun getElicitation ()Lkotlinx/serialization/json/JsonObject; + public final fun getElicitation ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; public final fun getExperimental ()Lkotlinx/serialization/json/JsonObject; public final fun getExtensions ()Ljava/util/Map; public final fun getRoots ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots; public final fun getSampling ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling; + public final fun getTasks ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -640,11 +681,42 @@ public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapa } public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Companion { - public final fun getElicitation ()Lkotlinx/serialization/json/JsonObject; + public final fun getElicitation ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; public final fun getSampling ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling; public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun component2 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; + public fun equals (Ljava/lang/Object;)Z + public final fun getForm ()Lkotlinx/serialization/json/JsonObject; + public final fun getUrl ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots$Companion; public fun ()V @@ -705,11 +777,132 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$S public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun component2 ()Lkotlinx/serialization/json/JsonObject; + public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests; + public final fun copy (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks; + public fun equals (Ljava/lang/Object;)Z + public final fun getCancel ()Lkotlinx/serialization/json/JsonObject; + public final fun getList ()Lkotlinx/serialization/json/JsonObject; + public final fun getRequests ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Companion; + public fun ()V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling; + public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests; + public fun equals (Ljava/lang/Object;)Z + public final fun getElicitation ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation; + public final fun getSampling ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation; + public fun equals (Ljava/lang/Object;)Z + public final fun getCreate ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling; + public fun equals (Ljava/lang/Object;)Z + public final fun getCreateMessage ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilitiesBuilder { public fun ()V public final fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; - public final fun elicitation (Lkotlin/jvm/functions/Function1;)V - public final fun elicitation (Lkotlinx/serialization/json/JsonObject;)V + public final fun elicitation (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;)V public final fun experimental (Lkotlin/jvm/functions/Function1;)V public final fun experimental (Lkotlinx/serialization/json/JsonObject;)V public final fun extensions (Ljava/util/Map;)V @@ -787,7 +980,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CompleteRequestBuild public fun ()V public final fun argument (Ljava/lang/String;Ljava/lang/String;)V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun context (Ljava/util/Map;)V public final fun context (Lkotlin/jvm/functions/Function1;)V public final fun ref (Lio/modelcontextprotocol/kotlin/sdk/types/Reference;)V @@ -997,6 +1190,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestParams; public final fun getStopSequences ()Ljava/util/List; public final fun getSystemPrompt ()Ljava/lang/String; + public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; public final fun getTemperature ()Ljava/lang/Double; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -1020,7 +1214,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getContext ()Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext; public final fun getMaxTokens ()Ljava/lang/Integer; public final fun getStopSequences ()Ljava/util/List; @@ -1041,11 +1235,12 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams$Companion; - public synthetic fun (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()I public final fun component10 ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice; - public final fun component11-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; + public final fun component11 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; + public final fun component12-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; public final fun component2 ()Ljava/util/List; public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences; public final fun component4 ()Ljava/lang/String; @@ -1054,8 +1249,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public final fun component7 ()Ljava/util/List; public final fun component8 ()Lkotlinx/serialization/json/JsonObject; public final fun component9 ()Ljava/util/List; - public final fun copy-APmi5RE (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams; - public static synthetic fun copy-APmi5RE$default (Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams;ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams; + public final fun copy-7OuPiQU (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams; + public static synthetic fun copy-7OuPiQU$default (Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams;ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams; public fun equals (Ljava/lang/Object;)Z public final fun getIncludeContext ()Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext; public final fun getMaxTokens ()I @@ -1065,6 +1260,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public final fun getModelPreferences ()Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences; public final fun getStopSequences ()Ljava/util/List; public final fun getSystemPrompt ()Ljava/lang/String; + public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; public final fun getTemperature ()Ljava/lang/Double; public final fun getToolChoice ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice; public final fun getTools ()Ljava/util/List; @@ -1212,6 +1408,104 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CustomRequest$Compan public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest$Companion; + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;)V + public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest;Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest; + public fun equals (Ljava/lang/Object;)Z + public final fun getMeta--Khlwt8 ()Lkotlinx/serialization/json/JsonObject; + public fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; + public fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; + public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestParams; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams$Companion; + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1--Khlwt8 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy-hVVZdjo (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; + public static synthetic fun copy-hVVZdjo$default (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; + public fun equals (Ljava/lang/Object;)Z + public fun getMeta--Khlwt8 ()Lkotlinx/serialization/json/JsonObject; + public synthetic fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverResult : io/modelcontextprotocol/kotlin/sdk/types/ServerResult { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult$Companion; + public fun (Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Ljava/lang/String;Ljava/lang/String;JLio/modelcontextprotocol/kotlin/sdk/types/CacheScope;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Ljava/lang/String;Ljava/lang/String;JLio/modelcontextprotocol/kotlin/sdk/types/CacheScope;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()J + public final fun component6 ()Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; + public final fun component7 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Ljava/lang/String;Ljava/lang/String;JLio/modelcontextprotocol/kotlin/sdk/types/CacheScope;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Ljava/lang/String;Ljava/lang/String;JLio/modelcontextprotocol/kotlin/sdk/types/CacheScope;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult; + public fun equals (Ljava/lang/Object;)Z + public final fun getCacheScope ()Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; + public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; + public final fun getInstructions ()Ljava/lang/String; + public fun getMeta ()Lkotlinx/serialization/json/JsonObject; + public final fun getResultType ()Ljava/lang/String; + public final fun getSupportedVersions ()Ljava/util/List; + public final fun getTtlMs ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/DiscoverResult$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverResult$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/DoubleSchema : io/modelcontextprotocol/kotlin/sdk/types/NumberSchemaDefinition { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/DoubleSchema$Companion; public fun ()V @@ -1285,7 +1579,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequest$Compan public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getMessage ()Ljava/lang/String; public final fun requestedSchema (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;)V public final fun requestedSchema (Lkotlin/jvm/functions/Function1;)V @@ -1663,7 +1957,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/GetPromptRequest$Com public final class io/modelcontextprotocol/kotlin/sdk/types/GetPromptRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/GetPromptRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getArguments ()Ljava/util/Map; public final fun getName ()Ljava/lang/String; public final fun setArguments (Ljava/util/Map;)V @@ -2108,7 +2402,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/InitializeRequest$Co public final class io/modelcontextprotocol/kotlin/sdk/types/InitializeRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/InitializeRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun capabilities (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;)V public final fun capabilities (Lkotlin/jvm/functions/Function1;)V public final fun getProtocolVersion ()Ljava/lang/String; @@ -2498,7 +2792,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequest$C public final class io/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListPromptsResult : io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { @@ -2569,7 +2863,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplate public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplatesRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplatesRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplatesResult : io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { @@ -2640,7 +2934,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequest public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourcesResult : io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { @@ -2710,7 +3004,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest$Com public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsResult : io/modelcontextprotocol/kotlin/sdk/types/ClientResult { @@ -2844,7 +3138,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListToolsRequest$Com public final class io/modelcontextprotocol/kotlin/sdk/types/ListToolsRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListToolsRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListToolsResult : io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { @@ -2968,7 +3262,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Logging_dslKt { public abstract interface annotation class io/modelcontextprotocol/kotlin/sdk/types/McpDsl : java/lang/annotation/Annotation { } -public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception { +public class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception { public fun (I)V public fun (ILjava/lang/String;)V public fun (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;)V @@ -3062,6 +3356,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Method$Defined : jav public static final field ResourcesUnsubscribe Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field RootsList Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field SamplingCreateMessage Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public static final field ServerDiscover Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field TasksCancel Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field TasksGet Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field TasksList Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; @@ -3270,7 +3565,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/PingRequest$Companio public final class io/modelcontextprotocol/kotlin/sdk/types/PingRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/PingRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/PingRequest_dslKt { @@ -3584,6 +3879,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/RPCError$ErrorCode { public static final field PARSE_ERROR I public static final field REQUEST_TIMEOUT I public static final field RESOURCE_NOT_FOUND I + public static final field UNSUPPORTED_PROTOCOL_VERSION I + public static final field URL_ELICITATION_REQUIRED I } public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { @@ -3620,7 +3917,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest$ public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getUri ()Ljava/lang/String; public final fun setUri (Ljava/lang/String;)V } @@ -3828,8 +4125,12 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/RequestMeta { public static fun equals-impl (Lkotlinx/serialization/json/JsonObject;Ljava/lang/Object;)Z public static final fun equals-impl0 (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Z public static final fun get-impl (Lkotlinx/serialization/json/JsonObject;Ljava/lang/String;)Lkotlinx/serialization/json/JsonElement; + public static final fun getClientCapabilities-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; + public static final fun getClientInfo-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/Implementation; public final fun getJson ()Lkotlinx/serialization/json/JsonObject; + public static final fun getLogLevel-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; public static final fun getProgressToken-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/RequestId; + public static final fun getProtocolVersion-impl (Lkotlinx/serialization/json/JsonObject;)Ljava/lang/String; public static final fun getRelatedTask-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/RelatedTaskMetadata; public fun hashCode ()I public static fun hashCode-impl (Lkotlinx/serialization/json/JsonObject;)I @@ -4303,17 +4604,18 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Sampling_dslKt { public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Companion; public fun ()V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools; public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources; public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts; public final fun component4 ()Lkotlinx/serialization/json/JsonObject; public final fun component5 ()Lkotlinx/serialization/json/JsonObject; - public final fun component6 ()Lkotlinx/serialization/json/JsonObject; - public final fun component7 ()Ljava/util/Map; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; + public final fun component6 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks; + public final fun component7 ()Lkotlinx/serialization/json/JsonObject; + public final fun component8 ()Ljava/util/Map; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; public fun equals (Ljava/lang/Object;)Z public final fun getCompletions ()Lkotlinx/serialization/json/JsonObject; public final fun getExperimental ()Lkotlinx/serialization/json/JsonObject; @@ -4321,6 +4623,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities { public final fun getLogging ()Lkotlinx/serialization/json/JsonObject; public final fun getPrompts ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts; public final fun getResources ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources; + public final fun getTasks ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks; public final fun getTools ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -4403,6 +4706,97 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$R public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun component2 ()Lkotlinx/serialization/json/JsonObject; + public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests; + public final fun copy (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks; + public fun equals (Ljava/lang/Object;)Z + public final fun getCancel ()Lkotlinx/serialization/json/JsonObject; + public final fun getList ()Lkotlinx/serialization/json/JsonObject; + public final fun getRequests ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Companion; + public fun ()V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests; + public fun equals (Ljava/lang/Object;)Z + public final fun getTools ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools; + public fun equals (Ljava/lang/Object;)Z + public final fun getCall ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools$Companion; public fun ()V @@ -4490,7 +4884,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/SetLevelRequest$Comp public final class io/modelcontextprotocol/kotlin/sdk/types/SetLevelRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/SetLevelRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getLoggingLevel ()Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; public final fun setLoggingLevel (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;)V } @@ -4656,7 +5050,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/SubscribeRequest$Com public final class io/modelcontextprotocol/kotlin/sdk/types/SubscribeRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/SubscribeRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getUri ()Ljava/lang/String; public final fun setUri (Ljava/lang/String;)V } @@ -5424,7 +5818,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequest$C public final class io/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getUri ()Ljava/lang/String; public final fun setUri (Ljava/lang/String;)V } @@ -5459,6 +5853,35 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequestPa public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData$Companion; + public fun (Ljava/util/List;Ljava/lang/String;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/util/List;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData; + public fun equals (Ljava/lang/Object;)Z + public final fun getRequested ()Ljava/lang/String; + public final fun getSupported ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema : io/modelcontextprotocol/kotlin/sdk/types/MultiSelectEnumSchema { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items;Ljava/util/List;)V @@ -5561,6 +5984,39 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/UntitledSingleSelect public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$Companion; + public fun (Ljava/util/List;)V + public final fun component1 ()Ljava/util/List; + public final fun copy (Ljava/util/List;)Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData; + public fun equals (Ljava/lang/Object;)Z + public final fun getElicitations ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredException : io/modelcontextprotocol/kotlin/sdk/types/McpException { + public fun (Ljava/util/List;Ljava/lang/String;)V + public synthetic fun (Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getElicitations ()Ljava/util/List; +} + public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/WithMeta { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/WithMeta$Companion; public abstract fun getMeta ()Lkotlinx/serialization/json/JsonObject; diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt index 8f86c03..908e524 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt @@ -27,15 +27,34 @@ import kotlinx.atomicfu.AtomicRef import kotlinx.atomicfu.atomic import kotlinx.atomicfu.getAndUpdate import kotlinx.atomicfu.update +import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.encodeToJsonElement +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.ContinuationInterceptor +import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -53,17 +72,26 @@ public typealias ProgressCallback = (Progress) -> Unit /** * Additional initialization options. * - * @property enforceStrictCapabilities whether to restrict emitted requests to only those that the - * remote side has indicated it can handle through its advertised capabilities. - * This does NOT affect checking of _local_ side capabilities, as it is considered a logic error - * to mis-specify those. - * Currently defaults to `false` for backwards compatibility with SDK versions that did not advertise - * capabilities correctly; in the future, this will default to `true`. + * [handlerCoroutineContext] is read once when [Protocol.connect] attaches a transport and stays + * fixed until the next [Protocol.connect]. + * + * @property enforceStrictCapabilities whether to restrict emitted requests to only those the remote + * side advertised support for. Local-side capability checks are unaffected. Defaults to `false` for + * backward compatibility with SDK versions that did not advertise capabilities correctly. * @property timeout default timeout for outgoing requests + * @property handlerCoroutineContext coroutine context used to run inbound request and notification + * handlers once initialization has completed. Must contain a real dispatching + * [kotlin.coroutines.ContinuationInterceptor]. `Dispatchers.Unconfined` and unconfined test + * dispatchers are unsupported, since handler resumptions would then run on the transport read loop + * and reintroduce head-of-line blocking. Any [kotlinx.coroutines.Job] in this context is ignored. */ public open class ProtocolOptions( public var enforceStrictCapabilities: Boolean = false, public var timeout: Duration = DEFAULT_REQUEST_TIMEOUT, + public var handlerCoroutineContext: CoroutineContext = Dispatchers.Default, + // Internal handler-concurrency bounds, read once at connect and not part of the public surface. + internal var maxConcurrentHandlers: Int = DEFAULT_MAX_CONCURRENT_HANDLERS, + internal var maxInFlightHandlers: Int = DEFAULT_MAX_IN_FLIGHT_HANDLERS, ) /** @@ -71,6 +99,12 @@ public open class ProtocolOptions( */ public val DEFAULT_REQUEST_TIMEOUT: Duration = 60.seconds +/** Default cap on concurrently executing inbound handlers per connection. */ +internal const val DEFAULT_MAX_CONCURRENT_HANDLERS: Int = 64 + +/** Default cap on launched-but-not-completed inbound handlers per connection. */ +internal const val DEFAULT_MAX_IN_FLIGHT_HANDLERS: Int = 256 + /** * Options that can be given per request. * @@ -121,7 +155,7 @@ public class RequestOptions( override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (onProgress?.hashCode() ?: 0) + result = 31 * result + onProgress.hashCode() result = 31 * result + timeout.hashCode() return result } @@ -132,11 +166,70 @@ public class RequestOptions( /** * Extra data given to request handlers. + * + * One instance exists per in-flight inbound request. It is passed as the second parameter to + * low-level handlers registered via [Protocol.setRequestHandler] **and** installed into the + * handler coroutine's [CoroutineContext], so code nested anywhere under the handler can reach it + * via [currentRequestHandlerExtra] without parameter plumbing. + * + * @property requestId the JSON-RPC id of the request being handled + * @property method the request's method */ -public class RequestHandlerExtra +public class RequestHandlerExtra internal constructor( + public val requestId: RequestId, + public val method: Method, + private val protocol: Protocol, + internal val capturedTransport: Transport, +) : AbstractCoroutineContextElement(Key) { + + /** Context key for retrieving the extra from a handler coroutine's context. */ + public companion object Key : CoroutineContext.Key + + /** + * Sends a notification tagged with `relatedRequestId = `[requestId], letting transports + * associate it with the request being handled (e.g. route it onto the request's SSE stream). + */ + public suspend fun sendNotification(notification: Notification) { + protocol.notification(notification, relatedRequestId = requestId) + } + + /** + * Sends a request to the peer tagged with `relatedRequestId = `[requestId]. + * + * Mirrors [Protocol.request]; cancellation of the handler coroutine propagates into this call. + */ + public suspend fun sendRequest(request: Request, options: RequestOptions? = null): T { + val base = options ?: RequestOptions() + return protocol.request( + request, + RequestOptions( + relatedRequestId = requestId, + resumptionToken = base.resumptionToken, + onResumptionToken = base.onResumptionToken, + onProgress = base.onProgress, + timeout = base.timeout, + ), + ) + } +} + +/** The [RequestHandlerExtra] of the MCP request being handled by the current coroutine, or `null` outside a handler. */ +public suspend fun currentRequestHandlerExtra(): RequestHandlerExtra? = currentCoroutineContext()[RequestHandlerExtra] internal val COMPLETED = CompletableDeferred(Unit).also { it.complete(Unit) } +/** Cap on how many recently-cancelled outbound request ids are remembered to quiet late responses/progress. */ +private const val CANCELLED_REQUEST_IDS_REMEMBERED = 256 + +// Bypass set: exempt from both bound tiers so the connection cannot self-block. +// Responses are not listed — they are handled inline before dispatch. +private val CONTROL_METHODS: Set = setOf( + Method.Defined.Ping.value, + Method.Defined.NotificationsCancelled.value, + Method.Defined.NotificationsProgress.value, + Method.Defined.NotificationsInitialized.value, +) + /** * Implements MCP protocol framing on top of a pluggable transport, including * features like request/response linking, notifications, and progress. @@ -144,9 +237,11 @@ internal val COMPLETED = CompletableDeferred(Unit).also { it.complete(Unit) } * @property options protocol-level configuration; `null` falls back to defaults */ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptions?) { + private val connectionRef: AtomicRef = atomic(null) + /** The active transport, or `null` if not connected. */ - public var transport: Transport? = null - private set + public val transport: Transport? + get() = connectionRef.value?.transport private val _requestHandlers: AtomicRef RequestResult?>> = @@ -184,6 +279,21 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio public val progressHandlers: Map get() = _progressHandlers.value + private val recentlyCancelledRequestIds: AtomicRef> = + atomic(persistentListOf()) + + /** Remembers a locally-cancelled outbound request id so its late response/progress is quieted. */ + private fun rememberCancelledRequestId(id: RequestId) { + recentlyCancelledRequestIds.update { current -> + val appended = current.adding(id) + if (appended.size > CANCELLED_REQUEST_IDS_REMEMBERED) appended.removingAt(0) else appended + } + } + + /** Whether [id] was recently cancelled locally (see [rememberCancelledRequestId]). */ + private fun isRecentlyCancelled(id: RequestId?): Boolean = + id != null && recentlyCancelledRequestIds.value.contains(id) + /** * Callback for when the connection is closed for any reason. * @@ -213,6 +323,11 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio public var fallbackNotificationHandler: (suspend (notification: JSONRPCNotification) -> Unit)? = null init { + setNotificationHandler(Method.Defined.NotificationsCancelled) { notification -> + handleCancelledNotification(notification) + COMPLETED + } + setNotificationHandler(Method.Defined.NotificationsProgress) { notification -> onProgress(notification) COMPLETED @@ -223,15 +338,64 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } } + /** + * Enables concurrent dispatch of inbound messages on the current connection. + * + * Called by subclasses when the MCP initialization phase completes (the client after the + * initialize exchange; the server session on receiving `notifications/initialized`). Until + * then, inbound messages are handled inline in arrival order. Reset by [connect]. + */ + protected fun enableConcurrentDispatch() { + val connection = connectionRef.value ?: return + connection.concurrentDispatchEnabled.value = true + } + + /** + * Router hook invoked whenever `notifications/initialized` is received, before handler lookup. + * + * Runs regardless of which notification handler is registered for the method, so replacing the + * handler via [setNotificationHandler] cannot disable subclass initialization logic. + */ + protected open fun onInitializedNotification() {} + /** * Attaches to the given transport, starts it, and starts listening for messages. * - * The Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward. + * The Protocol object assumes ownership of the Transport, replacing any callbacks that have + * already been set, and expects that it is the only user of the Transport instance going forward. + * + * @throws IllegalStateException if this Protocol is already connected; call [close] first. */ public open suspend fun connect(transport: Transport) { - this.transport = transport + val maxConcurrent = options?.maxConcurrentHandlers ?: DEFAULT_MAX_CONCURRENT_HANDLERS + val maxInFlight = options?.maxInFlightHandlers ?: DEFAULT_MAX_IN_FLIGHT_HANDLERS + require(maxConcurrent > 0) { "maxConcurrentHandlers must be positive, but was $maxConcurrent" } + require(maxInFlight >= maxConcurrent) { + "maxInFlightHandlers ($maxInFlight) must be >= maxConcurrentHandlers ($maxConcurrent)" + } + val handlerContext = (options?.handlerCoroutineContext ?: Dispatchers.Default).minusKey(Job) + val interceptor = handlerContext[ContinuationInterceptor] + if (interceptor === Dispatchers.Unconfined || + interceptor?.let { it::class.simpleName?.contains("Unconfined") } == true + ) { + logger.warn { + "handlerCoroutineContext uses an unconfined dispatcher ($interceptor); " + + "handler resumptions will run on the transport read loop and may block unrelated messages" + } + } + val connection = Connection( + transport = transport, + handlerScope = CoroutineScope(SupervisorJob() + handlerContext + CoroutineName("McpProtocol")), + executionSemaphore = Semaphore(maxConcurrent), + maxInFlightHandlers = maxInFlight, + ) + if (!connectionRef.compareAndSet(null, connection)) { + connection.handlerScope.cancel() + error("Protocol is already connected; close() the current connection before connecting a new transport") + } + transport.onClose { - doClose() + doClose(connection) } transport.onError { @@ -239,24 +403,43 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } transport.onMessage { message -> + if (connectionRef.value !== connection) { + logger.trace { "Dropping message received after close: ${message::class.simpleName}" } + return@onMessage + } when (message) { is JSONRPCResponse -> onResponse(message, null) - is JSONRPCRequest -> onRequest(message) - is JSONRPCNotification -> onNotification(message) + is JSONRPCRequest -> dispatchRequest(connection, message) + is JSONRPCNotification -> dispatchNotification(connection, message) is JSONRPCError -> onResponse(null, message) is JSONRPCEmptyMessage -> Unit } } logger.info { "Starting transport" } - transport.start() + try { + transport.start() + } catch (cause: Throwable) { + // Roll back so the Protocol can be reconnected after a failed start; idempotent with + // a transport-initiated doClose (both CAS on the same connection). + if (connectionRef.compareAndSet(connection, null)) { + connection.handlerScope.cancel() + } + throw cause + } } - private fun doClose() { - val handlersToNotify = _responseHandlers.value.values.toList() - _responseHandlers.getAndSet(persistentMapOf()) + private fun doClose(connection: Connection) { + // A stale onClose from a previous transport must not tear down the successor connection. + if (!connectionRef.compareAndSet(connection, null)) { + logger.trace { "Ignoring close signal from a stale transport" } + return + } + val handlersToNotify = _responseHandlers.getAndSet(persistentMapOf()).values.toList() _progressHandlers.getAndSet(persistentMapOf()) - transport = null + recentlyCancelledRequestIds.getAndSet(persistentListOf()) + connection.inFlightRequestJobs.getAndSet(persistentMapOf()) + connection.handlerScope.cancel() onClose() val error = McpException(RPCError.ErrorCode.CONNECTION_CLOSED, "Connection closed") @@ -265,6 +448,110 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } } + private suspend fun dispatchRequest(connection: Connection, request: JSONRPCRequest) { + if (!connection.concurrentDispatchEnabled.value) { + // Serial phase (before the peer completes MCP initialization): inline, in arrival + // order — the delivering coroutine awaits the handler. + onRequest(request, connection, trackCancellation = false) + return + } + val bypass = request.method in CONTROL_METHODS + if (!bypass && !tryAdmit(connection)) { + rejectOverloadedRequest(connection, request) + return + } + // UNDISPATCHED starts the handler synchronously in the delivering coroutine, preserving + // arrival order, then moves to handlerCoroutineContext at its first suspension. + connection.handlerScope.launch(start = CoroutineStart.UNDISPATCHED) { + // Register before any suspension point so a following notifications/cancelled can find + // and cancel even a handler still parked on admission. + registerInFlight(connection, request.id, coroutineContext.job) + try { + if (bypass) { + onRequest(request, connection, trackCancellation = true) + } else { + connection.executionSemaphore.withPermit { + onRequest(request, connection, trackCancellation = true) + } + } + } finally { + if (!bypass) connection.inFlightCount.decrementAndGet() + } + } + } + + private suspend fun dispatchNotification(connection: Connection, notification: JSONRPCNotification) { + if (notification.method == Method.Defined.NotificationsInitialized.value) { + onInitializedNotification() + } + if (!connection.concurrentDispatchEnabled.value) { + onNotification(notification) + return + } + val bypass = notification.method in CONTROL_METHODS + if (!bypass && !tryAdmit(connection)) { + val dropped = IllegalStateException( + "Dropped notification ${notification.method}: too many in-flight messages", + ) + logger.warn { dropped.message } + onError(dropped) + return + } + connection.handlerScope.launch(start = CoroutineStart.UNDISPATCHED) { + try { + if (bypass) { + onNotification(notification) + } else { + connection.executionSemaphore.withPermit { onNotification(notification) } + } + } finally { + if (!bypass) connection.inFlightCount.decrementAndGet() + } + } + } + + private fun tryAdmit(connection: Connection): Boolean { + while (true) { + val current = connection.inFlightCount.value + if (current >= connection.maxInFlightHandlers) return false + if (connection.inFlightCount.compareAndSet(current, current + 1)) return true + } + } + + private suspend fun rejectOverloadedRequest(connection: Connection, request: JSONRPCRequest) { + logger.warn { + "Rejecting request ${request.method} (id: ${request.id}): " + + "in-flight handler limit (${connection.maxInFlightHandlers}) reached" + } + try { + connection.transport.send( + JSONRPCError( + id = request.id, + error = RPCError( + code = RPCError.ErrorCode.INTERNAL_ERROR, + message = "Server is busy: too many in-flight messages", + ), + ), + ) + } catch (e: CancellationException) { + throw e + } catch (cause: Throwable) { + logger.error(cause) { "Failed to send busy rejection for request id: ${request.id}" } + onError(cause) + } + } + + private fun handleCancelledNotification(notification: CancelledNotification) { + val connection = connectionRef.value ?: return + val requestId = notification.params.requestId + val job = connection.inFlightRequestJobs.value[requestId] + if (job == null) { + logger.trace { "Ignoring cancellation for unknown or already-completed request: $requestId" } + return + } + job.cancel("Cancelled by peer: ${notification.params.reason ?: "unknown"}") + } + private suspend fun onNotification(notification: JSONRPCNotification) { logger.trace { "Received notification: ${notification.method}" } @@ -284,15 +571,16 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } } - private suspend fun onRequest(request: JSONRPCRequest) { + private suspend fun onRequest(request: JSONRPCRequest, connection: Connection, trackCancellation: Boolean) { logger.trace { "Received request: ${request.method} (id: ${request.id})" } val handler = requestHandlers[request.method] ?: fallbackRequestHandler + val capturedTransport = connection.transport if (handler === null) { logger.trace { "No handler found for request: ${request.method}" } try { - transport?.send( + capturedTransport.send( JSONRPCError( id = request.id, error = RPCError( @@ -310,36 +598,77 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio return } - try { - val result = handler(request, RequestHandlerExtra()) - logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" } + val extra = RequestHandlerExtra( + requestId = request.id, + method = Method.from(request.method), + protocol = this, + capturedTransport = capturedTransport, + ) - transport?.send( - JSONRPCResponse( - id = request.id, - result = result ?: EmptyResult(), - ), - ) + // Registration in the in-flight registry already happened in dispatchRequest's launch + // block (synchronously, pre-suspension). Here we only need the job for suppression checks. + val handlerJob = if (trackCancellation) currentCoroutineContext().job else null + + withContext(extra) { + try { + val result = handler(request, extra) + logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" } + if (handlerJob?.isCancelled == true) { + logger.trace { "Suppressing response for request cancelled by peer (id: ${request.id})" } + return@withContext + } + capturedTransport.send( + JSONRPCResponse( + id = request.id, + result = result ?: EmptyResult(), + ), + ) + } catch (e: CancellationException) { + // Intentional exception to the always-rethrow-CE convention, only at this boundary. + if (handlerJob == null) throw e // serial phase: inline dispatch is not response-suppressed + if (handlerJob.isCancelled) throw e // genuine peer or close cancel: suppress the response + // CE escaped a live handler job (e.g. a leaked inner withTimeout). Answer + // INTERNAL_ERROR so the peer does not hang until its own timeout. + respondWithError(capturedTransport, request, e) + } catch (cause: Throwable) { + if (handlerJob?.isCancelled == true) { + logger.trace { "Suppressing error response for request cancelled by peer (id: ${request.id})" } + return@withContext + } + respondWithError(capturedTransport, request, cause) + } + } + } + + private fun registerInFlight(connection: Connection, id: RequestId, job: Job) { + val previous = connection.inFlightRequestJobs.getAndUpdate { current -> current.putting(id, job) }[id] + if (previous != null) { + logger.warn { "Duplicate in-flight request id $id; replacing the previous handler job" } + } + job.invokeOnCompletion { + // Identity guard: never evict a successor job registered under a reused id. + connection.inFlightRequestJobs.update { current -> + if (current[id] === job) current.removing(id) else current + } + } + } + + private suspend fun respondWithError(transport: Transport, request: JSONRPCRequest, cause: Throwable) { + logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" } + try { + val rpcError = if (cause is McpException) { + RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data) + } else { + RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error") + } + transport.send(JSONRPCError(id = request.id, error = rpcError)) } catch (e: CancellationException) { throw e - } catch (cause: Throwable) { - logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" } - - try { - val rpcError = if (cause is McpException) { - RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data) - } else { - RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error") - } - transport?.send(JSONRPCError(id = request.id, error = rpcError)) - } catch (e: CancellationException) { - throw e - } catch (sendError: Throwable) { - logger.error(sendError) { - "Failed to send error response for request: ${request.method} (id: ${request.id})" - } - // Optionally implement fallback behavior here + } catch (sendError: Throwable) { + logger.error(sendError) { + "Failed to send error response for request: ${request.method} (id: ${request.id})" } + onError(sendError) } } @@ -354,6 +683,10 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val handler = _progressHandlers.value[progressToken] if (handler == null) { + if (isRecentlyCancelled(progressToken)) { + logger.trace { "Ignoring progress for a locally cancelled request: $progressToken" } + return + } val error = Error( "Received a progress notification for an unknown token: ${McpJson.encodeToString(notification)}", ) @@ -370,7 +703,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val oldResponseHandlers = _responseHandlers.getAndUpdate { current -> if (messageId != null && messageId in current) { - current.remove(messageId) + current.removing(messageId) } else { current } @@ -379,8 +712,12 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val handler = oldResponseHandlers[messageId] if (handler != null) { - messageId?.let { msg -> _progressHandlers.update { it.remove(msg) } } + messageId?.let { msg -> _progressHandlers.update { it.removing(msg) } } } else { + if (isRecentlyCancelled(messageId)) { + logger.trace { "Ignoring response for a locally cancelled request: $messageId" } + return + } onError( IllegalStateException( "Received a response for an unknown message ID: ${McpJson.encodeToString(error ?: response)}", @@ -393,7 +730,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio handler(response, null) } else { checkNotNull(error) - val mcpException = McpException( + val mcpException = McpException.fromError( code = error.error.code, message = error.error.message, data = error.error.data, @@ -438,7 +775,8 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio public suspend fun request(request: Request, options: RequestOptions? = null): T { logger.trace { "Sending request: ${request.method}" } val result = CompletableDeferred() - val transport = transport ?: error("Not connected") + val connection = connectionRef.value ?: error("Not connected") + val transport = connection.transport if (this@Protocol.options?.enforceStrictCapabilities == true) { assertCapabilityForMethod(request.method) @@ -448,7 +786,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio options?.onProgress?.let { progressHandler -> logger.trace { "Registering progress handler for request id: $id" } _progressHandlers.update { current -> - current.put(id, progressHandler) + current.putting(id, progressHandler) } val paramsObject = (this.params as? JsonObject) ?: JsonObject(emptyMap()) @@ -467,10 +805,10 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val jsonRpcRequestId = jsonRpcRequest.id _responseHandlers.update { current -> - current.put(jsonRpcRequestId) { response, error -> + current.putting(jsonRpcRequestId) { response, error -> if (error != null) { result.completeExceptionally(error) - return@put + return@putting } try { @@ -482,42 +820,84 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } } - val cancel: suspend (Throwable) -> Unit = { reason: Throwable -> - _responseHandlers.update { current -> current.remove(jsonRpcRequestId) } - _progressHandlers.update { current -> current.remove(jsonRpcRequestId) } + if (connectionRef.value !== connection) { + // Connection closed or replaced during registration; nothing would complete this handler. + _responseHandlers.update { it.removing(jsonRpcRequestId) } + _progressHandlers.update { it.removing(jsonRpcRequestId) } + throw McpException(RPCError.ErrorCode.CONNECTION_CLOSED, "Connection closed") + } - val notification = CancelledNotification( - params = CancelledNotificationParams( - requestId = jsonRpcRequestId, - reason = reason.message ?: "Unknown", - ), - ) + val cancelPending: suspend (reason: Throwable, notifyPeer: Boolean) -> Unit = { reason, notifyPeer -> + // Remember before removing: a racing onResponse/onProgress that sees the handler gone + // must also see the id, or it reports a late message as unknown. Order is load-bearing. + rememberCancelledRequestId(jsonRpcRequestId) + _responseHandlers.update { current -> current.removing(jsonRpcRequestId) } + _progressHandlers.update { current -> current.removing(jsonRpcRequestId) } - val jsonRpcNotification = notification.toJSON() - - transport.send(jsonRpcNotification, options) + if (notifyPeer) { + val notification = CancelledNotification( + params = CancelledNotificationParams( + requestId = jsonRpcRequestId, + reason = reason.message ?: "Unknown", + ), + ) + transport.send(notification.toJSON(), options) + } result.completeExceptionally(reason) } + // The MCP spec forbids cancelling `initialize`; local cleanup still runs, + // but no notifications/cancelled goes on the wire (timeout and cancel paths alike). + val notifyPeerOnCancel = request.method != Method.Defined.Initialize + val timeout = options?.timeout ?: DEFAULT_REQUEST_TIMEOUT try { - withTimeout(timeout) { + val response = withTimeoutOrNull(timeout) { logger.trace { "Sending request message with id: $jsonRpcRequestId" } - this@Protocol.transport?.send(jsonRpcRequest, options) + transport.send(jsonRpcRequest, options) + result.await() } - return result.await() - } catch (cause: TimeoutCancellationException) { - logger.error { "Request timed out after ${timeout.inWholeMilliseconds}ms: ${request.method}" } - cancel( - McpException( + if (response == null) { + // Our own request timeout expired. An outer withTimeout's cancellation propagates + // through withTimeoutOrNull instead of returning null and is handled by the catch below. + logger.error { "Request timed out after ${timeout.inWholeMilliseconds}ms: ${request.method}" } + val timeoutError = McpException( code = RPCError.ErrorCode.REQUEST_TIMEOUT, message = "Request timed out", data = JsonObject(mutableMapOf("timeout" to JsonPrimitive(timeout.inWholeMilliseconds))), - ), - ) - result.cancel(cause) + ) + withContext(NonCancellable) { + try { + cancelPending(timeoutError, notifyPeerOnCancel) + } catch (e: Throwable) { + logger.warn(e) { "Failed to notify peer about timed-out request" } + onError(e) + } + } + result.cancel() + throw timeoutError + } + return response + } catch (cause: CancellationException) { + // Plain caller cancellation, or an outer withTimeout that propagated through + // withTimeoutOrNull (only our own deadline returns null there). NonCancellable so the + // cancellation notification still goes out even though the caller's job is cancelled. + // A secondary send failure must never replace the original CancellationException. + withContext(NonCancellable) { + try { + cancelPending(cause, notifyPeerOnCancel) + } catch (e: Throwable) { + logger.warn(e) { "Failed to notify peer about cancelled request" } + onError(e) + } + } throw cause + } finally { + // Backstop for the failed-send path. The success, timeout and cancellation paths already + // removed these handlers, so this only bites when the outbound send failed. + _responseHandlers.update { it.removing(jsonRpcRequestId) } + _progressHandlers.update { it.removing(jsonRpcRequestId) } } } @@ -538,6 +918,11 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio * Registers a handler to invoke when this protocol object receives a request with the given method. * * Note that this will replace any previous request handler for the same method. + * + * Handlers may run concurrently after initialization and are cancelled cooperatively, so always + * re-throw [CancellationException]. Replacing a built-in control-method handler (`ping`, + * `notifications/cancelled`, `notifications/progress`, `notifications/initialized`) with slow + * code degrades connection liveness. */ public inline fun setRequestHandler( method: Method, @@ -556,8 +941,16 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val wrapped = wrapRequestHandler(method, block) _requestHandlers.update { current -> - current.put(method.value) { jSONRPCRequest, extraHandler -> - val request = jSONRPCRequest.fromJSON() + current.putting(method.value) { jSONRPCRequest, extraHandler -> + val request = try { + jSONRPCRequest.fromJSON() + } catch (cause: SerializationException) { + throw McpException( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "Invalid params", + cause = cause, + ) + } val response = wrapped(request as T, extraHandler) response } @@ -582,17 +975,23 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio * Removes the request handler for the given method. */ public fun removeRequestHandler(method: Method) { - _requestHandlers.update { current -> current.remove(method.value) } + _requestHandlers.update { current -> current.removing(method.value) } } /** * Registers a handler to invoke when this protocol object receives a notification with the given method. * * Note that this will replace any previous notification handler for the same method. + * + * Handlers may run concurrently after initialization and are cancelled cooperatively, so always + * re-throw [CancellationException]. Replacing a built-in control-method handler (`ping`, + * `notifications/cancelled`, `notifications/progress`, `notifications/initialized`) with slow + * code degrades connection liveness. Replacing `notifications/cancelled` in particular disables + * inbound request cancellation entirely, since that handler is what cancels the running job. */ public fun setNotificationHandler(method: Method, handler: (notification: T) -> Deferred) { _notificationHandlers.update { current -> - current.put(method.value) { + current.putting(method.value) { @Suppress("UNCHECKED_CAST") handler(it.fromJSON() as T) } @@ -603,6 +1002,22 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio * Removes the notification handler for the given method. */ public fun removeNotificationHandler(method: Method) { - _notificationHandlers.update { current -> current.remove(method.value) } + _notificationHandlers.update { current -> current.removing(method.value) } + } + + private class Connection( + val transport: Transport, + val handlerScope: CoroutineScope, + val executionSemaphore: Semaphore, + val maxInFlightHandlers: Int, + ) { + /** Init gate: inbound dispatch is inline/serial until this flips. */ + val concurrentDispatchEnabled = atomic(false) + + /** Admission tier: launched-but-not-completed handler jobs (running + parked). */ + val inFlightCount = atomic(0) + + /** In-flight request handler jobs, for `notifications/cancelled`. */ + val inFlightRequestJobs: AtomicRef> = atomic(persistentMapOf()) } } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt index c0c853f..8102cf6 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt @@ -4,13 +4,24 @@ import io.github.oshai.kotlinlogging.KotlinLogging import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.McpJson import kotlinx.io.Buffer +import kotlinx.io.IOException import kotlinx.io.indexOf import kotlinx.io.readString /** * Buffers a continuous stdio stream into discrete JSON-RPC messages. + * + * A single newline-terminated frame may not exceed an internal size cap (16 MiB): a peer that streams + * bytes without ever sending a newline triggers a [TooLongFrameException] from [readMessage] instead + * of growing the buffer without bound. A non-positive cap disables the check. */ -public class ReadBuffer { +public class ReadBuffer internal constructor(private val maxFrameSize: Int) { + + public constructor() : this(DEFAULT_MAX_FRAME_SIZE) + + internal companion object { + internal const val DEFAULT_MAX_FRAME_SIZE: Int = 16 * 1024 * 1024 + } private val logger = KotlinLogging.logger { } @@ -53,7 +64,11 @@ public class ReadBuffer { private fun readNextLine(): String? { val lfIndex = if (buffer.exhausted()) -1L else buffer.indexOf('\n'.code.toByte()) - if (lfIndex == -1L) return null + if (lfIndex == -1L) { + failIfFrameTooLong(buffer.size) + return null + } + failIfFrameTooLong(lfIndex) return if (lfIndex == 0L) { buffer.skip(1) @@ -71,6 +86,13 @@ public class ReadBuffer { } } + private fun failIfFrameTooLong(frameSize: Long) { + if (maxFrameSize in 1..(line) /** Serializes a [JSONRPCMessage] to its JSON string representation with a trailing newline. */ diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt index 56c851b..6a9015b 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt @@ -12,9 +12,26 @@ import kotlin.jvm.JvmOverloads * @property data optional additional error payload as a JSON element * @param cause the original cause */ -public class McpException @JvmOverloads public constructor( +public open class McpException @JvmOverloads public constructor( public val code: Int, message: String = "MCP error $code", public val data: JsonElement? = null, cause: Throwable? = null, -) : Exception(message, cause) +) : Exception(message, cause) { + internal companion object { + /** + * Reconstructs the most specific [McpException] subtype for a JSON-RPC error. + * + * Recognizes [RPCError.ErrorCode.URL_ELICITATION_REQUIRED] errors and returns a + * [UrlElicitationRequiredException] when [data] carries valid URL-mode elicitations; + * otherwise returns a plain [McpException]. Never throws — a malformed payload simply + * degrades to a plain [McpException]. + */ + fun fromError(code: Int, message: String, data: JsonElement?): McpException { + if (code == RPCError.ErrorCode.URL_ELICITATION_REQUIRED && data != null) { + UrlElicitationRequiredException.fromDataOrNull(message, data)?.let { return it } + } + return McpException(code = code, message = message, data = data) + } + } +} diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredException.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredException.kt new file mode 100644 index 0000000..f3da768 --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredException.kt @@ -0,0 +1,65 @@ +package io.modelcontextprotocol.kotlin.sdk.types + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement + +/** + * Payload carried by the [data][RPCError.data] of a [RPCError.ErrorCode.URL_ELICITATION_REQUIRED] error. + * + * @property elicitations The URL-mode elicitations that must be completed before the original request + * can succeed. Each entry is equivalent to an `elicitation/create` request. + */ +@Serializable +public data class UrlElicitationRequiredData(val elicitations: List) + +/** + * Signals that one or more URL-mode elicitations must be completed before a request can be processed. + * + * Serialized over the wire as a JSON-RPC error with code [RPCError.ErrorCode.URL_ELICITATION_REQUIRED] + * and [data][RPCError.data] holding the [elicitations]. A server throws this from a request handler to + * tell the client that a URL-mode elicitation is required; the client receives it from any request as a + * typed exception, equivalent to receiving an `elicitation/create` request. + * + * The client is responsible for obtaining explicit user consent and surfacing each [ElicitRequestURLParams] + * before navigation — the SDK never opens or validates URLs. + * + * Example client-side handling: + * ```kotlin + * try { + * client.callTool(name = "create_repo", arguments = args) + * } catch (e: UrlElicitationRequiredException) { + * for (elicitation in e.elicitations) { + * // Show elicitation.message and the target domain, then ask the user for consent. + * if (userConsents(elicitation.url)) openInBrowser(elicitation.url) + * // Await completion via Client.setElicitationCompleteHandler, then optionally retry the call. + * } + * } + * ``` + * + * @property elicitations The required URL-mode elicitations (always non-empty). + */ +public class UrlElicitationRequiredException( + public val elicitations: List, + message: String = defaultMessage(elicitations), +) : McpException( + code = RPCError.ErrorCode.URL_ELICITATION_REQUIRED, + message = message, + data = McpJson.encodeToJsonElement(UrlElicitationRequiredData(elicitations)), +) { + internal companion object { + private fun defaultMessage(elicitations: List): String = + if (elicitations.size > 1) "URL elicitations are required" else "URL elicitation is required" + + /** + * Reconstructs a [UrlElicitationRequiredException] from JSON-RPC error [data], or `null` when the + * payload is absent, malformed, or carries no elicitations. Never throws. + */ + fun fromDataOrNull(message: String, data: JsonElement): UrlElicitationRequiredException? = + runCatching { McpJson.decodeFromJsonElement(data).elicitations } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + ?.let { UrlElicitationRequiredException(it, message) } + } +} diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.dsl.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.dsl.kt index 75e923b..0d8b2b7 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.dsl.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.dsl.kt @@ -38,7 +38,7 @@ import kotlinx.serialization.json.buildJsonObject public class ClientCapabilitiesBuilder @PublishedApi internal constructor() { private var sampling: ClientCapabilities.Sampling? = null private var roots: ClientCapabilities.Roots? = null - private var elicitation: JsonObject? = null + private var elicitation: ClientCapabilities.Elicitation? = null private var extensions: Map? = null private var experimental: JsonObject? = null @@ -47,7 +47,7 @@ public class ClientCapabilitiesBuilder @PublishedApi internal constructor() { * * Pass `ClientCapabilities.Sampling()` to enable base sampling with no sub-capabilities. * Construct `ClientCapabilities.Sampling(tools = EmptyJsonObject, context = EmptyJsonObject)` - * directly to enable SEP-1577 sub-capabilities. + * directly to enable tools/context sub-capabilities. * * Example: * ```kotlin @@ -99,26 +99,10 @@ public class ClientCapabilitiesBuilder @PublishedApi internal constructor() { * * @param value The elicitation capability configuration */ - public fun elicitation(value: JsonObject) { + public fun elicitation(value: ClientCapabilities.Elicitation) { this.elicitation = value } - /** - * Indicates that the client supports elicitation from the server with custom configuration. - * - * Example: - * ```kotlin - * capabilities { - * elicitation { - * put("mode", JsonPrimitive("interactive")) - * } - * } - * ``` - * - * @param block Lambda for building the elicitation configuration - */ - public fun elicitation(block: JsonObjectBuilder.() -> Unit): Unit = elicitation(buildJsonObject(block)) - /** * Defines extensions that the client supports. * diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.kt index e13a750..ecb8c48 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.kt @@ -41,9 +41,11 @@ public data class Implementation( * supports that capability. * * @property sampling Present if the client supports sampling from an LLM. - * Use [ClientCapabilities.Sampling] to configure SEP-1577 sub-capabilities (tools, context). + * Use [ClientCapabilities.Sampling] to configure tools/context sub-capabilities. * @property roots Present if the client supports listing roots. * @property elicitation Present if the client supports elicitation from the server. + * Use [ClientCapabilities.Elicitation] to configure form/url mode sub-capabilities. + * @property tasks Present if the client supports task-augmented requests, listing, or cancellation. * @property experimental Experimental, non-standard capabilities that the client supports. * Keys are capability names, values are capability-specific configuration objects. * @property extensions Optional extensions that the client supports. @@ -54,23 +56,33 @@ public data class Implementation( public data class ClientCapabilities( public val sampling: Sampling? = null, public val roots: Roots? = null, - public val elicitation: JsonObject? = null, + public val elicitation: Elicitation? = null, + public val tasks: Tasks? = null, public val experimental: JsonObject? = null, public val extensions: Map? = null, ) { /** - * Source-compatibility constructor retaining the pre-SEP-1577 `sampling: JsonObject?` - * shape. Any non-null [sampling] is converted to an empty [Sampling] (sub-capabilities - * cannot be recovered from the old opaque `JsonObject`). + * Source-compatibility constructor retaining the older `sampling: JsonObject?` / + * `elicitation: JsonObject?` shapes. Any non-null [sampling] is converted to an + * empty [Sampling], and any non-null [elicitation] is converted to an empty + * [Elicitation] (sub-capabilities cannot be recovered from the old opaque + * `JsonObject`). */ @Deprecated( - "ClientCapabilities.sampling is now typed. Pass a ClientCapabilities.Sampling? " + + "ClientCapabilities.sampling and ClientCapabilities.elicitation are now typed. " + + "Pass typed ClientCapabilities.Sampling? / ClientCapabilities.Elicitation? " + "instead of JsonObject?.", ReplaceWith( - "ClientCapabilities(sampling?.let { ClientCapabilities.Sampling() }, " + - "roots, elicitation, experimental, extensions)", + "ClientCapabilities(" + + "sampling?.let { ClientCapabilities.Sampling() }, " + + "roots, " + + "elicitation?.let { ClientCapabilities.Elicitation() }, " + + "tasks = null, " + + "experimental = experimental, " + + "extensions = extensions)", ), + level = DeprecationLevel.WARNING, ) public constructor( sampling: JsonObject?, @@ -81,7 +93,8 @@ public data class ClientCapabilities( ) : this( sampling = sampling?.let { Sampling() }, roots = roots, - elicitation = elicitation, + elicitation = elicitation?.let { Elicitation() }, + tasks = null, experimental = experimental, extensions = extensions, ) @@ -101,11 +114,11 @@ public data class ClientCapabilities( /** * @property sampling convenience value to enable the base sampling capability with no sub-capabilities - * @property elicitation convenience value to enable the elicitation capability + * @property elicitation convenience value to enable the elicitation capability with default form mode */ public companion object { public val sampling: Sampling = Sampling() - public val elicitation: JsonObject = EmptyJsonObject + public val elicitation: Elicitation = Elicitation() } /** @@ -120,8 +133,71 @@ public data class ClientCapabilities( */ @Serializable public data class Roots(val listChanged: Boolean? = null) + + /** + * Sub-capabilities for elicitation. + * + * An empty [Elicitation] (both fields null) is equivalent to declaring `form` mode only. + * + * @property form Present if the client supports form-mode elicitation + * (in-band structured data collection). + * @property url Present if the client supports url-mode elicitation + * (out-of-band interaction via URL navigation). + */ + @Serializable + public data class Elicitation(public val form: JsonObject? = null, public val url: JsonObject? = null) + + /** + * Sub-capabilities for tasks (client side). + * + * Declares which client-side requests can be augmented with task execution, + * and whether the client supports listing and cancelling tasks. + * + * @property list Present if the client supports the `tasks/list` operation. + * @property cancel Present if the client supports the `tasks/cancel` operation. + * @property requests Present if the client supports task-augmented sampling and/or elicitation requests. + */ + @Serializable + public data class Tasks( + public val list: JsonObject? = null, + public val cancel: JsonObject? = null, + public val requests: Requests? = null, + ) { + /** + * Task-augmentable client-side request categories. + * + * @property sampling Present if the client supports task-augmented `sampling/createMessage` requests. + * @property elicitation Present if the client supports task-augmented `elicitation/create` requests. + */ + @Serializable + public data class Requests( + public val sampling: Sampling? = null, + public val elicitation: Elicitation? = null, + ) { + /** + * @property createMessage Present if the client supports task-augmented `sampling/createMessage` requests. + */ + @Serializable + public data class Sampling(public val createMessage: JsonObject? = null) + + /** + * @property create Present if the client supports task-augmented `elicitation/create` requests. + */ + @Serializable + public data class Elicitation(public val create: JsonObject? = null) + } + } } +/** + * Whether the client supports url-mode elicitation (out-of-band interaction via URL navigation). + * + * `false` when the client declared no elicitation capability at all, or only form mode (an empty + * [ClientCapabilities.Elicitation] is treated as form mode only). + */ +public val ClientCapabilities.Elicitation?.supportsUrl: Boolean + get() = this?.url != null + /** * Capabilities that a server may support. * @@ -137,6 +213,7 @@ public data class ClientCapabilities( * @property logging Present if the server supports sending log messages to the client. * @property completions Present if the server supports argument autocompletion suggestions. * Keys are capability names, values are capability-specific configuration objects. + * @property tasks Present if the server supports task-augmented requests, listing, or cancellation. * @property experimental Experimental, non-standard capabilities that the server supports. * @property extensions Optional extensions that the server supports. * Keys are extension identifiers (e.g., `"io.modelcontextprotocol/ui"`), @@ -149,6 +226,7 @@ public data class ServerCapabilities( val prompts: Prompts? = null, val logging: JsonObject? = null, val completions: JsonObject? = null, + val tasks: Tasks? = null, val experimental: JsonObject? = null, val extensions: Map? = null, ) { @@ -199,4 +277,35 @@ public data class ServerCapabilities( */ @Serializable public data class Prompts(val listChanged: Boolean? = null) + + /** + * Sub-capabilities for tasks (server side). + * + * Declares which server-side requests can be augmented with task execution, + * and whether the server supports listing and cancelling tasks. + * + * @property list Present if the server supports the `tasks/list` operation. + * @property cancel Present if the server supports the `tasks/cancel` operation. + * @property requests Present if the server supports task-augmented tool call requests. + */ + @Serializable + public data class Tasks( + public val list: JsonObject? = null, + public val cancel: JsonObject? = null, + public val requests: Requests? = null, + ) { + /** + * Task-augmentable server-side request categories. + * + * @property tools Present if the server supports task-augmented `tools/call` requests. + */ + @Serializable + public data class Requests(public val tools: Tools? = null) { + /** + * @property call Present if the server supports task-augmented `tools/call` requests. + */ + @Serializable + public data class Tools(public val call: JsonObject? = null) + } + } } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/discovery.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/discovery.kt new file mode 100644 index 0000000..d486b1f --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/discovery.kt @@ -0,0 +1,107 @@ +package io.modelcontextprotocol.kotlin.sdk.types + +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Required +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonObject + +/** + * Parameters for a `server/discover` request. + * + * Discovery belongs to the request-scoped lifecycle and therefore requires a protocol version and + * client capabilities in [meta]. Client information is recommended by the protocol but optional. + * Unknown metadata entries are preserved by [RequestMeta]. + * + * @property meta request-scoped protocol and client metadata + * @throws IllegalArgumentException if required request-scoped metadata is absent + * @throws SerializationException if required metadata is malformed + */ +@Serializable +@ExperimentalMcpApi +public data class DiscoverRequestParams( + @SerialName("_meta") + override val meta: RequestMeta, +) : RequestParams { + init { + requireNotNull(meta.protocolVersion) { "${RequestMetaKeys.PROTOCOL_VERSION} is required" } + requireNotNull(meta.clientCapabilities) { "${RequestMetaKeys.CLIENT_CAPABILITIES} is required" } + } +} + +/** + * Requests the protocol versions, capabilities, and metadata advertised by a server. + * + * @property params request metadata required by the request-scoped lifecycle + */ +@Serializable +@ExperimentalMcpApi +public data class DiscoverRequest(override val params: DiscoverRequestParams) : ClientRequest { + @OptIn(ExperimentalSerializationApi::class) + @EncodeDefault + override val method: Method = Method.Defined.ServerDiscover + + /** Metadata for this request. */ + public val meta: RequestMeta + get() = params.meta +} + +/** Defines where a cached discovery result may be reused. */ +@Serializable +@ExperimentalMcpApi +public enum class CacheScope { + /** The result may only be reused within the same authorization context. */ + @SerialName("private") + Private, + + /** The result may be shared across authorization contexts. */ + @SerialName("public") + Public, +} + +/** + * Result returned by `server/discover`. + * + * @property supportedVersions protocol versions supported by the server + * @property capabilities capabilities advertised by the server + * @property instructions optional guidance for clients using the server + * @property resultType discriminator for the result representation + * @property ttlMs number of milliseconds clients may treat this result as fresh + * @property cacheScope authorization boundary within which the result may be reused + * @property meta optional response metadata, including optional server information + * @throws IllegalArgumentException if [ttlMs] is negative + */ +@Serializable +@ExperimentalMcpApi +public data class DiscoverResult( + val supportedVersions: List, + val capabilities: ServerCapabilities, + val instructions: String? = null, + @EncodeDefault + val resultType: String = COMPLETE_RESULT_TYPE, + @Required + val ttlMs: Long = 0, + @Required + val cacheScope: CacheScope = CacheScope.Private, + @SerialName("_meta") + override val meta: JsonObject? = null, +) : ServerResult { + init { + require(ttlMs >= 0) { "ttlMs must be non-negative, but was $ttlMs" } + } +} + +/** + * Data returned when a request selects an unsupported protocol version. + * + * @property supported protocol versions supported by the receiver + * @property requested protocol version selected by the request + */ +@Serializable +@ExperimentalMcpApi +public data class UnsupportedProtocolVersionData(val supported: List, val requested: String) + +internal const val COMPLETE_RESULT_TYPE: String = "complete" diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.kt index a0ee74c..1bb73e6 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.kt @@ -133,6 +133,11 @@ public data class ElicitRequestFormParams( * Directs the user to an external URL for out-of-band interactions (e.g., OAuth flows, * payment processing, or entering sensitive credentials) that must not pass through the MCP client. * + * Handling the [url] is the responsibility of the host application: it should require HTTPS, clearly + * display the target domain, obtain explicit user consent, and open the URL in a secure browser context. + * The SDK neither opens, fetches, nor validates the URL. URLs must only appear in URL-mode requests — + * never render a URL from a form-mode request as a clickable link. + * * @property message The message explaining why the interaction is needed. * @property elicitationId A unique identifier for this elicitation. The client MUST treat * this ID as an opaque value. diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonRpc.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonRpc.kt index 7eefdb5..7e10904 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonRpc.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonRpc.kt @@ -269,6 +269,16 @@ public data class RPCError(val code: Int, val message: String, val data: JsonEle /** Resource not found */ public const val RESOURCE_NOT_FOUND: Int = -32002 + /** The request selected a protocol version that the receiver does not support. */ + public const val UNSUPPORTED_PROTOCOL_VERSION: Int = -32022 + + /** + * A URL-mode elicitation must be completed before the request can be processed. + * The error [data][RPCError.data] carries the required URL-mode elicitations + * (see [UrlElicitationRequiredException]). + */ + public const val URL_ELICITATION_REQUIRED: Int = -32042 + // Standard JSON-RPC 2.0 error codes /** Invalid JSON was received */ diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/methods.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/methods.kt index 19f4da5..1824ad0 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/methods.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/methods.kt @@ -16,6 +16,7 @@ public sealed interface Method { @Serializable public enum class Defined(override val value: String) : Method { Initialize("initialize"), + ServerDiscover("server/discover"), Ping("ping"), ResourcesList("resources/list"), ResourcesTemplatesList("resources/templates/list"), @@ -53,4 +54,13 @@ public sealed interface Method { */ @Serializable public data class Custom(override val value: String) : Method + + public companion object { + private val definedByValue: Map by lazy { + Defined.entries.associateBy { it.value } + } + + /** Resolves a wire method string to a [Defined] entry when known, [Custom] otherwise. */ + internal fun from(value: String): Method = definedByValue[value] ?: Custom(value) + } } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/request.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/request.kt index aacab9a..7d885fb 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/request.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/request.kt @@ -1,8 +1,10 @@ package io.modelcontextprotocol.kotlin.sdk.types +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -11,6 +13,13 @@ import kotlinx.serialization.json.long import kotlinx.serialization.json.longOrNull import kotlin.jvm.JvmInline +internal object RequestMetaKeys { + const val PROTOCOL_VERSION: String = "io.modelcontextprotocol/protocolVersion" + const val CLIENT_INFO: String = "io.modelcontextprotocol/clientInfo" + const val CLIENT_CAPABILITIES: String = "io.modelcontextprotocol/clientCapabilities" + const val LOG_LEVEL: String = "io.modelcontextprotocol/logLevel" +} + /** * Metadata attached to a request's `_meta` field. * @@ -40,6 +49,88 @@ public value class RequestMeta(public val json: JsonObject) { McpJson.decodeFromJsonElement(element) } + /** + * The MCP protocol version selected for this request, or `null` when absent. + * + * This field is required by the request-scoped lifecycle introduced in protocol version + * `2026-07-28`, but remains optional here so older requests retain their wire shape. + * + * @throws SerializationException if the field is present but is not a string + */ + @ExperimentalMcpApi + public val protocolVersion: String? + get() = json[RequestMetaKeys.PROTOCOL_VERSION]?.let { element -> + if (element !is JsonPrimitive || !element.isString) { + throw SerializationException("${RequestMetaKeys.PROTOCOL_VERSION} must be a JSON string") + } + element.content + } + + /** + * Information about the client making this request, or `null` when absent. + * + * Request-scoped clients should include this field unless specifically configured not to do + * so. The self-reported value is intended for display, logging, and debugging; servers should + * not use it to change behavior or rely on it for security decisions. + * + * @throws SerializationException if the field is present but is not a valid [Implementation] + */ + @ExperimentalMcpApi + public val clientInfo: Implementation? + get() = json[RequestMetaKeys.CLIENT_INFO]?.let { element -> + try { + McpJson.decodeFromJsonElement(element) + } catch (cause: SerializationException) { + throw SerializationException( + "${RequestMetaKeys.CLIENT_INFO} must be a valid client implementation", + cause, + ) + } + } + + /** + * Capabilities declared by the client for this request, or `null` when absent. + * + * Servers using the request-scoped lifecycle must not infer capabilities from an earlier + * request. An empty object means the client supports no optional capabilities. + * + * @throws SerializationException if the field is present but is not valid [ClientCapabilities] + */ + @ExperimentalMcpApi + public val clientCapabilities: ClientCapabilities? + get() = json[RequestMetaKeys.CLIENT_CAPABILITIES]?.let { element -> + try { + McpJson.decodeFromJsonElement(element) + } catch (cause: SerializationException) { + throw SerializationException( + "${RequestMetaKeys.CLIENT_CAPABILITIES} must be a valid client capabilities object", + cause, + ) + } + } + + /** + * Minimum severity of request-associated log notifications, or `null` when absent. + * + * In the request-scoped lifecycle, absence means that the server must not emit log + * notifications for this request. + * + * @throws SerializationException if the field is present but is not a valid [LoggingLevel] + */ + @Deprecated("Per-request log levels are deprecated as of MCP protocol version 2026-07-28 (SEP-2577).") + @ExperimentalMcpApi + public val logLevel: LoggingLevel? + get() = json[RequestMetaKeys.LOG_LEVEL]?.let { element -> + try { + McpJson.decodeFromJsonElement(element) + } catch (cause: SerializationException) { + throw SerializationException( + "${RequestMetaKeys.LOG_LEVEL} must be a valid logging level", + cause, + ) + } + } + /** * Retrieves the value associated with the specified key from the JSON object. * diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/sampling.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/sampling.kt index 211fdc7..7cc2685 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/sampling.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/sampling.kt @@ -91,6 +91,10 @@ public data class SamplingMessage( @SerialName("_meta") val meta: JsonObject? = null, ) { + init { + require(content.isNotEmpty()) { "content must contain at least one block" } + } + /** * Convenience constructor for a single-block message. Wraps [content] in a * singleton list so call sites can write `SamplingMessage(Role.User, TextContent("hi"))` @@ -178,6 +182,12 @@ public data class CreateMessageRequest(override val params: CreateMessageRequest public val metadata: JsonObject? get() = params.metadata + /** + * Task metadata, if the caller is requesting task-augmented execution. + */ + public val task: TaskMetadata? + get() = params.task + /** * Metadata for this request. May include a progressToken for out-of-band progress notifications. */ @@ -212,6 +222,8 @@ public data class CreateMessageRequest(override val params: CreateMessageRequest * @property toolChoice Optional policy controlling how the model uses the provided [tools]. * The client MUST return an error if this field is present but the client did not advertise * [ClientCapabilities.Sampling.tools]. + * @property task If set, requests task-augmented execution. A receiver that supports it returns + * a [CreateTaskResult] (result via `tasks/result`); otherwise it returns a [CreateMessageResult] directly. * @property meta Optional metadata for this request. May include a progressToken for * out-of-band progress notifications. */ @@ -227,6 +239,7 @@ public data class CreateMessageRequestParams( val metadata: JsonObject? = null, val tools: List? = null, val toolChoice: ToolChoice? = null, + val task: TaskMetadata? = null, @SerialName("_meta") override val meta: RequestMeta? = null, ) : RequestParams @@ -273,6 +286,10 @@ public data class CreateMessageResult( @SerialName("_meta") override val meta: JsonObject? = null, ) : ClientResult { + init { + require(content.isNotEmpty()) { "content must contain at least one block" } + } + /** * Convenience constructor for a single-block response. Wraps [content] in a * singleton list so call sites can write diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt index ab85e6d..36b7e37 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt @@ -1,6 +1,7 @@ package io.modelcontextprotocol.kotlin.sdk.types import io.github.oshai.kotlinlogging.KotlinLogging +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException @@ -59,18 +60,11 @@ internal object MethodSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("io.modelcontextprotocol.kotlin.sdk.types.Method", PrimitiveKind.STRING) - private val definedMethods: Map by lazy { - Method.Defined.entries.associateBy { it.value } - } - override fun serialize(encoder: Encoder, value: Method) { encoder.encodeString(value.value) } - override fun deserialize(decoder: Decoder): Method { - val decodedString = decoder.decodeString() - return definedMethods[decodedString] ?: Method.Custom(decodedString) - } + override fun deserialize(decoder: Decoder): Method = Method.from(decoder.decodeString()) } // ============================================================================ @@ -245,9 +239,11 @@ internal object ResourceContentsPolymorphicSerializer : // Request Serializers // ============================================================================ +@OptIn(ExperimentalMcpApi::class) private val clientRequestDeserializers: Map> by lazy { mapOf( Method.Defined.CompletionComplete.value to CompleteRequest.serializer(), + Method.Defined.ServerDiscover.value to DiscoverRequest.serializer(), Method.Defined.Initialize.value to InitializeRequest.serializer(), Method.Defined.Ping.value to PingRequest.serializer(), Method.Defined.LoggingSetLevel.value to SetLevelRequest.serializer(), @@ -424,9 +420,16 @@ private fun selectClientResultDeserializer(element: JsonElement): Deserializatio * Selects the appropriate deserializer for server results based on JSON content. * Returns null if the structure doesn't match any known server result type. */ +@OptIn(ExperimentalMcpApi::class) private fun selectServerResultDeserializer(element: JsonElement): DeserializationStrategy? { val jsonObject = element.jsonObject + val isDiscoverResult = + "supportedVersions" in jsonObject && + "capabilities" in jsonObject && + "ttlMs" in jsonObject && + "cacheScope" in jsonObject return when { + isDiscoverResult -> DiscoverResult.serializer() "protocolVersion" in jsonObject && "capabilities" in jsonObject -> InitializeResult.serializer() "completion" in jsonObject -> CompleteResult.serializer() "tools" in jsonObject -> ListToolsResult.serializer() diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tools.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tools.kt index 2ff890a..1f0f54e 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tools.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tools.kt @@ -203,6 +203,12 @@ public data class CallToolRequest(override val params: CallToolRequestParams) : public val arguments: JsonObject? get() = params.arguments + /** + * Task metadata, if the caller is requesting task-augmented execution. + */ + public val task: TaskMetadata? + get() = params.task + /** * Metadata for this request. May include a progressToken for out-of-band progress notifications. */ @@ -216,6 +222,8 @@ public data class CallToolRequest(override val params: CallToolRequestParams) : * @property name The name of the tool to invoke. * @property arguments Arguments to pass to the tool. Keys are argument names, values are the argument values. * The structure must match the tool's input schema. + * @property task If set, requests task-augmented execution. A receiver that supports it returns + * a [CreateTaskResult] (result via `tasks/result`); otherwise it returns a [CallToolResult] directly. * @property meta Optional metadata for this request. May include a progressToken for * out-of-band progress notifications. */ @@ -223,6 +231,7 @@ public data class CallToolRequest(override val params: CallToolRequestParams) : public data class CallToolRequestParams( val name: String, val arguments: JsonObject? = null, + val task: TaskMetadata? = null, @SerialName("_meta") override val meta: RequestMeta? = null, ) : RequestParams diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyTest.kt new file mode 100644 index 0000000..b3c95ac --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyTest.kt @@ -0,0 +1,524 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult +import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.PingRequest +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +class ProtocolConcurrencyTest { + + private fun TestScope.newProtocol(): TestProtocol = + TestProtocol(ProtocolOptions(handlerCoroutineContext = StandardTestDispatcher(testScheduler))) + + @Test + fun `a suspended slow handler does not block a later fast request`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/slow") delay(10.seconds) + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) + transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) + runCurrent() + + responsesOn(transport).map { it.id } shouldBe listOf(RequestId(2L)) + + advanceTimeBy(11.seconds) + runCurrent() + responsesOn(transport).map { it.id } shouldBe listOf(RequestId(2L), RequestId(1L)) + } + + @Test + fun `dispatch is serial until concurrent dispatch is enabled`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + // gate NOT flipped + + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/slow") delay(10.seconds) + EmptyResult() + } + + val delivery = launch { + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) + transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) + } + runCurrent() + // inline handling: the delivering coroutine is parked inside the slow handler + delivery.isCompleted shouldBe false + responsesOn(transport) shouldBe emptyList() + + advanceTimeBy(11.seconds) + runCurrent() + delivery.isCompleted shouldBe true + responsesOn(transport).map { it.id } shouldBe listOf(RequestId(1L), RequestId(2L)) + } + + // the hook fires before handler lookup and cannot be disabled by a user handler + @Test + fun `initialized notification invokes the router hook even when a user handler is registered`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + var userHandlerCalled = false + protocol.setNotificationHandler(Method.Defined.NotificationsInitialized) { + userHandlerCalled = true + COMPLETED + } + transport.deliver(JSONRPCNotification(method = Method.Defined.NotificationsInitialized.value)) + runCurrent() + + protocol.initializedNotificationCount shouldBe 1 + userHandlerCalled shouldBe true + } + + @Test + fun `cancelled notification cancels the in-flight handler and suppresses the response`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val entered = CompletableDeferred() + val sawCancellation = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + entered.complete(Unit) + try { + delay(10.seconds) + } catch (e: CancellationException) { + sawCancellation.complete(Unit) + throw e + } + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(5L), method = "test/slow")) + runCurrent() + entered.await() + + transport.deliver(cancelledNotification(requestId = RequestId(5L), reason = "user gave up")) + runCurrent() + sawCancellation.await() + advanceUntilIdle() + + responsesOn(transport) shouldBe emptyList() + errorsOn(transport) shouldBe emptyList() // JSONRPCError messages on the wire + protocol.errors shouldBe emptyList() + } + + @Test + fun `cancelled notification for an unknown id is a silent no-op`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + transport.deliver(cancelledNotification(requestId = RequestId(404L), reason = "nope")) + advanceUntilIdle() + + protocol.errors shouldBe emptyList() + } + + @Test + fun `handler leaking CancellationException without being cancelled produces INTERNAL_ERROR`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + protocol.fallbackRequestHandler = { _, _ -> throw CancellationException("leaked") } + + transport.deliver(JSONRPCRequest(id = RequestId(6L), method = "test/leak")) + advanceUntilIdle() + + val error = errorsOn(transport).single() + error.id shouldBe RequestId(6L) + error.error.code shouldBe RPCError.ErrorCode.INTERNAL_ERROR + } + + // suppression race variants + @Test + fun `handler that swallows cancellation and returns normally still gets no response`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val entered = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + entered.complete(Unit) + try { + delay(10.seconds) + } catch (_: CancellationException) { + // swallow (anti-pattern) and return normally + } + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(7L), method = "test/swallow")) + runCurrent() + entered.await() + transport.deliver(cancelledNotification(requestId = RequestId(7L), reason = "stop")) + advanceUntilIdle() + + responsesOn(transport) shouldBe emptyList() + } + + @Test + fun `handler throwing non-CE while cancelled gets no error response`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val entered = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + entered.complete(Unit) + try { + delay(10.seconds) + } catch (_: CancellationException) { + throw IllegalStateException("boom") + } + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(8L), method = "test/throw")) + runCurrent() + entered.await() + transport.deliver(cancelledNotification(requestId = RequestId(8L), reason = "stop")) + advanceUntilIdle() + + errorsOn(transport) shouldBe emptyList() + protocol.errors shouldBe emptyList() + } + + @Test + fun `suspension-free notification handlers observe strict arrival order`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val seen = mutableListOf() + protocol.fallbackNotificationHandler = { notification -> + seen.add((notification.params as JsonObject).getValue("i").jsonPrimitive.int) + } + + repeat(20) { i -> + transport.deliver( + JSONRPCNotification(method = "test/event", params = buildJsonObject { put("i", i) }), + ) + } + advanceUntilIdle() + + seen shouldBe (0 until 20).toList() + } + + @Test + fun `reconnect resets the dispatch gate to serial`() = runTest { + val protocol = newProtocol() + val transport1 = RecordingTransport() + protocol.connect(transport1) + protocol.enableConcurrency() + protocol.close() + + val transport2 = RecordingTransport() + protocol.connect(transport2) + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/slow") delay(10.seconds) + EmptyResult() + } + + val delivery = launch { + transport2.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) + transport2.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) + } + runCurrent() + delivery.isCompleted shouldBe false // serial again: gate reset + advanceTimeBy(11.seconds) + runCurrent() + responsesOn(transport2).map { it.id } shouldBe listOf(RequestId(1L), RequestId(2L)) + } + + @Test + fun `close cancels in-flight handlers suppresses responses and fails pending outbound requests`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val entered = CompletableDeferred() + val sawCancellation = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + entered.complete(Unit) + try { + delay(10.seconds) + } catch (e: CancellationException) { + sawCancellation.complete(Unit) + throw e + } + EmptyResult() + } + transport.deliver(JSONRPCRequest(id = RequestId(9L), method = "test/slow")) + runCurrent() + entered.await() + + // shouldThrow inside the async so its failure does not cancel the (non-supervisor) test scope. + val pending = async { shouldThrow { protocol.request(PingRequest()) } } + transport.awaitRequest() // outbound ping on the wire + + protocol.close() + runCurrent() + + sawCancellation.await() + responsesOn(transport) shouldBe emptyList() + val thrown = pending.await() + thrown.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED + } + + @Test + fun `message delivered after close is dropped without crash or response`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + protocol.close() + + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/anything")) + advanceUntilIdle() + + responsesOn(transport) shouldBe emptyList() + errorsOn(transport) shouldBe emptyList() + } + + @Test + fun `at most maxConcurrentHandlers handlers run concurrently while control messages bypass`() = runTest { + val protocol = TestProtocol( + ProtocolOptions( + handlerCoroutineContext = StandardTestDispatcher(testScheduler), + maxConcurrentHandlers = 2, + ), + ) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + var entered = 0 + val release = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + entered += 1 + release.await() + EmptyResult() + } + + repeat(4) { i -> transport.deliver(JSONRPCRequest(id = RequestId(i.toLong()), method = "test/slow")) } + runCurrent() + entered shouldBe 2 // exactly two running, two parked on the semaphore + + // ping (bypass) is answered while saturated + transport.deliver(JSONRPCRequest(id = RequestId(100L), method = Method.Defined.Ping.value)) + runCurrent() + responsesOn(transport).map { it.id } shouldBe listOf(RequestId(100L)) + + // responses (bypass, inline) are processed while saturated + val pending = async { protocol.request(PingRequest()) } + runCurrent() + val outbound = transport.awaitRequest() + transport.deliver(JSONRPCResponse(id = outbound.id, result = EmptyResult())) + runCurrent() + pending.isCompleted shouldBe true + + release.complete(Unit) + advanceUntilIdle() + entered shouldBe 4 + responsesOn(transport) shouldHaveSize 5 + } + + @Test + fun `flood beyond maxInFlightHandlers is rejected fail-fast and the read loop never suspends`() = runTest { + val protocol = TestProtocol( + ProtocolOptions( + handlerCoroutineContext = StandardTestDispatcher(testScheduler), + maxConcurrentHandlers = 1, + maxInFlightHandlers = 4, + ), + ) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val release = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + release.await() + EmptyResult() + } + + // 10 requests delivered back-to-back; delivery must complete without advancing + // virtual time — i.e. the read loop was never suspended on admission. + val delivery = launch { + repeat(10) { i -> transport.deliver(JSONRPCRequest(id = RequestId(i.toLong()), method = "test/slow")) } + } + runCurrent() + delivery.isCompleted shouldBe true + + // 4 admitted (1 running + 3 parked), 6 rejected immediately + val busyErrors = errorsOn(transport) + busyErrors shouldHaveSize 6 + busyErrors.forEach { + it.error.code shouldBe RPCError.ErrorCode.INTERNAL_ERROR + it.error.message shouldBe "Server is busy: too many in-flight messages" + } + + // ping still answered at full saturation + transport.deliver(JSONRPCRequest(id = RequestId(100L), method = Method.Defined.Ping.value)) + runCurrent() + responsesOn(transport).map { it.id } shouldBe listOf(RequestId(100L)) + + release.complete(Unit) + advanceUntilIdle() + responsesOn(transport) shouldHaveSize 5 // ping + the 4 admitted + } + + @Test + fun `overflowing notifications are dropped and reported via onError`() = runTest { + val protocol = TestProtocol( + ProtocolOptions( + handlerCoroutineContext = StandardTestDispatcher(testScheduler), + maxConcurrentHandlers = 1, + maxInFlightHandlers = 2, + ), + ) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + // parks one handler with a request: holds the sole execution permit and one in-flight slot + val release = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + release.await() + EmptyResult() + } + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/hog")) + runCurrent() + + // notification 1 is admitted (parked on the semaphore, behind the hogged permit); + // notification 2 overflows the admission cap and is dropped instead of parked. + val notificationRelease = CompletableDeferred() + protocol.fallbackNotificationHandler = { notificationRelease.await() } + transport.deliver(JSONRPCNotification(method = "test/event")) + transport.deliver(JSONRPCNotification(method = "test/event")) + runCurrent() + + protocol.errors shouldHaveSize 1 + protocol.errors.single().message.orEmpty() shouldContain "too many in-flight messages" + errorsOn(transport) shouldBe emptyList() // no wire error for dropped notifications + + release.complete(Unit) + notificationRelease.complete(Unit) + advanceUntilIdle() + } + + @Test + fun `cancelling saturating handlers releases permits and lets parked handlers run`() = runTest { + val protocol = TestProtocol( + ProtocolOptions( + handlerCoroutineContext = StandardTestDispatcher(testScheduler), + maxConcurrentHandlers = 1, + ), + ) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val secondCompleted = CompletableDeferred() + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/hog") { + delay(1000.seconds) // cancellable park + error("unreachable") + } + secondCompleted.complete(Unit) + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/hog")) // holds the permit + transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/next")) // parked on the semaphore + runCurrent() + secondCompleted.isCompleted shouldBe false + + // cancel the hog THROUGH the bypass while saturated + transport.deliver(cancelledNotification(requestId = RequestId(1L), reason = "make room")) + advanceUntilIdle() + + secondCompleted.isCompleted shouldBe true + responsesOn(transport).map { it.id } shouldBe listOf(RequestId(2L)) // hog suppressed, next answered + } + + @Test + fun `a parked handler can itself be cancelled before it ever runs`() = runTest { + val protocol = TestProtocol( + ProtocolOptions( + handlerCoroutineContext = StandardTestDispatcher(testScheduler), + maxConcurrentHandlers = 1, + ), + ) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/hog") { + delay(1000.seconds) // cancellable park + error("unreachable") + } + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/hog")) // holds the permit + transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/next")) // parked on the semaphore + runCurrent() + + // cancel the parked handler before it ever acquires the permit, then cancel the hog too + transport.deliver(cancelledNotification(requestId = RequestId(2L), reason = "give up while parked")) + transport.deliver(cancelledNotification(requestId = RequestId(1L), reason = "make room")) + advanceUntilIdle() + + // no response for id=2 at all: its withPermit acquire was cancelled before onRequest ever ran + responsesOn(transport) shouldBe emptyList() + errorsOn(transport) shouldBe emptyList() + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptionsTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptionsTest.kt new file mode 100644 index 0000000..81bf24a --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptionsTest.kt @@ -0,0 +1,16 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers +import kotlin.test.Test + +class ProtocolOptionsTest { + + @Test + fun `defaults expose concurrency knobs`() { + val options = ProtocolOptions() + options.handlerCoroutineContext shouldBe Dispatchers.Default + options.maxConcurrentHandlers shouldBe 64 + options.maxInFlightHandlers shouldBe 256 + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOutboundCancellationTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOutboundCancellationTest.kt new file mode 100644 index 0000000..38ad556 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOutboundCancellationTest.kt @@ -0,0 +1,167 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.PingRequest +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +class ProtocolOutboundCancellationTest { + + @Test + fun `request times out awaiting the response and sends CancelledNotification with timeout reason`() = runTest { + val protocol = TestProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + val inFlight = async { + shouldThrow { + protocol.request(PingRequest(), RequestOptions(timeout = 5.seconds)) + } + } + val sent = transport.awaitRequest() // request is on the wire; peer never responds + advanceTimeBy(6.seconds) + runCurrent() + + val thrown = inFlight.await() + thrown.code shouldBe RPCError.ErrorCode.REQUEST_TIMEOUT + thrown.data?.jsonObject?.get("timeout")?.jsonPrimitive?.long shouldBe 5.seconds.inWholeMilliseconds + + val cancelledJson = transport.sentWithOptions + .map { it.first } + .filterIsInstance() + .single { it.method == Method.Defined.NotificationsCancelled.value } + val cancelledParams = McpJson.decodeFromJsonElement(cancelledJson.params!!) + cancelledParams.requestId shouldBe sent.id + cancelledParams.reason.shouldNotBeNull() shouldContain "timed out" + } + + @Test + fun `outer withTimeout around request propagates the original timeout exception and notifies the peer`() = runTest { + val protocol = TestProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + val inFlight = async { + shouldThrow { + withTimeout(1.seconds) { + protocol.request(PingRequest()) + } + } + } + transport.awaitRequest() // request is on the wire; peer never responds + advanceTimeBy(2.seconds) + runCurrent() + inFlight.await() + + transport.sentWithOptions.map { it.first }.filterIsInstance() + .filter { it.method == Method.Defined.NotificationsCancelled.value } shouldHaveSize 1 + } + + @Test + fun `caller cancellation sends CancelledNotification and rethrows the original exception`() = runTest { + val protocol = TestProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + val cancelled = CompletableDeferred() + val job = launch { + try { + protocol.request(PingRequest()) + } catch (e: CancellationException) { + cancelled.complete(e) + throw e + } + } + transport.awaitRequest() + job.cancelAndJoin() + + cancelled.await() + val wire = transport.sentWithOptions.map { it.first }.filterIsInstance() + .filter { it.method == Method.Defined.NotificationsCancelled.value } + wire shouldHaveSize 1 + } + + @Test + fun `cancelling the initialize request performs local cleanup but sends no CancelledNotification`() = runTest { + val protocol = TestProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + val job = launch { + protocol.request( + InitializeRequest( + InitializeRequestParams( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = ClientCapabilities(), + clientInfo = Implementation(name = "t", version = "1"), + ), + ), + ) + } + val sent = transport.awaitRequest() + sent.method shouldBe Method.Defined.Initialize.value + job.cancelAndJoin() + + transport.sentWithOptions.map { it.first }.filterIsInstance() + .filter { it.method == Method.Defined.NotificationsCancelled.value } shouldHaveSize 0 + protocol.responseHandlers shouldBe emptyMap() + } + + @Test + fun `late response for a cancelled request is ignored silently`() = runTest { + val protocol = TestProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + val job = launch { protocol.request(PingRequest()) } + val sent = transport.awaitRequest() + job.cancelAndJoin() + + transport.deliver(JSONRPCResponse(id = sent.id, result = EmptyResult())) + + protocol.errors shouldHaveSize 0 // no "unknown message ID" onError + } + + @Test + fun `response for a genuinely unknown id still reports onError`() = runTest { + val protocol = TestProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + transport.deliver(JSONRPCResponse(id = RequestId(999L), result = EmptyResult())) + + protocol.errors shouldHaveSize 1 + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTest.kt index b726c2f..2846ccc 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTest.kt @@ -5,6 +5,9 @@ import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf import io.modelcontextprotocol.kotlin.sdk.types.CustomRequest import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage @@ -13,11 +16,16 @@ import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse import io.modelcontextprotocol.kotlin.sdk.types.McpJson import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.PingRequest +import io.modelcontextprotocol.kotlin.sdk.types.ProgressNotification +import io.modelcontextprotocol.kotlin.sdk.types.ProgressNotificationParams +import io.modelcontextprotocol.kotlin.sdk.types.ProgressToken import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.RequestId import io.modelcontextprotocol.kotlin.sdk.types.RequestMeta import kotlinx.coroutines.async -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObjectBuilder @@ -30,6 +38,7 @@ import kotlinx.serialization.json.jsonPrimitive import kotlin.coroutines.cancellation.CancellationException import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds class ProtocolTest { private lateinit var protocol: TestProtocol @@ -185,64 +194,174 @@ class ProtocolTest { transport.deliver(JSONRPCResponse(sent.id, EmptyResult())) inFlight.await() } -} -private class TestProtocol : Protocol(null) { - val errors = mutableListOf() + @Test + fun `request handler receives enriched extra and ambient context element`() = runTest { + protocol.connect(transport) - override fun onError(error: Throwable) { - errors.add(error) + var seenByParameter: RequestHandlerExtra? = null + var seenAmbient: RequestHandlerExtra? = null + protocol.fallbackRequestHandler = { _, extra -> + seenByParameter = extra + seenAmbient = currentRequestHandlerExtra() + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(7L), method = "custom/echo")) + + seenByParameter shouldNotBe null + seenByParameter?.requestId shouldBe RequestId(7L) + seenByParameter?.method shouldBe Method.Custom("custom/echo") + // the SAME instance flows through both delivery paths + seenAmbient shouldBe seenByParameter } - override fun assertCapabilityForMethod(method: Method) { - // noop - } - override fun assertNotificationCapability(method: Method) { - // noop - } - override fun assertRequestHandlerCapability(method: Method) { - // noop - } -} + @Test + fun `extra method resolves defined methods to Defined entries`() = runTest { + protocol.connect(transport) -private class RecordingTransport : Transport { - private val sentMessages = Channel(Channel.UNLIMITED) - private var onMessageCallback: (suspend (JSONRPCMessage) -> Unit)? = null - private var onCloseCallback: (() -> Unit)? = null + var seenMethod: Method? = null + protocol.setRequestHandler(Method.Defined.Ping) { _, extra -> + seenMethod = extra.method + EmptyResult() + } + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "ping")) - override suspend fun start() { - // noop + seenMethod shouldBe Method.Defined.Ping } - override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { - sentMessages.send(message) + @Test + fun `extra sendNotification stamps relatedRequestId`() = runTest { + protocol.connect(transport) + + protocol.fallbackRequestHandler = { _, extra -> + extra.sendNotification( + ProgressNotification( + ProgressNotificationParams(progressToken = ProgressToken(1L), progress = 0.5), + ), + ) + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(42L), method = "custom/progressing")) + + val sent = transport.sentWithOptions.first { it.first is JSONRPCNotification } + sent.second?.relatedRequestId shouldBe RequestId(42L) } - override suspend fun close() { - onCloseCallback?.invoke() + @Test + fun `extra sendRequest stamps relatedRequestId and preserves caller options`() = runTest { + protocol.connect(transport) + + protocol.fallbackRequestHandler = { _, extra -> + extra.sendRequest( + PingRequest(), + RequestOptions(resumptionToken = "tok", onProgress = { }, timeout = 30.seconds), + ) + EmptyResult() + } + + // The serial phase runs the handler inline inside deliver(), and the handler suspends + // awaiting the nested request's response — drive delivery from a child coroutine. + val delivery = launch { + transport.deliver(JSONRPCRequest(id = RequestId(42L), method = "custom/nested")) + } + + val outbound = transport.awaitRequest() + val recorded = transport.sentWithOptions + .first { (it.first as? JSONRPCRequest)?.id == outbound.id } + .second + val options = recorded.shouldBeInstanceOf() + options.relatedRequestId shouldBe RequestId(42L) + options.resumptionToken shouldBe "tok" + options.timeout shouldBe 30.seconds + options.onProgress shouldNotBe null + + transport.deliver(JSONRPCResponse(id = outbound.id, result = EmptyResult())) + delivery.join() } - override fun onClose(block: () -> Unit) { - onCloseCallback = block + @Test + fun `currentRequestHandlerExtra returns null outside handlers`() = runTest { + currentRequestHandlerExtra() shouldBe null } - override fun onError(block: (Throwable) -> Unit) { - // noop + @Test + fun `connect while already connected throws IllegalStateException`() = runTest { + protocol.connect(transport) + shouldThrow { + protocol.connect(RecordingTransport()) + } } - override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { - onMessageCallback = block + @Test + fun `stale onClose from a previous transport does not tear down the successor connection`() = runTest { + protocol.connect(transport) + val staleCloseCallback = transport.closeCallback ?: error("onClose not registered") + + protocol.close() // disconnect first transport (fires its own doClose) + val second = RecordingTransport() + protocol.connect(second) // reconnect + + staleCloseCallback() // late duplicate close signal from transport #1 + + // successor connection must still be alive + protocol.transport shouldBe second } - suspend fun awaitRequest(): JSONRPCRequest { - val message = sentMessages.receive() - return message as? JSONRPCRequest - ?: error("Expected JSONRPCRequest but received ${message::class.simpleName}") + @Test + fun `failed transport start rolls back the connection and allows reconnect`() = runTest { + val failing = object : Transport { + override suspend fun start(): Unit = error("boom") + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {} + override suspend fun close() {} + override fun onClose(block: () -> Unit) {} + override fun onError(block: (Throwable) -> Unit) {} + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {} + } + shouldThrow { protocol.connect(failing) } + protocol.transport shouldBe null // rolled back + protocol.connect(transport) // reconnect succeeds, no "already connected" + protocol.transport shouldBe transport } - suspend fun deliver(message: JSONRPCMessage) { - val callback = onMessageCallback ?: error("onMessage callback not registered") - callback(message) + @Test + fun `request cleans up its handlers when the transport send fails`() = runTest { + // Send fails without closing the connection, so the leak is not masked by doClose(). + val failingSend = object : Transport { + override suspend fun start() {} + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?): Unit = + throw IllegalStateException("send failed") + override suspend fun close() {} + override fun onClose(block: () -> Unit) {} + override fun onError(block: (Throwable) -> Unit) {} + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {} + } + protocol.connect(failingSend) + + shouldThrow { + protocol.request(PingRequest(), RequestOptions(onProgress = {})) + } + + protocol.responseHandlers.size shouldBe 0 + protocol.progressHandlers.size shouldBe 0 + } + + @Test + fun `connect rejects non-positive maxConcurrentHandlers`() = runTest { + val error = shouldThrow { + TestProtocol(ProtocolOptions(maxConcurrentHandlers = 0)).connect(RecordingTransport()) + } + error.message.shouldNotBeNull() shouldContain "maxConcurrentHandlers" + } + + @Test + fun `connect rejects maxInFlightHandlers below maxConcurrentHandlers`() = runTest { + val error = shouldThrow { + TestProtocol(ProtocolOptions(maxConcurrentHandlers = 4, maxInFlightHandlers = 0)) + .connect(RecordingTransport()) + } + error.message.shouldNotBeNull() shouldContain "maxInFlightHandlers" } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTestFakes.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTestFakes.kt new file mode 100644 index 0000000..9e8dd60 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTestFakes.kt @@ -0,0 +1,112 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import kotlinx.coroutines.channels.Channel +import kotlinx.serialization.json.encodeToJsonElement + +/** + * Shared [Protocol] test double: records [onError] calls, counts `notifications/initialized` + * router-hook invocations, and no-ops all capability assertions. + * + * The concurrency gate is flipped only via [enableConcurrency]; nothing in this double flips it + * implicitly, so tests keep today's serial semantics unless they opt in. + */ +internal class TestProtocol(options: ProtocolOptions? = null) : Protocol(options) { + val errors = mutableListOf() + var initializedNotificationCount = 0 + + fun enableConcurrency() { + enableConcurrentDispatch() + } + + override fun onInitializedNotification() { + initializedNotificationCount++ + // deliberately does NOT flip the gate — tests control the flip explicitly + } + + override fun onError(error: Throwable) { + errors.add(error) + } + + override fun assertCapabilityForMethod(method: Method) { + // noop + } + override fun assertNotificationCapability(method: Method) { + // noop + } + public override fun assertRequestHandlerCapability(method: Method) { + // noop + } +} + +/** Shared [Transport] test double: records every sent message (with its options) and replays inbound ones. */ +internal class RecordingTransport : Transport { + val sentMessages = Channel(Channel.UNLIMITED) + private var onMessageCallback: (suspend (JSONRPCMessage) -> Unit)? = null + private var onCloseCallback: (() -> Unit)? = null + + val sentWithOptions = mutableListOf>() + + var closeCallback: (() -> Unit)? = null + private set + + override suspend fun start() { + // noop + } + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + sentWithOptions.add(message to options) + sentMessages.send(message) + } + + override suspend fun close() { + onCloseCallback?.invoke() + } + + override fun onClose(block: () -> Unit) { + closeCallback = block + onCloseCallback = block + } + + override fun onError(block: (Throwable) -> Unit) { + // noop + } + + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { + onMessageCallback = block + } + + suspend fun awaitRequest(): JSONRPCRequest { + while (true) { + val message = sentMessages.receive() + if (message is JSONRPCRequest) return message + } + } + + /** Cross-thread-safe reader used by real-dispatcher tests; [sentWithOptions] is single-thread only. */ + suspend fun awaitSent(): JSONRPCMessage = sentMessages.receive() + + suspend fun deliver(message: JSONRPCMessage) { + val callback = onMessageCallback ?: error("onMessage callback not registered") + callback(message) + } +} + +internal fun responsesOn(transport: RecordingTransport): List = + transport.sentWithOptions.map { it.first }.filterIsInstance() + +internal fun errorsOn(transport: RecordingTransport): List = + transport.sentWithOptions.map { it.first }.filterIsInstance() + +internal fun cancelledNotification(requestId: RequestId, reason: String): JSONRPCNotification = JSONRPCNotification( + method = Method.Defined.NotificationsCancelled.value, + params = McpJson.encodeToJsonElement(CancelledNotificationParams(requestId = requestId, reason = reason)), +) diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferTest.kt index 7b4f053..e5e50f8 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferTest.kt @@ -6,7 +6,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification import kotlinx.serialization.json.Json import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull class ReadBufferTest { @@ -75,4 +77,35 @@ class ReadBufferTest { val message = readBuffer.readMessage() assertEquals(testMessage, message) } + + @Test + fun `should fail when an unframed blob exceeds the cap`() { + val readBuffer = ReadBuffer(maxFrameSize = 64) + // No newline ever arrives: the memory-exhaustion vector. + readBuffer.append(ByteArray(100) { 'a'.code.toByte() }) + val ex = assertFailsWith { readBuffer.readMessage() } + assertContains(ex.message.orEmpty(), "maximum size") + } + + @Test + fun `should fail when a completed line exceeds the cap`() { + val readBuffer = ReadBuffer(maxFrameSize = 64) + readBuffer.append(ByteArray(100) { 'a'.code.toByte() } + '\n'.code.toByte()) + assertFailsWith { readBuffer.readMessage() } + } + + @Test + fun `should not enforce a cap when maxFrameSize is non-positive`() { + val readBuffer = ReadBuffer(maxFrameSize = 0) + // Well beyond any small cap and still no newline — must not throw when disabled. + readBuffer.append(ByteArray(8192) { 'a'.code.toByte() }) + assertNull(readBuffer.readMessage()) + } + + @Test + fun `should parse a message that fits under the cap`() { + val readBuffer = ReadBuffer(maxFrameSize = 1024) + readBuffer.append(serializeMessage(testMessage).encodeToByteArray()) + assertEquals(testMessage, readBuffer.readMessage()) + } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesTest.kt index 8b2e4f8..3468aae 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesTest.kt @@ -313,7 +313,7 @@ class CapabilitiesTest { assertEquals(ClientCapabilities.Sampling(), capabilities.sampling) assertEquals(true, capabilities.roots?.listChanged) - assertEquals(EmptyJsonObject, capabilities.elicitation) + assertEquals(ClientCapabilities.Elicitation(), capabilities.elicitation) } @Test @@ -804,4 +804,143 @@ class CapabilitiesTest { val caps = ClientCapabilities() caps.sampling shouldBe null } + + @Test + fun `should serialize ServerCapabilities with tasks`() { + val capabilities = ServerCapabilities( + tasks = ServerCapabilities.Tasks( + list = EmptyJsonObject, + cancel = EmptyJsonObject, + requests = ServerCapabilities.Tasks.Requests( + tools = ServerCapabilities.Tasks.Requests.Tools(call = EmptyJsonObject), + ), + ), + ) + verifySerialization( + capabilities, + McpJson, + """ + { + "tasks": { + "list": {}, + "cancel": {}, + "requests": { + "tools": { + "call": {} + } + } + } + } + """.trimIndent(), + ) + } + + @Test + fun `should deserialize ServerCapabilities with tasks`() { + val json = """ + { + "tasks": { + "list": {}, + "requests": {"tools": {"call": {}}} + } + } + """.trimIndent() + val capabilities = verifyDeserialization(McpJson, json) + assertEquals(EmptyJsonObject, capabilities.tasks?.list) + assertNull(capabilities.tasks?.cancel) + assertEquals(EmptyJsonObject, capabilities.tasks?.requests?.tools?.call) + } + + @Test + fun `should serialize ClientCapabilities with tasks`() { + val capabilities = ClientCapabilities( + tasks = ClientCapabilities.Tasks( + list = EmptyJsonObject, + cancel = EmptyJsonObject, + requests = ClientCapabilities.Tasks.Requests( + sampling = ClientCapabilities.Tasks.Requests.Sampling(createMessage = EmptyJsonObject), + elicitation = ClientCapabilities.Tasks.Requests.Elicitation(create = EmptyJsonObject), + ), + ), + ) + verifySerialization( + capabilities, + McpJson, + """ + { + "tasks": { + "list": {}, + "cancel": {}, + "requests": { + "sampling": {"createMessage": {}}, + "elicitation": {"create": {}} + } + } + } + """.trimIndent(), + ) + } + + @Test + fun `should serialize ClientCapabilities with structured elicitation form and url`() { + val capabilities = ClientCapabilities( + elicitation = ClientCapabilities.Elicitation(form = EmptyJsonObject, url = EmptyJsonObject), + ) + verifySerialization( + capabilities, + McpJson, + """ + { + "elicitation": { + "form": {}, + "url": {} + } + } + """.trimIndent(), + ) + } + + @Test + fun `should deserialize ClientCapabilities with elicitation and tasks`() { + val json = """ + { + "elicitation": {"form": {}, "url": {}}, + "tasks": { + "list": {}, + "cancel": {}, + "requests": { + "sampling": {"createMessage": {}}, + "elicitation": {"create": {}} + } + } + } + """.trimIndent() + val capabilities = verifyDeserialization(McpJson, json) + assertEquals(EmptyJsonObject, capabilities.elicitation?.form) + assertEquals(EmptyJsonObject, capabilities.elicitation?.url) + assertEquals(EmptyJsonObject, capabilities.tasks?.list) + assertEquals(EmptyJsonObject, capabilities.tasks?.cancel) + assertEquals(EmptyJsonObject, capabilities.tasks?.requests?.sampling?.createMessage) + assertEquals(EmptyJsonObject, capabilities.tasks?.requests?.elicitation?.create) + } + + // ── Elicitation supportsUrl predicate ─────────────────────────────── + + @Test + fun `supportsUrl is false when no elicitation capability is declared`() { + val elicitation: ClientCapabilities.Elicitation? = null + elicitation.supportsUrl shouldBe false + } + + @Test + fun `supportsUrl is false for an empty form-only capability`() { + ClientCapabilities.Elicitation().supportsUrl shouldBe false + ClientCapabilities.Elicitation(form = EmptyJsonObject).supportsUrl shouldBe false + } + + @Test + fun `supportsUrl is true when url mode is declared`() { + ClientCapabilities.Elicitation(url = EmptyJsonObject).supportsUrl shouldBe true + ClientCapabilities.Elicitation(form = EmptyJsonObject, url = EmptyJsonObject).supportsUrl shouldBe true + } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/DiscoveryTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/DiscoveryTest.kt new file mode 100644 index 0000000..691af45 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/DiscoveryTest.kt @@ -0,0 +1,324 @@ +package io.modelcontextprotocol.kotlin.sdk.types + +import io.kotest.assertions.json.shouldEqualJson +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalMcpApi::class) +class DiscoveryTest { + + @Test + fun `should decode server discover request from its wire shape`() { + val request = McpJson.decodeFromString( + """ + { + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "test-client", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + } + """.trimIndent(), + ) + + val discover = assertIs(request) + assertEquals(Method.Defined.ServerDiscover, discover.method) + assertEquals("2026-07-28", discover.meta.protocolVersion) + assertEquals("test-client", discover.meta.clientInfo?.name) + assertEquals(ClientCapabilities(), discover.meta.clientCapabilities) + } + + @Test + fun `should decode discovery request without optional client info`() { + val request = McpJson.decodeFromString( + """ + { + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "com.example/traceId": "trace-123" + } + } + } + """.trimIndent(), + ) + + val discover = assertIs(request) + assertEquals("2026-07-28", discover.meta.protocolVersion) + assertEquals(ClientCapabilities(), discover.meta.clientCapabilities) + assertNull(discover.meta.clientInfo) + assertNotNull(discover.meta["com.example/traceId"]) + McpJson.encodeToString(discover) shouldEqualJson """ + { + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "com.example/traceId": "trace-123" + } + } + } + """.trimIndent() + } + + @Test + fun `should reject discovery requests without required request metadata`() { + val invalidParams = listOf( + "{}" to "_meta", + """{"_meta": {}}""" to RequestMetaKeys.PROTOCOL_VERSION, + """ + { + "_meta": { + "${RequestMetaKeys.PROTOCOL_VERSION}": "2026-07-28", + "${RequestMetaKeys.CLIENT_INFO}": { + "name": "test-client", + "version": "1.0.0" + } + } + } + """.trimIndent() to RequestMetaKeys.CLIENT_CAPABILITIES, + ) + + invalidParams.forEach { (params, missingField) -> + val failure = assertFails { + McpJson.decodeFromString( + """{"method": "server/discover", "params": $params}""", + ) + } + + assertTrue( + failure.message.orEmpty().contains(missingField), + "Expected failure to identify missing field $missingField, but was: $failure", + ) + } + } + + @Test + fun `should serialize every required discovery result field`() { + val result = DiscoverResult( + supportedVersions = listOf("2026-07-28"), + capabilities = ServerCapabilities( + tools = ServerCapabilities.Tools(listChanged = true), + ), + instructions = "Use tools intentionally.", + ) + + McpJson.encodeToString(result) shouldEqualJson """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": { + "tools": {"listChanged": true} + }, + "instructions": "Use tools intentionally.", + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private" + } + """.trimIndent() + } + + @Test + fun `should encode optional server info in result metadata`() { + val result = DiscoverResult( + supportedVersions = listOf("2026-07-28"), + capabilities = ServerCapabilities(), + meta = Json.parseToJsonElement( + """ + { + "io.modelcontextprotocol/serverInfo": { + "name": "test-server", + "version": "2.0.0" + }, + "com.example/source": "edge" + } + """.trimIndent(), + ).jsonObject, + ) + + McpJson.encodeToString(result) shouldEqualJson """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "test-server", + "version": "2.0.0" + }, + "com.example/source": "edge" + } + } + """.trimIndent() + assertEquals( + Json.parseToJsonElement("""{"name":"test-server","version":"2.0.0"}"""), + result.meta?.get("io.modelcontextprotocol/serverInfo"), + ) + } + + @Test + fun `should encode required defaults independently of McpJson`() { + val result = DiscoverResult( + supportedVersions = listOf("2026-07-28"), + capabilities = ServerCapabilities(), + ) + + Json.encodeToString(result) shouldEqualJson """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private" + } + """.trimIndent() + } + + @Test + fun `should reject discovery results without required cache fields`() { + val invalidResults = listOf( + """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "cacheScope": "private" + } + """.trimIndent(), + """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0 + } + """.trimIndent(), + ) + + invalidResults.forEach { wire -> + assertFails { McpJson.decodeFromString(wire) } + assertFails { McpJson.decodeFromString(wire) } + } + } + + @Test + fun `should use complete when a discovery result omits resultType`() { + val result = McpJson.decodeFromString( + """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private" + } + """.trimIndent(), + ) + + assertEquals(COMPLETE_RESULT_TYPE, assertIs(result).resultType) + } + + @Test + fun `should deserialize discovery results polymorphically`() { + val wire = + """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "resultType": "complete", + "ttlMs": 250, + "cacheScope": "public", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "server", + "version": "1" + }, + "com.example/source": "edge" + } + } + """.trimIndent() + val result = McpJson.decodeFromString(wire) + + val discover = assertIs(result) + assertEquals(250, discover.ttlMs) + assertEquals(CacheScope.Public, discover.cacheScope) + assertEquals( + Json.parseToJsonElement("""{"name":"server","version":"1"}"""), + discover.meta?.get("io.modelcontextprotocol/serverInfo"), + ) + assertNotNull(discover.meta?.get("com.example/source")) + McpJson.encodeToString(discover) shouldEqualJson wire + } + + @Test + fun `should not treat supportedVersions alone as a discovery discriminator`() { + val result = McpJson.decodeFromString( + """ + { + "tools": [], + "supportedVersions": ["extension-value"] + } + """.trimIndent(), + ) + + assertIs(result) + } + + @Test + fun `should not treat an initialize extension as a discovery result`() { + val result = McpJson.decodeFromString( + """ + { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "serverInfo": { + "name": "test-server", + "version": "1.0.0" + }, + "supportedVersions": ["extension-value"] + } + """.trimIndent(), + ) + + assertIs(result) + } + + @Test + fun `should reject a negative discovery ttl`() { + assertFailsWith { + DiscoverResult( + supportedVersions = listOf("2026-07-28"), + capabilities = ServerCapabilities(), + ttlMs = -1, + ) + } + } + + @Test + fun `unsupported version data should round trip without losing versions`() { + val data = UnsupportedProtocolVersionData( + supported = listOf("2026-07-28", "2025-11-25"), + requested = "2099-01-01", + ) + + val encoded = McpJson.encodeToString(data) + assertEquals(data, McpJson.decodeFromString(encoded)) + assertEquals(-32022, RPCError.ErrorCode.UNSUPPORTED_PROTOCOL_VERSION) + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ElicitationTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ElicitationTest.kt index 1a41863..bd5ef06 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ElicitationTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ElicitationTest.kt @@ -10,6 +10,7 @@ import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization import io.modelcontextprotocol.kotlin.test.utils.verifySerialization import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put @@ -273,4 +274,86 @@ class ElicitationTest { ) } } + + // ── URLElicitationRequired error (-32042) ─────────────────────────── + + @Test + fun `UrlElicitationRequiredException carries code and serialized elicitations`() { + val elicitation = ElicitRequestURLParams( + message = "Authorize access to continue", + elicitationId = "550e8400-e29b-41d4-a716-446655440000", + url = "https://oauth.example.com/authorize", + ) + val exception = UrlElicitationRequiredException(listOf(elicitation)) + + exception.code shouldBe RPCError.ErrorCode.URL_ELICITATION_REQUIRED + exception.elicitations shouldBe listOf(elicitation) + + val data = exception.data.shouldNotBeNull() + McpJson.encodeToString(data) shouldEqualJson """ + { + "elicitations": [ + { + "message": "Authorize access to continue", + "elicitationId": "550e8400-e29b-41d4-a716-446655440000", + "url": "https://oauth.example.com/authorize", + "mode": "url" + } + ] + } + """.trimIndent() + } + + @Test + fun `fromError reconstructs typed exception for -32042 with valid data`() { + val data = McpJson.encodeToJsonElement( + UrlElicitationRequiredData( + listOf(ElicitRequestURLParams(message = "m", elicitationId = "id-1", url = "https://example.com/a")), + ), + ) + + val exception = McpException.fromError( + code = RPCError.ErrorCode.URL_ELICITATION_REQUIRED, + message = "needs auth", + data = data, + ) + + val typed = exception.shouldBeInstanceOf() + typed.message shouldBe "needs auth" + typed.elicitations.single().elicitationId shouldBe "id-1" + } + + @Test + fun `fromError returns plain McpException for unrelated code`() { + val exception = McpException.fromError( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "bad params", + data = null, + ) + (exception is UrlElicitationRequiredException) shouldBe false + exception.code shouldBe RPCError.ErrorCode.INVALID_PARAMS + } + + @Test + fun `fromError degrades to plain McpException for -32042 with malformed data`() { + val malformed = buildJsonObject { put("unexpected", "shape") } + + val exception = McpException.fromError( + code = RPCError.ErrorCode.URL_ELICITATION_REQUIRED, + message = "x", + data = malformed, + ) + + (exception is UrlElicitationRequiredException) shouldBe false + exception.code shouldBe RPCError.ErrorCode.URL_ELICITATION_REQUIRED + } + + @Test + fun `fromError degrades to plain McpException when elicitations empty`() { + val data = McpJson.encodeToJsonElement(UrlElicitationRequiredData(emptyList())) + + val exception = McpException.fromError(RPCError.ErrorCode.URL_ELICITATION_REQUIRED, "x", data) + + (exception is UrlElicitationRequiredException) shouldBe false + } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RequestTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RequestTest.kt index 1d6da19..6087b20 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RequestTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RequestTest.kt @@ -2,20 +2,120 @@ package io.modelcontextprotocol.kotlin.sdk.types import io.kotest.assertions.json.shouldEqualJson import io.kotest.matchers.shouldBe +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization import io.modelcontextprotocol.kotlin.test.utils.verifySerialization +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.double import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue class RequestTest { + @OptIn(ExperimentalMcpApi::class) + @Suppress("DEPRECATION") + @Test + fun `should decode typed request metadata without discarding extensions`() { + val request = McpJson.decodeFromString( + """ + { + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "wire-client", + "version": "1.2.3" + }, + "io.modelcontextprotocol/clientCapabilities": { + "sampling": {}, + "com.example/custom-capability": {"enabled": true} + }, + "io.modelcontextprotocol/logLevel": "warning", + "com.example/traceId": "trace-123" + } + } + } + """.trimIndent(), + ) + + val listTools = assertIs(request) + val meta = assertNotNull(listTools.params?.meta) + meta.protocolVersion shouldBe "2026-07-28" + meta.clientInfo shouldBe Implementation(name = "wire-client", version = "1.2.3") + assertNotNull(meta.clientCapabilities?.sampling) + meta.logLevel shouldBe LoggingLevel.Warning + meta["com.example/traceId"]?.jsonPrimitive?.content shouldBe "trace-123" + assertNotNull(meta.json[RequestMetaKeys.CLIENT_CAPABILITIES]?.let { it as? JsonObject }) + } + + @OptIn(ExperimentalMcpApi::class) + @Test + fun `empty client capabilities should mean no optional capabilities`() { + val json = Json.parseToJsonElement( + """{"${RequestMetaKeys.CLIENT_CAPABILITIES}": {}}""", + ) as JsonObject + + RequestMeta(json).clientCapabilities shouldBe ClientCapabilities() + } + + @OptIn(ExperimentalMcpApi::class) + @Suppress("DEPRECATION") + @Test + fun `typed request metadata should reject malformed fields`() { + val malformedValues = listOf( + RequestMetaKeys.PROTOCOL_VERSION to "{}", + RequestMetaKeys.PROTOCOL_VERSION to "null", + RequestMetaKeys.CLIENT_INFO to "\"not-an-implementation\"", + RequestMetaKeys.CLIENT_INFO to "{\"name\":\"missing-version\"}", + RequestMetaKeys.CLIENT_INFO to "null", + RequestMetaKeys.CLIENT_CAPABILITIES to "\"not-capabilities\"", + RequestMetaKeys.CLIENT_CAPABILITIES to "[]", + RequestMetaKeys.CLIENT_CAPABILITIES to "null", + RequestMetaKeys.LOG_LEVEL to "\"verbose\"", + RequestMetaKeys.LOG_LEVEL to "7", + RequestMetaKeys.LOG_LEVEL to "null", + ) + + malformedValues.forEach { (key, value) -> + val json = Json.parseToJsonElement("""{"$key":$value}""") as JsonObject + val meta = RequestMeta(json) + + val failure = assertFailsWith { + when (key) { + RequestMetaKeys.PROTOCOL_VERSION -> meta.protocolVersion + RequestMetaKeys.CLIENT_INFO -> meta.clientInfo + RequestMetaKeys.CLIENT_CAPABILITIES -> meta.clientCapabilities + else -> meta.logLevel + } + } + assertTrue(failure.message.orEmpty().contains(key)) + } + } + + @OptIn(ExperimentalMcpApi::class) + @Test + fun `legacy requests should not gain request scoped metadata`() { + val request = ListToolsRequest() + + McpJson.encodeToString(request) shouldEqualJson """ + { + "method": "tools/list" + } + """.trimIndent() + assertNull(request.params?.meta?.protocolVersion) + } + @Test fun `should expose progress token from RequestMeta`() { val stringTokenMeta = RequestMeta(buildJsonObject { put("progressToken", "sync-1") }) diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/SamplingTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/SamplingTest.kt index 62a82cd..b7bc5cf 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/SamplingTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/SamplingTest.kt @@ -302,6 +302,13 @@ class SamplingTest { (m.content[0] as TextContent).text shouldBe "hi" } + @Test + fun `SamplingMessage rejects empty content`() { + assertFailsWith { + SamplingMessage(role = Role.User, content = emptyList()) + } + } + @Test fun `SamplingMessage single-element content serialises as single object`() { val m = SamplingMessage(role = Role.User, content = listOf(TextContent("hi"))) @@ -360,6 +367,26 @@ class SamplingTest { decoded.toolChoice shouldBe ToolChoice(mode = ToolChoice.Mode.Required) } + @Test + fun `CreateMessageRequestParams task defaults to null and is omitted when encoding`() { + val params = CreateMessageRequestParams(maxTokens = 100, messages = emptyList()) + params.task shouldBe null + val encoded = McpJson.encodeToString(CreateMessageRequestParams.serializer(), params) + assertEquals(false, "\"task\"" in encoded) + } + + @Test + fun `CreateMessageRequestParams round-trips task`() { + val original = CreateMessageRequestParams( + maxTokens = 100, + messages = emptyList(), + task = TaskMetadata(ttl = 60_000L), + ) + val encoded = McpJson.encodeToString(CreateMessageRequestParams.serializer(), original) + val decoded = McpJson.decodeFromString(CreateMessageRequestParams.serializer(), encoded) + decoded.task shouldBe TaskMetadata(ttl = 60_000L) + } + @Test fun `StopReason ToolUse serialises as toolUse`() { StopReason.ToolUse.value shouldBe "toolUse" @@ -408,6 +435,17 @@ class SamplingTest { (decoded.content[0] as TextContent).text shouldBe "hi" } + @Test + fun `CreateMessageResult rejects empty content`() { + assertFailsWith { + CreateMessageResult( + role = Role.Assistant, + content = emptyList(), + model = "test-model", + ) + } + } + // ============================================================================ // SamplingContentSerializer (single-or-array wire heuristic) // ============================================================================ diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ToolsTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ToolsTest.kt index d5e1d5a..358866a 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ToolsTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ToolsTest.kt @@ -319,6 +319,57 @@ class ToolsTest { assertEquals(ProgressToken(77), params.meta?.progressToken) } + @Test + fun `should serialize CallToolRequest with task augmentation`() { + val request = CallToolRequest( + CallToolRequestParams( + name = "long-running", + arguments = buildJsonObject { put("query", "MCP protocol") }, + task = TaskMetadata(ttl = 60_000L), + ), + ) + + verifySerialization( + request, + McpJson, + """ + { + "method": "tools/call", + "params": { + "name": "long-running", + "arguments": { + "query": "MCP protocol" + }, + "task": { + "ttl": 60000 + } + } + } + """.trimIndent(), + ) + + assertEquals(TaskMetadata(ttl = 60_000L), request.task) + } + + @Test + fun `should omit task from CallToolRequest when null`() { + val request = CallToolRequest(CallToolRequestParams(name = "plain")) + + assertEquals(null, request.task) + verifySerialization( + request, + McpJson, + """ + { + "method": "tools/call", + "params": { + "name": "plain" + } + } + """.trimIndent(), + ) + } + @Test fun `should serialize CallToolResult with structured content`() { val result = CallToolResult( diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt index ee2fe43..25d9ee6 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt @@ -44,11 +44,7 @@ class CapabilitiesDslTest { capabilities { sampling(ClientCapabilities.Sampling(tools = EmptyJsonObject)) roots(listChanged = true) - elicitation { - put("mode", "interactive") - put("timeout", 30) - put("retries", 3) - } + elicitation(ClientCapabilities.Elicitation(form = EmptyJsonObject, url = EmptyJsonObject)) experimental { put("customFeature", true) put("version", "1.0") @@ -74,9 +70,8 @@ class CapabilitiesDslTest { listChanged shouldBe true } elicitation shouldNotBeNull { - get("mode")?.jsonPrimitive?.content shouldBe "interactive" - get("timeout")?.jsonPrimitive?.content shouldBe "30" - get("retries")?.jsonPrimitive?.content shouldBe "3" + form shouldBe EmptyJsonObject + url shouldBe EmptyJsonObject } experimental shouldNotBeNull { get("customFeature")?.jsonPrimitive?.content shouldBe "true" diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/InitializeDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/InitializeDslTest.kt index 17e5ae6..da2a4cb 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/InitializeDslTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/InitializeDslTest.kt @@ -22,9 +22,7 @@ class InitializeDslTest { capabilities { sampling(ClientCapabilities.Sampling()) roots(listChanged = true) - elicitation { - put("mode", "interactive") - } + elicitation(ClientCapabilities.Elicitation(form = EmptyJsonObject)) experimental { put("custom", true) } @@ -46,7 +44,7 @@ class InitializeDslTest { request.params.capabilities.shouldNotBeNull { sampling shouldNotBeNull { } roots?.listChanged shouldBe true - elicitation?.get("mode")?.jsonPrimitive?.content shouldBe "interactive" + elicitation?.form shouldBe EmptyJsonObject experimental?.get("custom")?.jsonPrimitive?.content shouldBe "true" extensions shouldNotBeNull { get("io.modelcontextprotocol/ui") shouldBe EmptyJsonObject @@ -78,7 +76,7 @@ class InitializeDslTest { @Test fun `capabilities DSL should support direct typed values`() { val samplingValue = ClientCapabilities.Sampling(tools = EmptyJsonObject) - val elicitationObj = buildJsonObject { put("key", "value") } + val elicitationValue = ClientCapabilities.Elicitation(form = EmptyJsonObject, url = EmptyJsonObject) val experimentalObj = buildJsonObject { put("key", "value") } val extensionsMap = mapOf( "io.modelcontextprotocol/ui" to buildJsonObject { put("key", "value") }, @@ -88,7 +86,7 @@ class InitializeDslTest { protocolVersion = "1.0" capabilities { sampling(samplingValue) - elicitation(elicitationObj) + elicitation(elicitationValue) experimental(experimentalObj) extensions(extensionsMap) } @@ -97,7 +95,7 @@ class InitializeDslTest { request.params.capabilities.shouldNotBeNull { sampling shouldBe samplingValue - elicitation shouldBe elicitationObj + elicitation shouldBe elicitationValue experimental shouldBe experimentalObj extensions shouldBe extensionsMap } diff --git a/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyJvmTest.kt b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyJvmTest.kt new file mode 100644 index 0000000..60658f8 --- /dev/null +++ b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyJvmTest.kt @@ -0,0 +1,114 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.kotest.matchers.shouldBe +import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +/** + * Real-dispatcher counterparts to [ProtocolConcurrencyTest]. + * + * These run under [runBlocking] (real time, real threads on [Dispatchers.Default]) rather than + * `runTest`: a virtual-time scheduler auto-advances past the `withTimeout` guards and cannot + * observe genuine cross-thread parallelism, so the concurrency assertions could pass vacuously. + */ +class ProtocolConcurrencyJvmTest { + + @Test + fun `fast request completes while a slow handler is parked on Dispatchers Default`() = runBlocking { + val protocol = TestProtocol(ProtocolOptions(handlerCoroutineContext = Dispatchers.Default)) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val slowEntered = CompletableDeferred() + val releaseSlow = CompletableDeferred() + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/slow") { + slowEntered.complete(Unit) + releaseSlow.await() + } + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) + withTimeout(5.seconds) { slowEntered.await() } + transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) + + val fast = withTimeout(5.seconds) { transport.awaitSent() } + (fast as JSONRPCResponse).id shouldBe RequestId(2L) + + releaseSlow.complete(Unit) + val slow = withTimeout(5.seconds) { transport.awaitSent() } + (slow as JSONRPCResponse).id shouldBe RequestId(1L) + Unit + } + + @Test + fun `dispatch stays serial before the gate flips on Dispatchers Default`() = runBlocking { + val protocol = TestProtocol(ProtocolOptions(handlerCoroutineContext = Dispatchers.Default)) + val transport = RecordingTransport() + protocol.connect(transport) + // gate NOT flipped + + val slowEntered = CompletableDeferred() + val releaseSlow = CompletableDeferred() + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/slow") { + slowEntered.complete(Unit) + releaseSlow.await() + } + EmptyResult() + } + + // A single delivering coroutine models the transport read loop: the second deliver cannot + // start until the first returns, so a parked slow handler blocks the fast request. + val delivery = launch { + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) + transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) + } + withTimeout(5.seconds) { slowEntered.await() } + + // The delivering coroutine is parked inside the slow handler; fast was never delivered. + transport.sentMessages.tryReceive().isFailure shouldBe true + + releaseSlow.complete(Unit) + val first = withTimeout(5.seconds) { transport.awaitSent() } + (first as JSONRPCResponse).id shouldBe RequestId(1L) + val second = withTimeout(5.seconds) { transport.awaitSent() } + (second as JSONRPCResponse).id shouldBe RequestId(2L) + delivery.join() + } + + @Test + fun `suspension-free notification handlers keep arrival order on Dispatchers Default`() = runBlocking { + val protocol = TestProtocol(ProtocolOptions(handlerCoroutineContext = Dispatchers.Default)) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + // safe: appended only from the delivering coroutine (UNDISPATCHED, suspension-free) + val seen = mutableListOf() + protocol.fallbackNotificationHandler = { notification -> + seen.add((notification.params as JsonObject).getValue("i").jsonPrimitive.int) + } + repeat(100) { i -> + transport.deliver(JSONRPCNotification(method = "test/event", params = buildJsonObject { put("i", i) })) + } + seen shouldBe (0 until 100).toList() + } +} diff --git a/kotlin-sdk-server/api/kotlin-sdk-server.api b/kotlin-sdk-server/api/kotlin-sdk-server.api index 80da5b5..d8e4c7a 100644 --- a/kotlin-sdk-server/api/kotlin-sdk-server.api +++ b/kotlin-sdk-server/api/kotlin-sdk-server.api @@ -32,20 +32,37 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/ClientConnection$De public static synthetic fun ping$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Lio/modelcontextprotocol/kotlin/sdk/types/PingRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } +public final class io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionConfig { + public fun ()V + public final fun getAllowedHosts ()Ljava/util/List; + public final fun getAllowedOrigins ()Ljava/util/List; + public final fun setAllowedHosts (Ljava/util/List;)V + public final fun setAllowedOrigins (Ljava/util/List;)V +} + public abstract interface class io/modelcontextprotocol/kotlin/sdk/server/EventStore { public abstract fun getStreamIdForEventId (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun replayEventsAfter (Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun storeEvent (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class io/modelcontextprotocol/kotlin/sdk/server/HostValidationKt { + public static final fun getDnsRebindingProtection ()Lio/ktor/server/application/RouteScopedPlugin; +} + public final class io/modelcontextprotocol/kotlin/sdk/server/KtorServerKt { - public static final fun mcp (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)V - public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V - public static final fun mcp (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;)V + public static final fun mcp (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;)V + public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;)V + public static final fun mcp (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;)V + public static synthetic fun mcp$default (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static synthetic fun mcp$default (Lio/ktor/server/routing/Route;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static synthetic fun mcp$default (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V + public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;)V public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V - public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V - public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V } public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt : io/modelcontextprotocol/kotlin/sdk/server/Feature { @@ -154,8 +171,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/ServerOptions : io/ public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;)V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Z)V public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;Lkotlin/coroutines/CoroutineContext;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;Lkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; public final fun getResourceTemplateMatcherFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory; } @@ -183,6 +200,7 @@ public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelc public fun onClose ()V public final fun onClose (Lkotlin/jvm/functions/Function0;)V public final fun onInitialized (Lkotlin/jvm/functions/Function0;)V + protected fun onInitializedNotification ()V public final fun ping (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun sendElicitationComplete (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun sendLoggingMessage (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingMessageNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -193,7 +211,8 @@ public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelc } public final class io/modelcontextprotocol/kotlin/sdk/server/SseServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport { - public fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;)V + public fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;J)V + public synthetic fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;JILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getSessionId ()Ljava/lang/String; public final fun handleMessage (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -204,11 +223,22 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/SseServerTransport public final class io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport { public fun (Lkotlinx/io/Source;Lkotlinx/io/Sink;)V + public fun (Lkotlinx/io/Source;Lkotlinx/io/Sink;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lkotlinx/io/Source;Lkotlinx/io/Sink;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun send (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lio/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport$Builder { + public final fun getHandlerDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; + public final fun getIoDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; + public final fun getScope ()Lkotlinx/coroutines/CoroutineScope; + public final fun setHandlerDispatcher (Lkotlinx/coroutines/CoroutineDispatcher;)V + public final fun setIoDispatcher (Lkotlinx/coroutines/CoroutineDispatcher;)V + public final fun setScope (Lkotlinx/coroutines/CoroutineScope;)V +} + public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport { public static final field STANDALONE_SSE_STREAM_ID Ljava/lang/String; public fun ()V diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt index 9b4b2f3..21e0925 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt @@ -10,12 +10,14 @@ import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification +import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult import io.modelcontextprotocol.kotlin.sdk.types.IncludeContext import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest import io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification +import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.PingRequest import io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.Request @@ -25,6 +27,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification import io.modelcontextprotocol.kotlin.sdk.types.ServerNotification import io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification +import io.modelcontextprotocol.kotlin.sdk.types.supportsUrl private val logger = KotlinLogging.logger {} @@ -94,12 +97,17 @@ public interface ClientConnection { * Sends a message to the client requesting elicitation. * This typically results in a form being displayed to the end user. * + * Accepted content is validated against [requestedSchema]. If content is absent, an empty + * object is used for validation only, so declared `required` properties still apply. The + * returned [ElicitResult] is not modified. + * * @param message The message for the elicitation to display. - * @param requestedSchema The schema requested by the client for the elicitation result. + * @param requestedSchema The schema the server requests for the elicitation result. * Influences the form displayed to the user. * @param options Optional request options. * @return The result of the elicitation request. * @throws IllegalStateException If the server or client does not support elicitation. + * @throws McpException If an accepted response does not match [requestedSchema]. */ public suspend fun createElicitation( message: String, @@ -129,10 +137,15 @@ public interface ClientConnection { * Sends a message to the client requesting elicitation. * This typically results in a form being displayed to the user. * + * For form-mode requests, accepted content is validated against the requested schema. If + * content is absent, an empty object is used for validation only, so declared `required` + * properties still apply. The returned [ElicitResult] is not modified. + * * @param request The elicitation request parameters. * @param options Optional request options. * @return The result of the elicitation request. * @throws IllegalStateException If the server or client does not support elicitation. + * @throws McpException If an accepted form-mode response does not match its requested schema. */ public suspend fun createElicitation(request: ElicitRequest, options: RequestOptions? = null): ElicitResult @@ -238,6 +251,11 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client override suspend fun createElicitation(request: ElicitRequest, options: RequestOptions?): ElicitResult { val params = request.params + if (params is ElicitRequestURLParams) { + require(session.clientCapabilities?.elicitation.supportsUrl) { + "Client did not advertise elicitation.url capability; cannot send a URL-mode elicitation." + } + } logger.debug { when (params) { is ElicitRequestFormParams -> @@ -250,7 +268,13 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client } } logger.trace { "ElicitRequest: $request" } - return request(request, options) + val result: ElicitResult = request(request, options) + if (params is ElicitRequestFormParams && result.action == ElicitResult.Action.Accept) { + // Peer-response backstop: accepted form content is client-supplied. Absent content is + // validated as an empty object so declared `required` properties still apply. + validateElicitationContent(params.requestedSchema, result.content ?: EmptyJsonObject) + } + return result } override suspend fun createElicitation( @@ -299,6 +323,9 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client } override suspend fun sendElicitationComplete(notification: ElicitationCompleteNotification) { + require(session.clientCapabilities?.elicitation.supportsUrl) { + "Client did not advertise elicitation.url capability; cannot send an elicitation completion notification." + } logger.debug { "Sending elicitation complete notification for: ${notification.params.elicitationId}" } notification(notification) } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt new file mode 100644 index 0000000..d976d34 --- /dev/null +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt @@ -0,0 +1,174 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema +import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema +import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema +import io.modelcontextprotocol.kotlin.sdk.types.LegacyTitledEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.PrimitiveSchemaDefinition +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.StringSchema +import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull +import kotlin.math.floor + +/** + * Validates accepted form-mode elicitation [content] against the supported constraints in + * [requestedSchema]: types, `required`, numeric bounds, string lengths, enum membership, and item + * counts. Undeclared properties are permitted. + * [StringSchema.format] values are treated as annotations and are not asserted. + * + * @throws McpException with [RPCError.ErrorCode.INVALID_PARAMS] listing the detected violations + * (at most one per property). + */ +internal fun validateElicitationContent(requestedSchema: ElicitRequestParams.RequestedSchema, content: JsonObject) { + val errors = buildList { + for (name in requestedSchema.required.orEmpty()) { + if (name !in content) add("must have required property '$name'") + } + for ((name, schema) in requestedSchema.properties) { + val value = content[name] ?: continue + checkProperty(name, schema, value)?.let(::add) + } + } + if (errors.isNotEmpty()) { + throw McpException( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "Elicitation response content does not match requested schema: ${errors.joinToString(", ")}", + ) + } +} + +private fun checkProperty(name: String, schema: PrimitiveSchemaDefinition, value: JsonElement): String? = + when (schema) { + is StringSchema -> { + val string = stringValue(value) ?: return "'$name' must be string" + val minLength = schema.minLength + val maxLength = schema.maxLength + val length = codePointCount(string) + when { + minLength != null && length < minLength -> + "'$name' must NOT have fewer than $minLength characters" + + maxLength != null && length > maxLength -> + "'$name' must NOT have more than $maxLength characters" + + else -> null + } + } + + is IntegerSchema -> { + val integer = integerValue(value) ?: return "'$name' must be integer" + checkRange(name, integer, schema.minimum?.toLong(), schema.maximum?.toLong()) + } + + is DoubleSchema -> { + val number = doubleValue(value) ?: return "'$name' must be number" + checkRange(name, number, schema.minimum, schema.maximum) + } + + is BooleanSchema -> if (booleanValue(value) == null) "'$name' must be boolean" else null + + is UntitledSingleSelectEnumSchema -> checkEnum(name, value, schema.enumValues) + + is TitledSingleSelectEnumSchema -> checkEnum(name, value, schema.oneOf.map { it.const }) + + is LegacyTitledEnumSchema -> checkEnum(name, value, schema.enumValues) + + is UntitledMultiSelectEnumSchema -> + checkMultiSelect(name, value, schema.items.enumValues, schema.minItems, schema.maxItems) + + is TitledMultiSelectEnumSchema -> + checkMultiSelect(name, value, schema.items.anyOf.map { it.const }, schema.minItems, schema.maxItems) + } + +// Keep Double statically typed so comparisons use IEEE 754 semantics; generic Comparable ordering +// treats -0.0 as less than 0.0. +private fun checkRange( + name: String, + value: Long, + minimum: Long?, + maximum: Long?, +): String? = when { + minimum != null && value < minimum -> "'$name' must be >= $minimum" + maximum != null && value > maximum -> "'$name' must be <= $maximum" + else -> null +} + +private fun checkRange( + name: String, + value: Double, + minimum: Double?, + maximum: Double?, +): String? = when { + minimum != null && value < minimum -> "'$name' must be >= $minimum" + maximum != null && value > maximum -> "'$name' must be <= $maximum" + else -> null +} + +private fun checkEnum(name: String, value: JsonElement, allowed: List): String? { + val string = stringValue(value) ?: return "'$name' must be string" + return if (string in allowed) null else "'$name' must be equal to one of the allowed values" +} + +private fun checkMultiSelect( + name: String, + value: JsonElement, + allowed: List, + minItems: Int?, + maxItems: Int?, +): String? { + val array = value as? JsonArray ?: return "'$name' must be array" + if (minItems != null && array.size < minItems) return "'$name' must NOT have fewer than $minItems items" + if (maxItems != null && array.size > maxItems) return "'$name' must NOT have more than $maxItems items" + for (element in array) { + val item = stringValue(element) ?: return "'$name' items must be string" + if (item !in allowed) return "'$name' items must be equal to one of the allowed values" + } + return null +} + +// JSON distinguishes the string "42" from the number 42; kotlinx's JsonPrimitive accessors parse +// string content too, so every helper checks isString to preserve that distinction. + +private fun stringValue(value: JsonElement): String? = (value as? JsonPrimitive)?.takeIf { it.isString }?.content + +private fun integerValue(value: JsonElement): Long? { + val primitive = (value as? JsonPrimitive)?.takeIf { !it.isString } ?: return null + primitive.longOrNull?.let { return it } + // JSON Schema's "integer" also matches numbers with a zero fractional part (e.g. 5.0, 1e2). + // Values that do not parse as Long are evaluated using their finite Double representation. + val number = primitive.doubleOrNull ?: return null + return if (number.isFinite() && number == floor(number)) number.toLong() else null +} + +private fun doubleValue(value: JsonElement): Double? = + (value as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() } + +private fun booleanValue(value: JsonElement): Boolean? = + (value as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull + +// JSON Schema string lengths count Unicode code points (RFC 8259 characters), not UTF-16 units, +// so a surrogate pair is one character. +private fun codePointCount(string: String): Int { + var count = 0 + var index = 0 + while (index < string.length) { + if (string[index].isHighSurrogate() && index + 1 < string.length && string[index + 1].isLowSurrogate()) { + index++ + } + index++ + count++ + } + return count +} diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistry.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistry.kt index 0ed6558..142d59c 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistry.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistry.kt @@ -20,9 +20,12 @@ internal interface FeatureListener { * A generic registry for managing features of a specified type. This class provides thread-safe * operations for adding, removing, and retrieving features from the registry. * + * Feature keys are unique: [add] and [addAll] reject keys that are already registered. + * To replace a feature, [remove] it first and add the new one. + * * @param T The type of the feature, constrained to implement the [Feature] interface. * @param featureType A string description of the type of feature being managed. - * Used primarily for logging purposes. + * Used for logging and error messages. */ internal class FeatureRegistry(private val featureType: String) { @@ -44,11 +47,11 @@ internal class FeatureRegistry(private val featureType: String) { private val listeners = atomic(persistentListOf()) internal fun addListener(listener: FeatureListener) { - listeners.update { it.add(listener) } + listeners.update { it.adding(listener) } } internal fun removeListener(listener: FeatureListener) { - listeners.update { it.remove(listener) } + listeners.update { it.removing(listener) } } /** @@ -58,27 +61,42 @@ internal class FeatureRegistry(private val featureType: String) { */ internal fun add(feature: T) { logger.info { "Adding $featureType: \"${feature.key}\"" } - val oldMap = registry.getAndUpdate { current -> current.put(feature.key, feature) } - val oldFeature = oldMap[feature.key] + registry.update { current -> + require(!current.containsKey(feature.key)) { + "$featureType \"${feature.key}\" is already registered. Remove it first to replace it." + } + current.putting(feature.key, feature) + } logger.info { "Added $featureType: \"${feature.key}\"" } - notifyFeatureUpdated(oldFeature, feature) + notifyFeatureUpdated(null, feature) } /** * Adds the given list of features to the registry. Each feature is mapped by its key - * and added to the current registry. + * and added to the current registry. The operation is all-or-nothing: if any key is + * rejected, no features are added. * * @param features The list of features to add to the registry. */ internal fun addAll(features: List) { logger.info { "Adding ${featureType}s: ${features.size}" } - val oldMap = registry.getAndUpdate { current -> current.putAll(features.associateBy { it.key }) } + val newEntries = features.associateBy { it.key } + require(newEntries.size == features.size) { + val duplicated = features.groupingBy { it.key }.eachCount().filterValues { it > 1 }.keys + "Duplicate $featureType keys in batch: $duplicated" + } + registry.update { current -> + val conflicting = newEntries.keys.filter { current.containsKey(it) } + require(conflicting.isEmpty()) { + "${featureType}s already registered: $conflicting. Remove them first to replace them." + } + current.puttingAll(newEntries) + } logger.info { "Added ${featureType}s: ${features.size}" } for (feature in features) { - val oldFeature = oldMap[feature.key] - notifyFeatureUpdated(oldFeature, feature) + notifyFeatureUpdated(null, feature) } } @@ -90,7 +108,7 @@ internal class FeatureRegistry(private val featureType: String) { */ internal fun remove(key: FeatureKey): Boolean { logger.info { "Removing $featureType: \"$key\"" } - val oldMap = registry.getAndUpdate { current -> current.remove(key) } + val oldMap = registry.getAndUpdate { current -> current.removing(key) } val removedFeature = oldMap[key] val removed = removedFeature != null diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/HostValidation.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/HostValidation.kt new file mode 100644 index 0000000..ef010fe --- /dev/null +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/HostValidation.kt @@ -0,0 +1,195 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.parseUrl +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.RouteScopedPlugin +import io.ktor.server.application.createRouteScopedPlugin +import io.ktor.server.request.header +import io.ktor.server.response.respondText +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.RPCError + +/** + * Default list of hostnames allowed for localhost DNS rebinding protection. + * Matches the TypeScript SDK's `localhostAllowedHostnames()`. + */ +internal val LOCALHOST_ALLOWED_HOSTS: List = listOf("localhost", "127.0.0.1", "[::1]") + +/** + * Default list of `Origin` values allowed for localhost DNS rebinding protection. + * + * Mirrors [LOCALHOST_ALLOWED_HOSTS] but carries a scheme so the values parse as URLs: + * [extractOriginHost] rejects schemeless input. Comparison is hostname-only and + * scheme-agnostic, so these entries also match `https://` origins on the same host. + */ +internal val LOCALHOST_ALLOWED_ORIGINS: List = + listOf("http://localhost", "http://127.0.0.1", "http://[::1]") + +/** + * Characters that are valid in a URL but must not appear in an HTTP `Host` header. + * Rejecting them prevents the parser from accepting malformed values + * (e.g. `evil.com@localhost`, `host/path`) that a generic URL parser would silently allow. + */ +private val FORBIDDEN_HOST_CHARS: CharArray = charArrayOf('/', '@', '?', '#') + +/** + * Extracts the hostname from a Host header value, stripping the port. + * + * Only accepts the strict `host [ ":" port ]` / `"[" ipv6 "]" [ ":" port ]` + * format defined by RFC 7230. Values containing URL-only characters + * (`/`, `@`, `?`, `#`) or whitespace are rejected. + * + * Examples: + * - `"localhost:3000"` → `"localhost"` + * - `"127.0.0.1:8080"` → `"127.0.0.1"` + * - `"[::1]:3000"` → `"[::1]"` + * - `"example.com"` → `"example.com"` + * - `"evil.com@localhost"` → `null` + * + * @return the hostname, or `null` if the value is blank, malformed, or contains forbidden characters. + */ +internal fun extractHostname(hostHeader: String): String? = when { + hostHeader.isBlank() -> null + + hostHeader.any { it in FORBIDDEN_HOST_CHARS || it.isWhitespace() } -> null + + hostHeader.startsWith("[") -> { + val end = hostHeader.indexOf(']') + when { + end <= 1 -> null + isValidPortSuffix(hostHeader.substring(end + 1)) -> hostHeader.substring(0, end + 1) + else -> null + } + } + + else -> { + val colon = hostHeader.indexOf(':') + when { + colon < 0 -> hostHeader + colon == 0 -> null + isValidPortSuffix(hostHeader.substring(colon)) -> hostHeader.substring(0, colon) + else -> null + } + } +} + +/** + * Checks that [tail] is either empty (no port part) or `":"` followed by zero or more digits, + * matching RFC 7230 §3.2.3 where `port = *DIGIT`. Non-empty non-digit characters are rejected. + */ +private fun isValidPortSuffix(tail: String): Boolean = when { + tail.isEmpty() -> true + tail[0] != ':' -> false + else -> tail.substring(1).all { it.isDigit() } +} + +/** + * Extracts the hostname from an `Origin` header value. + * + * Uses Ktor's [parseUrl] to avoid reimplementing RFC 6454 parsing. + * Scheme, port, userinfo, and path are discarded — only the host matters for + * DNS-rebinding protection. The result is **not** normalized to lower-case; + * callers should apply [String.lowercase] if they need case-insensitive comparison. + * + * @return the hostname, or `null` if [origin] cannot be parsed or has no host part. + */ +internal fun extractOriginHost(origin: String): String? = parseUrl(origin)?.host?.takeIf(String::isNotEmpty) + +/** + * Configuration for the [DnsRebindingProtection] Ktor route-scoped plugin. + * + * @property allowedHosts List of hostnames allowed in the `Host` header. + * Comparison is port-agnostic and case-insensitive. + * Defaults to `localhost`, `127.0.0.1`, `[::1]`. + * An empty list will reject **all** requests. + * @property allowedOrigins Optional list of allowed `Origin` values. Entries are parsed as URLs + * (via [parseUrl]) and compared by **hostname only** — scheme and port are ignored. + * If `null`, origin validation is disabled. + * If configured, requests **with** an `Origin` header whose hostname is not in the list + * are rejected, but requests **without** an `Origin` header are allowed (non-browser clients). + */ +public class DnsRebindingProtectionConfig { + public var allowedHosts: List = LOCALHOST_ALLOWED_HOSTS + public var allowedOrigins: List? = null +} + +/** + * Ktor route-scoped plugin that validates `Host` and `Origin` headers + * to protect against DNS rebinding attacks. + * + * Install on a route to intercept all requests **before** handlers: + * ```kotlin + * route("/mcp") { + * install(DnsRebindingProtection) { + * allowedHosts = listOf("myapp.com", "localhost") + * } + * // handlers... + * } + * ``` + */ +public val DnsRebindingProtection: RouteScopedPlugin = + createRouteScopedPlugin( + "MCP-DnsRebindingProtection", + ::DnsRebindingProtectionConfig, + ) { + val hosts: Set = pluginConfig.allowedHosts.mapTo(mutableSetOf()) { + extractHostname(it)?.lowercase() + ?: error("Invalid host in DnsRebindingProtection.allowedHosts: '$it'") + } + val originHosts: Set? = pluginConfig.allowedOrigins?.mapTo(mutableSetOf()) { + extractOriginHost(it)?.lowercase() + ?: error("Invalid origin in DnsRebindingProtection.allowedOrigins: '$it'") + } + + onCall { call -> + val hostHeader = call.request.header(HttpHeaders.Host) + val hostname = hostHeader?.let { extractHostname(it) }?.lowercase() + + if (hostname == null) { + call.rejectDnsValidation("Invalid Host header: (malformed or missing)") + return@onCall + } + if (hostname !in hosts) { + call.rejectDnsValidation("Invalid Host: $hostname") + return@onCall + } + + if (originHosts != null) { + val originHeader = call.request.header(HttpHeaders.Origin) + // Allow requests without Origin (non-browser clients cannot perform DNS rebinding) + if (originHeader != null) { + val originHost = extractOriginHost(originHeader)?.lowercase() + if (originHost == null) { + call.rejectDnsValidation("Invalid Origin header: (unparseable)") + return@onCall + } + if (originHost !in originHosts) { + call.rejectDnsValidation("Invalid Origin host: $originHost") + return@onCall + } + } + } + } + } + +/** + * Responds with a 403 Forbidden JSON-RPC error without requiring ContentNegotiation. + */ +private suspend fun ApplicationCall.rejectDnsValidation(message: String) { + val error = JSONRPCError( + id = null, + error = RPCError( + code = -32_000, + message = message, + ), + ) + respondText( + McpJson.encodeToString(error), + ContentType.Application.Json, + HttpStatusCode.Forbidden, + ) +} diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt index 3f03a7a..3127499 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt @@ -1,6 +1,7 @@ package io.modelcontextprotocol.kotlin.sdk.server import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application @@ -20,8 +21,10 @@ import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.route import io.ktor.server.routing.routing +import io.ktor.server.sse.Heartbeat import io.ktor.server.sse.SSE import io.ktor.server.sse.ServerSSESession +import io.ktor.server.sse.heartbeat import io.ktor.server.sse.sse import io.ktor.utils.io.KtorDsl import io.modelcontextprotocol.kotlin.sdk.types.RPCError @@ -37,14 +40,31 @@ private val logger = KotlinLogging.logger {} * Use [Application.mcp] if you want SSE to be installed automatically. * * @param path the URL path to register the SSE endpoint. + * @param enableDnsRebindingProtection whether to install [DnsRebindingProtection] on this route. Defaults to `true`. + * @param allowedHosts hostnames allowed in the `Host` header. Defaults to `localhost`, `127.0.0.1`, `[::1]`. + * @param allowedOrigins origins allowed in the `Origin` header, compared by hostname only + * (scheme and port are ignored). Requests without an `Origin` header are allowed. + * When `null` while the localhost host defaults are in effect (no custom `allowedHosts`), + * the `Origin` header is validated against `localhost`, `127.0.0.1`, `[::1]`. + * With custom `allowedHosts`, `null` skips origin validation. + * @param maxRequestBodySize maximum allowed size, in bytes, of an incoming POST body; larger requests are + * rejected with `413 Payload Too Large`. Defaults to 4 MiB. * @param block factory block with access to the [ServerSSESession] * that creates and returns the [Server] to handle the connection. * @throws IllegalStateException if the [SSE] plugin is not installed. */ @KtorDsl -public fun Route.mcp(path: String, block: ServerSSESession.() -> Server) { +@Suppress("LongParameterList") +public fun Route.mcp( + path: String, + enableDnsRebindingProtection: Boolean = true, + allowedHosts: List? = null, + allowedOrigins: List? = null, + maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, + block: ServerSSESession.() -> Server, +) { route(path) { - mcp(block) + mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block) } } @@ -55,12 +75,28 @@ public fun Route.mcp(path: String, block: ServerSSESession.() -> Server) { * **Precondition:** the [SSE] plugin must be installed on the application before calling this function. * Use [Application.mcp] if you want SSE to be installed automatically. * + * @param enableDnsRebindingProtection whether to install [DnsRebindingProtection] on this route. Defaults to `true`. + * @param allowedHosts hostnames allowed in the `Host` header. Defaults to `localhost`, `127.0.0.1`, `[::1]`. + * @param allowedOrigins origins allowed in the `Origin` header, compared by hostname only + * (scheme and port are ignored). Requests without an `Origin` header are allowed. + * When `null` while the localhost host defaults are in effect (no custom `allowedHosts`), + * the `Origin` header is validated against `localhost`, `127.0.0.1`, `[::1]`. + * With custom `allowedHosts`, `null` skips origin validation. + * @param maxRequestBodySize maximum allowed size, in bytes, of an incoming POST body; larger requests are + * rejected with `413 Payload Too Large`. Defaults to 4 MiB. * @param block factory block with access to the [ServerSSESession] * that creates and returns the [Server] to handle the connection. * @throws IllegalStateException if the [SSE] plugin is not installed. */ @KtorDsl -public fun Route.mcp(block: ServerSSESession.() -> Server) { +@Suppress("LongParameterList") +public fun Route.mcp( + enableDnsRebindingProtection: Boolean = true, + allowedHosts: List? = null, + allowedOrigins: List? = null, + maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, + block: ServerSSESession.() -> Server, +) { try { plugin(SSE) } catch (e: MissingApplicationPluginException) { @@ -72,10 +108,12 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) { ) } + installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins) + val transportManager = TransportManager() sse { - mcpSseEndpoint("", transportManager, block) + mcpSseEndpoint("", transportManager, maxRequestBodySize, block) } post { @@ -91,22 +129,43 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) { * Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation] * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE]. * + * @param enableDnsRebindingProtection whether to install [DnsRebindingProtection] on this route. Defaults to `true`. + * @param allowedHosts hostnames allowed in the `Host` header. Defaults to `localhost`, `127.0.0.1`, `[::1]`. + * @param allowedOrigins origins allowed in the `Origin` header, compared by hostname only + * (scheme and port are ignored). Requests without an `Origin` header are allowed. + * When `null` while the localhost host defaults are in effect (no custom `allowedHosts`), + * the `Origin` header is validated against `localhost`, `127.0.0.1`, `[::1]`. + * With custom `allowedHosts`, `null` skips origin validation. + * @param maxRequestBodySize maximum allowed size, in bytes, of an incoming POST body; larger requests are + * rejected with `413 Payload Too Large`. Defaults to 4 MiB. * @param block factory block with access to the [ServerSSESession] * that creates and returns the [Server] to handle the connection. */ @KtorDsl -public fun Application.mcp(block: ServerSSESession.() -> Server) { +@Suppress("LongParameterList") +public fun Application.mcp( + enableDnsRebindingProtection: Boolean = true, + allowedHosts: List? = null, + allowedOrigins: List? = null, + maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, + block: ServerSSESession.() -> Server, +) { installMcpContentNegotiation() install(SSE) routing { - mcp(block) + mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block) } } +@Suppress("LongParameterList") private fun Application.mcpStreamableHttp( path: String = "/mcp", + enableDnsRebindingProtection: Boolean, + allowedHosts: List?, + allowedOrigins: List?, configuration: StreamableHttpServerTransport.Configuration, + sseHeartbeatConfig: (Heartbeat.() -> Unit)?, block: RoutingContext.() -> Server, ) { installMcpContentNegotiation() @@ -116,6 +175,8 @@ private fun Application.mcpStreamableHttp( routing { route(path) { + installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins) + // Set Mcp-Session-Id on GET responses before Ktor's sse {} commits headers. intercept(ApplicationCallPipeline.Plugins) { if (context.request.httpMethod == HttpMethod.Get) { @@ -128,6 +189,7 @@ private fun Application.mcpStreamableHttp( sse { val transport = existingStreamableTransport(call, transportManager) ?: return@sse + sseHeartbeatConfig?.let { config -> heartbeat(config) } transport.handleRequest(this, call) } @@ -160,47 +222,59 @@ private fun Application.mcpStreamableHttp( * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE]. * * @param path The base path for the MCP Streamable HTTP endpoint. Defaults to "/mcp". - * @param enableDnsRebindingProtection Enables DNS rebinding attack protection for the endpoint. Defaults to false. - * @param allowedHosts A list of hostnames allowed to access the endpoint. If `null`, no restrictions are applied. - * @param allowedOrigins A list of origins allowed to perform cross-origin requests (CORS). - * If `null`, no restrictions are applied. + * @param enableDnsRebindingProtection Enables DNS rebinding attack protection for the endpoint. Defaults to `true`. + * @param allowedHosts A list of hostnames allowed to access the endpoint. + * If `null` and DNS rebinding protection is enabled, defaults to `localhost`, `127.0.0.1`, `[::1]`. + * @param allowedOrigins A list of allowed `Origin` header values, compared by hostname only + * (scheme and port are ignored). Requests without an `Origin` header are allowed. + * When `null` while the localhost host defaults are in effect (no custom `allowedHosts`), + * the `Origin` header is validated against `localhost`, `127.0.0.1`, `[::1]`. + * With custom `allowedHosts`, `null` skips origin validation. * @param eventStore An optional [EventStore] instance to enable resumable event stream functionality. * Allows storing and replaying events. + * @param sseHeartbeatConfig The heartbeat configuration option for SSE connections. `null` means no heartbeat is sent. * @param block factory block with access to the [RoutingContext] (for reading request headers) * that creates and returns the [Server] to handle the connection. */ @KtorDsl public fun Application.mcpStreamableHttp( path: String = "/mcp", - enableDnsRebindingProtection: Boolean = false, + enableDnsRebindingProtection: Boolean = true, allowedHosts: List? = null, allowedOrigins: List? = null, eventStore: EventStore? = null, + sseHeartbeatConfig: (Heartbeat.() -> Unit)? = null, block: RoutingContext.() -> Server, ) { mcpStreamableHttp( path = path, + enableDnsRebindingProtection = enableDnsRebindingProtection, + allowedHosts = allowedHosts, + allowedOrigins = allowedOrigins, configuration = StreamableHttpServerTransport.Configuration( - enableDnsRebindingProtection = enableDnsRebindingProtection, - allowedHosts = allowedHosts, - allowedOrigins = allowedOrigins, eventStore = eventStore, enableJsonResponse = true, ), + sseHeartbeatConfig = sseHeartbeatConfig, block = block, ) } +@Suppress("LongParameterList") private fun Application.mcpStatelessStreamableHttp( path: String = "/mcp", + enableDnsRebindingProtection: Boolean, + allowedHosts: List?, + allowedOrigins: List?, configuration: StreamableHttpServerTransport.Configuration, block: RoutingContext.() -> Server, ) { installMcpContentNegotiation() - install(SSE) routing { route(path) { + installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins) + post { mcpStatelessStreamableHttpEndpoint( configuration = configuration, @@ -208,18 +282,10 @@ private fun Application.mcpStatelessStreamableHttp( ) } get { - call.reject( - HttpStatusCode.MethodNotAllowed, - RPCError.ErrorCode.CONNECTION_CLOSED, - "Method not allowed.", - ) + call.rejectUnsupportedMethod() } delete { - call.reject( - HttpStatusCode.MethodNotAllowed, - RPCError.ErrorCode.CONNECTION_CLOSED, - "Method not allowed.", - ) + call.rejectUnsupportedMethod() } } } @@ -230,47 +296,79 @@ private fun Application.mcpStatelessStreamableHttp( * over _stateless_ [Streamable HTTP Transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) * * Sets up an HTTP POST endpoint at [path]. GET and DELETE requests return 405 Method Not Allowed. - * Simple request/response pairs are returned as JSON (not SSE streams). + * Every request/response pair is returned as JSON, so this endpoint opens no SSE stream and + * offers no resumability. Use [mcpStreamableHttp] when either is required. * * Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation] - * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE]. + * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson]. * * @param path The URL path where the server listens for incoming JSON-RPC requests. Defaults to "/mcp". - * @param enableDnsRebindingProtection Determines whether DNS rebinding protection is enabled. Defaults to `false`. - * @param allowedHosts A list of allowed hostnames. If null, host filtering is disabled. - * @param allowedOrigins A list of allowed origins for CORS. If null, origin filtering is disabled. - * @param eventStore An optional [EventStore] implementation to provide resumability and event replay support. + * @param enableDnsRebindingProtection Determines whether DNS rebinding protection is enabled. Defaults to `true`. + * @param allowedHosts A list of allowed hostnames. If `null` and DNS rebinding protection is enabled, + * defaults to `localhost`, `127.0.0.1`, `[::1]`. + * @param allowedOrigins A list of allowed `Origin` header values, compared by hostname only + * (scheme and port are ignored). Requests without an `Origin` header are allowed. + * If `null`, origin validation is disabled. * @param block factory block with access to the [RoutingContext] (for reading request headers) * that creates and returns the [Server] to handle the connection. */ @KtorDsl public fun Application.mcpStatelessStreamableHttp( path: String = "/mcp", - enableDnsRebindingProtection: Boolean = false, + enableDnsRebindingProtection: Boolean = true, allowedHosts: List? = null, allowedOrigins: List? = null, - eventStore: EventStore? = null, block: RoutingContext.() -> Server, ) { mcpStatelessStreamableHttp( path = path, + enableDnsRebindingProtection = enableDnsRebindingProtection, + allowedHosts = allowedHosts, + allowedOrigins = allowedOrigins, configuration = StreamableHttpServerTransport.Configuration( - enableDnsRebindingProtection = enableDnsRebindingProtection, - allowedHosts = allowedHosts, - allowedOrigins = allowedOrigins, - eventStore = eventStore, enableJsonResponse = true, ), block = block, ) } +/** + * Retained only so that existing call sites get a migration hint instead of an unresolved parameter name. + * + * @param eventStore never consulted by a stateless endpoint. Resumption is driven by a `GET` carrying + * `Last-Event-ID`, and this endpoint answers `GET` with `405 Method Not Allowed`, so no stream + * exists to store events on or replay them to. Use [mcpStreamableHttp] when you need resumability. + */ +@Deprecated( + "Use mcpStatelessStreamableHttp without eventStore.", + ReplaceWith("mcpStatelessStreamableHttp(path, enableDnsRebindingProtection, allowedHosts, allowedOrigins, block)"), + DeprecationLevel.ERROR, +) +@Suppress("UnusedParameter") +public fun Application.mcpStatelessStreamableHttp( + path: String = "/mcp", + enableDnsRebindingProtection: Boolean = true, + allowedHosts: List? = null, + allowedOrigins: List? = null, + eventStore: EventStore?, + block: RoutingContext.() -> Server, +) { + mcpStatelessStreamableHttp( + path = path, + enableDnsRebindingProtection = enableDnsRebindingProtection, + allowedHosts = allowedHosts, + allowedOrigins = allowedOrigins, + block = block, + ) +} + private suspend fun ServerSSESession.mcpSseEndpoint( postEndpoint: String, transportManager: TransportManager, + maxRequestBodySize: Long, block: ServerSSESession.() -> Server, ) { - val transport = mcpSseTransport(postEndpoint, transportManager) + val transport = mcpSseTransport(postEndpoint, transportManager, maxRequestBodySize) val server = block() @@ -289,8 +387,9 @@ private suspend fun ServerSSESession.mcpSseEndpoint( private fun ServerSSESession.mcpSseTransport( postEndpoint: String, transportManager: TransportManager, + maxRequestBodySize: Long, ): SseServerTransport { - val transport = SseServerTransport(postEndpoint, this) + val transport = SseServerTransport(postEndpoint, this, maxRequestBodySize) transportManager.addTransport(transport.sessionId, transport) logger.info { "New SSE connection established and stored with sessionId: ${transport.sessionId}" } @@ -308,11 +407,17 @@ private suspend fun RoutingContext.mcpStatelessStreamableHttpEndpoint( logger.info { "New stateless StreamableHttp connection established without sessionId" } val server = block() - server.onClose { logger.info { "Server connection closed without sessionId" } } - server.createSession(transport) + val session = server.createSession(transport) - transport.handleRequest(null, this.call) - logger.debug { "Server connected to transport without sessionId" } + try { + transport.handleRequest(null, this.call) + logger.debug { "Server connected to transport without sessionId" } + } finally { + // A stateless session serves exactly one request: close it so the server's + // session registry and notification subscriptions do not grow unboundedly. + session.close() + logger.debug { "Stateless session closed after request completion" } + } } private suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportManager) { @@ -334,6 +439,16 @@ private suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportMa logger.trace { "Message handled for sessionId: $sessionId" } } +/** A stateless endpoint serves POST only, offering neither an SSE stream to open nor a session to delete. */ +private suspend fun ApplicationCall.rejectUnsupportedMethod() { + response.header(HttpHeaders.Allow, HttpMethod.Post.value) + reject( + HttpStatusCode.MethodNotAllowed, + RPCError.ErrorCode.CONNECTION_CLOSED, + "Method not allowed.", + ) +} + private fun ApplicationRequest.sessionId(): String? = header(MCP_SESSION_ID_HEADER) private suspend fun existingStreamableTransport( @@ -395,3 +510,14 @@ private suspend fun RoutingContext.streamableTransport( return transport } + +private fun Route.installDnsRebindingProtection(enabled: Boolean, hosts: List?, origins: List?) { + if (!enabled) return + install(DnsRebindingProtection) { + allowedHosts = hosts ?: LOCALHOST_ALLOWED_HOSTS + // Secure-by-default: when relying on the localhost host defaults, validate the Origin + // header against localhost too, so a request with a valid Host but a hostile Origin + // (e.g. a DNS-rebinding page) is rejected. Callers with custom hosts opt in explicitly. + allowedOrigins = origins ?: LOCALHOST_ALLOWED_ORIGINS.takeIf { hosts == null } + } +} diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBody.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBody.kt new file mode 100644 index 0000000..0cc4143 --- /dev/null +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBody.kt @@ -0,0 +1,34 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.receiveChannel +import io.ktor.utils.io.readRemaining +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.readString + +/** Default maximum size (in bytes) for an incoming HTTP request body: 4 MiB. */ +internal const val DEFAULT_MAX_REQUEST_BODY_SIZE: Long = 4L * 1024 * 1024 + +/** Signals that an incoming request body exceeded the maximum size a transport is willing to read. */ +internal class RequestBodyTooLargeException(val maxBodySize: Long) : + IOException("Request body exceeds the maximum of $maxBodySize bytes.") + +/** + * Reads the request body as a UTF-8 string, rejecting bodies larger than [maxBytes]. + * + * At most `maxBytes + 1` bytes are ever pulled from the request channel, so an oversized or unframed + * body cannot exhaust memory regardless of the (untrusted) `Content-Length` header. + * + * @throws RequestBodyTooLargeException if the body exceeds [maxBytes] + */ +internal suspend fun ApplicationCall.receiveTextWithLimit(maxBytes: Long): String { + val buffer = Buffer() + val readLimit = if (maxBytes == Long.MAX_VALUE) maxBytes else maxBytes + 1 + val read = receiveChannel().readRemaining(readLimit).transferTo(buffer) + if (read > maxBytes) { + buffer.clear() + throw RequestBodyTooLargeException(maxBytes) + } + return buffer.readString() +} diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt index a6d4618..f525ec2 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt @@ -5,7 +5,6 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.encodeURLPath import io.ktor.server.application.ApplicationCall import io.ktor.server.request.contentType -import io.ktor.server.request.receiveText import io.ktor.server.response.respondText import io.ktor.server.sse.ServerSSESession import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport @@ -29,10 +28,19 @@ internal const val SESSION_ID_PARAM = "sessionId" * * @param endpoint relative or absolute URL the client will POST messages to * @param session active SSE session used to deliver server-to-client events + * @param maxRequestBodySize maximum allowed size, in bytes, of an incoming POST body; larger requests are + * rejected with `413 Payload Too Large` without being buffered in full. Defaults to 4 MiB. */ @OptIn(ExperimentalAtomicApi::class) -public class SseServerTransport(private val endpoint: String, private val session: ServerSSESession) : - AbstractTransport() { +public class SseServerTransport( + private val endpoint: String, + private val session: ServerSSESession, + private val maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, +) : AbstractTransport() { + init { + require(maxRequestBodySize > 0) { "maxRequestBodySize must be greater than 0" } + } + private val initialized: AtomicBoolean = AtomicBoolean(false) /** Unique identifier for this transport session, generated randomly on creation. */ @@ -77,6 +85,7 @@ public class SseServerTransport(private val endpoint: String, private val sessio val message = "SSE connection not established" call.respondText(message, status = HttpStatusCode.InternalServerError) _onError.invoke(IllegalStateException(message)) + return } val body = try { @@ -85,9 +94,13 @@ public class SseServerTransport(private val endpoint: String, private val sessio error("Unsupported content-type: $ct") } - call.receiveText() + call.receiveTextWithLimit(maxRequestBodySize) } catch (e: CancellationException) { throw e + } catch (e: RequestBodyTooLargeException) { + call.respondText(e.message ?: "Request body too large", status = HttpStatusCode.PayloadTooLarge) + _onError.invoke(e) + return } catch (e: Exception) { call.respondText("Invalid message: ${e.message}", status = HttpStatusCode.BadRequest) _onError.invoke(e) diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingValidation.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingValidation.kt index ef40808..8243171 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingValidation.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingValidation.kt @@ -20,8 +20,8 @@ import io.modelcontextprotocol.kotlin.sdk.types.ToolUseContent * 3. If the previous message contains `tool_use` blocks, the last message's * `tool_result` ids MUST form exactly the same set. * - * On the first violation throws [IllegalArgumentException]. No-op when there are fewer - * than two messages or no tool_use / tool_result blocks are involved. + * On the first violation throws [IllegalArgumentException]. No-op when no + * tool_use / tool_result blocks are involved. */ internal fun validateSamplingMessages(messages: List) { if (messages.isEmpty()) return @@ -44,6 +44,9 @@ internal fun validateSamplingMessages(messages: List) { if (hasPreviousToolUse) { val toolUseIds = previous.filterIsInstance().map { it.id }.toSet() val toolResultIds = last.filterIsInstance().map { it.toolUseId }.toSet() + require(toolResultIds.isNotEmpty()) { + "tool_use blocks from previous message must be followed by matching tool_result blocks" + } require(toolUseIds == toolResultIds) { "ids of tool_result blocks and tool_use blocks from previous message do not match" } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt index 1b7bfe7..6f7711f 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt @@ -46,15 +46,18 @@ import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations import io.modelcontextprotocol.kotlin.sdk.types.ToolExecution import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest +import io.modelcontextprotocol.kotlin.sdk.types.UrlElicitationRequiredException import io.modelcontextprotocol.kotlin.sdk.utils.MatchResult import io.modelcontextprotocol.kotlin.sdk.utils.PathSegmentTemplateMatcher import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcher import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcherFactory import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +import kotlin.coroutines.CoroutineContext import kotlin.jvm.JvmOverloads import kotlin.time.ExperimentalTime @@ -68,12 +71,17 @@ private val logger = KotlinLogging.logger {} * Forwarded to [ProtocolOptions.enforceStrictCapabilities]. * @property resourceTemplateMatcherFactory The factory used to create [ResourceTemplateMatcher] instances * for matching resource URIs against registered templates. Defaults to [PathSegmentTemplateMatcher.factory]. + * @param handlerCoroutineContext Coroutine context for inbound handlers. See [ProtocolOptions.handlerCoroutineContext]. */ public class ServerOptions( public val capabilities: ServerCapabilities, enforceStrictCapabilities: Boolean = true, public val resourceTemplateMatcherFactory: ResourceTemplateMatcherFactory = PathSegmentTemplateMatcher.factory, -) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) { + handlerCoroutineContext: CoroutineContext = Dispatchers.Default, +) : ProtocolOptions( + enforceStrictCapabilities = enforceStrictCapabilities, + handlerCoroutineContext = handlerCoroutineContext, +) { @JvmOverloads public constructor( capabilities: ServerCapabilities, @@ -651,6 +659,9 @@ public open class Server( } } catch (e: CancellationException) { throw e + } catch (e: UrlElicitationRequiredException) { + // Surface a required URL-mode elicitation as a JSON-RPC error (-32042) + throw e } catch (e: Exception) { logger.error(e) { "Error executing tool ${requestParams.name}" } CallToolResult( @@ -671,7 +682,10 @@ public open class Server( val prompt = promptRegistry.get(requestParams.name) ?: run { logger.error { "Prompt not found: ${requestParams.name}" } - throw IllegalArgumentException("Prompt not found: ${requestParams.name}") + throw McpException( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "Prompt not found: ${requestParams.name}", + ) } return prompt.run { session.clientConnection.messageProvider(request) diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt index e7da26f..f20d3e1 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt @@ -108,10 +108,18 @@ public open class ServerSession( } } + override fun onInitializedNotification() { + // Enable concurrency before the user's onInitialized callback, which runs synchronously. + enableConcurrentDispatch() + } + internal val clientConnection: ClientConnection = ClientConnectionImpl(this) /** * Registers a callback to be invoked when the server has completed initialization. + * + * The callback must be synchronous and fast: it runs on the message-dispatch path for + * `notifications/initialized`, after concurrent dispatch has been enabled for the session. */ public fun onInitialized(block: () -> Unit) { val old = _onInitialized @@ -172,6 +180,12 @@ public open class ServerSession( } } + Defined.TasksGet, + Defined.TasksResult, + Defined.TasksList, + Defined.TasksCancel, + -> assertTasksCapabilityForMethod(method) + Defined.Ping -> { // No specific capability required } @@ -182,6 +196,28 @@ public open class ServerSession( } } + private fun assertTasksCapabilityForMethod(method: Method) { + val tasks = clientCapabilities?.tasks + ?: error("Client does not support tasks (required for ${method.value})") + when (method) { + Defined.TasksList -> { + if (tasks.list == null) { + error("Client does not support listing tasks (required for ${method.value})") + } + } + + Defined.TasksCancel -> { + if (tasks.cancel == null) { + error("Client does not support cancelling tasks (required for ${method.value})") + } + } + + else -> { + // TasksGet, TasksResult: base tasks capability suffices. + } + } + } + /** * Asserts that the server can handle the specified notification method. * @@ -225,6 +261,12 @@ public open class ServerSession( } } + Defined.NotificationsTasksStatus -> { + if (serverCapabilities.tasks == null) { + error("Server does not support tasks (required for ${method.value})") + } + } + Defined.NotificationsCancelled, Defined.NotificationsProgress, -> { @@ -379,12 +421,17 @@ public open class ServerSession( * Sends a message to the client requesting an elicitation. * This typically results in a form being displayed to the end user. * + * Accepted content is validated against [requestedSchema]. If content is absent, an empty + * object is used for validation only, so declared `required` properties still apply. The + * returned [ElicitResult] is not modified. + * * @param message The message for the elicitation to display. - * @param requestedSchema The schema requested by the client for the elicitation result. + * @param requestedSchema The schema the server requests for the elicitation result. * Influences the form displayed to the user. * @param options Optional request options. * @return The result of the elicitation request. * @throws IllegalStateException If the server or client does not support elicitation. + * @throws McpException If an accepted response does not match [requestedSchema]. */ public suspend fun createElicitation( message: String, diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt index a4d588c..9261be2 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt @@ -4,16 +4,27 @@ import io.github.oshai.kotlinlogging.KotlinLogging import io.modelcontextprotocol.kotlin.sdk.internal.IODispatcher import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer +import io.modelcontextprotocol.kotlin.sdk.shared.TooLongFrameException import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.McpDsl +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.RPCError import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedSendChannelException +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -23,191 +34,347 @@ import kotlinx.io.Source import kotlinx.io.buffered import kotlinx.io.readByteArray import kotlinx.io.writeString -import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.coroutines.CoroutineContext private const val READ_BUFFER_SIZE = 8192L /** - * A server transport that communicates with a client via standard I/O. + * Server-side MCP transport that exchanges JSON-RPC messages over arbitrary byte streams. * - * Reads from input [Source] and writes to output [Sink]. + * Reads framed messages from the supplied [Source] and writes framed messages to the supplied + * [Sink]. Three internal coroutines drive the pipeline: * - * @constructor Creates a new instance of [StdioServerTransport]. - * @param inputStream The input [Source] used to receive data. - * @param outputStream The output [Sink] used to send data. + * - **reader** — pulls bytes from the input source into the parsing buffer; runs on + * [Builder.ioDispatcher]. + * - **processor** — parses messages out of the buffer and invokes the registered message handler; + * runs on [Builder.handlerDispatcher] (defaults to [Dispatchers.Default]) so blocking handler + * code does not starve the I/O pool. + * - **writer** — serialises outbound messages and flushes them to the output sink; runs on + * [Builder.ioDispatcher]. + * + * Both internal channels are bounded, so a slow handler or slow output naturally back-pressures + * the upstream producer — [send] suspends when the outbound channel is full. + * + * Both explicit [close] and a natural EOF from the input perform a graceful drain: in-flight + * outbound messages are flushed before `onClose` fires, and the input source and output sink are + * released. + * + * Example: + * ```kotlin + * val transport = StdioServerTransport( + * input = System.`in`.asSource().buffered(), + * output = System.out.asSink().buffered(), + * ) { + * scope = myScope + * } + * transport.start() + * ``` */ @OptIn(ExperimentalAtomicApi::class) -public class StdioServerTransport(private val inputStream: Source, outputStream: Sink) : AbstractTransport() { +public class StdioServerTransport private constructor( + private val input: Source, + output: Sink, + private val scope: CoroutineScope?, + private val handlerDispatcher: CoroutineDispatcher, + private val ioDispatcher: CoroutineDispatcher, +) : AbstractTransport() { + + /** + * Creates a [StdioServerTransport] reading from [input] and writing to [output], with + * optional configuration applied through a [Builder] block. + * + * @param input source the transport reads JSON-RPC messages from + * @param output sink the transport writes JSON-RPC messages to + * @param block configuration applied to the underlying [Builder] + */ + public constructor( + input: Source, + output: Sink, + block: Builder.() -> Unit = {}, + ) : this(Builder(input, output).apply(block)) + + private constructor(builder: Builder) : this( + input = builder.input, + output = builder.output, + scope = builder.scope, + handlerDispatcher = builder.handlerDispatcher, + ioDispatcher = builder.ioDispatcher, + ) + + /** + * Creates a [StdioServerTransport] from the given input source and output sink with default + * configuration. Retained for binary compatibility; prefer the [Builder]-based constructor. + * + * @param inputStream source the transport reads JSON-RPC messages from + * @param outputStream sink the transport writes JSON-RPC messages to + */ + @Deprecated( + message = "Use StdioServerTransport(input, output) { ... } instead.", + replaceWith = ReplaceWith("StdioServerTransport(input = inputStream, output = outputStream)"), + level = DeprecationLevel.WARNING, + ) + public constructor(inputStream: Source, outputStream: Sink) : this( + input = inputStream, + output = outputStream, + scope = null, + handlerDispatcher = Dispatchers.Default, + ioDispatcher = IODispatcher.limitedParallelism(2), + ) private val logger = KotlinLogging.logger {} private val readBuffer = ReadBuffer() - private val initialized: AtomicBoolean = AtomicBoolean(false) - private var readingJob: Job? = null - private var sendingJob: Job? = null - private var processingJob: Job? = null + private val readChannel = Channel(Channel.BUFFERED) + private val writeChannel = Channel(Channel.BUFFERED) + private val outputSink = output.buffered() - private val coroutineContext: CoroutineContext = IODispatcher + SupervisorJob() - private val scope = CoroutineScope(coroutineContext) - private val readChannel = Channel(Channel.UNLIMITED) - private val writeChannel = Channel(Channel.UNLIMITED) - private val outputSink = outputStream.buffered() + private enum class State { New, Operational, Stopped } + private val state: AtomicReference = AtomicReference(State.New) + + private var readerJob: Job? = null + private var processorJob: Job? = null + private var writerJob: Job? = null + + private var effectiveScope: CoroutineScope? = null + private val ownsScope: Boolean = (scope == null) + + private val setupComplete = CompletableDeferred() + + /** + * Starts the reader, processor, and writer coroutines. Must be called exactly once before + * messages can be exchanged; subsequent calls throw. + */ override suspend fun start() { - if (!initialized.compareAndSet(expectedValue = false, newValue = true)) { - error("StdioServerTransport already started!") + if (!state.compareAndSet(State.New, State.Operational)) { + when (state.load()) { + State.Stopped -> error("StdioServerTransport is already closed!") + else -> error("StdioServerTransport already started!") + } } - // Launch a coroutine to read from stdin - readingJob = launchReadingJob() + try { + val resolvedScope = scope ?: CoroutineScope( + currentCoroutineContext() + IODispatcher + SupervisorJob(), + ) + effectiveScope = resolvedScope - // Launch a coroutine to process messages from readChannel - processingJob = launchProcessingJob() - - // Launch a coroutine to handle message sending - sendingJob = launchSendingJob() + readerJob = resolvedScope.launch( + ioDispatcher + CoroutineName("StdioServerTransport.reader"), + ) { readerPump() } + processorJob = resolvedScope.launch( + handlerDispatcher + CoroutineName("StdioServerTransport.processor"), + start = CoroutineStart.UNDISPATCHED, + ) { processorPump() } + writerJob = resolvedScope.launch( + ioDispatcher + CoroutineName("StdioServerTransport.writer"), + start = CoroutineStart.UNDISPATCHED, + ) { writerPump() } + } finally { + setupComplete.complete(Unit) + } } - private fun launchReadingJob(): Job { - val job = scope.launch { - val buf = Buffer() - try { - while (isActive) { - val bytesRead = inputStream.readAtMostTo(buf, READ_BUFFER_SIZE) - if (bytesRead == -1L) { - // EOF reached - break - } - if (bytesRead > 0) { - val chunk = buf.readByteArray() - readChannel.send(chunk) + private suspend fun readerPump() { + val buf = Buffer() + try { + while (currentCoroutineContext().isActive) { + val bytesRead = input.readAtMostTo(buf, READ_BUFFER_SIZE) + if (bytesRead == -1L) break + if (bytesRead > 0) { + val chunk = buf.readByteArray() + readChannel.send(chunk) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + if (state.load() == State.Stopped) { + logger.debug(e) { "Reader interrupted by close()" } + } else { + logger.error(e) { "Error reading from input source" } + _onError(e) + } + } finally { + readChannel.close() + } + } + + private suspend fun processorPump() { + try { + for (chunk in readChannel) { + readBuffer.append(chunk) + while (true) { + val message = try { + readBuffer.readMessage() + } catch (e: CancellationException) { + throw e + } catch (e: TooLongFrameException) { + throw e + } catch (e: Throwable) { + _onError(e) + null + } ?: break + try { + _onMessage(message) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.error(e) { "Error processing message" } + _onError(e) } } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - logger.error(e) { "Error reading from stdin" } - _onError.invoke(e) - } finally { - // Reached EOF or error, close connection - close() - } - } - job.invokeOnCompletion { cause -> - logJobCompletion("Message reading", cause) - } - return job - } - - private fun launchProcessingJob(): Job { - val job = scope.launch { - try { - for (chunk in readChannel) { - readBuffer.append(chunk) - processReadBuffer() - } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - _onError.invoke(e) - } - } - job.invokeOnCompletion { cause -> - logJobCompletion("Processing", cause) - } - return job - } - - private fun launchSendingJob(): Job { - val job = scope.launch { - try { - for (message in writeChannel) { - val json = serializeMessage(message) - outputSink.writeString(json) - outputSink.flush() - } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - logger.error(e) { "Error writing to stdout" } - _onError.invoke(e) - } - } - job.invokeOnCompletion { cause -> - logJobCompletion("Message sending", cause) - if (cause is CancellationException) { - readingJob?.cancel(cause) - } - } - return job - } - - private suspend fun processReadBuffer() { - while (true) { - val message = try { - readBuffer.readMessage() - } catch (e: Throwable) { - _onError.invoke(e) - null - } - - if (message == null) break - // Async invocation broke delivery order - try { - _onMessage.invoke(message) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - logger.error(e) { "Error processing message" } - _onError.invoke(e) } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + _onError(e) + } finally { + writeChannel.close() } } - private fun logJobCompletion(jobName: String, cause: Throwable?) { - when (cause) { - is CancellationException -> { - } - - null -> { - logger.debug { "$jobName job completed" } - } - - else -> { - logger.debug(cause) { "$jobName job completed exceptionally" } + private suspend fun writerPump() { + try { + for (message in writeChannel) { + val json = serializeMessage(message) + outputSink.writeString(json) + outputSink.flush() } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.error(e) { "Error writing to output sink" } + _onError(e) + } finally { + transitionToStoppedNaturally() } } + /** + * Closes the transport. When called after [start], waits for in-flight outbound messages to be + * flushed, releases the input source and output sink, cancels the internal scope when the + * transport owns it, and invokes `onClose`. When called before [start], transitions directly + * to the closed state without releasing the input/output or invoking `onClose` — the caller + * remains responsible for resources that were never handed off to a running transport. Safe to + * call multiple times and safe to race with [start]. + */ override suspend fun close() { - if (!initialized.compareAndSet(expectedValue = true, newValue = false)) return + var previous: State + do { + previous = state.load() + } while (previous != State.Stopped && !state.compareAndSet(previous, State.Stopped)) + + if (previous == State.New) { + setupComplete.complete(Unit) + return + } withContext(NonCancellable) { - writeChannel.close() - sendingJob?.cancelAndJoin() + setupComplete.await() - runCatching { - inputStream.close() - }.onFailure { logger.warn(it) { "Failed to close stdin" } } + if (previous == State.Stopped) { + writerJob?.join() + return@withContext + } - readingJob?.cancel() + runCatching { input.close() } + .onFailure { logger.warn(it) { "Failed to close input source" } } + readerJob?.cancel() readChannel.close() - - processingJob?.cancelAndJoin() - + processorJob?.cancelAndJoin() + writeChannel.close() + writerJob?.join() + runCatching { outputSink.close() } + .onFailure { logger.warn(it) { "Failed to close output sink" } } readBuffer.clear() - - runCatching { - outputSink.flush() - outputSink.close() - }.onFailure { logger.warn(it) { "Failed to close stdout" } } - + if (ownsScope) { + effectiveScope?.coroutineContext?.get(Job)?.cancel() + } invokeOnCloseCallback() } } + /** + * Queues [message] for the writer coroutine. Suspends when the outbound channel is full, + * applying back-pressure to the caller. Throws [McpException] with + * [RPCError.ErrorCode.CONNECTION_CLOSED] if the transport has not been started or has + * already closed. + * + * @param message JSON-RPC message to send + * @param options transport-specific send options; ignored by this transport + */ override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { - writeChannel.send(message) + when (state.load()) { + State.New -> throw McpException( + code = RPCError.ErrorCode.CONNECTION_CLOSED, + message = "Transport is not started", + ) + + State.Stopped -> throw McpException( + code = RPCError.ErrorCode.CONNECTION_CLOSED, + message = "Transport is closed", + ) + + State.Operational -> Unit + } + try { + writeChannel.send(message) + } catch (e: CancellationException) { + throw e + } catch (e: ClosedSendChannelException) { + throw McpException( + code = RPCError.ErrorCode.CONNECTION_CLOSED, + message = "Transport is closed", + cause = e, + ) + } + } + + private fun transitionToStoppedNaturally() { + if (!state.compareAndSet(State.Operational, State.Stopped)) return + runCatching { input.close() } + .onFailure { logger.warn(it) { "Failed to close input source" } } + runCatching { outputSink.close() } + .onFailure { logger.warn(it) { "Failed to close output sink" } } + readBuffer.clear() + if (ownsScope) { + // Non-null in practice: writer launches after effectiveScope is published. + effectiveScope?.coroutineContext?.get(Job)?.cancel() + } + invokeOnCloseCallback() + } + + /** + * Configuration builder for [StdioServerTransport]. Used via the + * `StdioServerTransport(input, output) { ... }` factory; the I/O endpoints are supplied + * positionally, while [scope], [handlerDispatcher], and [ioDispatcher] are configurable + * inside the block. + * + * Example: + * ```kotlin + * StdioServerTransport(input, output) { + * scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + * handlerDispatcher = myHandlerDispatcher + * ioDispatcher = Dispatchers.IO + * } + * ``` + * + * @property scope Optional caller-supplied [CoroutineScope]. When non-null, the transport's + * pipeline coroutines run as children of this scope and the transport does not tear it + * down on [close]. When `null`, the transport creates and owns an internal scope. + * @property handlerDispatcher Dispatcher for invoking the registered message handler. + * Defaults to [Dispatchers.Default]. + * @property ioDispatcher Dispatcher for the reader and writer coroutines. Must allow at + * least two threads to run concurrently so the reader and writer don't block each other. + * The default is a two-thread view of the platform I/O dispatcher + * (`IODispatcher.limitedParallelism(2)`); pass a different value to share or isolate I/O + * threads with the rest of your application. + */ + @McpDsl + public class Builder internal constructor(internal val input: Source, internal val output: Sink) { + public var scope: CoroutineScope? = null + public var handlerDispatcher: CoroutineDispatcher = Dispatchers.Default + public var ioDispatcher: CoroutineDispatcher = IODispatcher.limitedParallelism(2) } } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt index 870a92c..dcba848 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt @@ -1,25 +1,28 @@ package io.modelcontextprotocol.kotlin.sdk.server +import io.ktor.http.BadContentTypeFormatException import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode +import io.ktor.http.parseHeaderValue import io.ktor.server.application.ApplicationCall import io.ktor.server.request.contentType import io.ktor.server.request.header import io.ktor.server.request.httpMethod -import io.ktor.server.request.receiveText import io.ktor.server.response.header import io.ktor.server.response.respond -import io.ktor.server.response.respondNullable +import io.ktor.server.response.respondText import io.ktor.server.sse.ServerSSESession import io.ktor.util.collections.ConcurrentMap import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions +import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams import io.modelcontextprotocol.kotlin.sdk.types.DEFAULT_NEGOTIATED_PROTOCOL_VERSION import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCEmptyMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse import io.modelcontextprotocol.kotlin.sdk.types.McpJson @@ -28,6 +31,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.REQUEST_TIMEOUT import io.modelcontextprotocol.kotlin.sdk.types.RequestId import io.modelcontextprotocol.kotlin.sdk.types.SUPPORTED_PROTOCOL_VERSIONS +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.job @@ -48,7 +52,6 @@ import kotlin.uuid.Uuid internal const val MCP_SESSION_ID_HEADER = "mcp-session-id" private const val MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version" private const val MCP_RESUMPTION_TOKEN_HEADER = "Last-Event-ID" -private const val DEFAULT_MAX_REQUEST_BODY_SIZE: Long = 4L * 1024 * 1024 // 4 MB private const val MIN_PRIMING_EVENT_PROTOCOL_VERSION = "2025-11-25" /** @@ -136,8 +139,20 @@ public class StreamableHttpServerTransport(private val configuration: Configurat */ public class Configuration( public val enableJsonResponse: Boolean = false, + @Deprecated( + message = "Use install(DnsRebindingProtection) on your Ktor route instead", + level = DeprecationLevel.WARNING, + ) public val enableDnsRebindingProtection: Boolean = false, + @Deprecated( + message = "Use install(DnsRebindingProtection) on your Ktor route instead", + level = DeprecationLevel.WARNING, + ) public val allowedHosts: List? = null, + @Deprecated( + message = "Use install(DnsRebindingProtection) on your Ktor route instead", + level = DeprecationLevel.WARNING, + ) public val allowedOrigins: List? = null, public val eventStore: EventStore? = null, public val retryInterval: Duration? = null, @@ -165,6 +180,20 @@ public class StreamableHttpServerTransport(private val configuration: Configurat private val requestToStreamMapping: ConcurrentMap = ConcurrentMap() private val requestToResponseMapping: ConcurrentMap = ConcurrentMap() + /** + * JSON-response mode only: one entry per in-flight POST, completed by [send] with the collected + * responses once every request delivered by that POST has been answered. [handlePostRequest] + * awaits it so the HTTP exchange stays open while handlers run concurrently. + */ + private val pendingJsonResponses: ConcurrentMap>> = ConcurrentMap() + + /** + * JSON-response mode only: request ids retired by an inbound cancellation while their POST is + * still awaiting. Treated as settled-without-response so the POST's "a response for every id" + * completion condition is not blocked by a request that will never be answered. + */ + private val cancelledRequestIds: ConcurrentMap = ConcurrentMap() + private val sessionMutex = Mutex() private val streamMutex = Mutex() @@ -231,54 +260,117 @@ public class StreamableHttpServerTransport(private val configuration: Configurat return } + val isTerminated = message is JSONRPCResponse || message is JSONRPCError + + // In JSON mode request-related notifications have no per-request SSE stream. Route them to the + // standalone GET stream before the lookup below, so a notification for an already-retired + // request is delivered there instead of becoming a spurious error. + if (configuration.enableJsonResponse && !isTerminated) { + val standaloneStream = streamsMapping[STANDALONE_SSE_STREAM_ID] + standaloneStream?.let { emitOnStream(STANDALONE_SSE_STREAM_ID, it.session, message) } + return + } + val streamId = requestToStreamMapping[routingRequestId] - ?: error("No connection established for request id $routingRequestId") + if (streamId == null) { + // Request already retired (completed, cancelled, or the JSON POST disconnected). In JSON + // mode a late terminal response is undeliverable and dropped. In SSE mode an unknown id + // is a real routing error. + if (configuration.enableJsonResponse) return + error("No connection established for request id $routingRequestId") + } val activeStream = streamsMapping[streamId] if (!configuration.enableJsonResponse) { activeStream?.let { stream -> emitOnStream(streamId, stream.session, message) } - } - - val isTerminated = message is JSONRPCResponse || message is JSONRPCError - if (!isTerminated) { - if (configuration.enableJsonResponse) { - // In JSON response mode there is no per-request SSE stream, so route notifications - // that are logically associated with a request to the standalone GET SSE stream. - val standaloneStream = streamsMapping[STANDALONE_SSE_STREAM_ID] - standaloneStream?.let { emitOnStream(STANDALONE_SSE_STREAM_ID, it.session, message) } - } - return + if (!isTerminated) return } requestToResponseMapping[responseRequestId!!] = message - val relatedIds = requestToStreamMapping.filterValues { it == streamId }.keys - - if (relatedIds.any { it !in requestToResponseMapping }) return streamMutex.withLock { - if (activeStream == null) error("No connection established for request ID: $routingRequestId") + // The request may have been retired (disconnect or cancellation cleanup) between recording + // the response above and acquiring the lock. Reclaim the stray mapping and drop it. + if (routingRequestId !in requestToStreamMapping) { + requestToResponseMapping.remove(routingRequestId) + return + } + // Recomputed under the lock. Handlers may respond concurrently, and the sender that + // observes the last settled request also retires the ids, so a losing sender sees an + // empty set. A cancelled request counts as settled even though it produces no response. + val relatedIds = requestToStreamMapping.filterValues { it == streamId }.keys + if (relatedIds.isEmpty() || + relatedIds.any { it !in requestToResponseMapping && it !in cancelledRequestIds } + ) { + return + } if (configuration.enableJsonResponse) { - activeStream.call.response.header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - sessionId?.let { activeStream.call.response.header(MCP_SESSION_ID_HEADER, it) } - val responses = relatedIds.mapNotNull { requestToResponseMapping[it] } - val payload = if (responses.size == 1) { - responses.first() - } else { - responses + // Hand the collected responses to the POST suspended in [handlePostRequest], which + // completes the HTTP exchange. A null pendingPost means the POST was already retired + // (disconnect or cancellation), so the responses are undeliverable and dropped quietly. + val pendingPost = pendingJsonResponses.remove(streamId) + if (pendingPost == null) { + cleanUpRequests(relatedIds) + return } - activeStream.call.respond(payload) + val responses = relatedIds.mapNotNull { requestToResponseMapping[it] } + cleanUpRequests(relatedIds) + pendingPost.complete(responses) } else { - activeStream.session?.close() + // A null stream means the per-request SSE connection closed before the response + // settled, so the ids are retired rather than leaked. + activeStream?.session?.close() + cleanUpRequests(relatedIds) } + } + } - // Clean up - relatedIds.forEach { requestId -> - requestToResponseMapping.remove(requestId) - requestToStreamMapping.remove(requestId) + private fun cleanUpRequests(ids: Collection) { + ids.forEach { requestId -> + requestToResponseMapping.remove(requestId) + requestToStreamMapping.remove(requestId) + cancelledRequestIds.remove(requestId) + } + } + + /** + * Scans a delivered POST body for `notifications/cancelled` and retires each referenced request + * that is awaiting on a JSON-mode POST. No-op unless [Configuration.enableJsonResponse]. + */ + private suspend fun retireCancelledJsonRequests(messages: List) { + if (!configuration.enableJsonResponse) return + val cancelledIds = messages.asSequence() + .filterIsInstance() + .filter { it.method == Method.Defined.NotificationsCancelled.value } + .mapNotNull { notification -> + val params = notification.params ?: return@mapNotNull null + runCatching { + McpJson.decodeFromJsonElement(params).requestId + }.getOrNull() } + .toList() + cancelledIds.forEach { retireCancelledJsonRequest(it) } + } + + /** + * Marks a request cancelled by the peer as settled without a response and completes its POST once + * every request that POST delivered is settled (answered or cancelled). Without this, a cancelled + * request leaves the POST awaiting a response that, by design, is never sent. + */ + private suspend fun retireCancelledJsonRequest(requestId: RequestId) { + streamMutex.withLock { + val streamId = requestToStreamMapping[requestId] ?: return + cancelledRequestIds[requestId] = Unit + val relatedIds = requestToStreamMapping.filterValues { it == streamId }.keys + val allSettled = relatedIds.all { it in requestToResponseMapping || it in cancelledRequestIds } + if (!allSettled) return + val pendingPost = pendingJsonResponses.remove(streamId) ?: return + val responses = relatedIds.mapNotNull { requestToResponseMapping[it] } + cleanUpRequests(relatedIds) + pendingPost.complete(responses) } } @@ -294,13 +386,22 @@ public class StreamableHttpServerTransport(private val configuration: Configurat streamsMapping.clear() requestToStreamMapping.clear() requestToResponseMapping.clear() + cancelledRequestIds.clear() + // Release any POST calls suspended awaiting JSON responses. + pendingJsonResponses.values.forEach { it.cancel() } + pendingJsonResponses.clear() invokeOnCloseCallback() } } } /** - * Handles an incoming HTTP request, whether GET, POST or DELETE + * Handles an incoming HTTP request, whether GET, POST or DELETE. + * + * **Note:** this method does not perform DNS rebinding protection on its own. + * When using this transport outside the [mcpStreamableHttp] / [mcpStatelessStreamableHttp] + * DSL, install the [DnsRebindingProtection] plugin on your Ktor route + * (or apply equivalent middleware) to validate `Host` / `Origin` headers. */ public suspend fun handleRequest(session: ServerSSESession?, call: ApplicationCall) { validateHeaders(call)?.let { reason -> @@ -324,7 +425,12 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } /** - * Handles POST requests containing JSON-RPC messages + * Handles POST requests containing JSON-RPC messages. + * + * In JSON-response mode this suspends until a response has been produced for every request in + * the POST body, then completes the HTTP exchange with the collected JSON (a single object for + * one request, an array for a batch). Bodies without requests are acknowledged with 202 + * immediately. In SSE mode the responses are emitted onto the per-request SSE stream instead. */ public suspend fun handlePostRequest(session: ServerSSESession?, call: ApplicationCall) { try { @@ -355,9 +461,10 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } val messages = parseBody(call) ?: return - val isInitializationRequest = messages.any { - it is JSONRPCRequest && it.method == Method.Defined.Initialize.value + val initializationRequest = messages.filterIsInstance().firstOrNull { + it.method == Method.Defined.Initialize.value } + val isInitializationRequest = initializationRequest != null if (isInitializationRequest) { if (initialized.load() && sessionId != null) { @@ -365,15 +472,17 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.BadRequest, RPCError.ErrorCode.INVALID_REQUEST, "Invalid Request: Server already initialized", + initializationRequest.id, ) return } - if (!validateProtocolVersion(call)) return + if (!validateProtocolVersion(call, initializationRequest.id)) return if (messages.size > 1) { call.reject( HttpStatusCode.BadRequest, RPCError.ErrorCode.INVALID_REQUEST, "Invalid Request: Only one initialization request is allowed", + initializationRequest.id, ) return } @@ -385,13 +494,17 @@ public class StreamableHttpServerTransport(private val configuration: Configurat sessionId?.let { onSessionInitialized?.invoke(it) } } } else { - if (!validateSession(call) || !validateProtocolVersion(call)) return + val requestId = messages.filterIsInstance().firstOrNull()?.id + if (!validateSession(call, requestId) || !validateProtocolVersion(call, requestId)) return } val hasRequest = messages.any { it is JSONRPCRequest } if (!hasRequest) { - call.respondNullable(status = HttpStatusCode.Accepted, message = null) + call.respond(HttpStatusCode.Accepted) messages.forEach { message -> _onMessage(message) } + // A cancellation may target a request still awaiting delivery on a JSON-mode POST; + // retire it so that POST completes instead of waiting for a response that never comes. + retireCancelledJsonRequests(messages) return } @@ -399,8 +512,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat // For initialize requests, get from request params. // For other requests, get from header (already validated). val clientProtocolVersion = if (isInitializationRequest) { - val initRequest = messages.first() as JSONRPCRequest - (initRequest.params as? JsonObject)?.get("protocolVersion") + (initializationRequest.params as? JsonObject)?.get("protocolVersion") ?.let { McpJson.decodeFromJsonElement(it) } ?: DEFAULT_NEGOTIATED_PROTOCOL_VERSION } else { @@ -414,13 +526,50 @@ public class StreamableHttpServerTransport(private val configuration: Configurat maybeSendPrimingEvent(streamId, session, clientProtocolVersion) } + val pendingPost = if (configuration.enableJsonResponse) { + CompletableDeferred>() + } else { + null + } + streamMutex.withLock { streamsMapping[streamId] = SessionContext(session, call) messages.filterIsInstance().forEach { requestToStreamMapping[it.id] = streamId } + pendingPost?.let { pendingJsonResponses[streamId] = it } } call.coroutineContext.job.invokeOnCompletion { streamsMapping.remove(streamId) } - messages.forEach { message -> _onMessage(message) } + if (pendingPost == null) { + // SSE mode: responses are emitted onto this call's SSE stream by send(). + messages.forEach { message -> _onMessage(message) } + return + } + + // JSON-response mode. Handlers may run concurrently, so returning from delivery no longer + // implies the responses were produced. Suspend until send() has collected a response for + // every request in this POST. A client disconnect or transport close cancels this call. + try { + messages.forEach { message -> _onMessage(message) } + retireCancelledJsonRequests(messages) + val responses = pendingPost.await() + sessionId?.let { call.response.header(MCP_SESSION_ID_HEADER, it) } + if (responses.isEmpty()) { + // Every request in this POST was cancelled before producing a response; there is + // nothing to return, so acknowledge with 202 rather than an empty JSON body. + call.respond(HttpStatusCode.Accepted) + } else { + call.response.header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + val payload = if (responses.size == 1) responses.first() else responses + call.respond(payload) + } + } finally { + // On cancellation or failure, retire this POST's ids so a late response is dropped + // quietly at the send() boundary instead of leaking mappings. Idempotent no-ops on + // the success path (send() or retirement already cleaned up). + pendingJsonResponses.remove(streamId) + pendingPost.cancel() + cleanUpRequests(messages.filterIsInstance().map { it.id }) + } } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -500,7 +649,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat if (!validateSession(call) || !validateProtocolVersion(call)) return sessionId?.let { onSessionClosed?.invoke(it) } close() - call.respondNullable(status = HttpStatusCode.OK, message = null) + call.respond(HttpStatusCode.OK) } /** @@ -589,7 +738,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - private suspend fun validateSession(call: ApplicationCall): Boolean { + private suspend fun validateSession(call: ApplicationCall, id: RequestId? = null): Boolean { if (sessionIdGenerator == null) return true if (!initialized.load()) { @@ -597,6 +746,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.BadRequest, RPCError.ErrorCode.CONNECTION_CLOSED, "Bad Request: Server not initialized", + id, ) return false } @@ -608,6 +758,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.BadRequest, RPCError.ErrorCode.CONNECTION_CLOSED, "Bad Request: Mcp-Session-Id header is required", + id, ) return false } @@ -620,13 +771,14 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.NotFound, REQUEST_TIMEOUT, "Session not found", + id, ) false } } } - private suspend fun validateProtocolVersion(call: ApplicationCall): Boolean { + private suspend fun validateProtocolVersion(call: ApplicationCall, id: RequestId? = null): Boolean { val version = call.request.headers[MCP_PROTOCOL_VERSION_HEADER] ?: return true return when (version) { @@ -639,6 +791,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat ", ", ) })", + id, ) false } @@ -651,10 +804,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat if (!configuration.enableDnsRebindingProtection) return null configuration.allowedHosts?.let { hosts -> - val hostHeader = call.request.headers[HttpHeaders.Host]?.lowercase() - val allowedHostsLowercase = hosts.map { it.lowercase() } + val hostHeader = call.request.headers[HttpHeaders.Host] + val hostname = hostHeader?.let { extractHostname(it) }?.lowercase() + val allowedHostsLowercase = hosts.map { + extractHostname(it)?.lowercase() ?: it.lowercase() + } - if (hostHeader == null || hostHeader !in allowedHostsLowercase) { + if (hostname == null || hostname !in allowedHostsLowercase) { return "Invalid Host header: $hostHeader" } } @@ -663,7 +819,8 @@ public class StreamableHttpServerTransport(private val configuration: Configurat val originHeader = call.request.headers[HttpHeaders.Origin]?.lowercase() val allowedOriginsLowercase = origins.map { it.lowercase() } - if (originHeader == null || originHeader !in allowedOriginsLowercase) { + // Allow requests without Origin (non-browser clients cannot perform DNS rebinding) + if (originHeader != null && originHeader !in allowedOriginsLowercase) { return "Invalid Origin header: $originHeader" } } @@ -682,23 +839,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } private suspend fun parseBody(call: ApplicationCall): List? { - val maxSize = configuration.maxRequestBodySize - val contentLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull() ?: 0L - if (contentLength > maxSize) { + val body = try { + call.receiveTextWithLimit(configuration.maxRequestBodySize) + } catch (e: RequestBodyTooLargeException) { call.reject( HttpStatusCode.PayloadTooLarge, RPCError.ErrorCode.INVALID_REQUEST, - "Invalid Request: message size exceeds maximum of $maxSize bytes", - ) - return null - } - - val body = call.receiveText() - if (body.length.toLong() > maxSize) { - call.reject( - HttpStatusCode.PayloadTooLarge, - RPCError.ErrorCode.INVALID_REQUEST, - "Invalid Request: message size exceeds maximum of $maxSize bytes", + "Invalid Request: message size exceeds maximum of ${e.maxBodySize} bytes", ) return null } @@ -719,8 +866,24 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - private fun String?.accepts(mime: ContentType): Boolean = - this?.lowercase()?.contains(mime.toString().lowercase()) == true + /** + * Reports whether the `Accept` header admits [mime]. + * + * Comparison is over parsed media ranges rather than raw substrings, so wildcards are honored + * and a near miss such as `application/jsonp` no longer passes as `application/json`. An absent + * header states no preference and admits any type. + */ + private fun String?.accepts(mime: ContentType): Boolean { + if (this == null) return true + return parseHeaderValue(this).any { range -> + if (range.quality <= 0.0) return@any false + try { + mime.match(ContentType.parse(range.value)) + } catch (_: BadContentTypeFormatException) { + false + } + } + } private suspend fun emitOnStream(streamId: String, session: ServerSSESession?, message: JSONRPCMessage) { val eventId = configuration.eventStore?.storeEvent(streamId, message) @@ -775,7 +938,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } -internal suspend fun ApplicationCall.reject(status: HttpStatusCode, code: Int, message: String) { - this.response.status(status) - this.respond(JSONRPCError(id = null, error = RPCError(code = code, message = message))) +/** Writes an already serialized body so a client accepting only `text/event-stream` still receives [status]. */ +internal suspend fun ApplicationCall.reject( + status: HttpStatusCode, + code: Int, + message: String, + id: RequestId? = null, +) { + val error = JSONRPCError(id = id, error = RPCError(code = code, message = message)) + respondText(McpJson.encodeToString(error), ContentType.Application.Json, status) } diff --git a/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistryTest.kt b/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistryTest.kt new file mode 100644 index 0000000..c6bf72b --- /dev/null +++ b/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistryTest.kt @@ -0,0 +1,106 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlin.test.assertFailsWith + +private data class TestFeature(override val key: FeatureKey, val payload: String = "") : Feature + +private class RecordingListener : FeatureListener { + val updatedKeys = mutableListOf() + + override fun onFeatureUpdated(featureKey: String) { + updatedKeys.add(featureKey) + } +} + +class FeatureRegistryTest { + + private val registry = FeatureRegistry("Tool") + private val listener = RecordingListener() + + init { + registry.addListener(listener) + } + + @Test + fun `add should register feature and notify listener`() { + val feature = TestFeature("a") + + registry.add(feature) + + registry.values shouldBe mapOf("a" to feature) + listener.updatedKeys shouldBe listOf("a") + } + + @Test + fun `add should throw when key is already registered`() { + registry.add(TestFeature("a")) + + val exception = assertFailsWith { + registry.add(TestFeature("a", payload = "override")) + } + + exception.message shouldBe "Tool \"a\" is already registered. Remove it first to replace it." + } + + @Test + fun `add should keep original feature and not notify when duplicate is rejected`() { + val original = TestFeature("a", payload = "original") + registry.add(original) + + assertFailsWith { + registry.add(TestFeature("a", payload = "override")) + } + + registry.values shouldBe mapOf("a" to original) + listener.updatedKeys shouldBe listOf("a") + } + + @Test + fun `addAll should register all features and notify per feature`() { + val a = TestFeature("a") + val b = TestFeature("b") + + registry.addAll(listOf(a, b)) + + registry.values shouldBe mapOf("a" to a, "b" to b) + listener.updatedKeys shouldBe listOf("a", "b") + } + + @Test + fun `addAll should throw on duplicate keys within batch and register nothing`() { + val exception = assertFailsWith { + registry.addAll(listOf(TestFeature("a"), TestFeature("b"), TestFeature("a"))) + } + + exception.message shouldBe "Duplicate Tool keys in batch: [a]" + registry.values shouldBe emptyMap() + listener.updatedKeys shouldBe emptyList() + } + + @Test + fun `addAll should throw when any key is already registered and register nothing`() { + val existing = TestFeature("existing") + registry.add(existing) + + val exception = assertFailsWith { + registry.addAll(listOf(TestFeature("new"), TestFeature("existing", payload = "override"))) + } + + exception.message shouldBe "Tools already registered: [existing]. Remove them first to replace them." + registry.values shouldBe mapOf("existing" to existing) + listener.updatedKeys shouldBe listOf("existing") + } + + @Test + fun `add should succeed after the existing feature is removed`() { + registry.add(TestFeature("a", payload = "original")) + registry.remove("a") + + registry.add(TestFeature("a", payload = "replacement")) + + registry.values shouldBe mapOf("a" to TestFeature("a", payload = "replacement")) + listener.updatedKeys shouldBe listOf("a", "a", "a") + } +} diff --git a/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionAssertCapabilityTest.kt b/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionAssertCapabilityTest.kt new file mode 100644 index 0000000..a172e96 --- /dev/null +++ b/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionAssertCapabilityTest.kt @@ -0,0 +1,147 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.shared.Transport +import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.toJSON +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFailsWith + +/** + * Tests for the protected helpers [ServerSession.assertCapabilityForMethod] and + * [ServerSession.assertNotificationCapability]. + * + * Both helpers are `protected`, so this suite uses a small subclass [TestServerSession] + * that re-exports them via [TestServerSession.exposedAssertCapabilityForMethod] and + * [TestServerSession.exposedAssertNotificationCapability]. Client capabilities are + * seeded by replaying an [InitializeRequest] through [InitializeReplayTransport]: + * the transport drives the inbound side of the handshake on `start()`, which causes + * the session's internal [Method.Defined.Initialize] handler to populate + * [ServerSession.clientCapabilities]. + */ +class ServerSessionAssertCapabilityTest { + + @Test + fun `server TasksGet throws when client has no tasks capability`() = runTest { + val session = newTestServerSession(clientCapabilities = ClientCapabilities()) + val ex = assertFailsWith { + session.exposedAssertCapabilityForMethod(Method.Defined.TasksGet) + } + ex.message.orEmpty() shouldContain "Client does not support tasks" + } + + @Test + fun `server TasksGet succeeds when client declared tasks`() = runTest { + val session = newTestServerSession( + clientCapabilities = ClientCapabilities(tasks = ClientCapabilities.Tasks()), + ) + session.exposedAssertCapabilityForMethod(Method.Defined.TasksGet) + } + + @Test + fun `server TasksList throws when client tasks list is null`() = runTest { + val session = newTestServerSession( + clientCapabilities = ClientCapabilities(tasks = ClientCapabilities.Tasks()), + ) + val ex = assertFailsWith { + session.exposedAssertCapabilityForMethod(Method.Defined.TasksList) + } + ex.message.orEmpty() shouldContain "Client does not support listing tasks" + } + + @Test + fun `server TasksCancel throws when client tasks cancel is null`() = runTest { + val session = newTestServerSession( + clientCapabilities = ClientCapabilities(tasks = ClientCapabilities.Tasks()), + ) + val ex = assertFailsWith { + session.exposedAssertCapabilityForMethod(Method.Defined.TasksCancel) + } + ex.message.orEmpty() shouldContain "Client does not support cancelling tasks" + } + + @Test + fun `server NotificationsTasksStatus throws when server has no tasks capability`() = runTest { + val session = newTestServerSession(serverCapabilities = ServerCapabilities()) + val ex = assertFailsWith { + session.exposedAssertNotificationCapability(Method.Defined.NotificationsTasksStatus) + } + ex.message.orEmpty() shouldContain "Server does not support tasks" + } + + private suspend fun newTestServerSession( + clientCapabilities: ClientCapabilities = ClientCapabilities(), + serverCapabilities: ServerCapabilities = ServerCapabilities(), + ): TestServerSession { + val session = TestServerSession( + serverInfo = Implementation("test-server", "1.0.0"), + options = ServerOptions(capabilities = serverCapabilities), + instructions = null, + ) + val transport = InitializeReplayTransport(clientCapabilities) + session.connect(transport) + return session + } + + /** + * Test-only [ServerSession] subclass that exposes the protected + * [ServerSession.assertCapabilityForMethod] and + * [ServerSession.assertNotificationCapability] helpers to the test code. + */ + private class TestServerSession(serverInfo: Implementation, options: ServerOptions, instructions: String?) : + ServerSession(serverInfo, options, instructions) { + fun exposedAssertCapabilityForMethod(method: Method): Unit = assertCapabilityForMethod(method) + fun exposedAssertNotificationCapability(method: Method): Unit = assertNotificationCapability(method) + } + + /** + * Minimal in-memory [Transport] that drives the server-side initialize handshake. + * + * On [start], it dispatches an [InitializeRequest] carrying the configured + * [clientCapabilities] to the session via [onMessage]. The session's internal + * `initialize` handler runs synchronously and populates + * [ServerSession.clientCapabilities] before [start] returns. Other JSON-RPC + * traffic the session subsequently emits is silently consumed. + */ + private class InitializeReplayTransport(private val clientCapabilities: ClientCapabilities) : Transport { + private var onMessageBlock: (suspend (JSONRPCMessage) -> Unit)? = null + private var onCloseBlock: (() -> Unit)? = null + + override suspend fun start() { + val initializeRequest = InitializeRequest( + params = InitializeRequestParams( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = clientCapabilities, + clientInfo = Implementation("mock-client", "1.0.0"), + ), + ).toJSON().copy(id = RequestId.NumberId(1)) + onMessageBlock?.invoke(initializeRequest) + } + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) = Unit + + override suspend fun close() { + onCloseBlock?.invoke() + } + + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { + onMessageBlock = block + } + + override fun onClose(block: () -> Unit) { + onCloseBlock = block + } + + override fun onError(block: (Throwable) -> Unit) = Unit + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionTest.kt new file mode 100644 index 0000000..ea38219 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionTest.kt @@ -0,0 +1,428 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.application.install +import io.ktor.server.response.respondText +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import io.ktor.server.routing.routing +import io.ktor.server.sse.SSE +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import kotlin.test.Test + +class DnsRebindingProtectionTest { + + companion object { + @JvmStatic + fun invalidHostCases(): List = listOf( + Arguments.of("", "Invalid Host header: (malformed or missing)"), + Arguments.of("evil.com", "Invalid Host: evil.com"), + ) + + @JvmStatic + fun extractHostnameAcceptCases(): List = listOf( + Arguments.of("localhost", "localhost"), + Arguments.of("localhost:3000", "localhost"), + Arguments.of("127.0.0.1:8080", "127.0.0.1"), + Arguments.of("[::1]", "[::1]"), + Arguments.of("[::1]:3000", "[::1]"), + Arguments.of("localhost:", "localhost"), + Arguments.of("[::1]:", "[::1]"), + ) + + @JvmStatic + fun extractHostnameRejectCases(): List = listOf( + "", // empty + "evil.com@localhost", // userinfo + "evil.com/path", // path + "evil.com?q=1", // query + "evil.com#frag", // fragment + "[::1", // malformed IPv6 + "[]", // empty brackets + "[]:3000", // empty brackets + port + " localhost", // leading whitespace + "localhost ", // trailing whitespace + "localhost\t:80", // embedded tab + "localhost:abc", // non-numeric port + "localhost:-1", // negative port + "[::1]:abc", // non-numeric IPv6 port + ":80", // leading colon + ) + + @JvmStatic + fun extractOriginHostAcceptCases(): List = listOf( + Arguments.of("http://example.com", "example.com"), + Arguments.of("http://example.com:8080", "example.com"), + Arguments.of("https://Example.COM", "Example.COM"), + Arguments.of("https://example.com", "example.com"), + ) + + @JvmStatic + fun extractOriginHostRejectCases(): List = listOf( + "example.com", // no scheme + "not-a-url", // unparseable + "", // empty + ) + } + + private fun testWithPlugin( + config: DnsRebindingProtectionConfig.() -> Unit = {}, + test: suspend ApplicationTestBuilder.() -> Unit, + ): Unit = testApplication { + application { + install(SSE) + routing { + route("/mcp") { + install(DnsRebindingProtection, config) + post { call.respondText("ok") } + } + } + } + test() + } + + @ParameterizedTest + @ValueSource(strings = ["localhost", "127.0.0.1", "[::1]", "localhost:3000", "[::1]:8080"]) + fun `plugin accepts valid Host header`(hostHeader: String) = testWithPlugin { + val response = client.post("/mcp") { + header(HttpHeaders.Host, hostHeader) + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @ParameterizedTest + @MethodSource("invalidHostCases") + fun `plugin rejects invalid Host header`(hostHeader: String, expectedBody: String) = testWithPlugin { + val response = client.post("/mcp") { + header(HttpHeaders.Host, hostHeader) + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + response.bodyAsText() shouldContain expectedBody + } + + @Test + fun `plugin does not echo raw malformed Host in rejection`() = testWithPlugin { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "evil.com@localhost") + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + response.bodyAsText() shouldNotContain "evil.com@localhost" + response.bodyAsText() shouldContain "malformed or missing" + } + + @Test + fun `plugin allows request without Origin header`() = testWithPlugin( + config = { allowedOrigins = listOf("http://localhost:3000") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `plugin rejects disallowed Origin`() = testWithPlugin( + config = { allowedOrigins = listOf("http://localhost:3000") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "http://evil.com") + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + response.bodyAsText() shouldContain "Invalid Origin host: evil.com" + } + + @Test + fun `plugin allows matching Origin`() = testWithPlugin( + config = { allowedOrigins = listOf("http://localhost:3000") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "http://localhost:3000") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `plugin allows Origin with different port but same hostname`() = testWithPlugin( + config = { allowedOrigins = listOf("http://localhost:3000") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "http://localhost:9999") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `plugin allows Origin with different scheme but same hostname`() = testWithPlugin( + config = { allowedOrigins = listOf("http://localhost:3000") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "https://localhost:3000") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `plugin rejects unparseable Origin header`() = testWithPlugin( + config = { allowedOrigins = listOf("http://localhost:3000") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "not-a-url") + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + response.bodyAsText() shouldContain "Invalid Origin header: (unparseable)" + } + + @Test + fun `plugin with custom allowedHosts accepts matching host`() = testWithPlugin( + config = { allowedHosts = listOf("myapp.com") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "myapp.com:443") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `plugin with custom allowedHosts rejects non-matching host`() = testWithPlugin( + config = { allowedHosts = listOf("myapp.com") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + } + + @Test + fun `host validation is case insensitive`() = testWithPlugin( + config = { allowedHosts = listOf("MyApp.COM") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "myapp.com") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `origin validation is case insensitive`() = testWithPlugin( + config = { + allowedHosts = listOf("localhost") + allowedOrigins = listOf("https://MyApp.COM") + }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "https://myapp.com") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `plugin with empty allowedHosts rejects all requests`() = testWithPlugin( + config = { allowedHosts = emptyList() }, + ) { + val localhostResponse = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + } + localhostResponse.shouldHaveStatus(HttpStatusCode.Forbidden) + localhostResponse.bodyAsText() shouldContain "Invalid Host: localhost" + + val otherResponse = client.post("/mcp") { + header(HttpHeaders.Host, "myapp.com") + } + otherResponse.shouldHaveStatus(HttpStatusCode.Forbidden) + otherResponse.bodyAsText() shouldContain "Invalid Host: myapp.com" + } + + @Test + fun `plugin fails to install when allowedHosts contains an invalid host`() { + val ex = shouldThrow { + testWithPlugin( + config = { allowedHosts = listOf("https://example.com") }, + ) { + client.post("/mcp") { header(HttpHeaders.Host, "example.com") } + } + } + ex.message shouldContain "https://example.com" + } + + @Test + fun `plugin fails to install when allowedOrigins contains an invalid origin`() { + val ex = shouldThrow { + testWithPlugin( + config = { allowedOrigins = listOf("not-a-url") }, + ) { + client.post("/mcp") { header(HttpHeaders.Host, "localhost") } + } + } + ex.message shouldContain "not-a-url" + } + + @Test + fun `Route mcp with DNS protection enabled rejects non-localhost`() = testApplication { + application { + install(SSE) + routing { + mcp { testServer() } + } + } + + val response = client.post("/") { + header(HttpHeaders.Host, "evil.com") + contentType(ContentType.Application.Json) + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + } + + @Test + fun `Route mcp with DNS protection disabled allows any host`() = testApplication { + application { + install(SSE) + routing { + mcp(enableDnsRebindingProtection = false) { testServer() } + } + } + + val response = client.post("/") { + header(HttpHeaders.Host, "evil.com") + contentType(ContentType.Application.Json) + } + // Not 403 — the request reaches the handler (may get 400 for missing sessionId, etc.) + response.shouldHaveStatus(HttpStatusCode.BadRequest) + } + + @Test + fun `mcpStreamableHttp rejects non-localhost Host by default`() = testApplication { + application { + mcpStreamableHttp { testServer() } + } + + val response = client.post("/mcp") { + header(HttpHeaders.Host, "evil.com") + contentType(ContentType.Application.Json) + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + } + + @Test + fun `mcpStatelessStreamableHttp rejects non-localhost Host by default`() = testApplication { + application { + mcpStatelessStreamableHttp { testServer() } + } + + val response = client.post("/mcp") { + header(HttpHeaders.Host, "evil.com") + contentType(ContentType.Application.Json) + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + } + + // -- default Origin validation (secure-by-default for localhost) -- + + @Test + fun `mcpStreamableHttp rejects hostile Origin by default`() = testApplication { + application { + mcpStreamableHttp { testServer() } + } + + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "http://evil.com") + contentType(ContentType.Application.Json) + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + response.bodyAsText() shouldContain "Invalid Origin host: evil.com" + } + + @Test + fun `mcpStreamableHttp allows localhost Origin by default`() = testApplication { + application { + mcpStreamableHttp { testServer() } + } + + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "http://localhost:5173") + contentType(ContentType.Application.Json) + } + response.status shouldNotBe HttpStatusCode.Forbidden + } + + @Test + fun `mcpStreamableHttp with custom allowedHosts does not auto-validate Origin`() = testApplication { + application { + mcpStreamableHttp(allowedHosts = listOf("myapp.com")) { testServer() } + } + + val response = client.post("/mcp") { + header(HttpHeaders.Host, "myapp.com") + header(HttpHeaders.Origin, "http://evil.com") + contentType(ContentType.Application.Json) + } + response.status shouldNotBe HttpStatusCode.Forbidden + } + + // -- extractHostname unit tests -- + + @ParameterizedTest + @MethodSource("extractHostnameAcceptCases") + fun `extractHostname accepts valid host`(input: String, expected: String) { + extractHostname(input) shouldBe expected + } + + @ParameterizedTest + @MethodSource("extractHostnameRejectCases") + fun `extractHostname rejects invalid host`(input: String) { + extractHostname(input) shouldBe null + } + + // -- extractOriginHost unit tests -- + + @ParameterizedTest + @MethodSource("extractOriginHostAcceptCases") + fun `extractOriginHost extracts hostname`(input: String, expected: String) { + extractOriginHost(input) shouldBe expected + } + + @ParameterizedTest + @MethodSource("extractOriginHostRejectCases") + fun `extractOriginHost returns null for invalid origin`(input: String) { + extractOriginHost(input) shouldBe null + } + + private fun testServer(): Server = Server( + serverInfo = Implementation(name = "test-server", version = "1.0.0"), + options = ServerOptions(capabilities = ServerCapabilities()), + ) +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidationTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidationTest.kt new file mode 100644 index 0000000..b218b47 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidationTest.kt @@ -0,0 +1,438 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema +import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema +import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.EnumOption +import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema +import io.modelcontextprotocol.kotlin.sdk.types.LegacyTitledEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.PrimitiveSchemaDefinition +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.StringSchema +import io.modelcontextprotocol.kotlin.sdk.types.StringSchemaFormat +import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.junit.jupiter.api.assertDoesNotThrow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Unit tests for [validateElicitationContent]: pure-function checks of accepted form-mode + * content against the requested schema. + * + * End-to-end tests for the `createElicitation` round trip live in the `integration-test` + * module under `io.modelcontextprotocol.kotlin.sdk.client`. + */ +class ElicitationValidationTest { + + private fun schema(vararg properties: Pair, required: List? = null) = + ElicitRequestParams.RequestedSchema(properties = mapOf(*properties), required = required) + + private fun assertRejected( + schema: ElicitRequestParams.RequestedSchema, + content: JsonObject, + expectedError: String, + ) { + val exception = assertFailsWith { validateElicitationContent(schema, content) } + assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code) + val message = exception.message.orEmpty() + assertTrue( + message.startsWith("Elicitation response content does not match requested schema: "), + "Unexpected message prefix: $message", + ) + assertTrue(expectedError in message, "Expected '$expectedError' in: $message") + } + + @Test + fun `validate content matching schema passes`() { + assertDoesNotThrow { + validateElicitationContent( + schema( + "name" to StringSchema(minLength = 1, maxLength = 10), + "age" to IntegerSchema(minimum = 0, maximum = 120), + "score" to DoubleSchema(minimum = 0.0, maximum = 1.0), + "subscribed" to BooleanSchema(), + "color" to UntitledSingleSelectEnumSchema(enumValues = listOf("red", "green")), + "size" to TitledSingleSelectEnumSchema( + oneOf = listOf(EnumOption("s", "Small"), EnumOption("l", "Large")), + ), + "tags" to UntitledMultiSelectEnumSchema( + items = UntitledMultiSelectEnumSchema.Items(enumValues = listOf("a", "b", "c")), + minItems = 1, + maxItems = 2, + ), + required = listOf("name", "age"), + ), + buildJsonObject { + put("name", "octocat") + put("age", 42) + put("score", 0.5) + put("subscribed", true) + put("color", "red") + put("size", "l") + putJsonArray("tags") { add("a") } + }, + ) + } + } + + @Test + fun `validate missing required property fails`() { + assertRejected( + schema("name" to StringSchema(), required = listOf("name")), + JsonObject(emptyMap()), + "must have required property 'name'", + ) + } + + @Test + fun `validate empty content with no required properties passes`() { + assertDoesNotThrow { + validateElicitationContent(schema("name" to StringSchema()), JsonObject(emptyMap())) + } + } + + @Test + fun `validate wrong type for string property fails`() { + assertRejected( + schema("name" to StringSchema()), + buildJsonObject { put("name", 42) }, + "'name' must be string", + ) + } + + @Test + fun `validate string length constraints fail`() { + assertRejected( + schema("name" to StringSchema(minLength = 3)), + buildJsonObject { put("name", "ab") }, + "'name' must NOT have fewer than 3 characters", + ) + assertRejected( + schema("name" to StringSchema(maxLength = 3)), + buildJsonObject { put("name", "abcd") }, + "'name' must NOT have more than 3 characters", + ) + } + + @Test + fun `validate json string is not accepted for integer boolean or number`() { + // JsonPrimitive("42") is the JSON string "42", not the number 42; the validator must not + // parse string content as a number. + assertRejected( + schema("age" to IntegerSchema()), + buildJsonObject { put("age", "42") }, + "'age' must be integer", + ) + assertRejected( + schema("score" to DoubleSchema()), + buildJsonObject { put("score", "1.5") }, + "'score' must be number", + ) + assertRejected( + schema("subscribed" to BooleanSchema()), + buildJsonObject { put("subscribed", "true") }, + "'subscribed' must be boolean", + ) + } + + @Test + fun `validate integer outside minimum and maximum fails`() { + assertRejected( + schema("age" to IntegerSchema(minimum = 0)), + buildJsonObject { put("age", -1) }, + "'age' must be >= 0", + ) + assertRejected( + schema("age" to IntegerSchema(maximum = 120)), + buildJsonObject { put("age", 200) }, + "'age' must be <= 120", + ) + } + + @Test + fun `validate non-integral value for integer property fails`() { + assertRejected( + schema("age" to IntegerSchema()), + buildJsonObject { put("age", 3.5) }, + "'age' must be integer", + ) + } + + @Test + fun `validate integral number forms are accepted for integer property`() { + // JSON Schema's "integer" matches numbers with a zero fractional part. + assertDoesNotThrow { + validateElicitationContent( + schema("age" to IntegerSchema(minimum = 0, maximum = 200)), + buildJsonObject { put("age", 5.0) }, + ) + } + } + + @Test + fun `validate exponent form is accepted for integer property`() { + val content = McpJson.parseToJsonElement("""{"age": 1e2}""") as JsonObject + assertDoesNotThrow { + validateElicitationContent( + schema("age" to IntegerSchema(minimum = 0, maximum = 200)), + content, + ) + } + } + + @Test + fun `validate number outside range fails`() { + assertRejected( + schema("score" to DoubleSchema(maximum = 1.0)), + buildJsonObject { put("score", 1.5) }, + "'score' must be <= 1.0", + ) + } + + @Test + fun `validate integral value is accepted for number property`() { + assertDoesNotThrow { + validateElicitationContent( + schema("score" to DoubleSchema(minimum = 0.0)), + buildJsonObject { put("score", 5) }, + ) + } + } + + @Test + fun `validate string length counts unicode code points`() { + // JSON Schema string length counts RFC 8259 characters: one emoji is one character + // even when it is two UTF-16 units. + assertDoesNotThrow { + validateElicitationContent( + schema("emoji" to StringSchema(maxLength = 1)), + buildJsonObject { put("emoji", "😀") }, + ) + } + assertRejected( + schema("emoji" to StringSchema(minLength = 2)), + buildJsonObject { put("emoji", "😀") }, + "'emoji' must NOT have fewer than 2 characters", + ) + } + + @Test + fun `validate string format is treated as an annotation`() { + assertDoesNotThrow { + validateElicitationContent( + schema("email" to StringSchema(format = StringSchemaFormat.Email)), + buildJsonObject { put("email", "not-an-email") }, + ) + } + } + + @Test + fun `validate negative zero satisfies an inclusive zero bound`() { + // IEEE 754 comparison: -0.0 == 0.0, so an inclusive minimum of 0.0 admits -0.0. + assertDoesNotThrow { + validateElicitationContent( + schema("score" to DoubleSchema(minimum = 0.0)), + buildJsonObject { put("score", -0.0) }, + ) + } + } + + @Test + fun `validate enum value not in allowed set fails`() { + assertRejected( + schema("color" to UntitledSingleSelectEnumSchema(enumValues = listOf("red", "green"))), + buildJsonObject { put("color", "blue") }, + "'color' must be equal to one of the allowed values", + ) + assertRejected( + schema("size" to TitledSingleSelectEnumSchema(oneOf = listOf(EnumOption("s", "Small")))), + buildJsonObject { put("size", "xl") }, + "'size' must be equal to one of the allowed values", + ) + assertRejected( + schema("legacy" to LegacyTitledEnumSchema(enumValues = listOf("a", "b"))), + buildJsonObject { put("legacy", "c") }, + "'legacy' must be equal to one of the allowed values", + ) + } + + @Test + fun `validate multi-select constraints fail`() { + val tags = UntitledMultiSelectEnumSchema( + items = UntitledMultiSelectEnumSchema.Items(enumValues = listOf("a", "b", "c")), + minItems = 1, + maxItems = 2, + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { put("tags", "a") }, + "'tags' must be array", + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { + putJsonArray("tags") { + add("a") + add(42) + } + }, + "'tags' items must be string", + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { + putJsonArray("tags") { + add("a") + add("z") + } + }, + "'tags' items must be equal to one of the allowed values", + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { putJsonArray("tags") {} }, + "'tags' must NOT have fewer than 1 items", + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { + putJsonArray("tags") { + add("a") + add("b") + add("c") + } + }, + "'tags' must NOT have more than 2 items", + ) + } + + @Test + fun `validate titled multi-select membership uses option consts`() { + val sizes = TitledMultiSelectEnumSchema( + items = TitledMultiSelectEnumSchema.Items( + anyOf = listOf(EnumOption("s", "Small"), EnumOption("l", "Large")), + ), + ) + assertDoesNotThrow { + validateElicitationContent( + schema("sizes" to sizes), + buildJsonObject { putJsonArray("sizes") { add("s") } }, + ) + } + assertRejected( + schema("sizes" to sizes), + buildJsonObject { putJsonArray("sizes") { add("Small") } }, + "'sizes' items must be equal to one of the allowed values", + ) + } + + @Test + fun `validate json null is rejected with the type error for each schema kind`() { + // JsonNull is a JsonPrimitive whose content is "null"; it must fail the type check, not + // accidentally parse as a value. + assertRejected( + schema("name" to StringSchema()), + buildJsonObject { put("name", JsonNull) }, + "'name' must be string", + ) + assertRejected( + schema("age" to IntegerSchema()), + buildJsonObject { put("age", JsonNull) }, + "'age' must be integer", + ) + assertRejected( + schema("subscribed" to BooleanSchema()), + buildJsonObject { put("subscribed", JsonNull) }, + "'subscribed' must be boolean", + ) + } + + @Test + fun `validate non-primitive value is rejected for scalar schemas`() { + assertRejected( + schema("name" to StringSchema()), + buildJsonObject { putJsonArray("name") { add("x") } }, + "'name' must be string", + ) + assertRejected( + schema("age" to IntegerSchema()), + buildJsonObject { putJsonObject("age") {} }, + "'age' must be integer", + ) + } + + @Test + fun `validate number below minimum fails`() { + assertRejected( + schema("score" to DoubleSchema(minimum = 0.0)), + buildJsonObject { put("score", -0.5) }, + "'score' must be >= 0.0", + ) + } + + @Test + fun `validate non-finite value for number property fails`() { + // McpJson is lenient, so an unquoted NaN literal can arrive off the wire; it decodes to + // the same primitive as Double.NaN here. + assertRejected( + schema("score" to DoubleSchema(minimum = 0.0, maximum = 1.0)), + buildJsonObject { put("score", Double.NaN) }, + "'score' must be number", + ) + } + + @Test + fun `validate required property not declared in properties is still required`() { + // JSON Schema's `required` is independent of `properties`. + assertRejected( + schema("name" to StringSchema(), required = listOf("name", "consent")), + buildJsonObject { put("name", "octocat") }, + "must have required property 'consent'", + ) + } + + @Test + fun `validate properties not declared in schema pass`() { + // JSON Schema default: without an additionalProperties restriction, undeclared keys are permitted. + assertDoesNotThrow { + validateElicitationContent( + schema("name" to StringSchema(), required = listOf("name")), + buildJsonObject { + put("name", "octocat") + put("undeclared", 42) + }, + ) + } + } + + @Test + fun `validate reports every violation in one error`() { + val exception = assertFailsWith { + validateElicitationContent( + schema( + "name" to StringSchema(), + "age" to IntegerSchema(), + required = listOf("name"), + ), + buildJsonObject { put("age", "old") }, + ) + } + val message = exception.message.orEmpty() + assertTrue("must have required property 'name'" in message, message) + assertTrue("'age' must be integer" in message, message) + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt index 05200e5..9888867 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt @@ -24,9 +24,9 @@ class KtorApplicationExtensionsTest : AbstractKtorExtensionsTest() { * added to the same application. */ @Test - fun `Application mcp should installs SSE and coexist with other routes`() = testApplication { + fun `Application mcp should install SSE and coexist with other routes`() = testApplication { application { - mcp { testServer() } + mcp(enableDnsRebindingProtection = false) { testServer() } routing { get("/health") { call.respondText("healthy") } diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt index 5ba4448..d6b95b4 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt @@ -61,7 +61,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() { route("/api/mcp") { get("/test") { call.respondText("test-endpoint") } - mcp { testServer() } + mcp(enableDnsRebindingProtection = false) { testServer() } } } } @@ -91,7 +91,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() { route("/v1") { route("/services") { route("/mcp") { - mcp { testServer() } + mcp(enableDnsRebindingProtection = false) { testServer() } } } } @@ -112,7 +112,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() { routing { route("/api") { - mcp("/mcp-endpoint") { testServer() } + mcp("/mcp-endpoint", enableDnsRebindingProtection = false) { testServer() } } } } diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBodyTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBodyTest.kt new file mode 100644 index 0000000..9dc30cf --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBodyTest.kt @@ -0,0 +1,103 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.matchers.shouldBe +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respondText +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import kotlin.test.Test + +class RequestBodyTest { + + /** Wires a POST endpoint that echoes the body when it fits, or replies 413 when it is too large. */ + private fun ApplicationTestBuilder.installEchoEndpoint(maxBytes: Long) { + install(io.ktor.server.sse.SSE) + application { + routing { + post("/echo") { + val text = try { + call.receiveTextWithLimit(maxBytes) + } catch (e: RequestBodyTooLargeException) { + call.respondText(e.message ?: "too large", status = HttpStatusCode.PayloadTooLarge) + return@post + } + call.respondText(text) + } + } + } + } + + @Test + fun `body under the limit is returned intact`() = testApplication { + installEchoEndpoint(maxBytes = 1024) + val payload = "hello world" + + val response = client.post("/echo") { setBody(payload) } + + response.status shouldBe HttpStatusCode.OK + response.bodyAsText() shouldBe payload + } + + @Test + fun `body exactly at the limit is accepted`() = testApplication { + val payload = "x".repeat(64) + installEchoEndpoint(maxBytes = payload.length.toLong()) + + val response = client.post("/echo") { setBody(payload) } + + response.status shouldBe HttpStatusCode.OK + response.bodyAsText() shouldBe payload + } + + @Test + fun `body exceeding the limit is rejected with 413`() = testApplication { + val payload = "x".repeat(65) + installEchoEndpoint(maxBytes = 64) + + val response = client.post("/echo") { setBody(payload) } + + response.status shouldBe HttpStatusCode.PayloadTooLarge + } + + @Test + fun `large body exceeding the limit is rejected without buffering it whole`() = testApplication { + // 8 MB body against a 4 MB limit: must be rejected. + installEchoEndpoint(maxBytes = 4L * 1024 * 1024) + val payload = "x".repeat(8 * 1024 * 1024) + + val response = client.post("/echo") { setBody(payload) } + + response.status shouldBe HttpStatusCode.PayloadTooLarge + } + + @Test + fun `multibyte UTF-8 body larger than a read chunk decodes intact`() = testApplication { + // Emoji is 4 bytes in UTF-8; a payload larger than the internal read chunk forces the + // accumulated bytes to span multiple chunks. Decoding once at the end must not corrupt + // characters that straddle a chunk boundary. + val unit = "a😀b" // mixes 1- and 4-byte code units so boundaries land mid-character + val payload = unit.repeat(40_000) // well over a 64 KiB read chunk + installEchoEndpoint(maxBytes = payload.encodeToByteArray().size.toLong()) + + val response = client.post("/echo") { setBody(payload) } + + response.status shouldBe HttpStatusCode.OK + response.bodyAsText() shouldBe payload + } + + @Test + fun `body is returned intact when the limit is Long MAX_VALUE`() = testApplication { + installEchoEndpoint(maxBytes = Long.MAX_VALUE) + val payload = "hello world" + + val response = client.post("/echo") { setBody(payload) } + + response.status shouldBe HttpStatusCode.OK + response.bodyAsText() shouldBe payload + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt index 2b5446b..cfcbb62 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt @@ -8,6 +8,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.ToolUseContent import kotlinx.serialization.json.JsonObject import org.junit.jupiter.api.assertDoesNotThrow import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith /** @@ -83,4 +84,21 @@ class SamplingTest { ) } } + + @Test + fun `validate tool_use requires explicit tool_result in last message`() { + val error = assertFailsWith { + validateSamplingMessages( + listOf( + SamplingMessage(Role.Assistant, toolUse("c1")), + SamplingMessage(Role.User, TextContent("missing result")), + ), + ) + } + + assertEquals( + "tool_use blocks from previous message must be followed by matching tool_result blocks", + error.message, + ) + } } diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SseServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SseServerTransportTest.kt new file mode 100644 index 0000000..a0756f3 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SseServerTransportTest.kt @@ -0,0 +1,90 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.matchers.shouldBe +import io.ktor.client.request.post +import io.ktor.client.request.prepareGet +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.application.install +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import io.ktor.server.sse.SSE +import io.ktor.server.sse.ServerSSESession +import io.ktor.server.testing.testApplication +import io.ktor.utils.io.readLine +import io.mockk.mockk +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.Test + +class SseServerTransportTest : AbstractKtorExtensionsTest() { + + @Test + fun `handlePostMessage on a not-started transport does not deliver the message`() = testApplication { + // A registered onMessage callback opens the delivery gate, so if the not-initialized branch + // falls through it would wrongly hand the message to the application. It must return instead. + val transport = SseServerTransport("/messages", mockk(relaxed = true)) + val delivered = AtomicBoolean(false) + transport.onMessage { delivered.set(true) } + + application { + routing { + post("/messages") { transport.handlePostMessage(call) } + } + } + + val response = client.post("/messages") { + contentType(ContentType.Application.Json) + setBody("""{"jsonrpc":"2.0","id":1,"method":"ping"}""") + } + + response.status shouldBe HttpStatusCode.InternalServerError + delivered.get() shouldBe false + } + + @Test + fun `SSE POST exceeding maxRequestBodySize is rejected with 413`() = testApplication { + application { + install(SSE) + routing { + mcp(enableDnsRebindingProtection = false, maxRequestBodySize = MAX_BODY) { testServer() } + } + } + + client.prepareGet("/").execute { response -> + val sessionId = response.readSessionId() + requireNotNull(sessionId) { "sessionId not found in SSE endpoint event" } + + // A body one byte over the configured limit must be rejected before processing. + val oversized = "x".repeat((MAX_BODY + 1).toInt()) + val postResponse = client.post("/?sessionId=$sessionId") { + contentType(ContentType.Application.Json) + setBody(oversized) + } + postResponse.status shouldBe HttpStatusCode.PayloadTooLarge + } + } + + private suspend fun HttpResponse.readSessionId(): String? { + val channel = bodyAsChannel() + var eventName: String? = null + while (!channel.isClosedForRead) { + val line = channel.readLine() ?: break + when { + line.startsWith("event:") -> eventName = line.substringAfter("event:").trim() + + line.startsWith("data:") && eventName == "endpoint" -> { + return line.substringAfter("data:").trim().substringAfter("sessionId=").ifEmpty { null } + } + } + } + return null + } + + private companion object { + const val MAX_BODY = 1024L + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StatelessStreamableHttpSessionLifecycleTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StatelessStreamableHttpSessionLifecycleTest.kt new file mode 100644 index 0000000..3646faf --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StatelessStreamableHttpSessionLifecycleTest.kt @@ -0,0 +1,100 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.matchers.maps.shouldBeEmpty +import io.kotest.matchers.shouldBe +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.testing.testApplication +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.toJSON +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +/** + * Verifies that the stateless Streamable HTTP endpoint does not leak server sessions: + * every session created to serve a single request must be closed and removed from the + * session registry once that request completes (https://github.com/modelcontextprotocol/kotlin-sdk/issues/786). + */ +class StatelessStreamableHttpSessionLifecycleTest { + + private fun testServer(): Server = Server( + Implementation("test-server", "1.0"), + ServerOptions(capabilities = ServerCapabilities()), + ) + + @Test + fun `stateless endpoint removes session after each request`() = testApplication { + val server = testServer() + application { + mcpStatelessStreamableHttp { server } + } + + repeat(5) { + val response = client.post("/mcp") { + addStreamableHeaders() + setBody(initializeRequestBody()) + } + response.status shouldBe HttpStatusCode.OK + } + + eventually(5.seconds) { + server.sessions.shouldBeEmpty() + } + } + + @Test + fun `stateless endpoint removes session when the request is rejected`() = testApplication { + val server = testServer() + application { + mcpStatelessStreamableHttp { server } + } + + // Missing "text/event-stream" in Accept makes the transport reject the request. + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, ContentType.Application.Json.toString()) + contentType(ContentType.Application.Json) + setBody(initializeRequestBody()) + } + response.status shouldBe HttpStatusCode.NotAcceptable + + eventually(5.seconds) { + server.sessions.shouldBeEmpty() + } + } + + private fun HttpRequestBuilder.addStreamableHeaders() { + header(HttpHeaders.Host, "localhost") + header( + HttpHeaders.Accept, + listOf(ContentType.Application.Json, ContentType.Text.EventStream).joinToString(", "), + ) + contentType(ContentType.Application.Json) + } + + private fun initializeRequestBody(): String { + val request = InitializeRequest( + InitializeRequestParams( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = ClientCapabilities(), + clientInfo = Implementation(name = "test-client", version = "1.0.0"), + ), + ).toJSON() + + return McpJson.encodeToString(JSONRPCMessage.serializer(), request) + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportTest.kt index 32123a6..c6370a1 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportTest.kt @@ -5,17 +5,29 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.withClue import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.PingRequest +import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.toJSON import io.modelcontextprotocol.kotlin.test.utils.runIntegrationTest import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.io.Buffer import kotlinx.io.RawSink import kotlinx.io.RawSource @@ -33,9 +45,12 @@ import java.io.ByteArrayOutputStream import java.io.IOException import java.io.PipedInputStream import java.io.PipedOutputStream +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.test.fail +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -71,13 +86,13 @@ class StdioServerTransportTest { @Test fun `should be safe to close before start`() = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) server.close() // initialized guard makes this a no-op; must not throw } @Test fun `should start then close cleanly`() = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) server.onError { error -> throw error } @@ -96,7 +111,7 @@ class StdioServerTransportTest { @Test fun `should not read until started`() = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) server.onError { error -> throw error } @@ -125,7 +140,7 @@ class StdioServerTransportTest { @Test fun `should read multiple messages`() = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) server.onError { error -> throw error } @@ -162,7 +177,7 @@ class StdioServerTransportTest { @ParameterizedTest(name = "[{index}] input throws {0}") @MethodSource("inputErrors") fun `should invoke onError when input stream throws`(throwable: Throwable): Unit = runIntegrationTest { - val server = StdioServerTransport(FaultyRawSource(throwable).buffered(), printOutput) + val server = StdioServerTransport(input = FaultyRawSource(throwable).buffered(), output = printOutput) val capturedError = CompletableDeferred() server.onError { capturedError.complete(it) } server.onMessage {} @@ -176,7 +191,7 @@ class StdioServerTransportTest { @ParameterizedTest(name = "[{index}] output throws {0}") @MethodSource("outputErrors") fun `should invoke onError when output sink throws`(throwable: Throwable): Unit = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, FaultyRawSink(throwable).buffered()) + val server = StdioServerTransport(input = bufferedInput, output = FaultyRawSink(throwable).buffered()) val capturedError = CompletableDeferred() server.onError { capturedError.complete(it) } server.onMessage {} @@ -190,7 +205,7 @@ class StdioServerTransportTest { @Test fun `should call onClose when input EOF is reached`(): Unit = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) val didClose = CompletableDeferred() server.onError { throw it } server.onClose { didClose.complete(Unit) } @@ -206,7 +221,7 @@ class StdioServerTransportTest { @Test fun `should throw when starting twice`(): Unit = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) server.onMessage {} server.start() withClue("Server should not start twice") { @@ -217,10 +232,24 @@ class StdioServerTransportTest { server.close() } + @Test + fun `should report 'closed' error message when starting after close`(): Unit = runIntegrationTest { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) + server.onError {} + server.onMessage {} + server.start() + server.close() + + val ex = shouldThrow { + server.start() + } + ex.message shouldContain "closed" + } + @ParameterizedTest(name = "[{index}] handler throws {0}") @MethodSource("handlerErrors") fun `should continue processing messages after handler throws`(throwable: Throwable) = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) val capturedErrors = mutableListOf() val receivedMessages = mutableListOf() val secondMessageProcessed = CompletableDeferred() @@ -253,7 +282,7 @@ class StdioServerTransportTest { @Test fun `should not invoke onError for CancellationException in handler`() = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) val capturedError = CompletableDeferred() server.onError { capturedError.complete(it) } @@ -281,7 +310,7 @@ class StdioServerTransportTest { @Test fun `should continue receiving valid messages after malformed JSON is skipped`() = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) val received = CompletableDeferred() // ReadBuffer silently skips unparseable lines — no onError callback expected server.onError {} @@ -299,6 +328,297 @@ class StdioServerTransportTest { server.close() } + @Test + fun `should throw McpException when send is called before start`() = runIntegrationTest { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) + val ex = shouldThrow { + server.send(PingRequest().toJSON()) + } + ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED + } + + @Test + fun `should throw McpException when send is called after close`() = runIntegrationTest { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) + server.onError {} + server.onMessage {} + server.start() + server.close() + + val ex = shouldThrow { + server.send(PingRequest().toJSON()) + } + ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED + } + + @Test + fun `should drain in-flight outbound messages on graceful close`() = runIntegrationTest { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) + server.onError {} + server.onMessage {} + server.start() + + val numMessages = 50 + repeat(numMessages) { + server.send(PingRequest().toJSON()) + } + server.close() + + val outputLines = String(output.toByteArray()).lines().count { it.isNotBlank() } + outputLines shouldBe numMessages + } + + @Test + fun `should fail-fast when reader throws non-EOF IOException`() = runIntegrationTest { + val ioError = IOException("transient stream failure") + val server = StdioServerTransport(input = FaultyRawSource(ioError).buffered(), output = printOutput) + val errorCaptured = CompletableDeferred() + val closeCaptured = CompletableDeferred() + server.onError { errorCaptured.complete(it) } + server.onClose { closeCaptured.complete(Unit) } + server.onMessage {} + + server.start() + + errorCaptured.await() shouldBe ioError + closeCaptured.await() + } + + @Test + fun `should close input source on natural EOF`() = runIntegrationTest { + val inputClosed = CompletableDeferred() + val eofSource = object : RawSource { + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long = -1L + override fun close() { + inputClosed.complete(Unit) + } + } + val server = StdioServerTransport(input = eofSource.buffered(), output = printOutput) + val onCloseFired = CompletableDeferred() + server.onError {} + server.onMessage {} + server.onClose { onCloseFired.complete(Unit) } + + server.start() + + inputClosed.await() + onCloseFired.await() + } + + // region: concurrency + + @Test + fun `should be safe under concurrent start and close`() = runIntegrationTest(timeout = 30.seconds) { + val iterations = 2000 + repeat(iterations) { iteration -> + val server = StdioServerTransport(input = Buffer(), output = Buffer()) + server.onError {} + server.onMessage {} + + coroutineScope { + launch(Dispatchers.Default) { + try { + server.start() + } catch (_: IllegalStateException) { + } + } + launch(Dispatchers.Default) { + server.close() + } + } + + withClue("iteration $iteration: send() after race should fail with CONNECTION_CLOSED") { + val ex = shouldThrow { + server.send(PingRequest().toJSON()) + } + ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED + } + } + } + + @Test + fun `should suspend send under back-pressure`() = runIntegrationTest(timeout = 30.seconds) { + val unblock = CountDownLatch(1) + val blockingSink = object : RawSink { + override fun write(source: Buffer, byteCount: Long) { + source.clear() + unblock.await() + } + + override fun flush() { + // noop + } + + override fun close() { + unblock.countDown() + } + } + val server = StdioServerTransport(input = bufferedInput, output = blockingSink.buffered()) + server.onError {} + server.onMessage {} + server.start() + + val maxAttempts = 256 + var suspendedAt = -1 + for (i in 0 until maxAttempts) { + val ok = withTimeoutOrNull(100.milliseconds) { server.send(PingRequest().toJSON()) } + if (ok == null) { + suspendedAt = i + break + } + } + assertTrue( + suspendedAt in 0 until maxAttempts, + "send() should have suspended within $maxAttempts attempts, got suspendedAt=$suspendedAt", + ) + // Unblock writer so close() can drain. + unblock.countDown() + server.close() + } + + @Test + fun `should not hang close when handler suspends indefinitely`() = runIntegrationTest { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) + val handlerEntered = CompletableDeferred() + val neverComplete = CompletableDeferred() + server.onError {} + server.onMessage { + handlerEntered.complete(Unit) + neverComplete.await() + } + server.start() + + inputWriter.write(serializeMessage(PingRequest().toJSON())) + inputWriter.flush() + + handlerEntered.await() + server.close() + } + + @Test + fun `should not hang close when onMessage was never registered`() = runIntegrationTest { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) + server.onError {} + server.start() + + inputWriter.write(serializeMessage(PingRequest().toJSON())) + inputWriter.flush() + + delay(200.milliseconds) + + server.close() + } + + // endregion + + // region: scope and dispatcher knobs + + @Test + fun `should honor externally provided CoroutineScope`() = runIntegrationTest { + val externalScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) { + scope = externalScope + } + val closeCaptured = CompletableDeferred() + server.onError {} + server.onClose { closeCaptured.complete(Unit) } + server.onMessage {} + + server.start() + externalScope.cancel() + + closeCaptured.await() + + val ex = shouldThrow { + server.send(PingRequest().toJSON()) + } + ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED + } + + @Test + fun `should use ioDispatcher for reader and writer`() = runIntegrationTest { + val threadName = "stdio-test-io-thread" + val executor = Executors.newFixedThreadPool(2) { r -> Thread(r, threadName) } + try { + val readerThread = CompletableDeferred() + val writerThread = CompletableDeferred() + val unblockReader = CountDownLatch(1) + val recordingInput = object : RawSource { + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + if (!readerThread.isCompleted) { + readerThread.complete(Thread.currentThread().name) + } + unblockReader.await() + return -1L + } + + override fun close() { + unblockReader.countDown() + } + } + val recordingOutput = object : RawSink { + override fun write(source: Buffer, byteCount: Long) { + if (!writerThread.isCompleted) { + writerThread.complete(Thread.currentThread().name) + } + source.clear() + } + + override fun flush() { + // noop + } + + override fun close() { + // noop + } + } + val server = StdioServerTransport( + input = recordingInput.buffered(), + output = recordingOutput.buffered(), + ) { + ioDispatcher = executor.asCoroutineDispatcher() + } + server.onError {} + server.onMessage {} + server.start() + + readerThread.await() shouldContain threadName + server.send(PingRequest().toJSON()) + writerThread.await() shouldContain threadName + + server.close() + } finally { + executor.shutdownNow() + } + } + + @Test + fun `should use handlerDispatcher for message handling`() = runIntegrationTest { + val threadName = "stdio-test-handler-thread" + val executor = Executors.newSingleThreadExecutor { r -> Thread(r, threadName) } + try { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) { + handlerDispatcher = executor.asCoroutineDispatcher() + } + val observedThreadName = CompletableDeferred() + server.onError {} + server.onMessage { + observedThreadName.complete(Thread.currentThread().name) + } + server.start() + + inputWriter.write(serializeMessage(PingRequest().toJSON())) + inputWriter.flush() + + observedThreadName.await() shouldContain threadName + server.close() + } finally { + executor.shutdownNow() + } + } + + // endregion + private fun inputErrors() = listOf( IOException("simulated read failure"), RuntimeException("unexpected read exception"), diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpResponseDeliveryTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpResponseDeliveryTest.kt new file mode 100644 index 0000000..fc076eb --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpResponseDeliveryTest.kt @@ -0,0 +1,99 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.matchers.shouldBe +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.testing.testApplication +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import kotlin.test.Test + +/** + * A client opening the standalone stream sends `Accept: text/event-stream`, which matches no JSON + * converter. Responses rendered through ContentNegotiation used to reach such a client as an empty + * 406 with the intended status discarded. + */ +class StreamableHttpResponseDeliveryTest { + + private val eventStream = ContentType.Text.EventStream.toString() + + private fun testServer(): Server = Server( + Implementation("test-server", "1.0"), + ServerOptions(capabilities = ServerCapabilities()), + ) + + @Test + fun `stateless GET is rejected with 405 and advertises POST`() = testApplication { + application { mcpStatelessStreamableHttp { testServer() } } + + val response = client.get("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, eventStream) + } + + response.assertMethodNotAllowed() + } + + @Test + fun `stateless DELETE is rejected with 405 and advertises POST`() = testApplication { + application { mcpStatelessStreamableHttp { testServer() } } + + val response = client.delete("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, eventStream) + } + + response.assertMethodNotAllowed() + } + + @Test + fun `stateful DELETE for an unknown session returns 404`() = testApplication { + application { mcpStreamableHttp { testServer() } } + + val response = client.delete("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, eventStream) + header(MCP_SESSION_ID_HEADER, "unknown-session") + } + + response.status shouldBe HttpStatusCode.NotFound + response.decodeError().error.message shouldBe "Session not found" + } + + @Test + fun `POST carrying no request is acknowledged with a bodiless 202`() = testApplication { + application { mcpStatelessStreamableHttp { testServer() } } + + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, "${ContentType.Application.Json}, $eventStream") + contentType(ContentType.Application.Json) + setBody("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + } + + response.status shouldBe HttpStatusCode.Accepted + response.bodyAsText() shouldBe "" + } + + private suspend fun HttpResponse.assertMethodNotAllowed() { + status shouldBe HttpStatusCode.MethodNotAllowed + headers[HttpHeaders.Allow] shouldBe HttpMethod.Post.value + contentType()?.withoutParameters() shouldBe ContentType.Application.Json + decodeError().error.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED + } + + private suspend fun HttpResponse.decodeError(): JSONRPCError = McpJson.decodeFromString(bodyAsText()) +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt index 8b413d8..43ff605 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt @@ -9,6 +9,7 @@ import io.ktor.client.call.body import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.prepareGet @@ -19,18 +20,27 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.ApplicationCall import io.ktor.server.application.install +import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.routing +import io.ktor.server.sse.ServerSSESession import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.testApplication +import io.ktor.sse.ServerSentEvent +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readLine import io.ktor.utils.io.readUTF8Line +import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotification +import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse @@ -39,11 +49,22 @@ import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult import io.modelcontextprotocol.kotlin.sdk.types.McpJson import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.RequestId import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.Tool import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.toJSON +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -51,11 +72,14 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation @@ -79,6 +103,26 @@ class StreamableHttpServerTransportTest { Arguments.of(sizeTestPayload.length.toLong(), HttpStatusCode.BadRequest), Arguments.of(sizeTestPayload.length.toLong() + 1, HttpStatusCode.BadRequest), ) + + @JvmStatic + fun postAcceptHeaderCases(): List = listOf( + Arguments.of("application/json, text/event-stream", HttpStatusCode.OK), + Arguments.of("Application/JSON;q=0.9, TEXT/Event-Stream;charset=utf-8", HttpStatusCode.OK), + Arguments.of("*/*", HttpStatusCode.OK), + Arguments.of("application/*, text/*", HttpStatusCode.OK), + Arguments.of(null, HttpStatusCode.OK), + Arguments.of("application/json", HttpStatusCode.NotAcceptable), + Arguments.of("text/event-stream", HttpStatusCode.NotAcceptable), + Arguments.of("application/jsonp, text/event-stream-bogus", HttpStatusCode.NotAcceptable), + Arguments.of("application/json, text/event-stream;q=0", HttpStatusCode.NotAcceptable), + ) + + @JvmStatic + fun getAcceptHeaderCases() = listOf( + "application/json", + "text/event-stream-bogus", + "text/event-stream;q=0", + ) } private val path = "/transport" @@ -109,6 +153,90 @@ class StreamableHttpServerTransportTest { assertFalse(onMessageCalled.get(), "Transport should not deliver messages when headers are invalid") } + @ParameterizedTest + @MethodSource("postAcceptHeaderCases") + fun `POST Accept header is matched as media ranges`(acceptHeader: String?, expectedStatus: HttpStatusCode) = + testApplication { + configTestServer() + + // Bare client: the ContentNegotiation plugin would supply an Accept header of its own, + // leaving the absent-header case untestable. + val client = createClient {} + + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + val onMessageCalled = AtomicBoolean(false) + transport.onMessage { message -> + onMessageCalled.set(true) + if (message is JSONRPCRequest) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } + } + + configureTransportEndpoint(transport) + + val response = client.post(path) { + contentType(ContentType.Application.Json) + acceptHeader?.let { header(HttpHeaders.Accept, it) } + setBody(McpJson.encodeToString(JSONRPCMessage.serializer(), buildInitializeRequestPayload())) + } + + assertEquals(expectedStatus, response.status) + assertEquals( + expectedStatus == HttpStatusCode.OK, + onMessageCalled.get(), + "Only a request that clears the Accept gate may reach the transport", + ) + } + + @Test + fun `POST with an unparsable Accept header is rejected without failing the request`() = testApplication { + configTestServer() + + val client = createClient {} + + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + val onMessageCalled = AtomicBoolean(false) + transport.onMessage { + onMessageCalled.set(true) + } + + configureTransportEndpoint(transport) + + val response = client.post(path) { + contentType(ContentType.Application.Json) + header(HttpHeaders.Accept, "not-a-media-type") + setBody(McpJson.encodeToString(JSONRPCMessage.serializer(), buildInitializeRequestPayload())) + } + + // The exact code is decided by content negotiation once the transport has rejected the request. + assertTrue(response.status.value in 400..499, "Expected a client error, got ${response.status}") + assertFalse(onMessageCalled.get(), "Transport should not deliver messages when headers are invalid") + } + + @ParameterizedTest + @MethodSource("getAcceptHeaderCases") + fun `GET whose Accept header admits no event-stream range is rejected`(acceptHeader: String) = testApplication { + configTestServer() + + val client = createClient {} + + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + + application { + routing { + get(path) { + transport.handleGetRequest(FakeServerSSESession(call, call.coroutineContext), call) + } + } + } + + val response = client.get(path) { + header(HttpHeaders.Accept, acceptHeader) + } + + assertEquals(HttpStatusCode.NotAcceptable, response.status) + } + @Test fun `initialization request establishes session and returns json response`() = testApplication { configTestServer() @@ -144,7 +272,7 @@ class StreamableHttpServerTransportTest { } @Test - fun `second initialization request returns an HTTP error`() = testApplication { + fun `second initialization request returns JSON-RPC error with request id`() = testApplication { configTestServer() val client = createTestClient() @@ -167,13 +295,17 @@ class StreamableHttpServerTransportTest { firstResponse.status shouldBe HttpStatusCode.OK + val secondRequest = buildInitializeRequestPayload().copy(id = RequestId("second-init")) val secondResponse = client.post(path) { addStreamableHeaders() header("mcp-session-id", firstResponse.headers[MCP_SESSION_ID_HEADER]) - setBody(payload) + setBody(secondRequest) } secondResponse.status shouldBe HttpStatusCode.BadRequest + val error = secondResponse.body() + error.id shouldBe secondRequest.id + error.error.message shouldBe "Invalid Request: Server already initialized" } @Test @@ -191,14 +323,46 @@ class StreamableHttpServerTransportTest { configureTransportEndpoint(transport) + val payload = buildInitializeRequestPayload() val initResponse = client.post(path) { addStreamableHeaders() header("mcp-protocol-version", "1900-01-01") - setBody(buildInitializeRequestPayload()) + setBody(payload) } initResponse.status shouldBe HttpStatusCode.BadRequest initResponse.headers[MCP_SESSION_ID_HEADER] shouldBe null + initResponse.body().id shouldBe payload.id + } + + @Test + fun `malformed initialize params return invalid params over streamable http`() = testApplication { + val mcpPath = "/mcp" + + application { + mcpStreamableHttp(mcpPath, enableDnsRebindingProtection = false) { + Server( + Implementation("test-server", "1.0.0"), + ServerOptions(capabilities = ServerCapabilities()), + ) + } + } + + val client = createTestClient() + + val response = client.post(mcpPath) { + addStreamableHeaders() + setBody( + """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{},"clientInfo":{"name":"repro","version":"0.1.0"}}} + """.trimIndent(), + ) + } + + response.status shouldBe HttpStatusCode.OK + val error = response.body() + error.error.code shouldBe RPCError.ErrorCode.INVALID_PARAMS + error.error.message.contains("kotlinx.serialization") shouldBe false } @Test @@ -242,6 +406,65 @@ class StreamableHttpServerTransportTest { } response.status shouldBe HttpStatusCode.BadRequest + response.body().id shouldBe RequestId("test-1") + } + + @Test + fun `batch with an initialization request echoes the initialize id when rejected`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + transport.onMessage { } + + configureTransportEndpoint(transport) + + val initPayload = buildInitializeRequestPayload() + val response = client.post(path) { + addStreamableHeaders() + setBody( + encodeMessages( + listOf( + initPayload, + JSONRPCRequest(id = RequestId("extra"), method = Method.Defined.ToolsList.value), + ), + ), + ) + } + + response.status shouldBe HttpStatusCode.BadRequest + val error = response.body() + error.error.message shouldBe "Invalid Request: Only one initialization request is allowed" + error.id shouldBe initPayload.id + } + + @Test + fun `non-init request before initialization echoes the request id`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + transport.onMessage { } + + configureTransportEndpoint(transport) + + val response = client.post(path) { + addStreamableHeaders() + setBody( + encodeMessages( + listOf( + JSONRPCRequest(id = RequestId("before-init"), method = Method.Defined.ToolsList.value), + ), + ), + ) + } + + response.status shouldBe HttpStatusCode.BadRequest + val error = response.body() + error.error.message shouldBe "Bad Request: Server not initialized" + error.id shouldBe RequestId("before-init") } @Test @@ -347,6 +570,306 @@ class StreamableHttpServerTransportTest { assertEquals("second", secondMeta?.get("label")?.jsonPrimitive?.content)*/ } + @Test + fun `json response waits for a response produced after message delivery returns`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = true), + ) + // Simulates concurrent dispatch: message delivery returns before the handler responds. + val handlerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + transport.onMessage { message -> + if (message is JSONRPCRequest) { + if (message.method == Method.Defined.Initialize.value) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } else { + handlerScope.launch { + delay(100) + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } + } + } + } + + configureTransportEndpoint(transport) + + val initResponse = client.post(path) { + addStreamableHeaders() + setBody(buildInitializeRequestPayload()) + } + initResponse.status shouldBe HttpStatusCode.OK + + val request = JSONRPCRequest(id = RequestId("async-1"), method = Method.Defined.ToolsList.value) + val response = withTimeout(10.seconds) { + client.post(path) { + addStreamableHeaders() + header(MCP_SESSION_ID_HEADER, initResponse.headers[MCP_SESSION_ID_HEADER]) + setBody(encodeMessages(listOf(request))) + } + } + + response.status shouldBe HttpStatusCode.OK + response.body() shouldBe JSONRPCResponse(request.id) + } finally { + handlerScope.cancel() + } + } + + @Test + fun `json response for a batch waits until every asynchronously produced response is ready`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = true), + ) + val firstRequest = JSONRPCRequest(id = RequestId("slow"), method = Method.Defined.ToolsList.value) + val secondRequest = JSONRPCRequest(id = RequestId("fast"), method = Method.Defined.ResourcesList.value) + + // Simulates concurrent dispatch with out-of-order completion: the second request responds + // first; the POST must stay open until the slower first response settles too. + val handlerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + transport.onMessage { message -> + if (message is JSONRPCRequest) { + if (message.method == Method.Defined.Initialize.value) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } else { + handlerScope.launch { + delay(if (message.id == firstRequest.id) 200 else 50) + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } + } + } + } + + configureTransportEndpoint(transport) + + val initResponse = client.post(path) { + addStreamableHeaders() + setBody(buildInitializeRequestPayload()) + } + initResponse.status shouldBe HttpStatusCode.OK + + val response = withTimeout(10.seconds) { + client.post(path) { + addStreamableHeaders() + header(MCP_SESSION_ID_HEADER, initResponse.headers[MCP_SESSION_ID_HEADER]) + setBody(encodeMessages(listOf(firstRequest, secondRequest))) + } + } + + response.status shouldBe HttpStatusCode.OK + val responses = response.body>() + responses.map { it.id }.toSet() shouldBe setOf(firstRequest.id, secondRequest.id) + } finally { + handlerScope.cancel() + } + } + + @Test + fun `json response POST completes with 202 when its only request is cancelled`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = true), + ) + val requestDelivered = CompletableDeferred() + // Concurrent dispatch: delivery returns before a response is produced. This request is never + // answered here, modelling a handler cancelled by notifications/cancelled. + transport.onMessage { message -> + if (message is JSONRPCRequest) { + if (message.method == Method.Defined.Initialize.value) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } else { + requestDelivered.complete(Unit) + } + } + } + + configureTransportEndpoint(transport) + + val clientScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + val initResponse = client.post(path) { + addStreamableHeaders() + setBody(buildInitializeRequestPayload()) + } + initResponse.status shouldBe HttpStatusCode.OK + val sessionId = initResponse.headers[MCP_SESSION_ID_HEADER] + + val request = JSONRPCRequest(id = RequestId("cancel-me"), method = Method.Defined.ToolsList.value) + val requestPost = clientScope.async { + client.post(path) { + addStreamableHeaders() + header(MCP_SESSION_ID_HEADER, sessionId) + setBody(encodeMessages(listOf(request))) + } + } + + // The request must be registered in-flight before it can be cancelled. + withTimeout(5.seconds) { requestDelivered.await() } + + val cancellation = CancelledNotification( + CancelledNotificationParams(requestId = request.id, reason = "client cancelled"), + ) + val cancelResponse = client.post(path) { + addStreamableHeaders() + header(MCP_SESSION_ID_HEADER, sessionId) + setBody(encodeMessages(listOf(cancellation.toJSON()))) + } + cancelResponse.status shouldBe HttpStatusCode.Accepted + + // Without retirement of the cancelled id this await never completes and the timeout fires. + val response = withTimeout(10.seconds) { requestPost.await() } + response.status shouldBe HttpStatusCode.Accepted + } finally { + clientScope.cancel() + } + } + + @Test + fun `json response for a batch returns only the non-cancelled responses`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = true), + ) + val answeredA = JSONRPCRequest(id = RequestId("a"), method = Method.Defined.ToolsList.value) + val answeredB = JSONRPCRequest(id = RequestId("b"), method = Method.Defined.ToolsList.value) + val cancelledC = JSONRPCRequest(id = RequestId("c"), method = Method.Defined.ToolsList.value) + + val cancelledDelivered = CompletableDeferred() + val handlerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + transport.onMessage { message -> + if (message is JSONRPCRequest) { + when { + message.method == Method.Defined.Initialize.value -> + transport.send(JSONRPCResponse(message.id, EmptyResult())) + + message.id == answeredA.id || message.id == answeredB.id -> + handlerScope.launch { transport.send(JSONRPCResponse(message.id, EmptyResult())) } + + message.id == cancelledC.id -> cancelledDelivered.complete(Unit) + } + } + } + + configureTransportEndpoint(transport) + + val initResponse = client.post(path) { + addStreamableHeaders() + setBody(buildInitializeRequestPayload()) + } + initResponse.status shouldBe HttpStatusCode.OK + val sessionId = initResponse.headers[MCP_SESSION_ID_HEADER] + + val batchPost = handlerScope.async { + client.post(path) { + addStreamableHeaders() + header(MCP_SESSION_ID_HEADER, sessionId) + setBody(encodeMessages(listOf(answeredA, answeredB, cancelledC))) + } + } + + withTimeout(5.seconds) { cancelledDelivered.await() } + + val cancellation = CancelledNotification(CancelledNotificationParams(requestId = cancelledC.id)) + client.post(path) { + addStreamableHeaders() + header(MCP_SESSION_ID_HEADER, sessionId) + setBody(encodeMessages(listOf(cancellation.toJSON()))) + }.status shouldBe HttpStatusCode.Accepted + + val response = withTimeout(10.seconds) { batchPost.await() } + response.status shouldBe HttpStatusCode.OK + val responses = response.body>() + responses.map { it.id }.toSet() shouldBe setOf(answeredA.id, answeredB.id) + } finally { + handlerScope.cancel() + } + } + + @Test + fun `json response send for a retired request id is dropped without error`() = runTest { + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + // No stream is mapped for this id (already retired by disconnect/cancellation cleanup); + // a late terminal response must be dropped quietly, not raise "No connection established". + transport.send(JSONRPCResponse(RequestId("retired"), EmptyResult())) + } + + @Test + fun `sse response send for an unknown request id still errors`() = runTest { + val transport = StreamableHttpServerTransport(enableJsonResponse = false) + assertFailsWith { + transport.send(JSONRPCResponse(RequestId("unknown"), EmptyResult())) + } + } + + @Test + fun `sse terminal response is dropped without leaking when the per-request stream is gone`() = testApplication { + configTestServer() + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = false), + ) + transport.setSessionIdGenerator(null) // stateless: accept requests without an init handshake + val requestId = RequestId("held-request") + val delivered = CompletableDeferred() + val release = CompletableDeferred() + transport.onMessage { message -> + if (message is JSONRPCRequest) { + delivered.complete(Unit) + release.await() // keep the POST and its stream mapping alive until the test releases it + } + } + + application { + routing { + post(path) { + transport.handlePostRequest(FakeServerSSESession(call, call.coroutineContext), call) + } + } + } + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + val post = scope.async { + client.post(path) { + addStreamableHeaders() + setBody( + encodeMessages( + listOf(JSONRPCRequest(id = requestId, method = Method.Defined.ToolsList.value)), + ), + ) + } + } + withTimeout(5.seconds) { delivered.await() } + + // Drop the per-request SSE stream while the request is still mapped in-flight; the handler's + // terminal response is now undeliverable and must be dropped quietly rather than raise + // "No connection established" (which would also strand the request mappings). + transport.closeSseStream(requestId) + transport.send(JSONRPCResponse(requestId, EmptyResult())) + + post.cancel() + } finally { + release.complete(Unit) + scope.cancel() + } + } + @ParameterizedTest @MethodSource("invalidPayloads") fun `POST with a null or empty body returns an error`(payload: String?) = testApplication { @@ -421,6 +944,34 @@ class StreamableHttpServerTransportTest { response.status shouldBe HttpStatusCode.PayloadTooLarge } + @Test + fun `POST with oversized chunked body and no Content-Length returns 413`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = true, maxRequestBodySize = 1024), + ) + transport.onMessage { message -> + if (message is JSONRPCRequest) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } + } + + configureTransportEndpoint(transport) + + // Stream the body as a channel so the client uses chunked transfer-encoding with no + // Content-Length header: the size limit must hold without trusting the (absent) header. + val oversized = ByteReadChannel("x".repeat(4096).encodeToByteArray()) + val response = client.post(path) { + addStreamableHeaders() + setBody(oversized) + } + + response.status shouldBe HttpStatusCode.PayloadTooLarge + } + @ParameterizedTest @MethodSource("maxBodySizeTestCases") fun `POST with custom max request body size validates payload size`( @@ -477,6 +1028,7 @@ class StreamableHttpServerTransportTest { // Step 1: Initialize session via POST val initResponse = client.post(mcpPath) { + header(HttpHeaders.Host, "localhost") addStreamableHeaders() setBody(buildInitializeRequestPayload()) } @@ -485,6 +1037,7 @@ class StreamableHttpServerTransportTest { // Step 2: Open first GET SSE stream client.prepareGet(mcpPath) { + header(HttpHeaders.Host, "localhost") header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) header(MCP_SESSION_ID_HEADER, sessionId) header("mcp-protocol-version", LATEST_PROTOCOL_VERSION) @@ -495,6 +1048,7 @@ class StreamableHttpServerTransportTest { // Step 3: Open a second GET — the transport closes the old session // and the new stream takes over. client.prepareGet(mcpPath) { + header(HttpHeaders.Host, "localhost") header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) header(MCP_SESSION_ID_HEADER, sessionId) header("mcp-protocol-version", LATEST_PROTOCOL_VERSION) @@ -504,7 +1058,7 @@ class StreamableHttpServerTransportTest { // New stream is alive val secondChannel = secondResponse.bodyAsChannel() - val firstLine = secondChannel.readUTF8Line() + val firstLine = secondChannel.readLine() firstLine.shouldNotBeNull() secondChannel.isClosedForRead shouldBe false } @@ -528,6 +1082,7 @@ class StreamableHttpServerTransportTest { // Step 1: Initialize session via POST val initResponse = client.post(mcpPath) { + header(HttpHeaders.Host, "localhost") addStreamableHeaders() setBody(buildInitializeRequestPayload()) } @@ -536,17 +1091,19 @@ class StreamableHttpServerTransportTest { // Step 2: Open and then close a GET SSE stream client.prepareGet(mcpPath) { + header(HttpHeaders.Host, "localhost") header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) header(MCP_SESSION_ID_HEADER, sessionId) header("mcp-protocol-version", LATEST_PROTOCOL_VERSION) }.execute { response -> response.status shouldBe HttpStatusCode.OK - response.bodyAsChannel().readUTF8Line() + response.bodyAsChannel().readLine() } // Step 3: Immediately reconnect — the transport should close the stale // stream and allow the new one. client.prepareGet(mcpPath) { + header(HttpHeaders.Host, "localhost") header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) header(MCP_SESSION_ID_HEADER, sessionId) header("mcp-protocol-version", LATEST_PROTOCOL_VERSION) @@ -555,7 +1112,7 @@ class StreamableHttpServerTransportTest { response.headers[MCP_SESSION_ID_HEADER] shouldBe sessionId val channel = response.bodyAsChannel() - val firstLine = channel.readUTF8Line() + val firstLine = channel.readLine() firstLine.shouldNotBeNull() channel.isClosedForRead shouldBe false } @@ -578,6 +1135,7 @@ class StreamableHttpServerTransportTest { // Step 1: Initialize session via POST val initResponse = client.post(mcpPath) { + header(HttpHeaders.Host, "localhost") addStreamableHeaders() setBody(buildInitializeRequestPayload()) } @@ -586,6 +1144,7 @@ class StreamableHttpServerTransportTest { // Step 2: Open GET SSE stream with session ID client.prepareGet(mcpPath) { + header(HttpHeaders.Host, "localhost") header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) header(MCP_SESSION_ID_HEADER, sessionId) header("mcp-protocol-version", LATEST_PROTOCOL_VERSION) @@ -596,7 +1155,7 @@ class StreamableHttpServerTransportTest { // Verify the stream is alive by reading at least one line (flush event) val channel = response.bodyAsChannel() - val firstLine = channel.readUTF8Line() + val firstLine = channel.readLine() firstLine.shouldNotBeNull() channel.isClosedForRead shouldBe false } @@ -656,3 +1215,12 @@ class StreamableHttpServerTransportTest { } } } + +/** No-op [ServerSSESession] double that lets a POST route drive [StreamableHttpServerTransport] in SSE mode. */ +private class FakeServerSSESession( + override val call: ApplicationCall, + override val coroutineContext: CoroutineContext, +) : ServerSSESession { + override suspend fun send(event: ServerSentEvent) {} + override suspend fun close() {} +} diff --git a/samples/kotlin-mcp-client/gradle/libs.versions.toml b/samples/kotlin-mcp-client/gradle/libs.versions.toml index 69ebdde..b58eef2 100644 --- a/samples/kotlin-mcp-client/gradle/libs.versions.toml +++ b/samples/kotlin-mcp-client/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] -anthropic = "2.27.0" -kotlin = "2.3.21" -ktor = "3.3.3" -mcp-kotlin = "0.11.1" -shadow = "9.4.1" -slf4j = "2.0.17" +anthropic = "2.62.0" +kotlin = "2.4.20" +ktor = "3.5.2" +mcp-kotlin = "0.15.0" +shadow = "9.6.1" +slf4j = "2.0.19" [libraries] anthropic-java = { group = "com.anthropic", name = "anthropic-java", version.ref = "anthropic" } diff --git a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.jar b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.jar index d997cfc..eddabd2 100644 Binary files a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.jar and b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties index c61a118..ad7845b 100644 --- a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties +++ b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/kotlin-mcp-client/gradlew b/samples/kotlin-mcp-client/gradlew index 739907d..249efbb 100755 --- a/samples/kotlin-mcp-client/gradlew +++ b/samples/kotlin-mcp-client/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/samples/kotlin-mcp-client/gradlew.bat b/samples/kotlin-mcp-client/gradlew.bat index e509b2d..8508ef6 100644 --- a/samples/kotlin-mcp-client/gradlew.bat +++ b/samples/kotlin-mcp-client/gradlew.bat @@ -19,12 +19,12 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,29 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/samples/kotlin-mcp-server/gradle/libs.versions.toml b/samples/kotlin-mcp-server/gradle/libs.versions.toml index a6c07dc..d46c2f1 100644 --- a/samples/kotlin-mcp-server/gradle/libs.versions.toml +++ b/samples/kotlin-mcp-server/gradle/libs.versions.toml @@ -1,9 +1,9 @@ [versions] -kotlin = "2.3.21" -ktor = "3.3.3" -mcp-kotlin = "0.11.1" -slf4j = "2.0.17" -shadow = "9.4.1" +kotlin = "2.4.10" +ktor = "3.5.2" +mcp-kotlin = "0.15.0" +slf4j = "2.0.19" +shadow = "9.6.1" [libraries] ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } diff --git a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.jar b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.jar index d997cfc..eddabd2 100644 Binary files a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.jar and b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties index c61a118..ad7845b 100644 --- a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/kotlin-mcp-server/gradlew b/samples/kotlin-mcp-server/gradlew index 739907d..249efbb 100755 --- a/samples/kotlin-mcp-server/gradlew +++ b/samples/kotlin-mcp-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/samples/kotlin-mcp-server/gradlew.bat b/samples/kotlin-mcp-server/gradlew.bat index e509b2d..8508ef6 100644 --- a/samples/kotlin-mcp-server/gradlew.bat +++ b/samples/kotlin-mcp-server/gradlew.bat @@ -19,12 +19,12 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,29 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/samples/kotlinlang-mcp-server/gradle/libs.versions.toml b/samples/kotlinlang-mcp-server/gradle/libs.versions.toml index 20dff78..0c11d3b 100644 --- a/samples/kotlinlang-mcp-server/gradle/libs.versions.toml +++ b/samples/kotlinlang-mcp-server/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] -kotlin = "2.3.20" -ktor = "3.3.3" -mcp-kotlin = "0.11.1" -serialization = "1.10.0" -coroutines = "1.10.2" -logback = "1.5.32" -shadow = "9.4.1" +kotlin = "2.4.20" +ktor = "3.5.2" +mcp-kotlin = "0.15.0" +serialization = "1.11.0" +coroutines = "1.11.0" +logback = "1.6.3" +shadow = "9.6.1" [libraries] ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } diff --git a/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.jar b/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.jar index d997cfc..eddabd2 100644 Binary files a/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.jar and b/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties b/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties index c61a118..ad7845b 100644 --- a/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/kotlinlang-mcp-server/gradlew b/samples/kotlinlang-mcp-server/gradlew index 739907d..249efbb 100755 --- a/samples/kotlinlang-mcp-server/gradlew +++ b/samples/kotlinlang-mcp-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/samples/kotlinlang-mcp-server/gradlew.bat b/samples/kotlinlang-mcp-server/gradlew.bat index c4bdd3a..8508ef6 100644 --- a/samples/kotlinlang-mcp-server/gradlew.bat +++ b/samples/kotlinlang-mcp-server/gradlew.bat @@ -1,93 +1,82 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/samples/simple-streamable-server/gradle/libs.versions.toml b/samples/simple-streamable-server/gradle/libs.versions.toml index dc668c0..5f35f74 100644 --- a/samples/simple-streamable-server/gradle/libs.versions.toml +++ b/samples/simple-streamable-server/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] -kotlin = "2.3.21" -ktor = "3.3.3" -junit = "6.0.3" -mcp-kotlin = "0.11.1" -slf4j = "2.0.17" -shadow = "9.4.1" +kotlin = "2.4.20" +ktor = "3.5.2" +junit = "6.1.3" +mcp-kotlin = "0.15.0" +slf4j = "2.0.19" +shadow = "9.6.1" [libraries] ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } diff --git a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar index d997cfc..eddabd2 100644 Binary files a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar and b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties index c61a118..ad7845b 100644 --- a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/simple-streamable-server/gradlew b/samples/simple-streamable-server/gradlew index 739907d..249efbb 100755 --- a/samples/simple-streamable-server/gradlew +++ b/samples/simple-streamable-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/samples/simple-streamable-server/gradlew.bat b/samples/simple-streamable-server/gradlew.bat index e509b2d..8508ef6 100644 --- a/samples/simple-streamable-server/gradlew.bat +++ b/samples/simple-streamable-server/gradlew.bat @@ -19,12 +19,12 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,29 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/samples/weather-stdio-server/gradle/libs.versions.toml b/samples/weather-stdio-server/gradle/libs.versions.toml index 19874f4..9d609a9 100644 --- a/samples/weather-stdio-server/gradle/libs.versions.toml +++ b/samples/weather-stdio-server/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] -coroutines = "1.10.2" -kotlin = "2.3.21" -ktor = "3.3.3" -logging = "8.0.01" -mcp-kotlin = "0.11.1" -shadow = "9.4.1" -slf4j = "2.0.17" +coroutines = "1.11.0" +kotlin = "2.4.20" +ktor = "3.5.2" +logging = "8.0.4" +mcp-kotlin = "0.15.0" +shadow = "9.6.1" +slf4j = "2.0.19" [libraries] kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging-jvm", version.ref = "logging" } diff --git a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar index d997cfc..eddabd2 100644 Binary files a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar and b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties index c61a118..ad7845b 100644 --- a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/weather-stdio-server/gradlew b/samples/weather-stdio-server/gradlew index 739907d..249efbb 100755 --- a/samples/weather-stdio-server/gradlew +++ b/samples/weather-stdio-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/samples/weather-stdio-server/gradlew.bat b/samples/weather-stdio-server/gradlew.bat index e509b2d..8508ef6 100644 --- a/samples/weather-stdio-server/gradlew.bat +++ b/samples/weather-stdio-server/gradlew.bat @@ -19,12 +19,12 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,29 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL%