diff --git a/.github/workflows/apidocs.yaml b/.github/workflows/apidocs.yaml index 67770a7..995f7d8 100644 --- a/.github/workflows/apidocs.yaml +++ b/.github/workflows/apidocs.yaml @@ -24,17 +24,17 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} cache: gradle - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 - name: Generate Dokka Site run: |- diff --git a/.github/workflows/automations.yml b/.github/workflows/automations.yml index 60a9d3b..4f88d37 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@v11 + - uses: actions/stale@v10 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@v11 + - uses: actions/stale@v10 with: debug-only: ${{ env.DRY_RUN }} only-labels: 'stale' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ff99581..ad4cc7a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,17 +43,17 @@ jobs: max-workers: 2 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Set up Node.js if: matrix.os == 'ubuntu-latest' - uses: actions/setup-node@v7 + uses: actions/setup-node@v6 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.3.0 + uses: gradle/actions/setup-gradle@v6 with: validate-wrappers: true add-job-summary: 'always' @@ -126,16 +126,16 @@ jobs: contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 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@v7 + - uses: actions/checkout@v6 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: add-job-summary: 'always' cache-read-only: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2e7c58f..5697b61 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,22 +25,22 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@v6 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.JAVA_VERSION }} - name: Initialize CodeQL - uses: github/codeql-action/init@v4.38.0 + uses: github/codeql-action/init@v4 with: languages: java-kotlin build-mode: manual - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 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.38.0 + uses: github/codeql-action/analyze@v4 with: category: '/language:java-kotlin' diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index b3e3935..4058d02 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -25,16 +25,16 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -76,16 +76,16 @@ jobs: - sse-retry steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -107,16 +107,16 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -146,16 +146,16 @@ jobs: - auth/cross-app-access-complete-flow steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml index f01f9c8..6ed0756 100644 --- a/.github/workflows/gradle-publish.yml +++ b/.github/workflows/gradle-publish.yml @@ -17,20 +17,20 @@ jobs: packages: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: '21' distribution: 'temurin' - name: Set up Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@v6 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.3.0 + uses: gradle/actions/setup-gradle@v6 - name: Check run: ./gradlew check --no-configuration-cache diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 7463a9e..2c910fd 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@v7 + - uses: actions/checkout@v6 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: validate-wrappers: true add-job-summary: 'always' diff --git a/README.md b/README.md index aefadcf..58a2cad 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?.firstOrNull() + val content = request.messages.lastOrNull()?.content 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 a82b404..98fce0e 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -416,6 +416,7 @@ 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/ConformanceTools.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTools.kt index c81c98c..e288272 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,13 +179,7 @@ fun Server.registerConformanceTools() { ), ), ) - 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))) + CallToolResult(listOf(TextContent(result.content.joinToString("\n") { it.toString() }))) } // 9. Elicitation diff --git a/gradle.properties b/gradle.properties index a5655c3..e5294e8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,4 +8,4 @@ kotlin.daemon.jvmargs=-Xmx4G kotlin.native.ignoreDisabledTargets=true group=io.modelcontextprotocol -version=0.15.0 +version=0.13.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a8d034f..4982b6d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,28 +1,28 @@ [versions] # plugins version -atomicfu = "0.33.0" -binaryCompatibilityValidatorPlugin = "0.18.2" -detekt = "2.0.0-alpha.6" +atomicfu = "0.32.1" +binaryCompatibilityValidatorPlugin = "0.18.1" +detekt = "2.0.0-alpha.3" dokka = "2.2.0" -kotlin = "2.4.10" -kover = "0.9.9" +kotlin = "2.3.21" +kover = "0.9.8" ktlint = "14.2.0" knit = "0.5.1" -mavenPublish = "0.37.0" -netty = "4.2.18.Final" +mavenPublish = "0.36.0" +netty = "4.2.14.Final" # libraries version awaitility = "4.3.0" -collections-immutable = "0.5.2" +collections-immutable = "0.4.0" 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" +kotest = "6.1.11" +kotlinx-io = "0.9.0" +ktor = "3.4.3" +logging = "8.0.03" +mockk = "1.14.9" serialization = "1.11.0" -slf4j = "2.0.19" -logback = "1.6.3" +slf4j = "2.0.18" +logback = "1.5.32" junit = "5.14.4" [libraries] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2..b1b8ef5 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 ad7845b..df6a6ad 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index 249efbb..b9bb139 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# gradlew start up script for POSIX generated by Gradle. +# Gradle 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 gradlew +# ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index 8508ef6..aa5f10b 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute gradlew +@rem Execute Gradle @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 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 099329d..5c00b23 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,17 +14,13 @@ 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 @@ -50,10 +46,8 @@ 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 @@ -73,7 +67,6 @@ 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 @@ -546,15 +539,6 @@ 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) @@ -580,7 +564,6 @@ class ClientTest { def.await() runCatching { job.cancel("Cancelled by test") } defCancel.await() - cancelledOnWire.await() defTimeOut.await() } @@ -619,9 +602,17 @@ class ClientTest { val serverSession = serverSessionResult.await() serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> - // 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() + // 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()) } // Request with 1 msec timeout should fail immediately @@ -900,8 +891,8 @@ class ClientTest { ), ) - // The handler runs on Dispatchers.Default after concurrent dispatch is enabled; await the deferred. - val rootListChangedReceived = CompletableDeferred() + // Track notifications + var rootListChangedNotificationReceived = false val serverSessionResult = CompletableDeferred() @@ -920,13 +911,16 @@ class ClientTest { serverSession.setNotificationHandler( Method.Defined.NotificationsRootsListChanged, ) { - rootListChangedReceived.complete(Unit) + rootListChangedNotificationReceived = true CompletableDeferred(Unit) } client.sendRootsListChanged() - withTimeout(5.seconds) { rootListChangedReceived.await() } + assertTrue( + rootListChangedNotificationReceived, + "Notification should be sent when sendRootsListChanged is called", + ) } @Test @@ -1124,103 +1118,6 @@ 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() @@ -1354,189 +1251,6 @@ 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"), 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 fc3c1d3..585af3f 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,13 +7,9 @@ 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 @@ -22,12 +18,10 @@ 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 @@ -115,43 +109,6 @@ 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() @@ -190,22 +147,4 @@ 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 deleted file mode 100644 index d8d64ec..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/ProtocolConcurrencyIntegrationTest.kt +++ /dev/null @@ -1,306 +0,0 @@ -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 fb97782..eaa51ad 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 INVALID_PARAMS: ${RPCError.ErrorCode.INVALID_PARAMS}") { - exception.code shouldBe RPCError.ErrorCode.INVALID_PARAMS + withClue("Exception code should be INTERNAL_ERROR: -32603") { + exception.code shouldBe -32603 } 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 ab2a854..da7d85b 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,6 +77,23 @@ 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 535393e..9bc0c17 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,7 +3,6 @@ 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 @@ -18,10 +17,7 @@ import io.ktor.server.cio.CIO as ServerCIO open class AbstractStreamableHttpIntegrationTest { - suspend fun initTestServer( - name: String? = null, - sseHeartbeatConfig: (Heartbeat.() -> Unit)? = null, - ): StreamableHttpTestServer { + suspend fun initTestServer(name: String? = null): StreamableHttpTestServer { val mcpServer = Server( Implementation(name = name ?: DEFAULT_SERVER_NAME, version = VERSION), ServerOptions( @@ -56,7 +52,7 @@ open class AbstractStreamableHttpIntegrationTest { host = URL, port = PORT, ) { - mcpStreamableHttp(sseHeartbeatConfig = sseHeartbeatConfig) { mcpServer } + mcpStreamableHttp { mcpServer } } return StreamableHttpTestServer( 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 deleted file mode 100644 index 8019557..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpHeartbeatIntegrationTest.kt +++ /dev/null @@ -1,174 +0,0 @@ -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 a1cf797..81a3b41 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,7 +1,5 @@ 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 @@ -19,9 +17,6 @@ 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() { @@ -61,7 +56,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { @ParameterizedTest @EnumSource(LoggingLevel::class) fun `notification should filter logging messages below level`(minLevel: LoggingLevel): Unit = runBlocking { - val receivedMessages = CopyOnWriteArrayList() + val receivedMessages = mutableListOf() client.setNotificationHandler(Method.Defined.NotificationsMessage) { receivedMessages.add(it) CompletableDeferred(Unit) @@ -84,10 +79,8 @@ 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 } - eventually(5.seconds) { receivedMessages shouldHaveSize expectedLevels.size } + receivedMessages shouldHaveSize expectedLevels.size receivedMessages.map { it.params.level } shouldBe expectedLevels } @@ -122,7 +115,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { @ParameterizedTest @EnumSource(LoggingLevel::class) fun `sendLoggingMessage should filter messages below level`(minLevel: LoggingLevel): Unit = runBlocking { - val receivedMessages = CopyOnWriteArrayList() + val receivedMessages = mutableListOf() client.setNotificationHandler(Method.Defined.NotificationsMessage) { receivedMessages.add(it) CompletableDeferred(Unit) @@ -145,16 +138,14 @@ 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 } - eventually(5.seconds) { receivedMessages shouldHaveSize expectedLevels.size } + 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 = CopyOnWriteArrayList() + val receivedMessages = mutableListOf() client.setNotificationHandler(Method.Defined.NotificationsMessage) { receivedMessages.add(it) CompletableDeferred(Unit) @@ -176,7 +167,6 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { } client.callTool(CallToolRequest(CallToolRequestParams("test-logging-highest"))) - // Handlers dispatch asynchronously, so hold for a window to confirm nothing ever arrives. - continually(500.milliseconds) { receivedMessages.shouldBeEmpty() } + 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 581e5ae..ee8953a 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,7 +9,6 @@ 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 @@ -62,7 +61,6 @@ 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 52f6908..1eb828f 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,6 +1,5 @@ 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 @@ -58,39 +57,6 @@ 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 @@ -123,25 +89,6 @@ 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 @@ -176,27 +123,6 @@ 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 44b1e42..d7ae46d 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,8 +1,5 @@ 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 @@ -113,26 +110,6 @@ 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 8405469..c8ace19 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,7 +5,6 @@ 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 @@ -163,24 +162,6 @@ 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 a830788..fafc3c8 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,10 +1,7 @@ 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 @@ -118,24 +115,6 @@ 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 38b169a..5031a9c 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,11 +7,9 @@ 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 @@ -20,13 +18,9 @@ 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 @@ -47,18 +41,6 @@ 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() @@ -83,30 +65,6 @@ 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 9ac744b..3572082 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,7 +11,6 @@ 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 @@ -82,27 +81,6 @@ 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 1b3df91..54c998e 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,8 +1,5 @@ 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 @@ -111,42 +108,6 @@ 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 4d798ce..ea246db 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,6 +6,7 @@ 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 @@ -15,6 +16,9 @@ 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 @@ -39,9 +43,18 @@ class ChannelTransportTest { clientInfo = Implementation(name = "test client", version = "1.0"), ) - val serverSession = server.createSession(serverTransport) - client.connect(clientTransport) + val serverSessionResult = CompletableDeferred() + 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 ffb53fc..9e757a5 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.30.0", + "@modelcontextprotocol/sdk": "^1.29.0", "express": "^5.2.1", - "zod": "^4.6.2" + "zod": "^4.4.3" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^26.5.1", + "@types/node": "^25.9.1", "cors": "^2.8.6", - "tsx": "^4.23.13", - "typescript": "^7.0.2" + "tsx": "^4.22.3", + "typescript": "^6.0.3" } }, "node_modules/@esbuild/aix-ppc64": { - "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==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -39,9 +39,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -56,9 +56,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -73,9 +73,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -90,9 +90,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -107,9 +107,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -124,9 +124,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "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==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -141,9 +141,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "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==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -158,9 +158,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -175,9 +175,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -192,9 +192,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -209,9 +209,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "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==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -226,9 +226,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -243,9 +243,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -260,9 +260,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -277,9 +277,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -294,9 +294,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -311,9 +311,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -328,9 +328,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -345,9 +345,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -362,9 +362,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "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==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -379,9 +379,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -396,9 +396,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -413,9 +413,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -430,9 +430,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -447,9 +447,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "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==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -464,24 +464,24 @@ } }, "node_modules/@hono/node-server": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", - "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=18.14.1" }, "peerDependencies": { "hono": "^4" } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", + "@hono/node-server": "^1.19.9", "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": "26.5.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", - "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.9.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/qs": { @@ -623,346 +623,6 @@ "@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", @@ -1010,20 +670,20 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "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==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^1.0.5", "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" @@ -1033,19 +693,6 @@ "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", @@ -1229,9 +876,9 @@ } }, "node_modules/es-object-atoms": { - "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==", + "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==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1241,9 +888,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1254,32 +901,32 @@ "node": ">=18" }, "optionalDependencies": { - "@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" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escape-html": { @@ -1362,13 +1009,12 @@ } }, "node_modules/express-rate-limit": { - "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==", + "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==", "license": "MIT", "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -1387,9 +1033,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -1526,9 +1172,9 @@ } }, "node_modules/hasown": { - "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==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1538,9 +1184,9 @@ } }, "node_modules/hono": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", - "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1589,9 +1235,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -1802,13 +1448,12 @@ } }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -1945,14 +1590,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1964,13 +1609,13 @@ } }, "node_modules/side-channel-list": { - "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==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" + "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" @@ -2035,9 +1680,9 @@ } }, "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", "dev": true, "license": "MIT", "dependencies": { @@ -2054,75 +1699,37 @@ } }, "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { - "content-type": "^2.0.0", + "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "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": ">= 0.6" } }, "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "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": ">=14.17" } }, "node_modules/undici-types": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", - "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -2166,9 +1773,9 @@ "license": "ISC" }, "node_modules/zod": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz", - "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "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 b0147e4..40c58b3 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.30.0", + "@modelcontextprotocol/sdk": "^1.29.0", "express": "^5.2.1", - "zod": "^4.6.2" + "zod": "^4.4.3" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^26.5.1", + "@types/node": "^25.9.1", "cors": "^2.8.6", - "tsx": "^4.23.13", - "typescript": "^7.0.2" + "tsx": "^4.22.3", + "typescript": "^6.0.3" } } diff --git a/kotlin-sdk-client/api/kotlin-sdk-client.api b/kotlin-sdk-client/api/kotlin-sdk-client.api index d856f5b..8b6fb79 100644 --- a/kotlin-sdk-client/api/kotlin-sdk-client.api +++ b/kotlin-sdk-client/api/kotlin-sdk-client.api @@ -34,7 +34,6 @@ 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; @@ -52,8 +51,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;ZLkotlin/coroutines/CoroutineContext;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZLkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)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 final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; } @@ -102,8 +101,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;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 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 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/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 3352104..ee865af 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,7 +19,6 @@ 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 @@ -64,7 +63,6 @@ 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 @@ -72,14 +70,11 @@ 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 {} @@ -89,16 +84,11 @@ 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, - handlerCoroutineContext: CoroutineContext = Dispatchers.Default, -) : ProtocolOptions( - enforceStrictCapabilities = enforceStrictCapabilities, - handlerCoroutineContext = handlerCoroutineContext, -) +) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) /** * Initializes and connects an MCP client using the provided clientInfo [Implementation], client options, @@ -223,7 +213,6 @@ 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() @@ -250,18 +239,13 @@ 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, @@ -407,7 +391,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 completions. + * @throws IllegalStateException If the server does not support prompts or completion. */ public suspend fun complete(params: CompleteRequest, options: RequestOptions? = null): CompleteResult = request(params, options) @@ -659,18 +643,9 @@ public open class Client(private val clientInfo: Implementation, options: Client /** * Sets the elicitation handler. * - * 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 + * When the handler returns [ElicitResult.Action.Accept], any properties missing from * [ElicitResult.content] are automatically populated with default values defined in the - * elicitation schema. URL-mode responses carry no content. + * elicitation schema. * * @param handler The elicitation handler. * @throws IllegalStateException if the client does not support elicitation. @@ -688,35 +663,6 @@ 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/StdioClientTransport.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt index 5e990ee..63f681c 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,20 +244,10 @@ 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 6df352a..c27ed79 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,10 +21,8 @@ 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 @@ -43,10 +41,6 @@ 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 @@ -55,18 +49,6 @@ 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. @@ -93,23 +75,15 @@ 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( @@ -124,12 +98,7 @@ public class StreamableHttpClientTransport( url: String, reconnectionTime: Duration?, requestBuilder: HttpRequestBuilder.() -> Unit = {}, - ) : this( - client, - url, - ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), - requestBuilder = requestBuilder, - ) + ) : this(client, url, ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), requestBuilder) override val logger: KLogger = KotlinLogging.logger {} @@ -175,7 +144,6 @@ 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) @@ -399,36 +367,6 @@ 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?, @@ -520,14 +458,7 @@ public class StreamableHttpClientTransport( } while (!channel.isClosedForRead) { - // 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 + val line = channel.readUTF8Line() ?: break if (line.isEmpty()) { dispatch(id = id, eventName = eventName, data = sb.toString()) // reset @@ -541,13 +472,7 @@ public class StreamableHttpClientTransport( line.startsWith("event:") -> eventName = line.substringAfter("event:").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("data:") -> sb.append(line.substringAfter("data:").trim()) 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 index e9379a7..2f322ec 100644 --- 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 @@ -84,25 +84,6 @@ class ClientAssertCapabilityTest { 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(), 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 d9aaf7a..b380f64 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,7 +19,6 @@ 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 @@ -44,15 +43,11 @@ 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( - maxInlineSseEventSize: Int = 16 * 1024 * 1024, - handler: MockRequestHandler, - ): StreamableHttpClientTransport { + private fun createTransport(handler: MockRequestHandler): StreamableHttpClientTransport { val mockEngine = MockEngine(handler) val httpClient = HttpClient(mockEngine) { install(SSE) { @@ -60,11 +55,7 @@ class StreamableHttpClientTransportTest { } } - return StreamableHttpClientTransport( - httpClient, - url = "http://localhost:8080/mcp", - maxInlineSseEventSize = maxInlineSseEventSize, - ) + return StreamableHttpClientTransport(httpClient, url = "http://localhost:8080/mcp") } private fun buildSseMessage(id: String, method: String, params: String): String = buildString { @@ -246,100 +237,6 @@ 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 @@ -654,97 +551,6 @@ class StreamableHttpClientTransportTest { 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 -> @@ -910,7 +716,7 @@ class StreamableHttpClientTransportTest { eventually { while (receivedMessages.isEmpty()) { - delay(10.milliseconds) + delay(10) } } diff --git a/kotlin-sdk-core/api/kotlin-sdk-core.api b/kotlin-sdk-core/api/kotlin-sdk-core.api index b3e88d8..0895722 100644 --- a/kotlin-sdk-core/api/kotlin-sdk-core.api +++ b/kotlin-sdk-core/api/kotlin-sdk-core.api @@ -59,7 +59,6 @@ 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; @@ -72,7 +71,6 @@ 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; @@ -86,18 +84,15 @@ 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 (ZJLkotlin/coroutines/CoroutineContext;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (ZJLkotlin/coroutines/CoroutineContext;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZJLkotlin/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 } @@ -112,16 +107,8 @@ 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 : 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/RequestHandlerExtra { + public fun ()V } public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestOptions : io/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions { @@ -390,19 +377,6 @@ 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 @@ -416,7 +390,6 @@ 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; } @@ -441,26 +414,24 @@ 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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$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;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 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 final fun component1 ()Ljava/lang/String; public final fun component2 ()Lkotlinx/serialization/json/JsonObject; - 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 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 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; } @@ -639,10 +610,6 @@ 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 @@ -980,7 +947,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$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 @@ -1190,7 +1157,6 @@ 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; @@ -1214,7 +1180,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$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; @@ -1235,12 +1201,11 @@ 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;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 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 final fun component1 ()I public final fun component10 ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice; - public final fun component11 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; - public final fun component12-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; + public final fun component11-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; @@ -1249,8 +1214,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-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 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 fun equals (Ljava/lang/Object;)Z public final fun getIncludeContext ()Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext; public final fun getMaxTokens ()I @@ -1260,7 +1225,6 @@ 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; @@ -1408,104 +1372,6 @@ 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 @@ -1579,7 +1445,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$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 @@ -1957,7 +1823,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$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 @@ -2402,7 +2268,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$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; @@ -2792,7 +2658,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$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 { @@ -2863,7 +2729,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$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 { @@ -2934,7 +2800,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$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 { @@ -3004,7 +2870,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsResult : io/modelcontextprotocol/kotlin/sdk/types/ClientResult { @@ -3138,7 +3004,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$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 { @@ -3262,7 +3128,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 class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception { +public final 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 @@ -3356,7 +3222,6 @@ 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; @@ -3565,7 +3430,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/PingRequest_dslKt { @@ -3879,8 +3744,6 @@ 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 { @@ -3917,7 +3780,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getUri ()Ljava/lang/String; public final fun setUri (Ljava/lang/String;)V } @@ -4125,12 +3988,8 @@ 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 @@ -4884,7 +4743,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$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 } @@ -5050,7 +4909,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getUri ()Ljava/lang/String; public final fun setUri (Ljava/lang/String;)V } @@ -5818,7 +5677,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$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getUri ()Ljava/lang/String; public final fun setUri (Ljava/lang/String;)V } @@ -5853,35 +5712,6 @@ 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 @@ -5984,39 +5814,6 @@ 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 908e524..8f86c03 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,34 +27,15 @@ 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.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.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout 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 @@ -72,26 +53,17 @@ public typealias ProgressCallback = (Progress) -> Unit /** * Additional initialization options. * - * [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 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`. * @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, ) /** @@ -99,12 +71,6 @@ 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. * @@ -155,7 +121,7 @@ public class RequestOptions( override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + onProgress.hashCode() + result = 31 * result + (onProgress?.hashCode() ?: 0) result = 31 * result + timeout.hashCode() return result } @@ -166,70 +132,11 @@ 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 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] +public class 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. @@ -237,11 +144,9 @@ private val CONTROL_METHODS: Set = setOf( * @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 val transport: Transport? - get() = connectionRef.value?.transport + public var transport: Transport? = null + private set private val _requestHandlers: AtomicRef RequestResult?>> = @@ -279,21 +184,6 @@ 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. * @@ -323,11 +213,6 @@ 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 @@ -338,64 +223,15 @@ 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. - * - * @throws IllegalStateException if this Protocol is already connected; call [close] first. + * 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. */ public open suspend fun connect(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") - } - + this.transport = transport transport.onClose { - doClose(connection) + doClose() } transport.onError { @@ -403,43 +239,24 @@ 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 -> dispatchRequest(connection, message) - is JSONRPCNotification -> dispatchNotification(connection, message) + is JSONRPCRequest -> onRequest(message) + is JSONRPCNotification -> onNotification(message) is JSONRPCError -> onResponse(null, message) is JSONRPCEmptyMessage -> Unit } } logger.info { "Starting transport" } - 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 - } + transport.start() } - 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() + private fun doClose() { + val handlersToNotify = _responseHandlers.value.values.toList() + _responseHandlers.getAndSet(persistentMapOf()) _progressHandlers.getAndSet(persistentMapOf()) - recentlyCancelledRequestIds.getAndSet(persistentListOf()) - connection.inFlightRequestJobs.getAndSet(persistentMapOf()) - connection.handlerScope.cancel() + transport = null onClose() val error = McpException(RPCError.ErrorCode.CONNECTION_CLOSED, "Connection closed") @@ -448,110 +265,6 @@ 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}" } @@ -571,16 +284,15 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } } - private suspend fun onRequest(request: JSONRPCRequest, connection: Connection, trackCancellation: Boolean) { + private suspend fun onRequest(request: JSONRPCRequest) { 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 { - capturedTransport.send( + transport?.send( JSONRPCError( id = request.id, error = RPCError( @@ -598,77 +310,36 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio return } - val extra = RequestHandlerExtra( - requestId = request.id, - method = Method.from(request.method), - protocol = this, - capturedTransport = capturedTransport, - ) - - // 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)) + val result = handler(request, RequestHandlerExtra()) + logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" } + + transport?.send( + JSONRPCResponse( + id = request.id, + result = result ?: EmptyResult(), + ), + ) } catch (e: CancellationException) { throw e - } catch (sendError: Throwable) { - logger.error(sendError) { - "Failed to send error response for request: ${request.method} (id: ${request.id})" + } 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 } - onError(sendError) } } @@ -683,10 +354,6 @@ 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)}", ) @@ -703,7 +370,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val oldResponseHandlers = _responseHandlers.getAndUpdate { current -> if (messageId != null && messageId in current) { - current.removing(messageId) + current.remove(messageId) } else { current } @@ -712,12 +379,8 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val handler = oldResponseHandlers[messageId] if (handler != null) { - messageId?.let { msg -> _progressHandlers.update { it.removing(msg) } } + messageId?.let { msg -> _progressHandlers.update { it.remove(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)}", @@ -730,7 +393,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio handler(response, null) } else { checkNotNull(error) - val mcpException = McpException.fromError( + val mcpException = McpException( code = error.error.code, message = error.error.message, data = error.error.data, @@ -775,8 +438,7 @@ 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 connection = connectionRef.value ?: error("Not connected") - val transport = connection.transport + val transport = transport ?: error("Not connected") if (this@Protocol.options?.enforceStrictCapabilities == true) { assertCapabilityForMethod(request.method) @@ -786,7 +448,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.putting(id, progressHandler) + current.put(id, progressHandler) } val paramsObject = (this.params as? JsonObject) ?: JsonObject(emptyMap()) @@ -805,10 +467,10 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val jsonRpcRequestId = jsonRpcRequest.id _responseHandlers.update { current -> - current.putting(jsonRpcRequestId) { response, error -> + current.put(jsonRpcRequestId) { response, error -> if (error != null) { result.completeExceptionally(error) - return@putting + return@put } try { @@ -820,84 +482,42 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } } - 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 cancel: suspend (Throwable) -> Unit = { reason: Throwable -> + _responseHandlers.update { current -> current.remove(jsonRpcRequestId) } + _progressHandlers.update { current -> current.remove(jsonRpcRequestId) } - 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 notification = CancelledNotification( + params = CancelledNotificationParams( + requestId = jsonRpcRequestId, + reason = reason.message ?: "Unknown", + ), + ) - if (notifyPeer) { - val notification = CancelledNotification( - params = CancelledNotificationParams( - requestId = jsonRpcRequestId, - reason = reason.message ?: "Unknown", - ), - ) - transport.send(notification.toJSON(), options) - } + val jsonRpcNotification = notification.toJSON() + + transport.send(jsonRpcNotification, 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 { - val response = withTimeoutOrNull(timeout) { + withTimeout(timeout) { logger.trace { "Sending request message with id: $jsonRpcRequestId" } - transport.send(jsonRpcRequest, options) - result.await() + this@Protocol.transport?.send(jsonRpcRequest, options) } - 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( + return result.await() + } catch (cause: TimeoutCancellationException) { + logger.error { "Request timed out after ${timeout.inWholeMilliseconds}ms: ${request.method}" } + cancel( + McpException( code = RPCError.ErrorCode.REQUEST_TIMEOUT, message = "Request timed out", data = JsonObject(mutableMapOf("timeout" to JsonPrimitive(timeout.inWholeMilliseconds))), - ) - 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) - } - } + ), + ) + result.cancel(cause) 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) } } } @@ -918,11 +538,6 @@ 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, @@ -941,16 +556,8 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val wrapped = wrapRequestHandler(method, block) _requestHandlers.update { current -> - 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, - ) - } + current.put(method.value) { jSONRPCRequest, extraHandler -> + val request = jSONRPCRequest.fromJSON() val response = wrapped(request as T, extraHandler) response } @@ -975,23 +582,17 @@ 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.removing(method.value) } + _requestHandlers.update { current -> current.remove(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.putting(method.value) { + current.put(method.value) { @Suppress("UNCHECKED_CAST") handler(it.fromJSON() as T) } @@ -1002,22 +603,6 @@ 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.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()) + _notificationHandlers.update { current -> current.remove(method.value) } } } 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 6a9015b..56c851b 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,26 +12,9 @@ import kotlin.jvm.JvmOverloads * @property data optional additional error payload as a JSON element * @param cause the original cause */ -public open class McpException @JvmOverloads public constructor( +public 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) { - 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) - } - } -} +) : Exception(message, cause) 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 deleted file mode 100644 index f3da768..0000000 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredException.kt +++ /dev/null @@ -1,65 +0,0 @@ -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.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.kt index ecb8c48..cd78b58 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 @@ -189,15 +189,6 @@ public data class ClientCapabilities( } } -/** - * 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. * 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 deleted file mode 100644 index d486b1f..0000000 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/discovery.kt +++ /dev/null @@ -1,107 +0,0 @@ -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 1bb73e6..a0ee74c 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,11 +133,6 @@ 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 7e10904..7eefdb5 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,16 +269,6 @@ 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 1824ad0..19f4da5 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,7 +16,6 @@ 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"), @@ -54,13 +53,4 @@ 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 7d885fb..aacab9a 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,10 +1,8 @@ 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 @@ -13,13 +11,6 @@ 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. * @@ -49,88 +40,6 @@ 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 7cc2685..211fdc7 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,10 +91,6 @@ 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"))` @@ -182,12 +178,6 @@ 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. */ @@ -222,8 +212,6 @@ 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. */ @@ -239,7 +227,6 @@ 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 @@ -286,10 +273,6 @@ 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 36b7e37..ab85e6d 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,7 +1,6 @@ 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 @@ -60,11 +59,18 @@ 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 = Method.from(decoder.decodeString()) + override fun deserialize(decoder: Decoder): Method { + val decodedString = decoder.decodeString() + return definedMethods[decodedString] ?: Method.Custom(decodedString) + } } // ============================================================================ @@ -239,11 +245,9 @@ 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(), @@ -420,16 +424,9 @@ 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 1f0f54e..2ff890a 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,12 +203,6 @@ 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. */ @@ -222,8 +216,6 @@ 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. */ @@ -231,7 +223,6 @@ 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 deleted file mode 100644 index b3c95ac..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyTest.kt +++ /dev/null @@ -1,524 +0,0 @@ -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 deleted file mode 100644 index 81bf24a..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptionsTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 38ad556..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOutboundCancellationTest.kt +++ /dev/null @@ -1,167 +0,0 @@ -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 2846ccc..b726c2f 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,9 +5,6 @@ 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 @@ -16,16 +13,11 @@ 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.launch +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObjectBuilder @@ -38,7 +30,6 @@ 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 @@ -194,174 +185,64 @@ class ProtocolTest { transport.deliver(JSONRPCResponse(sent.id, EmptyResult())) inFlight.await() } +} - @Test - fun `request handler receives enriched extra and ambient context element`() = runTest { - protocol.connect(transport) +private class TestProtocol : Protocol(null) { + val errors = mutableListOf() - 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 onError(error: Throwable) { + errors.add(error) } - @Test - fun `extra method resolves defined methods to Defined entries`() = runTest { - protocol.connect(transport) + override fun assertCapabilityForMethod(method: Method) { + // noop + } + override fun assertNotificationCapability(method: Method) { + // noop + } + override fun assertRequestHandlerCapability(method: Method) { + // noop + } +} - var seenMethod: Method? = null - protocol.setRequestHandler(Method.Defined.Ping) { _, extra -> - seenMethod = extra.method - EmptyResult() - } - transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "ping")) +private class RecordingTransport : Transport { + private val sentMessages = Channel(Channel.UNLIMITED) + private var onMessageCallback: (suspend (JSONRPCMessage) -> Unit)? = null + private var onCloseCallback: (() -> Unit)? = null - seenMethod shouldBe Method.Defined.Ping + override suspend fun start() { + // noop } - @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 send(message: JSONRPCMessage, options: TransportSendOptions?) { + sentMessages.send(message) } - @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 suspend fun close() { + onCloseCallback?.invoke() } - @Test - fun `currentRequestHandlerExtra returns null outside handlers`() = runTest { - currentRequestHandlerExtra() shouldBe null + override fun onClose(block: () -> Unit) { + onCloseCallback = block } - @Test - fun `connect while already connected throws IllegalStateException`() = runTest { - protocol.connect(transport) - shouldThrow { - protocol.connect(RecordingTransport()) - } + override fun onError(block: (Throwable) -> Unit) { + // noop } - @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 + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { + onMessageCallback = block } - @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 awaitRequest(): JSONRPCRequest { + val message = sentMessages.receive() + return message as? JSONRPCRequest + ?: error("Expected JSONRPCRequest but received ${message::class.simpleName}") } - @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" + suspend fun deliver(message: JSONRPCMessage) { + val callback = onMessageCallback ?: error("onMessage callback not registered") + callback(message) } } 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 deleted file mode 100644 index 9e8dd60..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTestFakes.kt +++ /dev/null @@ -1,112 +0,0 @@ -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/types/CapabilitiesTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesTest.kt index 3468aae..06949a6 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 @@ -923,24 +923,4 @@ class CapabilitiesTest { 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 deleted file mode 100644 index 691af45..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/DiscoveryTest.kt +++ /dev/null @@ -1,324 +0,0 @@ -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 bd5ef06..1a41863 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,7 +10,6 @@ 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 @@ -274,86 +273,4 @@ 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 6087b20..1d6da19 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,120 +2,20 @@ 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 b7bc5cf..62a82cd 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,13 +302,6 @@ 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"))) @@ -367,26 +360,6 @@ 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" @@ -435,17 +408,6 @@ 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 358866a..d5e1d5a 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,57 +319,6 @@ 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/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyJvmTest.kt b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyJvmTest.kt deleted file mode 100644 index 60658f8..0000000 --- a/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyJvmTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -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 d8e4c7a..3acaa64 100644 --- a/kotlin-sdk-server/api/kotlin-sdk-server.api +++ b/kotlin-sdk-server/api/kotlin-sdk-server.api @@ -51,18 +51,16 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/HostValidationKt { } public final class io/modelcontextprotocol/kotlin/sdk/server/KtorServerKt { - 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 mcp (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;)V + public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;)V + public static final fun mcp (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun mcp$default (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lkotlin/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;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static synthetic fun mcp$default (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;Lkotlin/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 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 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 final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt : io/modelcontextprotocol/kotlin/sdk/server/Feature { @@ -171,8 +169,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;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 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 final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; public final fun getResourceTemplateMatcherFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory; } @@ -200,7 +198,6 @@ 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; @@ -211,8 +208,7 @@ 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;J)V - public synthetic fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;)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; 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 21e0925..9b4b2f3 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,14 +10,12 @@ 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 @@ -27,7 +25,6 @@ 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 {} @@ -97,17 +94,12 @@ 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 the server requests for the elicitation result. + * @param requestedSchema The schema requested by the client 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, @@ -137,15 +129,10 @@ 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 @@ -251,11 +238,6 @@ 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 -> @@ -268,13 +250,7 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client } } logger.trace { "ElicitRequest: $request" } - 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 + return request(request, options) } override suspend fun createElicitation( @@ -323,9 +299,6 @@ 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 deleted file mode 100644 index d976d34..0000000 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt +++ /dev/null @@ -1,174 +0,0 @@ -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 142d59c..0ed6558 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,12 +20,9 @@ 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 for logging and error messages. + * Used primarily for logging purposes. */ internal class FeatureRegistry(private val featureType: String) { @@ -47,11 +44,11 @@ internal class FeatureRegistry(private val featureType: String) { private val listeners = atomic(persistentListOf()) internal fun addListener(listener: FeatureListener) { - listeners.update { it.adding(listener) } + listeners.update { it.add(listener) } } internal fun removeListener(listener: FeatureListener) { - listeners.update { it.removing(listener) } + listeners.update { it.remove(listener) } } /** @@ -61,42 +58,27 @@ internal class FeatureRegistry(private val featureType: String) { */ internal fun add(feature: T) { logger.info { "Adding $featureType: \"${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) - } + val oldMap = registry.getAndUpdate { current -> current.put(feature.key, feature) } + val oldFeature = oldMap[feature.key] logger.info { "Added $featureType: \"${feature.key}\"" } - notifyFeatureUpdated(null, feature) + notifyFeatureUpdated(oldFeature, feature) } /** * Adds the given list of features to the registry. Each feature is mapped by its key - * and added to the current registry. The operation is all-or-nothing: if any key is - * rejected, no features are added. + * and added to the current registry. * * @param features The list of features to add to the registry. */ internal fun addAll(features: List) { logger.info { "Adding ${featureType}s: ${features.size}" } - 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) - } + val oldMap = registry.getAndUpdate { current -> current.putAll(features.associateBy { it.key }) } logger.info { "Added ${featureType}s: ${features.size}" } for (feature in features) { - notifyFeatureUpdated(null, feature) + val oldFeature = oldMap[feature.key] + notifyFeatureUpdated(oldFeature, feature) } } @@ -108,7 +90,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.removing(key) } + val oldMap = registry.getAndUpdate { current -> current.remove(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 index ef010fe..88b56bc 100644 --- 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 @@ -19,16 +19,6 @@ import io.modelcontextprotocol.kotlin.sdk.types.RPCError */ 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 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 3127499..d6191df 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,7 +1,6 @@ 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 @@ -21,10 +20,8 @@ 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 @@ -44,11 +41,7 @@ private val logger = KotlinLogging.logger {} * @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. + * Pass `null` to skip origin validation. * @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. @@ -60,11 +53,10 @@ public fun Route.mcp( enableDnsRebindingProtection: Boolean = true, allowedHosts: List? = null, allowedOrigins: List? = null, - maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, block: ServerSSESession.() -> Server, ) { route(path) { - mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block) + mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, block) } } @@ -79,22 +71,16 @@ public fun Route.mcp( * @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. + * Pass `null` to skip origin validation. * @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 -@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 { @@ -113,7 +99,7 @@ public fun Route.mcp( val transportManager = TransportManager() sse { - mcpSseEndpoint("", transportManager, maxRequestBodySize, block) + mcpSseEndpoint("", transportManager, block) } post { @@ -133,28 +119,22 @@ public fun Route.mcp( * @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. + * Pass `null` to skip origin validation. * @param block factory block with access to the [ServerSSESession] * that creates and returns the [Server] to handle the connection. */ @KtorDsl -@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(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block) + mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, block) } } @@ -165,7 +145,6 @@ private fun Application.mcpStreamableHttp( allowedHosts: List?, allowedOrigins: List?, configuration: StreamableHttpServerTransport.Configuration, - sseHeartbeatConfig: (Heartbeat.() -> Unit)?, block: RoutingContext.() -> Server, ) { installMcpContentNegotiation() @@ -189,7 +168,6 @@ private fun Application.mcpStreamableHttp( sse { val transport = existingStreamableTransport(call, transportManager) ?: return@sse - sseHeartbeatConfig?.let { config -> heartbeat(config) } transport.handleRequest(this, call) } @@ -227,12 +205,9 @@ private fun Application.mcpStreamableHttp( * 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. + * If `null`, origin validation is disabled. * @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. */ @@ -243,7 +218,6 @@ public fun Application.mcpStreamableHttp( allowedHosts: List? = null, allowedOrigins: List? = null, eventStore: EventStore? = null, - sseHeartbeatConfig: (Heartbeat.() -> Unit)? = null, block: RoutingContext.() -> Server, ) { mcpStreamableHttp( @@ -255,7 +229,6 @@ public fun Application.mcpStreamableHttp( eventStore = eventStore, enableJsonResponse = true, ), - sseHeartbeatConfig = sseHeartbeatConfig, block = block, ) } @@ -270,6 +243,7 @@ private fun Application.mcpStatelessStreamableHttp( block: RoutingContext.() -> Server, ) { installMcpContentNegotiation() + install(SSE) routing { route(path) { @@ -282,10 +256,18 @@ private fun Application.mcpStatelessStreamableHttp( ) } get { - call.rejectUnsupportedMethod() + call.reject( + HttpStatusCode.MethodNotAllowed, + RPCError.ErrorCode.CONNECTION_CLOSED, + "Method not allowed.", + ) } delete { - call.rejectUnsupportedMethod() + call.reject( + HttpStatusCode.MethodNotAllowed, + RPCError.ErrorCode.CONNECTION_CLOSED, + "Method not allowed.", + ) } } } @@ -296,11 +278,10 @@ 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. - * 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. + * Simple request/response pairs are returned as JSON (not SSE streams). * * Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation] - * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson]. + * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE]. * * @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 `true`. @@ -309,6 +290,7 @@ private fun Application.mcpStatelessStreamableHttp( * @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 eventStore An optional [EventStore] implementation to provide resumability and event replay support. * @param block factory block with access to the [RoutingContext] (for reading request headers) * that creates and returns the [Server] to handle the connection. */ @@ -318,6 +300,7 @@ public fun Application.mcpStatelessStreamableHttp( enableDnsRebindingProtection: Boolean = true, allowedHosts: List? = null, allowedOrigins: List? = null, + eventStore: EventStore? = null, block: RoutingContext.() -> Server, ) { mcpStatelessStreamableHttp( @@ -326,49 +309,19 @@ public fun Application.mcpStatelessStreamableHttp( allowedHosts = allowedHosts, allowedOrigins = allowedOrigins, configuration = StreamableHttpServerTransport.Configuration( + 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, maxRequestBodySize) + val transport = mcpSseTransport(postEndpoint, transportManager) val server = block() @@ -387,9 +340,8 @@ private suspend fun ServerSSESession.mcpSseEndpoint( private fun ServerSSESession.mcpSseTransport( postEndpoint: String, transportManager: TransportManager, - maxRequestBodySize: Long, ): SseServerTransport { - val transport = SseServerTransport(postEndpoint, this, maxRequestBodySize) + val transport = SseServerTransport(postEndpoint, this) transportManager.addTransport(transport.sessionId, transport) logger.info { "New SSE connection established and stored with sessionId: ${transport.sessionId}" } @@ -407,17 +359,11 @@ private suspend fun RoutingContext.mcpStatelessStreamableHttpEndpoint( logger.info { "New stateless StreamableHttp connection established without sessionId" } val server = block() - val session = server.createSession(transport) + server.onClose { logger.info { "Server connection closed without sessionId" } } + server.createSession(transport) - 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" } - } + transport.handleRequest(null, this.call) + logger.debug { "Server connected to transport without sessionId" } } private suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportManager) { @@ -439,16 +385,6 @@ 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( @@ -515,9 +451,6 @@ private fun Route.installDnsRebindingProtection(enabled: Boolean, hosts: List 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 f525ec2..a6d4618 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,6 +5,7 @@ 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 @@ -28,19 +29,10 @@ 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, - private val maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, -) : AbstractTransport() { - init { - require(maxRequestBodySize > 0) { "maxRequestBodySize must be greater than 0" } - } - +public class SseServerTransport(private val endpoint: String, private val session: ServerSSESession) : + AbstractTransport() { private val initialized: AtomicBoolean = AtomicBoolean(false) /** Unique identifier for this transport session, generated randomly on creation. */ @@ -85,7 +77,6 @@ public class SseServerTransport( val message = "SSE connection not established" call.respondText(message, status = HttpStatusCode.InternalServerError) _onError.invoke(IllegalStateException(message)) - return } val body = try { @@ -94,13 +85,9 @@ public class SseServerTransport( error("Unsupported content-type: $ct") } - call.receiveTextWithLimit(maxRequestBodySize) + call.receiveText() } 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 8243171..ef40808 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 no - * tool_use / tool_result blocks are involved. + * On the first violation throws [IllegalArgumentException]. No-op when there are fewer + * than two messages or no tool_use / tool_result blocks are involved. */ internal fun validateSamplingMessages(messages: List) { if (messages.isEmpty()) return @@ -44,9 +44,6 @@ 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 6f7711f..1b7bfe7 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,18 +46,15 @@ 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 @@ -71,17 +68,12 @@ 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, - handlerCoroutineContext: CoroutineContext = Dispatchers.Default, -) : ProtocolOptions( - enforceStrictCapabilities = enforceStrictCapabilities, - handlerCoroutineContext = handlerCoroutineContext, -) { +) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) { @JvmOverloads public constructor( capabilities: ServerCapabilities, @@ -659,9 +651,6 @@ 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( @@ -682,10 +671,7 @@ public open class Server( val prompt = promptRegistry.get(requestParams.name) ?: run { logger.error { "Prompt not found: ${requestParams.name}" } - throw McpException( - code = RPCError.ErrorCode.INVALID_PARAMS, - message = "Prompt not found: ${requestParams.name}", - ) + throw IllegalArgumentException("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 f20d3e1..d215bd5 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,18 +108,10 @@ 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 @@ -421,17 +413,12 @@ 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 the server requests for the elicitation result. + * @param requestedSchema The schema requested by the client 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/StreamableHttpServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt index dcba848..ae8a39d 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,28 +1,25 @@ 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.respondText +import io.ktor.server.response.respondNullable 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 @@ -31,7 +28,6 @@ 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 @@ -52,6 +48,7 @@ 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" /** @@ -180,20 +177,6 @@ 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() @@ -260,117 +243,54 @@ 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] - 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") - } + ?: error("No connection established for request id $routingRequestId") val activeStream = streamsMapping[streamId] if (!configuration.enableJsonResponse) { activeStream?.let { stream -> emitOnStream(streamId, stream.session, message) } - if (!isTerminated) return + } + + 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 } requestToResponseMapping[responseRequestId!!] = message + val relatedIds = requestToStreamMapping.filterValues { it == streamId }.keys + + if (relatedIds.any { it !in requestToResponseMapping }) return streamMutex.withLock { - // 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 (activeStream == null) error("No connection established for request ID: $routingRequestId") if (configuration.enableJsonResponse) { - // 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.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] } - cleanUpRequests(relatedIds) - pendingPost.complete(responses) + val payload = if (responses.size == 1) { + responses.first() + } else { + responses + } + activeStream.call.respond(payload) } else { - // 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) + activeStream.session?.close() } - } - } - 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() + // Clean up + relatedIds.forEach { requestId -> + requestToResponseMapping.remove(requestId) + requestToStreamMapping.remove(requestId) } - .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) } } @@ -386,10 +306,6 @@ 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() } } @@ -425,12 +341,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } /** - * 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. + * Handles POST requests containing JSON-RPC messages */ public suspend fun handlePostRequest(session: ServerSSESession?, call: ApplicationCall) { try { @@ -461,10 +372,9 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } val messages = parseBody(call) ?: return - val initializationRequest = messages.filterIsInstance().firstOrNull { - it.method == Method.Defined.Initialize.value + val isInitializationRequest = messages.any { + it is JSONRPCRequest && it.method == Method.Defined.Initialize.value } - val isInitializationRequest = initializationRequest != null if (isInitializationRequest) { if (initialized.load() && sessionId != null) { @@ -472,17 +382,15 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.BadRequest, RPCError.ErrorCode.INVALID_REQUEST, "Invalid Request: Server already initialized", - initializationRequest.id, ) return } - if (!validateProtocolVersion(call, initializationRequest.id)) return + if (!validateProtocolVersion(call)) return if (messages.size > 1) { call.reject( HttpStatusCode.BadRequest, RPCError.ErrorCode.INVALID_REQUEST, "Invalid Request: Only one initialization request is allowed", - initializationRequest.id, ) return } @@ -494,17 +402,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat sessionId?.let { onSessionInitialized?.invoke(it) } } } else { - val requestId = messages.filterIsInstance().firstOrNull()?.id - if (!validateSession(call, requestId) || !validateProtocolVersion(call, requestId)) return + if (!validateSession(call) || !validateProtocolVersion(call)) return } val hasRequest = messages.any { it is JSONRPCRequest } if (!hasRequest) { - call.respond(HttpStatusCode.Accepted) + call.respondNullable(status = HttpStatusCode.Accepted, message = null) 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 } @@ -512,7 +416,8 @@ 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) { - (initializationRequest.params as? JsonObject)?.get("protocolVersion") + val initRequest = messages.first() as JSONRPCRequest + (initRequest.params as? JsonObject)?.get("protocolVersion") ?.let { McpJson.decodeFromJsonElement(it) } ?: DEFAULT_NEGOTIATED_PROTOCOL_VERSION } else { @@ -526,50 +431,13 @@ 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) } - 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 }) - } + messages.forEach { message -> _onMessage(message) } } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -649,7 +517,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat if (!validateSession(call) || !validateProtocolVersion(call)) return sessionId?.let { onSessionClosed?.invoke(it) } close() - call.respond(HttpStatusCode.OK) + call.respondNullable(status = HttpStatusCode.OK, message = null) } /** @@ -738,7 +606,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - private suspend fun validateSession(call: ApplicationCall, id: RequestId? = null): Boolean { + private suspend fun validateSession(call: ApplicationCall): Boolean { if (sessionIdGenerator == null) return true if (!initialized.load()) { @@ -746,7 +614,6 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.BadRequest, RPCError.ErrorCode.CONNECTION_CLOSED, "Bad Request: Server not initialized", - id, ) return false } @@ -758,7 +625,6 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.BadRequest, RPCError.ErrorCode.CONNECTION_CLOSED, "Bad Request: Mcp-Session-Id header is required", - id, ) return false } @@ -771,14 +637,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.NotFound, REQUEST_TIMEOUT, "Session not found", - id, ) false } } } - private suspend fun validateProtocolVersion(call: ApplicationCall, id: RequestId? = null): Boolean { + private suspend fun validateProtocolVersion(call: ApplicationCall): Boolean { val version = call.request.headers[MCP_PROTOCOL_VERSION_HEADER] ?: return true return when (version) { @@ -791,7 +656,6 @@ public class StreamableHttpServerTransport(private val configuration: Configurat ", ", ) })", - id, ) false } @@ -839,13 +703,23 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } private suspend fun parseBody(call: ApplicationCall): List? { - val body = try { - call.receiveTextWithLimit(configuration.maxRequestBodySize) - } catch (e: RequestBodyTooLargeException) { + val maxSize = configuration.maxRequestBodySize + val contentLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull() ?: 0L + if (contentLength > maxSize) { call.reject( HttpStatusCode.PayloadTooLarge, RPCError.ErrorCode.INVALID_REQUEST, - "Invalid Request: message size exceeds maximum of ${e.maxBodySize} bytes", + "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", ) return null } @@ -866,24 +740,8 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - /** - * 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 fun String?.accepts(mime: ContentType): Boolean = + this?.lowercase()?.contains(mime.toString().lowercase()) == true private suspend fun emitOnStream(streamId: String, session: ServerSSESession?, message: JSONRPCMessage) { val eventId = configuration.eventStore?.storeEvent(streamId, message) @@ -938,13 +796,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } -/** 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) +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))) } 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 deleted file mode 100644 index c6bf72b..0000000 --- a/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistryTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -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/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionTest.kt index ea38219..96c44ef 100644 --- 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 @@ -3,7 +3,6 @@ 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 @@ -348,51 +347,6 @@ class DnsRebindingProtectionTest { 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 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 deleted file mode 100644 index b218b47..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidationTest.kt +++ /dev/null @@ -1,438 +0,0 @@ -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/RequestBodyTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBodyTest.kt deleted file mode 100644 index 9dc30cf..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBodyTest.kt +++ /dev/null @@ -1,103 +0,0 @@ -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 cfcbb62..2b5446b 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,7 +8,6 @@ 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 /** @@ -84,21 +83,4 @@ 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 deleted file mode 100644 index a0756f3..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SseServerTransportTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 3646faf..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StatelessStreamableHttpSessionLifecycleTest.kt +++ /dev/null @@ -1,100 +0,0 @@ -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/StreamableHttpResponseDeliveryTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpResponseDeliveryTest.kt deleted file mode 100644 index fc076eb..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpResponseDeliveryTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -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 43ff605..f6dda15 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,7 +9,6 @@ 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 @@ -20,27 +19,18 @@ 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 @@ -49,22 +39,11 @@ 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 @@ -72,14 +51,11 @@ 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 @@ -103,26 +79,6 @@ 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" @@ -153,90 +109,6 @@ 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() @@ -272,7 +144,7 @@ class StreamableHttpServerTransportTest { } @Test - fun `second initialization request returns JSON-RPC error with request id`() = testApplication { + fun `second initialization request returns an HTTP error`() = testApplication { configTestServer() val client = createTestClient() @@ -295,17 +167,13 @@ 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(secondRequest) + setBody(payload) } secondResponse.status shouldBe HttpStatusCode.BadRequest - val error = secondResponse.body() - error.id shouldBe secondRequest.id - error.error.message shouldBe "Invalid Request: Server already initialized" } @Test @@ -323,46 +191,14 @@ class StreamableHttpServerTransportTest { configureTransportEndpoint(transport) - val payload = buildInitializeRequestPayload() val initResponse = client.post(path) { addStreamableHeaders() header("mcp-protocol-version", "1900-01-01") - setBody(payload) + setBody(buildInitializeRequestPayload()) } 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 @@ -406,65 +242,6 @@ 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 @@ -570,306 +347,6 @@ 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 { @@ -944,34 +421,6 @@ 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`( @@ -1058,7 +507,7 @@ class StreamableHttpServerTransportTest { // New stream is alive val secondChannel = secondResponse.bodyAsChannel() - val firstLine = secondChannel.readLine() + val firstLine = secondChannel.readUTF8Line() firstLine.shouldNotBeNull() secondChannel.isClosedForRead shouldBe false } @@ -1097,7 +546,7 @@ class StreamableHttpServerTransportTest { header("mcp-protocol-version", LATEST_PROTOCOL_VERSION) }.execute { response -> response.status shouldBe HttpStatusCode.OK - response.bodyAsChannel().readLine() + response.bodyAsChannel().readUTF8Line() } // Step 3: Immediately reconnect — the transport should close the stale @@ -1112,7 +561,7 @@ class StreamableHttpServerTransportTest { response.headers[MCP_SESSION_ID_HEADER] shouldBe sessionId val channel = response.bodyAsChannel() - val firstLine = channel.readLine() + val firstLine = channel.readUTF8Line() firstLine.shouldNotBeNull() channel.isClosedForRead shouldBe false } @@ -1155,7 +604,7 @@ class StreamableHttpServerTransportTest { // Verify the stream is alive by reading at least one line (flush event) val channel = response.bodyAsChannel() - val firstLine = channel.readLine() + val firstLine = channel.readUTF8Line() firstLine.shouldNotBeNull() channel.isClosedForRead shouldBe false } @@ -1215,12 +664,3 @@ 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 b58eef2..c263c59 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.62.0" -kotlin = "2.4.20" -ktor = "3.5.2" -mcp-kotlin = "0.15.0" -shadow = "9.6.1" -slf4j = "2.0.19" +anthropic = "2.34.0" +kotlin = "2.3.21" +ktor = "3.3.3" +mcp-kotlin = "0.12.0" +shadow = "9.4.1" +slf4j = "2.0.18" [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 eddabd2..b1b8ef5 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 ad7845b..df6a6ad 100644 --- a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties +++ b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/samples/kotlin-mcp-client/gradlew b/samples/kotlin-mcp-client/gradlew index 249efbb..b9bb139 100755 --- a/samples/kotlin-mcp-client/gradlew +++ b/samples/kotlin-mcp-client/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# gradlew start up script for POSIX generated by Gradle. +# Gradle 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 gradlew +# ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/samples/kotlin-mcp-client/gradlew.bat b/samples/kotlin-mcp-client/gradlew.bat index 8508ef6..aa5f10b 100644 --- a/samples/kotlin-mcp-client/gradlew.bat +++ b/samples/kotlin-mcp-client/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute gradlew +@rem Execute Gradle @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 diff --git a/samples/kotlin-mcp-server/gradle/libs.versions.toml b/samples/kotlin-mcp-server/gradle/libs.versions.toml index d46c2f1..91d7878 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.4.10" -ktor = "3.5.2" -mcp-kotlin = "0.15.0" -slf4j = "2.0.19" -shadow = "9.6.1" +kotlin = "2.3.21" +ktor = "3.3.3" +mcp-kotlin = "0.12.0" +slf4j = "2.0.18" +shadow = "9.4.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 eddabd2..b1b8ef5 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 ad7845b..df6a6ad 100644 --- a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/samples/kotlin-mcp-server/gradlew b/samples/kotlin-mcp-server/gradlew index 249efbb..b9bb139 100755 --- a/samples/kotlin-mcp-server/gradlew +++ b/samples/kotlin-mcp-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# gradlew start up script for POSIX generated by Gradle. +# Gradle 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 gradlew +# ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/samples/kotlin-mcp-server/gradlew.bat b/samples/kotlin-mcp-server/gradlew.bat index 8508ef6..aa5f10b 100644 --- a/samples/kotlin-mcp-server/gradlew.bat +++ b/samples/kotlin-mcp-server/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute gradlew +@rem Execute Gradle @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 diff --git a/samples/kotlinlang-mcp-server/gradle/libs.versions.toml b/samples/kotlinlang-mcp-server/gradle/libs.versions.toml index 0c11d3b..5b7988e 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.4.20" -ktor = "3.5.2" -mcp-kotlin = "0.15.0" +kotlin = "2.3.21" +ktor = "3.3.3" +mcp-kotlin = "0.12.0" serialization = "1.11.0" coroutines = "1.11.0" -logback = "1.6.3" -shadow = "9.6.1" +logback = "1.5.32" +shadow = "9.4.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 eddabd2..b1b8ef5 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 ad7845b..df6a6ad 100644 --- a/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/samples/kotlinlang-mcp-server/gradlew b/samples/kotlinlang-mcp-server/gradlew index 249efbb..b9bb139 100755 --- a/samples/kotlinlang-mcp-server/gradlew +++ b/samples/kotlinlang-mcp-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# gradlew start up script for POSIX generated by Gradle. +# Gradle 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 gradlew +# ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/samples/kotlinlang-mcp-server/gradlew.bat b/samples/kotlinlang-mcp-server/gradlew.bat index 8508ef6..aa5f10b 100644 --- a/samples/kotlinlang-mcp-server/gradlew.bat +++ b/samples/kotlinlang-mcp-server/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute gradlew +@rem Execute Gradle @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 diff --git a/samples/simple-streamable-server/gradle/libs.versions.toml b/samples/simple-streamable-server/gradle/libs.versions.toml index 5f35f74..b607a1c 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.4.20" -ktor = "3.5.2" -junit = "6.1.3" -mcp-kotlin = "0.15.0" -slf4j = "2.0.19" -shadow = "9.6.1" +kotlin = "2.3.21" +ktor = "3.3.3" +junit = "6.1.0" +mcp-kotlin = "0.12.0" +slf4j = "2.0.18" +shadow = "9.4.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 eddabd2..b1b8ef5 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 ad7845b..df6a6ad 100644 --- a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/samples/simple-streamable-server/gradlew b/samples/simple-streamable-server/gradlew index 249efbb..b9bb139 100755 --- a/samples/simple-streamable-server/gradlew +++ b/samples/simple-streamable-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# gradlew start up script for POSIX generated by Gradle. +# Gradle 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 gradlew +# ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/samples/simple-streamable-server/gradlew.bat b/samples/simple-streamable-server/gradlew.bat index 8508ef6..aa5f10b 100644 --- a/samples/simple-streamable-server/gradlew.bat +++ b/samples/simple-streamable-server/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute gradlew +@rem Execute Gradle @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 diff --git a/samples/weather-stdio-server/gradle/libs.versions.toml b/samples/weather-stdio-server/gradle/libs.versions.toml index 9d609a9..daf5fc1 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.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" +kotlin = "2.3.21" +ktor = "3.3.3" +logging = "8.0.03" +mcp-kotlin = "0.12.0" +shadow = "9.4.1" +slf4j = "2.0.18" [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 eddabd2..b1b8ef5 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 ad7845b..df6a6ad 100644 --- a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/samples/weather-stdio-server/gradlew b/samples/weather-stdio-server/gradlew index 249efbb..b9bb139 100755 --- a/samples/weather-stdio-server/gradlew +++ b/samples/weather-stdio-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# gradlew start up script for POSIX generated by Gradle. +# Gradle 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 gradlew +# ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/samples/weather-stdio-server/gradlew.bat b/samples/weather-stdio-server/gradlew.bat index 8508ef6..aa5f10b 100644 --- a/samples/weather-stdio-server/gradlew.bat +++ b/samples/weather-stdio-server/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute gradlew +@rem Execute Gradle @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