diff --git a/.claude/skills/kdoc/SKILL.md b/.claude/skills/kdoc/SKILL.md index 4760293..cfacfc3 100644 --- a/.claude/skills/kdoc/SKILL.md +++ b/.claude/skills/kdoc/SKILL.md @@ -5,40 +5,62 @@ description: "Add KDoc documentation to Kotlin public API. Use whenever the user # KDoc Generator -Add KDoc comments to public Kotlin API declarations. +Add KDoc to all public Kotlin declarations (classes, interfaces, objects, functions, properties, enums, type aliases, annotations — including `@Deprecated`). Read the implementation, not just the signature, to write accurate descriptions. -## What to document +## Tag placement -All public declarations (no explicit `private`/`internal`/`protected`): -- Classes, interfaces, objects, sealed classes/interfaces -- Functions, extension functions -- Properties -- Enum classes and entries -- Type aliases, annotation classes +| Where the declaration lives | Tag | +|---|---| +| Primary-ctor `val` / `var` (public, internal, or protected) | `@property` in class KDoc | +| Primary-ctor `name: Type` (no `val`/`var`) | `@param` in class KDoc | +| Primary-ctor `private val` / `private var` | `@param` in class KDoc | +| Body of class/object/interface/sealed interface | inline `/** */` above the declaration | +| Function parameter | `@param` | +| Non-Unit return type | `@return` | -Include `@Deprecated` elements. +## Style -## Context +- Summary: one concise sentence, third-person verb ("Creates", "Returns", "Represents"). Expand to 2-3 sentences only when genuinely complex. +- **Example block**: mandatory for DSL builders (show full receiver-scope usage); add for complex functions or non-obvious extension functions; skip for one-liners and simple getters. +- KDoc links (`[ClassName]`, `[methodName]`) only where they add navigational value. +- Don't write `@throws` or "suspend" notes. +- Rewrite existing KDoc if incomplete or low quality. -Read the implementation, not just the signature, to write accurate descriptions. Understanding what the code actually does prevents superficial or misleading documentation. +## Examples -## KDoc format - -**Class/interface example:** +**Class with mixed primary-constructor parameters:** ````kotlin /** * Manages active client sessions and their lifecycle. * - * Sessions are created on first connection and cleaned up - * when the transport closes. - * * @property maxSessions upper limit on concurrent sessions - * @property timeout idle timeout before a session is evicted + * @param scope coroutine scope for session lifecycle tasks + * @param secret used internally for session token signing */ +public class SessionManager( + public val maxSessions: Int, // public val → @property + scope: CoroutineScope, // no val/var → @param + private val secret: ByteArray, // private val → @param +) ```` -**Function example:** +**Body and interface properties (inline `/** */`):** + +````kotlin +/** Represents an entity that includes additional metadata. */ +public interface WithMeta { + /** Optional metadata attached to this entity. */ + public val meta: JsonObject? +} + +public class Connection(public val id: String) { + /** Capabilities negotiated during the handshake, or `null` before initialize completes. */ + public val capabilities: ServerCapabilities? get() = ... +} +```` + +**Function with Example block:** ````kotlin /** @@ -46,10 +68,7 @@ Read the implementation, not just the signature, to write accurate descriptions. * * Example: * ```kotlin - * server.addTool( - * name = "echo", - * description = "Echoes input back", - * ) { request -> + * server.addTool(name = "echo", description = "Echoes input back") { request -> * CallToolResult(content = listOf(TextContent("Echo: ${request.arguments}"))) * } * ``` @@ -60,15 +79,3 @@ Read the implementation, not just the signature, to write accurate descriptions. * @return the registered tool definition */ ```` - -## Rules - -- Summary: concise first sentence starting with a third-person verb ("Creates", "Returns", "Represents"). Expand to 2-3 sentences only when genuinely complex -- **@property** in class-level KDoc for all public properties (never as individual KDoc on the property); **@param** for function parameters -- **@return** for non-Unit return types -- **Example block**: add for DSL builders, complex functions, extension functions with non-obvious usage. Skip for trivial one-liners and simple getters -- **DSL builders**: always include an Example showing full usage with the receiver scope — this is critical for discoverability -- KDoc links (`[ClassName]`, `[methodName]`): only where it adds clear navigational value -- No **@throws** — don't document exceptions -- No **suspend** notes — coroutine nature is visible from the signature -- **Existing KDoc**: rewrite if incomplete (missing @param/@return/@property) or low quality \ No newline at end of file diff --git a/.github/workflows/apidocs.yaml b/.github/workflows/apidocs.yaml index 76f4434..67770a7 100644 --- a/.github/workflows/apidocs.yaml +++ b/.github/workflows/apidocs.yaml @@ -24,24 +24,24 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} cache: gradle - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 - name: Generate Dokka Site run: |- ./gradlew knit docs:classes docs:knitCheck dokkaGenerate - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: build/dokka/html diff --git a/.github/workflows/automations.yml b/.github/workflows/automations.yml index 4f88d37..60a9d3b 100644 --- a/.github/workflows/automations.yml +++ b/.github/workflows/automations.yml @@ -27,7 +27,7 @@ jobs: name: Mark/unmark stale PRs and issues runs-on: ubuntu-latest steps: - - uses: actions/stale@v10 + - uses: actions/stale@v11 with: debug-only: ${{ env.DRY_RUN }} exempt-draft-pr: true @@ -66,7 +66,7 @@ jobs: name: Close stale PRs and issues runs-on: ubuntu-latest steps: - - uses: actions/stale@v10 + - uses: actions/stale@v11 with: debug-only: ${{ env.DRY_RUN }} only-labels: 'stale' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 08242f7..ff99581 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,7 +34,7 @@ jobs: include: - os: ubuntu-latest job-name: "Linux Tests" - gradle-tasks: "ktlintCheck build" + gradle-tasks: "build" max-workers: 3 - os: macos-latest @@ -43,17 +43,17 @@ jobs: max-workers: 2 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Set up Node.js if: matrix.os == 'ubuntu-latest' - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed @@ -63,7 +63,7 @@ jobs: run: npm ci - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: validate-wrappers: true add-job-summary: 'always' @@ -104,22 +104,6 @@ jobs: report_paths: '**/test-results/**/TEST-*.xml' truncate_stack_traces: false - - name: Upload coverage reports to Codecov - if: ${{ !cancelled() && matrix.os == 'ubuntu-latest' }} - uses: codecov/codecov-action@v6 - with: - token: ${{ secrets.CODECOV_TOKEN }} - report_type: coverage - directory: ./build/reports/kover - files: report.xml - - - name: Upload test results to Codecov - if: ${{ !cancelled() && matrix.os == 'ubuntu-latest' }} - uses: codecov/codecov-action@v6 - with: - token: ${{ secrets.CODECOV_TOKEN }} - report_type: test_results - - name: Archive Maven Local Repository if: ${{ success() && matrix.os == 'ubuntu-latest' }} @@ -142,16 +126,16 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: validate-wrappers: true add-job-summary: 'always' @@ -184,16 +168,16 @@ jobs: name: "Build Sample: ${{ matrix.sample }}" timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: add-job-summary: 'always' cache-read-only: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5697b61..2e7c58f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,22 +25,22 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.JAVA_VERSION }} - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v4.38.0 with: languages: java-kotlin build-mode: manual - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: validate-wrappers: true cache-read-only: true @@ -52,6 +52,6 @@ jobs: --no-daemon --rerun-tasks --stacktrace - name: Analyze - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v4.38.0 with: category: '/language:java-kotlin' diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 4058d02..b3e3935 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -25,16 +25,16 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -76,16 +76,16 @@ jobs: - sse-retry steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -107,16 +107,16 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -146,16 +146,16 @@ jobs: - auth/cross-app-access-complete-flow steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 6073684..e7e94c3 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -11,7 +11,7 @@ jobs: if: github.actor == 'dependabot[bot]' runs-on: ubuntu-latest steps: - - uses: dependabot/fetch-metadata@v2 + - uses: dependabot/fetch-metadata@v3 id: metadata - if: >- diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml index 6b60e87..f01f9c8 100644 --- a/.github/workflows/gradle-publish.yml +++ b/.github/workflows/gradle-publish.yml @@ -17,18 +17,23 @@ jobs: packages: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: '21' distribution: 'temurin' - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed - - name: Build - run: ./gradlew build --rerun-tasks --max-workers 2 --continue + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6.3.0 + + - name: Check + run: ./gradlew check --no-configuration-cache - name: Publish to Maven Central Portal id: publish diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 2c910fd..7463a9e 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -36,16 +36,16 @@ jobs: name: Build Sample (${{ matrix.sample }}) timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.3.0 with: validate-wrappers: true add-job-summary: 'always' diff --git a/.gitignore b/.gitignore index d9f0fde..805579c 100644 --- a/.gitignore +++ b/.gitignore @@ -56,7 +56,7 @@ node_modules dist ### SWE agents ### -.claude/settings.local.json +.claude/ .junie/ ### Conformance test results ### diff --git a/.idea/detekt.xml b/.idea/detekt.xml deleted file mode 100644 index d89f908..0000000 --- a/.idea/detekt.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/.idea/externalDependencies.xml b/.idea/externalDependencies.xml deleted file mode 100644 index 0a081df..0000000 --- a/.idea/externalDependencies.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/.idea/ktlint-plugin.xml b/.idea/ktlint-plugin.xml deleted file mode 100644 index e8bd90c..0000000 --- a/.idea/ktlint-plugin.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - DISTRACT_FREE - DEFAULT - - \ No newline at end of file diff --git a/README.md b/README.md index 58a2cad..aefadcf 100644 --- a/README.md +++ b/README.md @@ -729,7 +729,7 @@ val client = Client( ) client.setRequestHandler(Method.Defined.SamplingCreateMessage) { request, _ -> - val content = request.messages.lastOrNull()?.content + val content = request.messages.lastOrNull()?.content?.firstOrNull() val prompt = if (content is TextContent) content.text else "your topic" CreateMessageResult( model = "gpt-4o-mini", diff --git a/build.gradle.kts b/build.gradle.kts index 72c5553..2c2dbe3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,5 @@ -import dev.detekt.gradle.extensions.FailOnSeverity - plugins { id("mcp.dokka") - alias(libs.plugins.detekt) alias(libs.plugins.ktlint) alias(libs.plugins.kover) } @@ -23,16 +20,6 @@ dependencies { subprojects { apply(plugin = "org.jlleitschuh.gradle.ktlint") apply(plugin = "org.jetbrains.kotlinx.kover") - - if (name != "conformance-test" && name != "docs") { - apply(plugin = "dev.detekt") - - detekt { - config = files("$rootDir/config/detekt/detekt.yml") - buildUponDefaultConfig = true - failOnSeverity.set(FailOnSeverity.Error) - } - } } dokka { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 84676a4..4a057ce 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -8,6 +8,7 @@ repositories { } dependencies { + implementation(libs.detekt.gradle) implementation(libs.kotlin.gradle) implementation(libs.kotlin.serialization) implementation(libs.kotlinx.atomicfu.gradle) diff --git a/buildSrc/src/main/kotlin/mcp.detekt.gradle.kts b/buildSrc/src/main/kotlin/mcp.detekt.gradle.kts new file mode 100644 index 0000000..3c1419e --- /dev/null +++ b/buildSrc/src/main/kotlin/mcp.detekt.gradle.kts @@ -0,0 +1,23 @@ +import dev.detekt.gradle.Detekt +import dev.detekt.gradle.extensions.FailOnSeverity + +plugins { + id("dev.detekt") +} + +detekt { + config = files("$rootDir/config/detekt/detekt.yml") + buildUponDefaultConfig = false + parallel = true + failOnSeverity = FailOnSeverity.Error +} + +tasks.withType().configureEach { + exclude("**/generated/**", "**/generated-sources/**", "**/build/**") +} + +pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { + tasks.named("detekt").configure { + dependsOn("detektMainJvm") + } +} diff --git a/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts b/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts index a331139..98a0a5e 100644 --- a/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts +++ b/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts @@ -45,11 +45,6 @@ kotlin { events("failed") } systemProperty("kotest.output.ansi", "true") - reports { - junitXml.required.set(true) - junitXml.includeSystemOutLog.set(true) - junitXml.includeSystemErrLog.set(true) - } } } macosArm64() @@ -62,12 +57,3 @@ kotlin { explicitApi = ExplicitApiMode.Strict jvmToolchain(21) } - -tasks.named("detekt").configure { - dependsOn( - "detektMainJvm", - "detektCommonMainSourceSet", - "detektTestJvm", - "detektCommonTestSourceSet", - ) -} diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 9135aae..0000000 --- a/codecov.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Codecov configuration for Mokksy and AI-Mocks -# https://docs.codecov.com/docs/codecov-yaml - -codecov: - require_ci_to_pass: yes - -coverage: - precision: 2 - round: down - status: - project: - default: - target: auto - threshold: 1% - patch: - default: - target: auto - threshold: 1% - -comment: - layout: "header, diff, flags, components" - behavior: default - require_changes: true - -component_management: - default_rules: - statuses: - - type: project - target: auto - individual_components: - - - component_id: kotlin-sdk-core - name: SDK Core - paths: - - kotlin-sdk-core/** - - - component_id: kotlin-sdk-client - name: SDK Client - paths: - - kotlin-sdk-client/** - - - component_id: kotlin-sdk-server - name: SDK Server - paths: - - kotlin-sdk-server/** - - - component_id: kotlin-sdk-testing - name: SDK Testing - paths: - - kotlin-sdk-testing/** - diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 4d44734..a82b404 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -3,41 +3,839 @@ config: warningsAsErrors: false checkExhaustiveness: false -complexity: - LargeClass: - excludes: &testFolders - - '**/test/**' - - '**/commonTest/**' - - '**/jvmTest/**' - - '**/jsTest/**' - - '**/iosTest/**' - LongMethod: - excludes: *testFolders +processors: + active: false -coroutines: - InjectDispatcher: - excludes: *testFolders - -empty-blocks: - EmptyFunctionBlock: - excludes: *testFolders +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'IssuesReport' + - 'FileBasedIssuesReport' + # - 'LiteIssuesReport' comments: - UndocumentedPublicClass: - active: true - excludes: &testAndGeneratedFolders - - '**/test/**' - - '**/commonTest/**' - - '**/jvmTest/**' - - '**/jsTest/**' - - '**/iosTest/**' - - '**/generated-sources/**' - ignoreDefaultCompanionObject: true - UndocumentedPublicFunction: - active: true - excludes: *testAndGeneratedFolders - UndocumentedPublicProperty: - active: true - excludes: *testAndGeneratedFolders - searchProtectedProperty: false - ignoreEnumEntries: true + active: true + AbsentOrWrongFileLicense: + active: false + licenseTemplateIsRegex: false + licenseTemplate: '' + DeprecatedBlockTag: + active: true + DocumentationOverPrivateFunction: + active: false + DocumentationOverPrivateProperty: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + KDocReferencesNonPublicProperty: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + OutdatedDocumentation: + active: false + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + UndocumentedPublicClass: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + searchInProtectedClass: false + ignoreDefaultCompanionObject: true + UndocumentedPublicFunction: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedFunction: false + UndocumentedPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedProperty: false + ignoreEnumEntries: false + +complexity: + active: true + CognitiveComplexMethod: + active: false + allowedComplexity: 20 + ComplexCondition: + active: true + allowedConditions: 4 + ComplexInterface: + active: false + allowedDefinitions: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ignoreOverloaded: false + CyclomaticComplexMethod: + active: false + allowedComplexity: 15 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + ignoreLocalFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false + ignoredLabels: [] + LargeClass: + active: false + allowedLines: 700 + LongMethod: + active: false + allowedLines: 110 + LongParameterList: + active: false + allowedFunctionParameters: 8 + allowedConstructorParameters: 8 + ignoreDefaultParameters: false + ignoreDataClasses: true + ignoreAnnotatedParameter: [] + MethodOverloading: + active: false + allowedOverloads: 6 + NamedArguments: + active: false + allowedArguments: 3 + ignoreMethods: [] + ignoreArgumentsMatchingNames: false + NestedBlockDepth: + active: true + allowedDepth: 4 + NestedScopeFunctions: + active: false + allowedDepth: 1 + functions: + - 'kotlin.apply' + - 'kotlin.run' + - 'kotlin.with' + - 'kotlin.let' + - 'kotlin.also' + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + allowedDuplications: 3 + ignoreAnnotation: true + allowedWithLengthLessThan: 5 + ignoreStringsRegex: '$^' + TooManyFunctions: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + allowedFunctionsPerFile: 20 + allowedFunctionsPerClass: 20 + allowedFunctionsPerInterface: 20 + allowedFunctionsPerObject: 20 + allowedFunctionsPerEnum: 11 + ignoreDeprecated: true + ignorePrivate: true + ignoreInternal: true + ignoreOverridden: false + ignoreAnnotatedFunctions: [] + +coroutines: + active: true + CoroutineLaunchedInTestWithoutRunTest: + active: false + GlobalCoroutineUsage: + active: true + InjectDispatcher: + active: false + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunInFinallySection: + active: false + SuspendFunSwallowedCancellation: + active: false + SuspendFunWithCoroutineScopeReceiver: + active: false + aliases: ['SuspendFunctionOnCoroutineScope'] + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKotlinFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ErrorUsageWithThrowable: + active: false + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + NotImplementedDeclaration: + active: true + ObjectExtendsThrowable: + active: true + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'Exception' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: false + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: false + allowedPattern: '^(is|has|are)' + ClassNaming: + active: true + aliases: ['ClassName'] + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + aliases: ['EnumEntryName'] + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [] + FunctionNameMaxLength: + active: false + aliases: ['FunctionMaxNameLength'] + maximumFunctionNameLength: 30 + FunctionNameMinLength: + active: false + aliases: ['FunctionMinNameLength'] + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + aliases: ['FunctionName'] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + functionPattern: '[a-z][a-zA-Z0-9]*' + excludeClassPattern: '$^' + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + InvalidPackageDeclaration: + active: true + aliases: ['PackageDirectoryMismatch'] + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: false + mustBeFirst: true + multiplatformTargets: + - 'ios' + - 'android' + - 'js' + - 'jvm' + - 'native' + - 'iosArm64' + - 'iosX64' + - 'macosX64' + - 'mingwX64' + - 'linuxX64' + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: true + ObjectPropertyNaming: + active: true + aliases: ['ObjectPropertyName'] + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + aliases: ['PackageName'] + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: false + aliases: ['PropertyName'] + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: false + allowedOperations: 3 + ForEachOnRange: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + SpreadOperator: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnnecessaryInitOnArray: + active: false + UnnecessaryPartOfBinaryExpression: + active: true + UnnecessaryTemporaryInstantiation: + active: true + UnnecessaryTypeCasting: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + forbiddenTypePatterns: + - 'kotlin.String' + CastNullableToNonNullableType: + active: true + ignorePlatformTypes: true + CastToNullableType: + active: false + CharArrayToStringCall: + active: false + Deprecation: + active: false + aliases: ['DEPRECATION'] + DontDowncastCollectionTypes: + active: false + DoubleMutabilityForCollection: + active: true + aliases: ['DoubleMutability'] + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + ElseCaseInsteadOfExhaustiveWhen: + active: false + ignoredSubjectTypes: [] + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: false + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + restrictToConfig: true + returnValueAnnotations: + - 'CheckResult' + - '*.CheckResult' + - 'CheckReturnValue' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - 'CanIgnoreReturnValue' + - '*.CanIgnoreReturnValue' + returnValueTypes: + - 'kotlin.Function*' + - 'kotlin.sequences.Sequence' + - 'kotlinx.coroutines.flow.*Flow' + - 'java.util.stream.*Stream' + ignoreFunctionCall: [] + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: false + ignoreAnnotated: ['Test'] + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: true + excludes: ['**/*.kts'] + MissingSuperCall: + active: false + mustInvokeSuperAnnotations: + - 'androidx.annotation.CallSuper' + - 'javax.annotation.OverridingMethodsMustInvokeSuper' + MissingUseCall: + active: false + ignoreClass: + - 'java.io.ByteArrayInputStream' + - 'java.io.ByteArrayOutputStream' + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + PropertyUsedBeforeDeclaration: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnamedParameterUse: + active: false + allowAdjacentDifferentTypeParams: true + allowSingleParamUse: true + ignoreArgumentsMatchingNames: true + ignoreFunctionCall: [] + UnnecessaryNotNullCheck: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnsafeCast: + active: true + aliases: ['UNCHECKED_CAST'] + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AbstractClassCanBeConcreteClass: + active: true + AbstractClassCanBeInterface: + active: true + AlsoCouldBeApply: + active: false + BracesOnIfStatements: + active: false + singleLine: 'never' + multiLine: 'always' + BracesOnWhenStatements: + active: false + singleLine: 'necessary' + multiLine: 'consistent' + CanBeNonNullable: + active: false + CascadingCallWrapping: + active: false + includeElvis: true + ClassOrdering: + active: false + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: + - 'to' + allowOperators: false + DataClassShouldBeImmutable: + active: false + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + DoubleNegativeExpression: + active: false + DoubleNegativeLambda: + active: false + negativeFunctions: + - reason: 'Use `takeIf` instead.' + value: 'takeUnless' + - reason: 'Use `all` instead.' + value: 'none' + negativeFunctionNameParts: + - 'not' + - 'non' + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaMultipleParameters: + active: true + ExplicitItLambdaParameter: + active: false + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenAnnotation: + active: true + annotations: + - reason: 'it is a java annotation. Use `Suppress` instead.' + value: 'java.lang.SuppressWarnings' + - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' + value: 'java.lang.Deprecated' + - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' + value: 'java.lang.annotation.Documented' + - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' + value: 'java.lang.annotation.Target' + - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' + value: 'java.lang.annotation.Retention' + - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' + value: 'java.lang.annotation.Repeatable' + ForbiddenComment: + active: true + comments: + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' + value: 'FIXME:' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' + value: 'STOPSHIP:' + - reason: 'Forbidden TODO todo marker in comment, please do the changes.' + value: 'TODO:' + allowedPatterns: '' + ForbiddenImport: + active: false + forbiddenImports: [] + allowedImports: [] + ForbiddenMethodCall: + active: true + methods: + - reason: 'print does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.print' + - reason: 'println does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.println' + - reason: 'using `BigDecimal(Double)` can result in unexpected floating point precision behavior. Use `BigDecimal.valueOf(Double)` or `String.toBigDecimalOrNull()` instead.' + value: 'java.math.BigDecimal.(kotlin.Double)' + - reason: 'using `BigDecimal(String)` can result in a `NumberFormatException`. Use `String.toBigDecimalOrNull()`' + value: 'java.math.BigDecimal.(kotlin.String)' + - reason: 'It is marked as obsolete. Use `kotlin.time.measureTime` instead.' + value: 'kotlin.system.measureTimeMillis' + ForbiddenNamedParam: + active: false + methods: [] + ForbiddenOptIn: + active: false + markerClasses: [] + ForbiddenSuppress: + active: false + rules: [] + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: [] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts'] + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: true + ignoreLocalVariableDeclaration: true + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + MandatoryBracesLoops: + active: false + MaxChainedCallsOnSameLine: + active: false + maxChainedCalls: 5 + MaxLineLength: + active: false + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + excludeRawStrings: true + MayBeConstant: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + MultilineRawStringIndentation: + active: false + indentSize: 4 + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + NullableBooleanCheck: + active: false + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + ProtectedMemberInFinalClass: + active: true + RangeUntilInsteadOfRangeTo: + active: true + RedundantConstructorKeyword: + active: false + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifier: + active: false + ReturnCount: + active: false + max: 2 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingAfterPackageAndImports: + active: false + StringShouldBeRawString: + active: false + maxEscapedCharacterCount: 2 + ignoredCharacters: [] + ThrowsCount: + active: false + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: false + TrimMultilineRawString: + active: false + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + UnderscoresInNumericLiterals: + active: false + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAny: + active: false + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: false + UnnecessaryBracesAroundTrailingLambda: + active: false + UnnecessaryFilter: + active: true + UnnecessaryFullyQualifiedName: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + allowForUnclearPrecedence: false + UnnecessaryReversed: + active: false + UnusedImport: + active: false + additionalOperatorSet: [] + UnusedParameter: + active: true + aliases: ['UNUSED_PARAMETER', 'unused'] + allowedNames: 'ignored|expected' + UnusedPrivateClass: + active: true + aliases: ['unused'] + UnusedPrivateFunction: + active: true + aliases: ['unused'] + allowedNames: '' + UnusedPrivateProperty: + active: true + aliases: ['unused'] + allowedNames: 'ignored|expected|serialVersionUID' + UnusedVariable: + active: true + aliases: ['UNUSED_VARIABLE', 'unused'] + allowedNames: 'ignored|_' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: false + allowVars: false + UseEmptyCounterpart: + active: false + UseIfEmptyOrIfBlank: + active: false + UseIfInsteadOfWhen: + active: false + ignoreWhenContainingVariableDeclaration: false + UseIsNullOrEmpty: + active: true + UseLet: + active: false + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UseSumOfInsteadOfFlatMapSize: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + aliases: ['CanBeVal'] + ignoreLateinitVar: false + WildcardImport: + active: false + excludeImports: + - 'java.util.*' diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt index 03d5ee3..8a18957 100644 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt +++ b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt @@ -12,8 +12,6 @@ fun main() { val port = System.getenv("MCP_PORT")?.toIntOrNull() ?: 3001 embeddedServer(CIO, port = port) { mcpStreamableHttp( - enableDnsRebindingProtection = true, - allowedHosts = listOf("localhost", "127.0.0.1", "localhost:$port", "127.0.0.1:$port"), eventStore = InMemoryEventStore(), ) { createConformanceServer() diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTools.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTools.kt index 54d5c8c..c81c98c 100644 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTools.kt +++ b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTools.kt @@ -45,7 +45,6 @@ internal const val WAV_BASE64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABA private val logger = KotlinLogging.logger {} -@Suppress("LongMethod") fun Server.registerConformanceTools() { // 1. Simple text addTool( @@ -176,11 +175,17 @@ fun Server.registerConformanceTools() { CreateMessageRequest( CreateMessageRequestParams( maxTokens = 10000, - messages = listOf(SamplingMessage(Role.User, TextContent(prompt))), + messages = listOf(SamplingMessage(Role.User, listOf(TextContent(prompt)))), ), ), ) - CallToolResult(listOf(TextContent(result.content.toString()))) + val sampledText = result.content.joinToString("\n") { content -> + when (content) { + is TextContent -> content.text + else -> "Non-text sampling content: ${content::class.simpleName}" + } + } + CallToolResult(listOf(TextContent(sampledText))) } // 9. Elicitation diff --git a/context7.json b/context7.json index a28c0fc..5fde250 100644 --- a/context7.json +++ b/context7.json @@ -12,7 +12,6 @@ "CONTRIBUTING.md", "LICENSE", "SECURITY.md", - "codecov.yml", "gradlew", "gradlew.bat", ".editorconfig", diff --git a/gradle.properties b/gradle.properties index ca4a217..a5655c3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,4 +8,4 @@ kotlin.daemon.jvmargs=-Xmx4G kotlin.native.ignoreDisabledTargets=true group=io.modelcontextprotocol -version=0.11.1 +version=0.15.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2836ef..a8d034f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,34 +1,33 @@ [versions] # plugins version -atomicfu = "0.32.1" -binaryCompatibilityValidatorPlugin = "0.18.1" -detekt = "2.0.0-alpha.2" +atomicfu = "0.33.0" +binaryCompatibilityValidatorPlugin = "0.18.2" +detekt = "2.0.0-alpha.6" dokka = "2.2.0" -kotlin = "2.3.10" -kover = "0.9.8" +kotlin = "2.4.10" +kover = "0.9.9" ktlint = "14.2.0" knit = "0.5.1" -mavenPublish = "0.36.0" -netty = "4.2.12.Final" -openapi-generator = "7.21.0" +mavenPublish = "0.37.0" +netty = "4.2.18.Final" # libraries version awaitility = "4.3.0" -collections-immutable = "0.4.0" -coroutines = "1.10.2" -kotest = "6.1.11" -kotlinx-io = "0.9.0" -ktor = "3.3.3" -logging = "8.0.01" -mockk = "1.14.9" -mokksy = "0.8.1" -serialization = "1.10.0" -slf4j = "2.0.17" -logback = "1.5.32" -junit = "5.14.3" +collections-immutable = "0.5.2" +coroutines = "1.11.0" +kotest = "6.2.5" +kotlinx-io = "0.9.1" +ktor = "3.5.2" +logging = "8.0.4" +mockk = "1.14.11" +serialization = "1.11.0" +slf4j = "2.0.19" +logback = "1.6.3" +junit = "5.14.4" [libraries] # Plugins +detekt-gradle = { module = "dev.detekt:detekt-gradle-plugin", version.ref = "detekt" } dokka-gradle = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } @@ -65,7 +64,6 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } -mokksy = { group = "dev.mokksy", name = "mokksy", version.ref = "mokksy" } netty-bom = { group = "io.netty", name = "netty-bom", version.ref = "netty" } slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } @@ -76,9 +74,7 @@ ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = " ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" } [plugins] -detekt = { id = "dev.detekt", version.ref = "detekt" } kotlinx-binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidatorPlugin" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } knit = { id = "org.jetbrains.kotlinx.knit", version.ref = "knit" } -openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d997cfc..eddabd2 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c61a118..ad7845b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 739907d..249efbb 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/gradlew.bat b/gradlew.bat index c4bdd3a..8508ef6 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,93 +1,82 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/integration-test/build.gradle.kts b/integration-test/build.gradle.kts index 30afe9e..7a3a4a3 100644 --- a/integration-test/build.gradle.kts +++ b/integration-test/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("mcp.multiplatform") + id("mcp.detekt") } kotlin { diff --git a/integration-test/detekt-baseline-test.xml b/integration-test/detekt-baseline-test.xml deleted file mode 100644 index 2df611a..0000000 --- a/integration-test/detekt-baseline-test.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - AbstractClassCanBeConcreteClass:AbstractPromptIntegrationTest.kt:AbstractPromptIntegrationTest$AbstractPromptIntegrationTest - AbstractClassCanBeConcreteClass:AbstractResourceIntegrationTest.kt:AbstractResourceIntegrationTest$AbstractResourceIntegrationTest - AbstractClassCanBeConcreteClass:AbstractToolIntegrationTest.kt:AbstractToolIntegrationTest$AbstractToolIntegrationTest - AbstractClassCanBeConcreteClass:BaseTransportTest.kt:BaseTransportTest$BaseTransportTest - CyclomaticComplexMethod:AbstractToolIntegrationTest.kt:AbstractToolIntegrationTest$private fun setupCalculatorTool - ForbiddenComment:StdioClientTransportTest.kt:StdioClientTransportTest$// TODO: fix running on windows - MatchingDeclarationName:PromptIntegrationTestSse.kt:SchemaPromptIntegrationTestSse : AbstractPromptIntegrationTest - ThrowsCount:AbstractPromptIntegrationTest.kt:AbstractPromptIntegrationTest$override fun configureServer - UseCheckOrError:ClientTest.kt:ClientTest.<no name provided>$throw IllegalStateException("Test error") - UseOrEmpty:AbstractToolIntegrationTest.kt:AbstractToolIntegrationTest$(request.params.arguments?.get("tags") as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList() - UseRequire:AbstractPromptIntegrationTest.kt:AbstractPromptIntegrationTest$throw IllegalArgumentException("Missing required argument: $argName") - - 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 d0acda5..099329d 100644 --- a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt @@ -14,13 +14,17 @@ import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult +import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification +import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotificationParams import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION @@ -46,8 +50,10 @@ import io.modelcontextprotocol.kotlin.sdk.types.Tool import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UrlElicitationRequiredException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.joinAll @@ -67,6 +73,7 @@ import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds class ClientTest { @Test @@ -106,7 +113,7 @@ class ClientTest { ), options = ClientOptions( capabilities = ClientCapabilities( - sampling = EmptyJsonObject, + sampling = ClientCapabilities.sampling, ), ), ) @@ -152,7 +159,7 @@ class ClientTest { ), options = ClientOptions( capabilities = ClientCapabilities( - sampling = EmptyJsonObject, + sampling = ClientCapabilities.sampling, ), ), ) @@ -339,7 +346,7 @@ class ClientTest { val client = Client( clientInfo = Implementation(name = "test client", version = "1.0"), options = ClientOptions( - capabilities = ClientCapabilities(sampling = EmptyJsonObject), + capabilities = ClientCapabilities(sampling = ClientCapabilities.sampling), ), ) @@ -539,6 +546,15 @@ class ClientTest { val serverSession = serverSessionResult.await() + // Register the tap after createSession so it chains onto the session's subscription instead + // of pre-empting the handshake. + val cancelledOnWire = CompletableDeferred() + serverTransport.onMessage { message -> + if (message is JSONRPCNotification && message.method == Method.Defined.NotificationsCancelled.value) { + cancelledOnWire.complete(Unit) + } + } + serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> // Simulate delay def.complete(Unit) @@ -564,6 +580,7 @@ class ClientTest { def.await() runCatching { job.cancel("Cancelled by test") } defCancel.await() + cancelledOnWire.await() defTimeOut.await() } @@ -602,17 +619,9 @@ class ClientTest { val serverSession = serverSessionResult.await() serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> - // Simulate a delayed response - // Wait ~100ms unless canceled - try { - withTimeout(100L) { - // Just delay here, if timeout is 0 on the client side, this won't return in time - delay(100) - } - } catch (_: Exception) { - // If aborted, just rethrow or return early - } - ListResourcesResult(resources = emptyList()) + // Never respond: the handler runs on a real dispatcher while the 1 ms timeout runs on the + // virtual test clock, so a real-time delay could race it. Parking keeps the test deterministic. + awaitCancellation() } // Request with 1 msec timeout should fail immediately @@ -633,18 +642,16 @@ class ClientTest { ), options = ClientOptions( capabilities = ClientCapabilities( - sampling = EmptyJsonObject, + sampling = ClientCapabilities.sampling, ), ), ) client.setRequestHandler(Method.Defined.SamplingCreateMessage) { _, _ -> CreateMessageResult( - model = "test-model", role = Role.Assistant, - content = TextContent( - text = "Test response", - ), + content = TextContent(text = "Test response"), + model = "test-model", ) } @@ -670,7 +677,7 @@ class ClientTest { val client = Client( clientInfo = Implementation(name = "test client", version = "1.0"), options = ClientOptions( - capabilities = ClientCapabilities(sampling = EmptyJsonObject), + capabilities = ClientCapabilities(sampling = ClientCapabilities.sampling), ), ) @@ -893,8 +900,8 @@ class ClientTest { ), ) - // Track notifications - var rootListChangedNotificationReceived = false + // The handler runs on Dispatchers.Default after concurrent dispatch is enabled; await the deferred. + val rootListChangedReceived = CompletableDeferred() val serverSessionResult = CompletableDeferred() @@ -913,16 +920,13 @@ class ClientTest { serverSession.setNotificationHandler( Method.Defined.NotificationsRootsListChanged, ) { - rootListChangedNotificationReceived = true + rootListChangedReceived.complete(Unit) CompletableDeferred(Unit) } client.sendRootsListChanged() - assertTrue( - rootListChangedNotificationReceived, - "Notification should be sent when sendRootsListChanged is called", - ) + withTimeout(5.seconds) { rootListChangedReceived.await() } } @Test @@ -1060,7 +1064,7 @@ class ClientTest { Implementation(name = "test client", version = "1.0"), ClientOptions( capabilities = ClientCapabilities( - elicitation = EmptyJsonObject, + elicitation = ClientCapabilities.Elicitation(), ), ), ) @@ -1120,6 +1124,103 @@ class ClientTest { assertEquals(elicitationResultContent, result.content) } + @Test + fun `should reject accepted elicitation content that does not match requested schema`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + required = listOf("name"), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = buildJsonObject { put("name", 42) }) + } + + val exception = assertFailsWith { + serverSession.createElicitation(message = "mismatch", requestedSchema = requestedSchema) + } + assertTrue( + exception.message.orEmpty() + .startsWith("Elicitation response content does not match requested schema: "), + "Unexpected message: ${exception.message}", + ) + assertTrue("'name' must be string" in exception.message.orEmpty(), "Unexpected message: ${exception.message}") + + client.close() + } + + @Test + fun `should reject accepted elicitation without content when schema has required properties`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + required = listOf("name"), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = null) + } + + val exception = assertFailsWith { + serverSession.createElicitation(message = "no content", requestedSchema = requestedSchema) + } + assertTrue( + "must have required property 'name'" in exception.message.orEmpty(), + "Unexpected message: ${exception.message}", + ) + + client.close() + } + + @Test + fun `should accept elicitation without content when schema has no required properties`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = null) + } + + val result = serverSession.createElicitation(message = "no required", requestedSchema = requestedSchema) + + assertEquals(ElicitResult.Action.Accept, result.action) + assertNull(result.content) + + client.close() + } + + @Test + fun `should pass through declined elicitation without validation`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + required = listOf("name"), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Decline) + } + + val result = serverSession.createElicitation(message = "decline", requestedSchema = requestedSchema) + + assertEquals(ElicitResult.Action.Decline, result.action) + assertNull(result.content) + + client.close() + } + + @Test + fun `should pass through cancelled elicitation without validation`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + required = listOf("name"), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Cancel) + } + + val result = serverSession.createElicitation(message = "cancel", requestedSchema = requestedSchema) + + assertEquals(ElicitResult.Action.Cancel, result.action) + assertNull(result.content) + + client.close() + } + @Test fun `should apply elicitation defaults for missing fields in empty content`() = runTest { val schema = defaultsTestSchema() @@ -1253,6 +1354,189 @@ class ClientTest { client.close() } + // ── URL-mode elicitation (SEP-1036) ───────────────────────────────── + + @Test + fun `should handle URL mode elicitation end-to-end`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), + ), + ), + ) + + val elicitationId = "550e8400-e29b-41d4-a716-446655440000" + val url = "https://oauth.example.com/authorize" + + client.setElicitationHandler { request -> + val params = assertIs(request.params) + assertEquals(elicitationId, params.elicitationId) + assertEquals(url, params.url) + ElicitResult(action = ElicitResult.Action.Accept) + } + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + val server = Server( + serverInfo = Implementation(name = "test server", version = "1.0"), + options = ServerOptions(capabilities = ServerCapabilities()), + ) + + val serverSessionResult = CompletableDeferred() + listOf( + launch { client.connect(clientTransport) }, + launch { serverSessionResult.complete(server.createSession(serverTransport)) }, + ).joinAll() + val serverSession = serverSessionResult.await() + + val result = serverSession.createElicitation( + message = "Authorize access to continue", + elicitationId = elicitationId, + url = url, + ) + + assertEquals(ElicitResult.Action.Accept, result.action) + assertNull(result.content) + + client.close() + } + + @Test + fun `should reject URL mode elicitation when client supports only form mode`() = runTest { + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept) + } + + val exception = assertFailsWith { + serverSession.createElicitation( + message = "Authorize", + elicitationId = "id-1", + url = "https://example.com/auth", + ) + } + assertTrue(exception.message!!.contains("elicitation.url")) + + client.close() + } + + @Test + fun `should deliver elicitation complete notification to client`() = runTest { + val received = CompletableDeferred() + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), + ), + ), + ) + client.setElicitationCompleteHandler { received.complete(it) } + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + val server = Server( + serverInfo = Implementation(name = "test server", version = "1.0"), + options = ServerOptions(capabilities = ServerCapabilities()), + ) + + val serverSessionResult = CompletableDeferred() + listOf( + launch { client.connect(clientTransport) }, + launch { serverSessionResult.complete(server.createSession(serverTransport)) }, + ).joinAll() + val serverSession = serverSessionResult.await() + + val elicitationId = "complete-id-1" + serverSession.sendElicitationComplete( + ElicitationCompleteNotification(ElicitationCompleteNotificationParams(elicitationId = elicitationId)), + ) + + val notification = received.await() + assertEquals(elicitationId, notification.params.elicitationId) + + client.close() + } + + @Test + fun `should reject elicitation complete when client supports only form mode`() = runTest { + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept) + } + + val exception = assertFailsWith { + serverSession.sendElicitationComplete( + ElicitationCompleteNotification(ElicitationCompleteNotificationParams(elicitationId = "id-1")), + ) + } + assertTrue(exception.message!!.contains("elicitation.url")) + + client.close() + } + + @Test + fun `setElicitationCompleteHandler should require url capability`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + elicitation = ClientCapabilities.Elicitation(), + ), + ), + ) + + assertFailsWith { + client.setElicitationCompleteHandler { } + } + } + + @Test + fun `should surface URL elicitation required error to client as typed exception`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), + ), + ), + ) + + val elicitationId = "auth-required-1" + val url = "https://oauth.example.com/authorize" + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + val server = Server( + serverInfo = Implementation(name = "test server", version = "1.0"), + options = ServerOptions(capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(true))), + ) + server.addTool("needs-auth", "Requires URL elicitation") { + throw UrlElicitationRequiredException( + listOf( + ElicitRequestURLParams( + message = "Authorize to continue", + elicitationId = elicitationId, + url = url, + ), + ), + ) + } + + val serverSessionResult = CompletableDeferred() + listOf( + launch { client.connect(clientTransport) }, + launch { serverSessionResult.complete(server.createSession(serverTransport)) }, + ).joinAll() + serverSessionResult.await() + + val exception = assertFailsWith { + client.callTool(name = "needs-auth", arguments = emptyMap()) + } + val elicitation = exception.elicitations.single() + assertEquals(elicitationId, elicitation.elicitationId) + assertEquals(url, elicitation.url) + + client.close() + } + private fun defaultsTestSchema(): ElicitRequestParams.RequestedSchema = ElicitRequestParams.RequestedSchema( properties = mapOf( "name" to StringSchema(description = "User name", default = "John Doe"), @@ -1272,7 +1556,7 @@ class ClientTest { ): Pair = kotlinx.coroutines.coroutineScope { val client = Client( Implementation(name = "test client", version = "1.0"), - ClientOptions(capabilities = ClientCapabilities(elicitation = EmptyJsonObject)), + ClientOptions(capabilities = ClientCapabilities(elicitation = ClientCapabilities.Elicitation())), ) client.setElicitationHandler(handler) diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt index 585af3f..fc3c1d3 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt @@ -7,9 +7,13 @@ import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.test.utils.createSleepyProcessBuilder import io.modelcontextprotocol.kotlin.test.utils.createTeeProcessBuilder import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.io.Buffer +import kotlinx.io.RawSource import kotlinx.io.asSink import kotlinx.io.asSource import kotlinx.io.buffered @@ -18,10 +22,12 @@ import org.junit.jupiter.api.Timeout import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.condition.DisabledOnOs import org.junit.jupiter.api.condition.OS +import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail import kotlin.time.Duration.Companion.seconds @@ -109,6 +115,43 @@ class StdioClientTransportTest : BaseTransportTest() { } } + @OptIn(ExperimentalAtomicApi::class) + @Test + fun `should close cleanly while stdin read is blocked`(): Unit = runBlocking(Dispatchers.IO) { + val input = BlockingRawSource() + + val transport = StdioClientTransport( + input = input.buffered(), + output = Buffer(), + ) + + val didClose = AtomicBoolean(false) + transport.onClose { didClose.store(true) } + transport.onError { error -> + fail("Unexpected error while closing transport: $error") + } + + transport.start() + + eventually(2.seconds) { + assertTrue(input.readStarted, "Transport should start reading stdin before close") + } + + val closeJob = async { + transport.close() + } + + val closed = withTimeoutOrNull(1.seconds) { + closeJob.await() + } + + input.close() + closeJob.await() + + assertNotNull(closed, "Transport.close() should not wait for stdin to produce data or EOF") + assertTrue(didClose.load(), "Transport should be closed after close() call") + } + @Test fun `should read messages`() = runTest { val processBuilder = createTeeProcessBuilder() @@ -147,4 +190,22 @@ class StdioClientTransportTest : BaseTransportTest() { process.waitFor() process.destroyForcibly() } + + private class BlockingRawSource : RawSource { + private val closed = CountDownLatch(1) + + @Volatile + var readStarted: Boolean = false + private set + + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + readStarted = true + closed.await() + return -1L + } + + override fun close() { + closed.countDown() + } + } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/ProtocolConcurrencyIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/ProtocolConcurrencyIntegrationTest.kt new file mode 100644 index 0000000..d8d64ec --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/ProtocolConcurrencyIntegrationTest.kt @@ -0,0 +1,306 @@ +package io.modelcontextprotocol.kotlin.sdk.integration + +import io.kotest.matchers.shouldBe +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.ServerSession +import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport +import io.modelcontextprotocol.kotlin.sdk.shared.currentRequestHandlerExtra +import io.modelcontextprotocol.kotlin.sdk.testing.ChannelTransport +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest +import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest +import io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult +import io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest +import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import io.modelcontextprotocol.kotlin.sdk.types.Role +import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.toJSON +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.time.Duration.Companion.seconds + +/** + * End-to-end tests for concurrent inbound dispatch enabled after the MCP handshake. + * + * Handlers run on a real [kotlinx.coroutines.Dispatchers.Default], so these use [runBlocking] with + * explicit [withTimeout] guards. A virtual test clock would false-fire timeouts against cross-thread + * real dispatchers. + */ +@OptIn(ExperimentalMcpApi::class) +class ProtocolConcurrencyIntegrationTest { + + private fun rootsClient(): Client = Client( + clientInfo = Implementation(name = "test client", version = "1.0"), + options = ClientOptions( + capabilities = ClientCapabilities(roots = ClientCapabilities.Roots(listChanged = null)), + ), + ) + + private fun toolsServer(): Server = Server( + serverInfo = Implementation(name = "test server", version = "1.0"), + options = ServerOptions( + capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = null)), + ), + ) + + // ChannelTransport delivers inbound messages as soon as its read loop starts, which is before + // start() marks it operational. Creating the session first keeps the client's initialize from + // landing on a transport that would reject the session's response. + private suspend fun connect( + client: Client, + clientTransport: ChannelTransport, + server: Server, + serverTransport: ChannelTransport, + ): ServerSession { + val serverSession = server.createSession(serverTransport) + client.connect(clientTransport) + return serverSession + } + + // The motivating deadlock over real serial read loops: a server tool handler makes a + // server-to-client request (roots/list) while still handling the client's tools/call. + @Test + fun `server tool handler can call roots list mid-handling without deadlock`() = runBlocking { + val client = rootsClient() + val server = toolsServer() + val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() + + val serverSession = connect(client, clientTransport, server, serverTransport) + + // Register the tools/call handler directly on the session so it can call back to the client + // mid-handling. Before concurrent dispatch this would deadlock: the server read loop is + // blocked by the tool handler and can never deliver the roots/list response. + serverSession.setRequestHandler(Method.Defined.ToolsCall) { _, _ -> + val roots = serverSession.request(ListRootsRequest()) + CallToolResult(content = listOf(TextContent(text = "roots=${roots.roots.size}"))) + } + + val result = withTimeout(10.seconds) { + client.callTool(CallToolRequest(CallToolRequestParams("needs-roots"))) + } + val text = (result as CallToolResult).content.filterIsInstance().single().text + text shouldBe "roots=0" + + client.close() + server.close() + } + + // Client-side mirror: a client sampling handler makes a client-to-server request (tools/list) + // while still handling the server's sampling/createMessage. + @Test + fun `client sampling handler can call the server mid-handling without deadlock`() = runBlocking { + val client = Client( + clientInfo = Implementation(name = "test client", version = "1.0"), + options = ClientOptions( + capabilities = ClientCapabilities(sampling = ClientCapabilities.Sampling()), + ), + ) + val server = toolsServer() + val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() + + client.setRequestHandler(Method.Defined.SamplingCreateMessage) { _, _ -> + val tools = client.request(ListToolsRequest()) + CreateMessageResult( + role = Role.Assistant, + content = TextContent(text = "tools=${tools.tools.size}"), + model = "test-model", + ) + } + + val serverSession = connect(client, clientTransport, server, serverTransport) + + val result = withTimeout(10.seconds) { + serverSession.request( + CreateMessageRequest( + CreateMessageRequestParams( + maxTokens = 100, + messages = listOf(SamplingMessage(role = Role.User, content = TextContent(text = "hi"))), + ), + ), + ) + } + val text = result.content.filterIsInstance().single().text + text shouldBe "tools=0" + + client.close() + server.close() + } + + // Nested-cancel cascade: cancelling the client's tools/call must reach the server tool handler + // over the wire and cascade into its nested roots/list request, cancelling the parked + // client-side roots handler. No tools/call response is ever sent. + @Test + fun `cancelling a tool call cascades into its nested roots list request`() = runBlocking { + val client = rootsClient() + val server = toolsServer() + val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() + + val capturedToolCallId = AtomicReference(null) + val toolHandlerCancelled = CompletableDeferred() + val rootsHandlerEntered = CompletableDeferred() + val rootsHandlerCancelled = CompletableDeferred() + val parkForever = CompletableDeferred() + val unexpectedToolCallResponse = CompletableDeferred() + + // Tap the server->client direction (client transport inbound) to prove no tools/call + // response is ever delivered once the call is cancelled. + clientTransport.onMessage { message -> + if (message is JSONRPCResponse && message.id == capturedToolCallId.get()) { + unexpectedToolCallResponse.complete(Unit) + } + } + + val serverSession = connect(client, clientTransport, server, serverTransport) + + // Client roots handler parks until cancelled and records the cancellation. + client.setRequestHandler(Method.Defined.RootsList) { _, _ -> + rootsHandlerEntered.complete(Unit) + try { + parkForever.await() + } catch (e: CancellationException) { + rootsHandlerCancelled.complete(e) + throw e + } + } + + // Tool handler makes a nested server->client request and records its own cancellation. + serverSession.setRequestHandler(Method.Defined.ToolsCall) { _, _ -> + val extra = currentRequestHandlerExtra()!! + capturedToolCallId.set(extra.requestId) + try { + extra.sendRequest(ListRootsRequest()) + } catch (e: CancellationException) { + toolHandlerCancelled.complete(e) + throw e + } + CallToolResult(content = listOf(TextContent(text = "unreachable"))) + } + + val call = launch { client.callTool(CallToolRequest(CallToolRequestParams("nested"))) } + + withTimeout(10.seconds) { rootsHandlerEntered.await() } + call.cancel(CancellationException("user aborted")) + + withTimeout(10.seconds) { toolHandlerCancelled.await() } + withTimeout(10.seconds) { rootsHandlerCancelled.await() } + + // The server suppresses the response for a cancelled request, so none is ever sent. + assertFalse( + unexpectedToolCallResponse.isCompleted, + "no tools/call response should be delivered after the call was cancelled", + ) + + client.close() + server.close() + } + + // Init gate plus context element in a high-level handler: initialize and tools/list are + // pipelined before notifications/initialized and answered serially in order. After the + // handshake a high-level addTool handler can observe currentRequestHandlerExtra(). + @Test + fun `pipelined initialize and tools list are processed serially then context element is available`(): Unit = + runBlocking { + val server = toolsServer() + server.addTool(name = "ctx-tool", description = "returns its request id") { + val extra = currentRequestHandlerExtra() + checkNotNull(extra) { "currentRequestHandlerExtra() must be non-null inside a tool handler" } + CallToolResult(content = listOf(TextContent(text = extra.requestId.asText()))) + } + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + val arrivalOrder = CopyOnWriteArrayList() + val pending = ConcurrentHashMap>() + clientTransport.onMessage { message -> + val id = when (message) { + is JSONRPCResponse -> message.id + is JSONRPCError -> message.id + else -> null + } + if (id != null) { + arrivalOrder.add(id) + pending[id]?.complete(message) + } + } + + // Drive the client side manually over the raw transport. + server.createSession(serverTransport) + + val initRequest = InitializeRequest( + InitializeRequestParams( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = ClientCapabilities(), + clientInfo = Implementation(name = "raw-client", version = "1.0"), + ), + ).toJSON() + val toolsListRequest = ListToolsRequest().toJSON() + + val initAnswered = pending.expect(initRequest.id) + val toolsListAnswered = pending.expect(toolsListRequest.id) + + // Pipelined before notifications/initialized: both handled in the serial dispatch phase. + clientTransport.send(initRequest) + clientTransport.send(toolsListRequest) + + withTimeout(10.seconds) { initAnswered.await() } + withTimeout(10.seconds) { toolsListAnswered.await() } + + // Deterministic serial ordering: initialize answered before tools/list. + assertEquals( + listOf(initRequest.id, toolsListRequest.id), + arrivalOrder.toList(), + "pipelined requests must be answered in arrival order", + ) + + // Complete the handshake, then call the high-level tool. + clientTransport.send(InitializedNotification().toJSON()) + + val toolCallRequest = CallToolRequest(CallToolRequestParams("ctx-tool")).toJSON() + val toolCallAnswered = pending.expect(toolCallRequest.id) + clientTransport.send(toolCallRequest) + + val toolCallResponse = withTimeout(10.seconds) { toolCallAnswered.await() } as JSONRPCResponse + val text = (toolCallResponse.result as CallToolResult).content.filterIsInstance().single().text + // The handler echoed its request id, proving currentRequestHandlerExtra() was available. + text shouldBe toolCallRequest.id.asText() + } + + private fun ConcurrentHashMap>.expect( + id: RequestId, + ): CompletableDeferred = CompletableDeferred().also { put(id, it) } + + private fun RequestId.asText(): String = when (this) { + is RequestId.StringId -> value + is RequestId.NumberId -> value.toString() + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt index d14ae88..fb97782 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt @@ -6,9 +6,15 @@ 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.ListPromptsRequest +import io.modelcontextprotocol.kotlin.sdk.types.ListPromptsResult import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.PaginatedRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.Prompt import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage +import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.Role import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.TextContent @@ -18,7 +24,9 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -690,11 +698,76 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() { val expectedMessage = "Prompt not found: non-existent-prompt" - withClue("Exception code should be INTERNAL_ERROR: -32603") { - exception.code shouldBe -32603 + withClue("Exception code should be INVALID_PARAMS: ${RPCError.ErrorCode.INVALID_PARAMS}") { + exception.code shouldBe RPCError.ErrorCode.INVALID_PARAMS } withClue("Unexpected error message for non-existent prompt") { exception.message shouldBe expectedMessage } } + + @Test + fun testListPromptsPagination() = runBlocking(Dispatchers.IO) { + val receivedCursors = CopyOnWriteArrayList() + val page1 = listOf(Prompt(name = "p-1", description = "desc"), Prompt(name = "p-2", description = "desc")) + val page2 = listOf(Prompt(name = "p-3", description = "desc"), Prompt(name = "p-4", description = "desc")) + val page3 = listOf(Prompt(name = "p-5", description = "desc")) + + server.sessions.forEach { (_, session) -> + session.setRequestHandler(Method.Defined.PromptsList) { request, _ -> + receivedCursors += request.cursor + when (request.cursor) { + null -> ListPromptsResult(prompts = page1, nextCursor = "cursor-2") + "cursor-2" -> ListPromptsResult(prompts = page2, nextCursor = "cursor-3") + "cursor-3" -> ListPromptsResult(prompts = page3, nextCursor = null) + else -> error("Unexpected cursor: ${request.cursor}") + } + } + } + + val collected = mutableListOf() + var cursor: String? = null + do { + val request = if (cursor == null) { + ListPromptsRequest() + } else { + ListPromptsRequest(PaginatedRequestParams(cursor = cursor)) + } + val response = client.listPrompts(request) + collected += response.prompts + cursor = response.nextCursor + } while (cursor != null) + + assertEquals( + listOf(null, "cursor-2", "cursor-3"), + receivedCursors.toList(), + "Client must forward each nextCursor into the next request", + ) + assertEquals( + listOf("p-1", "p-2", "p-3", "p-4", "p-5"), + collected.map { it.name }, + "Client must accumulate pages in order without duplicates or reordering", + ) + } + + @Test + fun testListPromptsInvalidCursor() = runBlocking(Dispatchers.IO) { + val invalidCursor = "not-a-valid-cursor" + + server.sessions.forEach { (_, session) -> + session.setRequestHandler(Method.Defined.PromptsList) { _, _ -> + throw McpException( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "Invalid cursor: $invalidCursor", + ) + } + } + + val exception = assertFailsWith { + client.listPrompts(ListPromptsRequest(PaginatedRequestParams(cursor = invalidCursor))) + } + + assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code) + assertEquals("Invalid cursor: $invalidCursor", exception.message) + } } 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 4fc53a5..ab2a854 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractResourceIntegrationTest.kt @@ -1,11 +1,16 @@ package io.modelcontextprotocol.kotlin.sdk.integration.kotlin import io.modelcontextprotocol.kotlin.sdk.types.BlobResourceContents +import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesRequest +import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.PaginatedRequestParams import io.modelcontextprotocol.kotlin.sdk.types.RPCError 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.Resource import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams @@ -18,8 +23,10 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -70,23 +77,6 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() { ) } - server.addResource( - uri = testResourceUri, - name = testResourceName, - description = testResourceDescription, - mimeType = "text/plain", - ) { request -> - ReadResourceResult( - contents = listOf( - TextResourceContents( - text = testResourceContent, - uri = request.params.uri, - mimeType = "text/plain", - ), - ), - ) - } - server.addResource( uri = binaryResourceUri, name = binaryResourceName, @@ -309,4 +299,76 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() { assertTrue(result.contents.isNotEmpty(), "Result contents should not be empty") } } + + @Test + fun testListResourcesPagination() = runBlocking(Dispatchers.IO) { + val receivedCursors = CopyOnWriteArrayList() + val page1 = listOf( + Resource(uri = "test://r-1", name = "r-1"), + Resource(uri = "test://r-2", name = "r-2"), + Resource(uri = "test://r-3", name = "r-3"), + ) + val page2 = listOf( + Resource(uri = "test://r-4", name = "r-4"), + Resource(uri = "test://r-5", name = "r-5"), + ) + val page3 = listOf(Resource(uri = "test://r-6", name = "r-6")) + + server.sessions.forEach { (_, session) -> + session.setRequestHandler(Method.Defined.ResourcesList) { request, _ -> + receivedCursors += request.cursor + when (request.cursor) { + null -> ListResourcesResult(resources = page1, nextCursor = "cursor-2") + "cursor-2" -> ListResourcesResult(resources = page2, nextCursor = "cursor-3") + "cursor-3" -> ListResourcesResult(resources = page3, nextCursor = null) + else -> error("Unexpected cursor: ${request.cursor}") + } + } + } + + val collected = mutableListOf() + var cursor: String? = null + do { + val request = if (cursor == null) { + ListResourcesRequest() + } else { + ListResourcesRequest(PaginatedRequestParams(cursor = cursor)) + } + val response = client.listResources(request) + collected += response.resources + cursor = response.nextCursor + } while (cursor != null) + + assertEquals( + listOf(null, "cursor-2", "cursor-3"), + receivedCursors.toList(), + "Client must forward each nextCursor into the next request", + ) + assertEquals( + listOf("test://r-1", "test://r-2", "test://r-3", "test://r-4", "test://r-5", "test://r-6"), + collected.map { it.uri }, + "Client must accumulate pages in order without duplicates or reordering", + ) + } + + @Test + fun testListResourcesInvalidCursor() = runBlocking(Dispatchers.IO) { + val invalidCursor = "not-a-valid-cursor" + + server.sessions.forEach { (_, session) -> + session.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> + throw McpException( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "Invalid cursor: $invalidCursor", + ) + } + } + + val exception = assertFailsWith { + client.listResources(ListResourcesRequest(PaginatedRequestParams(cursor = invalidCursor))) + } + + assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code) + assertEquals("Invalid cursor: $invalidCursor", exception.message) + } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt index 7da82cc..b802122 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractToolIntegrationTest.kt @@ -6,8 +6,15 @@ import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.ContentBlock import io.modelcontextprotocol.kotlin.sdk.types.ImageContent +import io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest +import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.PaginatedRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.Tool import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -24,7 +31,9 @@ import org.junit.jupiter.api.Test import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.Locale +import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -791,4 +800,75 @@ abstract class AbstractToolIntegrationTest : KotlinTestBase() { "Error message should indicate the tool was not found", ) } + + @Test + fun testListToolsPagination() = runBlocking(Dispatchers.IO) { + val receivedCursors = CopyOnWriteArrayList() + val page1 = listOf( + Tool(name = "t-1", inputSchema = ToolSchema()), + Tool(name = "t-2", inputSchema = ToolSchema()), + ) + val page2 = listOf( + Tool(name = "t-3", inputSchema = ToolSchema()), + Tool(name = "t-4", inputSchema = ToolSchema()), + ) + val page3 = listOf(Tool(name = "t-5", inputSchema = ToolSchema())) + + server.sessions.forEach { (_, session) -> + session.setRequestHandler(Method.Defined.ToolsList) { request, _ -> + receivedCursors += request.cursor + when (request.cursor) { + null -> ListToolsResult(tools = page1, nextCursor = "cursor-2") + "cursor-2" -> ListToolsResult(tools = page2, nextCursor = "cursor-3") + "cursor-3" -> ListToolsResult(tools = page3, nextCursor = null) + else -> error("Unexpected cursor: ${request.cursor}") + } + } + } + + val collected = mutableListOf() + var cursor: String? = null + do { + val request = if (cursor == null) { + ListToolsRequest() + } else { + ListToolsRequest(PaginatedRequestParams(cursor = cursor)) + } + val response = client.listTools(request) + collected += response.tools + cursor = response.nextCursor + } while (cursor != null) + + assertEquals( + listOf(null, "cursor-2", "cursor-3"), + receivedCursors.toList(), + "Client must forward each nextCursor into the next request", + ) + assertEquals( + listOf("t-1", "t-2", "t-3", "t-4", "t-5"), + collected.map { it.name }, + "Client must accumulate pages in order without duplicates or reordering", + ) + } + + @Test + fun testListToolsInvalidCursor() = runBlocking(Dispatchers.IO) { + val invalidCursor = "not-a-valid-cursor" + + server.sessions.forEach { (_, session) -> + session.setRequestHandler(Method.Defined.ToolsList) { _, _ -> + throw McpException( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "Invalid cursor: $invalidCursor", + ) + } + } + + val exception = assertFailsWith { + client.listTools(ListToolsRequest(PaginatedRequestParams(cursor = invalidCursor))) + } + + assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code) + assertEquals("Invalid cursor: $invalidCursor", exception.message) + } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt index 9bc0c17..535393e 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt @@ -3,6 +3,7 @@ package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer +import io.ktor.server.sse.Heartbeat import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp @@ -17,7 +18,10 @@ import io.ktor.server.cio.CIO as ServerCIO open class AbstractStreamableHttpIntegrationTest { - suspend fun initTestServer(name: String? = null): StreamableHttpTestServer { + suspend fun initTestServer( + name: String? = null, + sseHeartbeatConfig: (Heartbeat.() -> Unit)? = null, + ): StreamableHttpTestServer { val mcpServer = Server( Implementation(name = name ?: DEFAULT_SERVER_NAME, version = VERSION), ServerOptions( @@ -52,7 +56,7 @@ open class AbstractStreamableHttpIntegrationTest { host = URL, port = PORT, ) { - mcpStreamableHttp { mcpServer } + mcpStreamableHttp(sseHeartbeatConfig = sseHeartbeatConfig) { mcpServer } } return StreamableHttpTestServer( diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpClientTransportIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpClientTransportIntegrationTest.kt new file mode 100644 index 0000000..598825f --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpClientTransportIntegrationTest.kt @@ -0,0 +1,270 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp + +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import io.ktor.client.HttpClient +import io.ktor.client.plugins.sse.SSE +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.engine.embeddedServer +import io.ktor.server.request.receiveText +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.response.respondText +import io.ktor.server.response.respondTextWriter +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions +import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.Tool +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import io.modelcontextprotocol.kotlin.test.utils.actualPort +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import kotlin.test.Test +import io.ktor.client.engine.cio.CIO as ClientCIO +import io.ktor.server.cio.CIO as ServerCIO + +private const val SESSION_ID_HEADER = "mcp-session-id" +private const val HOST = "127.0.0.1" +private const val MCP_PATH = "/mcp" + +private val INITIALIZE_RESULT = """ + { + "capabilities": {"tools": {"listChanged": false}}, + "protocolVersion": "2025-03-26", + "serverInfo": {"name": "test-server", "version": "1.0.0"} + } +""".trimIndent() + +private val TOOLS_LIST_RESULT = """ + { + "tools": [ + { + "name": "get_weather", + "title": "Weather Information Provider", + "description": "Get current weather information for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "City name or zip code"} + }, + "required": ["location"] + }, + "outputSchema": { + "type": "object", + "properties": { + "temperature": {"type": "number", "description": "Temperature, Celsius"} + }, + "required": ["temperature"] + }, + "_meta": {} + } + ] + } +""".trimIndent() + +private const val PROGRESS_NOTIFICATION_1 = + """{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""" + +private const val PROGRESS_NOTIFICATION_2 = + """{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":75,"total":100}}""" + +internal class StreamableHttpClientTransportIntegrationTest { + + @Test + fun `client receives progress notifications via GET SSE and lists tools`(): Unit = runBlocking(Dispatchers.IO) { + val sessionId = "sid-progress" + runWithServer({ + mcpPostRoute(sessionId, extraResults = mapOf("tools/list" to TOOLS_LIST_RESULT)) + get(MCP_PATH) { + call.response.header(SESSION_ID_HEADER, sessionId) + call.respondTextWriter(contentType = ContentType.Text.EventStream) { + write(sseEvent(id = "1", data = PROGRESS_NOTIFICATION_1)) + flush() + delay(200) + write(sseEvent(id = "2", data = PROGRESS_NOTIFICATION_2)) + flush() + awaitCancellation() + } + } + }) { url -> + val client = newClient() + client.connect(StreamableHttpClientTransport(client = newHttpClient(), url = url)) + + val listToolsResult = client.listTools() + + listToolsResult.tools shouldContain Tool( + name = "get_weather", + title = "Weather Information Provider", + description = "Get current weather information for a location", + inputSchema = ToolSchema( + properties = buildJsonObject { + putJsonObject("location") { + put("type", "string") + put("description", "City name or zip code") + } + }, + required = listOf("location"), + ), + outputSchema = ToolSchema( + properties = buildJsonObject { + putJsonObject("temperature") { + put("type", "number") + put("description", "Temperature, Celsius") + } + }, + required = listOf("temperature"), + ), + annotations = null, + meta = EmptyJsonObject, + ) + + client.close() + } + } + + @Test + fun `terminateSession sends DELETE request and clears sessionId`(): Unit = runBlocking(Dispatchers.IO) { + val sessionId = "sid-terminate" + runWithServer({ + mcpPostRoute(sessionId) + get(MCP_PATH) { + call.response.header(SESSION_ID_HEADER, sessionId) + call.respondTextWriter(contentType = ContentType.Text.EventStream) { + awaitCancellation() + } + } + delete(MCP_PATH) { + call.respond(HttpStatusCode.OK) + } + }) { url -> + val client = newClient() + val transport = StreamableHttpClientTransport(client = newHttpClient(), url = url) + client.connect(transport) + + transport.sessionId shouldBe sessionId + transport.terminateSession() + transport.sessionId shouldBe null + + client.close() + } + } + + @Test + fun `client survives 405 on GET SSE`(): Unit = runBlocking(Dispatchers.IO) { + val sessionId = "sid-405" + runWithServer({ + mcpPostRoute(sessionId, extraResults = mapOf("ping" to "{}")) + get(MCP_PATH) { + call.respondText( + text = "", + contentType = ContentType.Text.EventStream, + status = HttpStatusCode.MethodNotAllowed, + ) + } + }) { url -> + val client = newClient() + client.connect(StreamableHttpClientTransport(client = newHttpClient(), url = url)) + + client.ping() + + client.close() + } + } + + @Test + fun `client survives non streaming JSON response on GET SSE`(): Unit = runBlocking(Dispatchers.IO) { + val sessionId = "sid-json" + runWithServer({ + mcpPostRoute(sessionId, extraResults = mapOf("ping" to "{}")) + get(MCP_PATH) { + call.response.header(SESSION_ID_HEADER, sessionId) + call.respondText( + text = "", + contentType = ContentType.Application.Json, + status = HttpStatusCode.OK, + ) + } + }) { url -> + val client = newClient() + client.connect(StreamableHttpClientTransport(client = newHttpClient(), url = url)) + + client.ping() + + client.close() + } + } + + private fun Route.mcpPostRoute(sessionId: String, extraResults: Map = emptyMap()) { + post(MCP_PATH) { + val body = call.receiveText() + val parsed = Json.parseToJsonElement(body).jsonObject + val method = parsed["method"]?.jsonPrimitive?.content + val idJson = parsed["id"]?.toString() ?: "null" + call.response.header(SESSION_ID_HEADER, sessionId) + when { + method == "initialize" -> call.respondText( + text = jsonRpcResult(idJson, INITIALIZE_RESULT), + contentType = ContentType.Application.Json, + status = HttpStatusCode.OK, + ) + + method == "notifications/initialized" -> call.respond(HttpStatusCode.Accepted) + + method != null && method in extraResults -> call.respondText( + text = jsonRpcResult(idJson, extraResults.getValue(method)), + contentType = ContentType.Application.Json, + status = HttpStatusCode.OK, + ) + + else -> call.respond(HttpStatusCode.NotFound) + } + } + } + + private suspend fun runWithServer(routes: Route.() -> Unit, block: suspend (url: String) -> Unit) { + val server = embeddedServer(ServerCIO, host = HOST, port = 0) { + routing { routes() } + }.startSuspend(wait = false) + try { + val port = server.actualPort() + block("http://$HOST:$port$MCP_PATH") + } finally { + server.stopSuspend(1000, 2000) + } + } + + private fun newClient(): Client = Client( + clientInfo = Implementation(name = "client1", version = "1.0.0"), + options = ClientOptions(capabilities = ClientCapabilities()), + ) + + private fun newHttpClient(): HttpClient = HttpClient(ClientCIO) { + install(SSE) + } + + private fun jsonRpcResult(idJson: String, resultJson: String): String = + """{"jsonrpc":"2.0","id":$idJson,"result":$resultJson}""" + + private fun sseEvent(id: String, data: String): String = buildString { + append("event: message\n") + append("id: $id\n") + append("data: $data\n\n") + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpHeartbeatIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpHeartbeatIntegrationTest.kt new file mode 100644 index 0000000..8019557 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpHeartbeatIntegrationTest.kt @@ -0,0 +1,174 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp + +import io.kotest.matchers.shouldBe +import io.ktor.client.HttpClient +import io.ktor.client.plugins.sse.SSE +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.prepareGet +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.sse.ServerSentEvent +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readUTF8Line +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.toJSON +import io.modelcontextprotocol.kotlin.test.utils.actualPort +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.milliseconds +import io.ktor.client.engine.cio.CIO as ClientCIO + +private const val SESSION_ID_HEADER = "mcp-session-id" +private const val PROTOCOL_VERSION_HEADER = "mcp-protocol-version" +private const val DEFAULT_HEARTBEAT_LINE = ": heartbeat" +private const val CUSTOM_HEARTBEAT_LINE = ": mcp-heartbeat" + +class StreamableHttpHeartbeatIntegrationTest : AbstractStreamableHttpIntegrationTest() { + + @Test + fun `GET SSE stream emits configured heartbeat`(): Unit = runBlocking(Dispatchers.IO) { + var server: StreamableHttpTestServer? = null + var httpClient: HttpClient? = null + + try { + server = initTestServer("heartbeat-test") { + period = 50.milliseconds + event = ServerSentEvent(comments = "mcp-heartbeat") + } + val mcpUrl = "http://$URL:${server.ktorServer.actualPort()}/mcp" + httpClient = HttpClient(ClientCIO) { install(SSE) } + val sessionId = initializeSession(httpClient, mcpUrl) + + httpClient.prepareGet(mcpUrl) { + addSseHeaders(sessionId) + }.execute { response -> + response.status shouldBe HttpStatusCode.OK + response.headers[SESSION_ID_HEADER] shouldBe sessionId + + response.bodyAsChannel().readLineMatching(CUSTOM_HEARTBEAT_LINE) shouldBe CUSTOM_HEARTBEAT_LINE + } + } finally { + httpClient?.close() + server?.ktorServer?.stopSuspend(1000, 2000) + } + } + + @Test + fun `GET SSE stream does not emit heartbeat by default`(): Unit = runBlocking(Dispatchers.IO) { + var server: StreamableHttpTestServer? = null + var httpClient: HttpClient? = null + + try { + server = initTestServer("no-heartbeat-test") + val mcpUrl = "http://$URL:${server.ktorServer.actualPort()}/mcp" + httpClient = HttpClient(ClientCIO) { install(SSE) } + val sessionId = initializeSession(httpClient, mcpUrl) + + httpClient.prepareGet(mcpUrl) { + addSseHeaders(sessionId) + }.execute { response -> + response.status shouldBe HttpStatusCode.OK + response.headers[SESSION_ID_HEADER] shouldBe sessionId + + response.bodyAsChannel().readLineMatching(timeoutMillis = 150) { line -> + line.isHeartbeatSseLine() + } shouldBe null + } + } finally { + httpClient?.close() + server?.ktorServer?.stopSuspend(1000, 2000) + } + } + + @Test + fun `GET SSE stream emits configured heartbeat repeatedly`(): Unit = runBlocking(Dispatchers.IO) { + var server: StreamableHttpTestServer? = null + var httpClient: HttpClient? = null + + try { + server = initTestServer("repeating-heartbeat-test") { + period = 50.milliseconds + event = ServerSentEvent(comments = "mcp-heartbeat") + } + val mcpUrl = "http://$URL:${server.ktorServer.actualPort()}/mcp" + httpClient = HttpClient(ClientCIO) { install(SSE) } + val sessionId = initializeSession(httpClient, mcpUrl) + + httpClient.prepareGet(mcpUrl) { + addSseHeaders(sessionId) + }.execute { response -> + response.status shouldBe HttpStatusCode.OK + response.headers[SESSION_ID_HEADER] shouldBe sessionId + + val channel = response.bodyAsChannel() + channel.readLineMatching(CUSTOM_HEARTBEAT_LINE) shouldBe CUSTOM_HEARTBEAT_LINE + channel.readLineMatching(CUSTOM_HEARTBEAT_LINE) shouldBe CUSTOM_HEARTBEAT_LINE + } + } finally { + httpClient?.close() + server?.ktorServer?.stopSuspend(1000, 2000) + } + } + + private suspend fun initializeSession(client: HttpClient, mcpUrl: String): String { + val response = client.post(mcpUrl) { + contentType(ContentType.Application.Json) + header( + HttpHeaders.Accept, + "${ContentType.Application.Json}, ${ContentType.Text.EventStream}", + ) + setBody(Json.encodeToString(buildInitPayload())) + } + + response.status shouldBe HttpStatusCode.OK + return assertNotNull(response.headers[SESSION_ID_HEADER]) + } + + private fun buildInitPayload(): JSONRPCRequest = InitializeRequest( + InitializeRequestParams( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = ClientCapabilities(), + clientInfo = Implementation(name = "heartbeat-test-client", version = "1.0.0"), + ), + ).toJSON() + + private fun io.ktor.client.request.HttpRequestBuilder.addSseHeaders(sessionId: String) { + header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) + header(SESSION_ID_HEADER, sessionId) + header(PROTOCOL_VERSION_HEADER, LATEST_PROTOCOL_VERSION) + } + + private suspend fun ByteReadChannel.readLineMatching(expectedLine: String, timeoutMillis: Long = 2_000): String? = + readLineMatching(timeoutMillis) { line -> line == expectedLine } + + private suspend fun ByteReadChannel.readLineMatching( + timeoutMillis: Long = 2_000, + matches: (String) -> Boolean, + ): String? = withTimeoutOrNull(timeoutMillis.milliseconds) { + var line = readUTF8Line() + while (line != null) { + if (matches(line)) return@withTimeoutOrNull line + line = readUTF8Line() + } + null + } + + private fun String.isHeartbeatSseLine(): Boolean = this == DEFAULT_HEARTBEAT_LINE || + this == CUSTOM_HEARTBEAT_LINE || + (startsWith(":") && contains("heartbeat", ignoreCase = true)) || + (startsWith("event:") && contains("heartbeat", ignoreCase = true)) +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpSseReconnectTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpSseReconnectTest.kt new file mode 100644 index 0000000..751f8dd --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpSseReconnectTest.kt @@ -0,0 +1,171 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp + +import io.kotest.matchers.nulls.shouldNotBeNull +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.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.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertNotNull +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" + +/** + * Integration tests for GET SSE stream reconnection using a real embedded CIO server. + * + * Verifies that the transport correctly evicts stale STANDALONE_SSE_STREAM_ID + * entries when a client reconnects after a disconnect, rather than silently + * rejecting the new stream. + */ +class StreamableHttpSseReconnectTest : AbstractStreamableHttpIntegrationTest() { + + /** + * Verifies that after a GET SSE stream disconnects and the client + * immediately reconnects, the server evicts the stale stream mapping + * and allows the new stream to succeed. + */ + @Test + fun `GET SSE reconnect after disconnect should succeed`(): Unit = runBlocking(Dispatchers.IO) { + var server: StreamableHttpTestServer? = null + var httpClient: HttpClient? = null + + try { + server = initTestServer("reconnect-test") + val port = server.ktorServer.actualPort() + val mcpUrl = "http://$URL:$port/mcp" + + httpClient = HttpClient(ClientCIO) { install(SSE) } + + // Step 1: Initialize session via POST + val initResponse = httpClient.post(mcpUrl) { + contentType(ContentType.Application.Json) + header( + HttpHeaders.Accept, + "${ContentType.Application.Json}, ${ContentType.Text.EventStream}", + ) + setBody(Json.encodeToString(buildInitPayload())) + } + initResponse.status shouldBe HttpStatusCode.OK + val sessionId = assertNotNull(initResponse.headers[SESSION_ID_HEADER]) + + // Step 2: Open GET SSE stream, consume the flush event, then disconnect + httpClient.prepareGet(mcpUrl) { + header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) + header(SESSION_ID_HEADER, sessionId) + header(PROTOCOL_VERSION_HEADER, LATEST_PROTOCOL_VERSION) + }.execute { response -> + response.status shouldBe HttpStatusCode.OK + response.bodyAsChannel().readUTF8Line() + } + + // Step 3: Immediately reconnect. The transport detects that the + // previous stream's coroutine is no longer active and evicts the + // stale mapping, allowing the new stream to succeed. + httpClient.prepareGet(mcpUrl) { + header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) + header(SESSION_ID_HEADER, sessionId) + header(PROTOCOL_VERSION_HEADER, LATEST_PROTOCOL_VERSION) + }.execute { response -> + response.status shouldBe HttpStatusCode.OK + response.headers[SESSION_ID_HEADER] shouldBe sessionId + + val channel = response.bodyAsChannel() + val firstLine = channel.readUTF8Line() + firstLine.shouldNotBeNull() + channel.isClosedForRead shouldBe false + } + } finally { + httpClient?.close() + server?.ktorServer?.stopSuspend(1000, 2000) + } + } + + /** + * Verifies that a second concurrent GET SSE stream on the same session + * closes the old stream and takes over. The new stream should be live. + */ + @Test + fun `concurrent GET SSE stream closes old stream and takes over`(): Unit = runBlocking(Dispatchers.IO) { + var server: StreamableHttpTestServer? = null + var httpClient: HttpClient? = null + + try { + server = initTestServer("takeover-test") + val port = server.ktorServer.actualPort() + val mcpUrl = "http://$URL:$port/mcp" + + httpClient = HttpClient(ClientCIO) { install(SSE) } + + // Step 1: Initialize session via POST + val initResponse = httpClient.post(mcpUrl) { + contentType(ContentType.Application.Json) + header( + HttpHeaders.Accept, + "${ContentType.Application.Json}, ${ContentType.Text.EventStream}", + ) + setBody(Json.encodeToString(buildInitPayload())) + } + initResponse.status shouldBe HttpStatusCode.OK + val sessionId = assertNotNull(initResponse.headers[SESSION_ID_HEADER]) + + // Step 2: Open first GET SSE stream and keep it open + httpClient.prepareGet(mcpUrl) { + header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) + header(SESSION_ID_HEADER, sessionId) + header(PROTOCOL_VERSION_HEADER, LATEST_PROTOCOL_VERSION) + }.execute { firstResponse -> + firstResponse.status shouldBe HttpStatusCode.OK + firstResponse.bodyAsChannel().readUTF8Line() + + // Step 3: Open a second GET — closes old stream, new one takes over + httpClient.prepareGet(mcpUrl) { + header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) + header(SESSION_ID_HEADER, sessionId) + header(PROTOCOL_VERSION_HEADER, LATEST_PROTOCOL_VERSION) + }.execute { secondResponse -> + secondResponse.status shouldBe HttpStatusCode.OK + secondResponse.headers[SESSION_ID_HEADER] shouldBe sessionId + + // New stream is alive + val secondChannel = secondResponse.bodyAsChannel() + val firstLine = secondChannel.readUTF8Line() + firstLine.shouldNotBeNull() + secondChannel.isClosedForRead shouldBe false + } + } + } finally { + httpClient?.close() + server?.ktorServer?.stopSuspend(1000, 2000) + } + } + + private fun buildInitPayload(): JSONRPCRequest = InitializeRequest( + InitializeRequestParams( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = ClientCapabilities(), + clientInfo = Implementation(name = "reconnect-test-client", version = "1.0.0"), + ), + ).toJSON() +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionLoggingTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionLoggingTest.kt index 81a3b41..a1cf797 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionLoggingTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionLoggingTest.kt @@ -1,5 +1,7 @@ package io.modelcontextprotocol.kotlin.sdk.server +import io.kotest.assertions.nondeterministic.continually +import io.kotest.assertions.nondeterministic.eventually import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe @@ -17,6 +19,9 @@ import kotlinx.serialization.json.JsonPrimitive import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { @@ -56,7 +61,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { @ParameterizedTest @EnumSource(LoggingLevel::class) fun `notification should filter logging messages below level`(minLevel: LoggingLevel): Unit = runBlocking { - val receivedMessages = mutableListOf() + val receivedMessages = CopyOnWriteArrayList() client.setNotificationHandler(Method.Defined.NotificationsMessage) { receivedMessages.add(it) CompletableDeferred(Unit) @@ -79,8 +84,10 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { client.callTool(CallToolRequest(CallToolRequestParams("test-notification-filtered"))) + // Handlers dispatch on Dispatchers.Default after the handshake, so await the expected count + // before asserting order. val expectedLevels = LoggingLevel.entries.filter { it >= minLevel } - receivedMessages shouldHaveSize expectedLevels.size + eventually(5.seconds) { receivedMessages shouldHaveSize expectedLevels.size } receivedMessages.map { it.params.level } shouldBe expectedLevels } @@ -115,7 +122,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { @ParameterizedTest @EnumSource(LoggingLevel::class) fun `sendLoggingMessage should filter messages below level`(minLevel: LoggingLevel): Unit = runBlocking { - val receivedMessages = mutableListOf() + val receivedMessages = CopyOnWriteArrayList() client.setNotificationHandler(Method.Defined.NotificationsMessage) { receivedMessages.add(it) CompletableDeferred(Unit) @@ -138,14 +145,16 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { client.callTool(CallToolRequest(CallToolRequestParams("test-logging-level"))) + // Handlers dispatch on Dispatchers.Default after the handshake, so await the expected count + // before asserting order. val expectedLevels = LoggingLevel.entries.filter { it >= minLevel } - receivedMessages shouldHaveSize expectedLevels.size + eventually(5.seconds) { receivedMessages shouldHaveSize expectedLevels.size } receivedMessages.map { it.params.level } shouldBe expectedLevels } @Test fun `sendLoggingMessage should send no messages when level is set to highest`(): Unit = runBlocking { - val receivedMessages = mutableListOf() + val receivedMessages = CopyOnWriteArrayList() client.setNotificationHandler(Method.Defined.NotificationsMessage) { receivedMessages.add(it) CompletableDeferred(Unit) @@ -167,6 +176,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { } client.callTool(CallToolRequest(CallToolRequestParams("test-logging-highest"))) - receivedMessages.shouldBeEmpty() + // Handlers dispatch asynchronously, so hold for a window to confirm nothing ever arrives. + continually(500.milliseconds) { receivedMessages.shouldBeEmpty() } } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt index ee8953a..581e5ae 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt @@ -9,6 +9,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotificationParams +import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest @@ -61,6 +62,7 @@ class ClientConnectionTest : AbstractServerFeaturesTest() { override fun getClientCapabilities(): ClientCapabilities = ClientCapabilities( roots = ClientCapabilities.Roots(listChanged = true), + elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), ) private val sampleRoots = listOf(Root("file:///project", "Project Root")) diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt new file mode 100644 index 0000000..f161d8e --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt @@ -0,0 +1,251 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions +import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport +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.EmptyJsonObject +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.IncludeContext +import io.modelcontextprotocol.kotlin.sdk.types.Method +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.StopReason +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.Tool +import io.modelcontextprotocol.kotlin.sdk.types.ToolChoice +import io.modelcontextprotocol.kotlin.sdk.types.ToolResultContent +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import io.modelcontextprotocol.kotlin.sdk.types.ToolUseContent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import org.junit.jupiter.api.assertDoesNotThrow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SamplingTest { + + private val dummyTool = Tool( + name = "t", + inputSchema = ToolSchema(properties = buildJsonObject { }, required = emptyList()), + ) + + private val weatherTool = Tool( + name = "get_weather", + description = "Return the current temperature in Celsius.", + inputSchema = ToolSchema( + properties = buildJsonObject { + put("location", buildJsonObject { put("type", JsonPrimitive("string")) }) + }, + required = listOf("location"), + ), + ) + + private val minimalMessages = listOf(SamplingMessage(Role.User, TextContent("hi"))) + + /** + * Builds a connected [Server]+[Client] pair using [InMemoryTransport]. + * + * @param clientCapabilities the capabilities the client advertises during initialize + * @param samplingHandler the handler the client uses to respond to sampling requests + * @return Pair of (server, sessionId) ready for [Server.createMessage] calls. + */ + private fun buildPair( + clientCapabilities: ClientCapabilities = ClientCapabilities( + sampling = ClientCapabilities.Sampling(), + ), + samplingHandler: (CreateMessageRequest) -> CreateMessageResult = { _ -> + CreateMessageResult(role = Role.Assistant, content = TextContent("ok"), model = "m") + }, + ): Pair { + val server = Server( + serverInfo = Implementation(name = "srv", version = "1.0"), + options = ServerOptions(capabilities = ServerCapabilities()), + ) + + val client = Client( + clientInfo = Implementation(name = "cli", version = "1.0"), + options = ClientOptions(capabilities = clientCapabilities), + ) + + client.setRequestHandler(Method.Defined.SamplingCreateMessage) { req, _ -> + samplingHandler(req) + } + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + var sessionId: String? = null + runBlocking { + val sessionDeferred = CompletableDeferred() + launch { client.connect(clientTransport) } + launch { + val session = server.createSession(serverTransport) + sessionDeferred.complete(session.sessionId) + } + sessionId = sessionDeferred.await() + } + + return Pair(server, checkNotNull(sessionId)) + } + + // ============================================================================ + // Server.createMessage — capability enforcement (SEP-1577) + // ============================================================================ + + @Test + fun `tools field rejected when client has no sampling tools capability`() { + val (server, sessionId) = buildPair() + assertFailsWith { + runBlocking { + server.createMessage( + sessionId = sessionId, + params = CreateMessageRequest( + params = CreateMessageRequestParams( + maxTokens = 100, + messages = minimalMessages, + tools = listOf(dummyTool), + ), + ), + ) + } + } + } + + @Test + fun `toolChoice field rejected when client has no sampling tools capability`() { + val (server, sessionId) = buildPair() + assertFailsWith { + runBlocking { + server.createMessage( + sessionId = sessionId, + params = CreateMessageRequest( + params = CreateMessageRequestParams( + maxTokens = 100, + messages = minimalMessages, + toolChoice = ToolChoice(), + ), + ), + ) + } + } + } + + @Test + fun `includeContext with no sampling context capability succeeds with a warning`() { + val (server, sessionId) = buildPair() + assertDoesNotThrow { + runBlocking { + server.createMessage( + sessionId = sessionId, + params = CreateMessageRequest( + params = CreateMessageRequestParams( + maxTokens = 100, + messages = minimalMessages, + includeContext = IncludeContext.ThisServer, + ), + ), + ) + } + } + } + + // ============================================================================ + // End-to-end tool-loop integration + // ============================================================================ + + @Test + fun `server sends tools, client returns tool_use then final text`() { + var turn = 0 + val (server, sessionId) = buildPair( + clientCapabilities = ClientCapabilities( + sampling = ClientCapabilities.Sampling(tools = EmptyJsonObject), + ), + samplingHandler = { _ -> + turn++ + if (turn == 1) { + CreateMessageResult( + role = Role.Assistant, + content = listOf( + TextContent("Let me check."), + ToolUseContent( + id = "call_1", + name = "get_weather", + input = buildJsonObject { put("location", JsonPrimitive("London")) }, + ), + ), + model = "test", + stopReason = StopReason.ToolUse, + ) + } else { + CreateMessageResult( + role = Role.Assistant, + content = TextContent("The temperature in London is 20°C."), + model = "test", + stopReason = StopReason.EndTurn, + ) + } + }, + ) + + runBlocking { + val messages = mutableListOf( + SamplingMessage(Role.User, TextContent("What is the weather in London?")), + ) + + // — Turn 1: server sends tools, expects tool_use stop reason + val first = server.createMessage( + sessionId = sessionId, + params = CreateMessageRequest( + params = CreateMessageRequestParams( + maxTokens = 256, + messages = messages.toList(), + tools = listOf(weatherTool), + toolChoice = ToolChoice(mode = ToolChoice.Mode.Auto), + ), + ), + ) + assertEquals(StopReason.ToolUse, first.stopReason) + assertEquals(2, first.content.size) + + // — Append assistant turn and inject tool result + messages.add(SamplingMessage(Role.Assistant, first.content)) + val toolUse = first.content.filterIsInstance().single() + messages.add( + SamplingMessage( + Role.User, + ToolResultContent( + toolUseId = toolUse.id, + content = listOf(TextContent("""{"tempC":20}""")), + ), + ), + ) + + // — Turn 2: server sends updated history, expects final text + val second = server.createMessage( + sessionId = sessionId, + params = CreateMessageRequest( + params = CreateMessageRequestParams( + maxTokens = 256, + messages = messages.toList(), + tools = listOf(weatherTool), + toolChoice = ToolChoice(mode = ToolChoice.Mode.Auto), + ), + ), + ) + assertEquals(StopReason.EndTurn, second.stopReason) + assertEquals(1, second.content.size) + val text = second.content.single() as TextContent + assertEquals("The temperature in London is 20°C.", text.text) + + server.close() + } + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerBulkFeaturesTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerBulkFeaturesTest.kt index 1eb828f..52f6908 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerBulkFeaturesTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerBulkFeaturesTest.kt @@ -1,5 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.server +import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.collections.shouldHaveSize @@ -57,6 +58,39 @@ class ServerBulkFeaturesTest : AbstractServerFeaturesTest() { tools.map { it.name } shouldContainExactlyInAnyOrder listOf("existing", "new-a", "new-b") } + @Test + fun `addTools should throw and register nothing when a tool is already registered`() = runTest { + server.addTool("existing", "Existing") { CallToolResult(emptyList()) } + + shouldThrow { + server.addTools( + listOf( + RegisteredTool(Tool("new-a", ToolSchema())) { CallToolResult(emptyList()) }, + RegisteredTool(Tool("existing", ToolSchema())) { CallToolResult(emptyList()) }, + ), + ) + } + + val tools = client.listTools().tools + + tools shouldHaveSize 1 + tools.map { it.name } shouldContainExactlyInAnyOrder listOf("existing") + } + + @Test + fun `addTools should throw when batch contains duplicate names`() = runTest { + shouldThrow { + server.addTools( + listOf( + RegisteredTool(Tool("dup", ToolSchema())) { CallToolResult(emptyList()) }, + RegisteredTool(Tool("dup", ToolSchema())) { CallToolResult(emptyList()) }, + ), + ) + } + + client.listTools().tools.shouldBeEmpty() + } + // ── addPrompts ───────────────────────────────────────────────────────────── @Test @@ -89,6 +123,25 @@ class ServerBulkFeaturesTest : AbstractServerFeaturesTest() { prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("existing-prompt", "new-p") } + @Test + fun `addPrompts should throw and register nothing when a prompt is already registered`() = runTest { + server.addPrompt("existing-prompt", "Existing") { GetPromptResult(messages = emptyList()) } + + shouldThrow { + server.addPrompts( + listOf( + RegisteredPrompt(Prompt("new-p")) { GetPromptResult(messages = emptyList()) }, + RegisteredPrompt(Prompt("existing-prompt")) { GetPromptResult(messages = emptyList()) }, + ), + ) + } + + val prompts = client.listPrompts().prompts + + prompts shouldHaveSize 1 + prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("existing-prompt") + } + // ── addResources ─────────────────────────────────────────────────────────── @Test @@ -123,6 +176,27 @@ class ServerBulkFeaturesTest : AbstractServerFeaturesTest() { resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://existing-res", "test://new-res") } + @Test + fun `addResources should throw and register nothing when a resource is already registered`() = runTest { + server.addResource("test://existing-res", "Existing", "Existing resource") { + ReadResourceResult(emptyList()) + } + + shouldThrow { + server.addResources( + listOf( + RegisteredResource(Resource("test://new-res", "New")) { ReadResourceResult(emptyList()) }, + RegisteredResource(Resource("test://existing-res", "Dup")) { ReadResourceResult(emptyList()) }, + ), + ) + } + + val resources = client.listResources().resources + + resources shouldHaveSize 1 + resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://existing-res") + } + // ── Partial-batch remove ─────────────────────────────────────────────────── @Test diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsTest.kt index d7ae46d..44b1e42 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsTest.kt @@ -1,5 +1,8 @@ package io.modelcontextprotocol.kotlin.sdk.server +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest +import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.Method @@ -110,6 +113,26 @@ class ServerPromptsTest : AbstractServerFeaturesTest() { assertFalse(promptListChangedNotificationReceived, "No notification should be sent when prompt doesn't exist") } + @Test + fun `addPrompt should throw when prompt with same name is already registered`() = runTest { + server.addPrompt(Prompt("test-prompt", "Original Prompt", null)) { + GetPromptResult(description = "original", messages = emptyList()) + } + + val exception = assertThrows { + server.addPrompt(Prompt("test-prompt", "Duplicate Prompt", null)) { + GetPromptResult(description = "duplicate", messages = emptyList()) + } + } + exception.message.orEmpty() shouldContain "Prompt \"test-prompt\" is already registered" + + // The original registration is intact + val prompts = client.listPrompts().prompts + assertEquals(1, prompts.size, "Only the original prompt should be registered") + val result = client.getPrompt(GetPromptRequest(GetPromptRequestParams("test-prompt"))) + assertEquals("original", result.description) + } + @Test fun `removePrompt should throw when prompts capability is not supported`() = runTest { var promptListChangedNotificationReceived = false diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt index c8ace19..8405469 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt @@ -5,6 +5,7 @@ import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.maps.shouldContainKey import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesRequest import io.modelcontextprotocol.kotlin.sdk.types.McpException @@ -162,6 +163,24 @@ class ServerResourceTemplateTest : AbstractServerFeaturesTest() { removed shouldBe false } + @Test + fun `addResourceTemplate should throw when template with same uriTemplate is already registered`() { + server.addResourceTemplate("test://items/{id}", "Item") { _, _ -> + ReadResourceResult(emptyList()) + } + + val exception = assertThrows { + server.addResourceTemplate("test://items/{id}", "Duplicate Item") { _, _ -> + ReadResourceResult(emptyList()) + } + } + exception.message.orEmpty() shouldContain "ResourceTemplate \"test://items/{id}\" is already registered" + + // The original registration is intact + server.resourceTemplates shouldHaveSize 1 + server.resourceTemplates[0].name shouldBe "Item" + } + @Test fun `addResourceTemplate should throw when resources capability is not supported`() { val noResourcesServer = Server( diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesTest.kt index fafc3c8..a830788 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesTest.kt @@ -1,7 +1,10 @@ package io.modelcontextprotocol.kotlin.sdk.server +import io.kotest.matchers.string.shouldContain import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest +import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities @@ -115,6 +118,24 @@ class ServerResourcesTest : AbstractServerFeaturesTest() { ) } + @Test + fun `addResource should throw when resource with same uri is already registered`() = runTest { + server.addResource("test://resource", "Original", "Original resource") { + ReadResourceResult(listOf(TextResourceContents(text = "original", uri = "test://resource"))) + } + + val exception = assertThrows { + server.addResource("test://resource", "Duplicate", "Duplicate resource") { + ReadResourceResult(listOf(TextResourceContents(text = "duplicate", uri = "test://resource"))) + } + } + exception.message.orEmpty() shouldContain "Resource \"test://resource\" is already registered" + + // The original registration is intact + val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://resource"))) + assertEquals("original", (result.contents.single() as TextResourceContents).text) + } + @Test fun `removeResource should throw when resources capability is not supported`() = runTest { // Create server without resources capability diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt index 5031a9c..38b169a 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt @@ -7,9 +7,11 @@ import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.RequestId import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.toJSON import kotlinx.coroutines.CompletableDeferred @@ -18,9 +20,13 @@ import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject import org.junit.jupiter.api.Test import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -41,6 +47,18 @@ class ServerSessionInitializeTest { ), ) + private fun createMalformedInitializeRequest(): JSONRPCRequest = JSONRPCRequest( + id = RequestId(1), + method = "initialize", + params = buildJsonObject { + putJsonObject("capabilities") {} + putJsonObject("clientInfo") { + put("name", "repro") + put("version", "0.1.0") + } + }, + ) + @Test fun `should handle first initialize request successfully`() = runTest { val session = createSession() @@ -65,6 +83,30 @@ class ServerSessionInitializeTest { assertEquals("test-client", session.clientVersion?.name) } + // Both initialize requests arrive before notifications/initialized, in the serial dispatch + // phase, so processing and response order stay deterministic. + @Test + fun `should classify malformed initialize params as invalid params`() = runTest { + val session = createSession() + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + val responseDone = CompletableDeferred() + clientTransport.onMessage { message -> + if (message is JSONRPCError) { + responseDone.complete(message) + } + } + + session.connect(serverTransport) + clientTransport.send(createMalformedInitializeRequest()) + + val response = responseDone.await() + assertEquals(RPCError.ErrorCode.INVALID_PARAMS, response.error.code) + assertFalse(response.error.message.contains("kotlinx.serialization")) + assertNull(session.clientCapabilities) + assertNull(session.clientVersion) + } + @Test fun `should reject duplicate initialize request`() = runTest { val session = createSession() diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsNotificationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsNotificationTest.kt index 3572082..9ac744b 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsNotificationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsNotificationTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.test.runTest import org.awaitility.kotlin.await import org.awaitility.kotlin.untilAsserted import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -81,6 +82,27 @@ class ServerToolsNotificationTest : AbstractServerFeaturesTest() { } } + @Test + fun `addTool should not send notification when duplicate is rejected`() = runTest { + // Track notifications + val notifications = mutableListOf() + client.setNotificationHandler( + Method.Defined.NotificationsToolsListChanged, + ) { notification -> + notifications.add(notification) + CompletableDeferred(Unit) + } + + server.addTool("test-tool", "Test Tool") { CallToolResult(emptyList()) } + assertThrows { + server.addTool("test-tool", "Duplicate Tool") { CallToolResult(emptyList()) } + } + // Close the server to stop processing further events and flush notifications + server.close() + + assertEquals(1, notifications.size, "Only the first registration should send a notification") + } + @Test fun `notification should not be send when removed tool does not exists`() = runTest { // Track notifications diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt index c2e8201..1b3df91 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt @@ -1,5 +1,8 @@ package io.modelcontextprotocol.kotlin.sdk.server +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.Method @@ -98,6 +101,52 @@ class ServerToolsTest : AbstractServerFeaturesTest() { assertFalse(toolListChangedNotificationReceived, "No notification should be sent when tool doesn't exist") } + @Test + fun `addTool should succeed with non-conforming tool name`() = runTest { + server.addTool("my invalid tool!", "Tool with non-conforming name", ToolSchema()) { + CallToolResult(listOf(TextContent("It works"))) + } + + val result = server.removeTool("my invalid tool!") + assertTrue(result, "Tool with non-conforming name should have been registered and removable") + } + + @Test + fun `addTool should throw when tool with same name is already registered`() = runTest { + server.addTool("test-tool", "Original Tool") { + CallToolResult(listOf(TextContent("Original result"))) + } + + val exception = assertThrows { + server.addTool("test-tool", "Duplicate Tool") { + CallToolResult(listOf(TextContent("Duplicate result"))) + } + } + exception.message.orEmpty() shouldContain "Tool \"test-tool\" is already registered" + + // The original registration is intact + val tools = client.listTools().tools + assertEquals(1, tools.size, "Only the original tool should be registered") + val result = client.callTool(CallToolRequest(CallToolRequestParams("test-tool"))) + assertEquals("Original result", (result.content.single() as TextContent).text) + } + + @Test + fun `addTool should succeed after removing existing tool`() = runTest { + server.addTool("test-tool", "Original Tool") { + CallToolResult(listOf(TextContent("Original result"))) + } + + server.removeTool("test-tool") + server.addTool("test-tool", "Replacement Tool") { + CallToolResult(listOf(TextContent("Replacement result"))) + } + + assertEquals(1, client.listTools().tools.size, "Re-adding after removal should not duplicate the tool") + val result = client.callTool(CallToolRequest(CallToolRequestParams("test-tool"))) + assertEquals("Replacement result", (result.content.single() as TextContent).text) + } + @Test fun `removeTool should throw when tools capability is not supported`() = runTest { var toolListChangedNotificationReceived = false diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt index ea246db..4d798ce 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt @@ -6,7 +6,6 @@ import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions -import io.modelcontextprotocol.kotlin.sdk.server.ServerSession import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult @@ -16,9 +15,6 @@ import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult import io.modelcontextprotocol.kotlin.sdk.types.Method import io.modelcontextprotocol.kotlin.sdk.types.Resource import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlin.test.Test @@ -43,18 +39,9 @@ class ChannelTransportTest { clientInfo = Implementation(name = "test client", version = "1.0"), ) - val serverSessionResult = CompletableDeferred() + val serverSession = server.createSession(serverTransport) + client.connect(clientTransport) - listOf( - launch { - client.connect(clientTransport) - }, - launch { - serverSessionResult.complete(server.createSession(serverTransport)) - }, - ).joinAll() - - val serverSession = serverSessionResult.await() serverSession.setRequestHandler(Method.Defined.Initialize) { _, _ -> InitializeResult( protocolVersion = LATEST_PROTOCOL_VERSION, diff --git a/integration-test/src/jvmTest/typescript/package-lock.json b/integration-test/src/jvmTest/typescript/package-lock.json index dd0c9b4..ffb53fc 100644 --- a/integration-test/src/jvmTest/typescript/package-lock.json +++ b/integration-test/src/jvmTest/typescript/package-lock.json @@ -8,23 +8,23 @@ "name": "mcp-typescript-test-sdk", "version": "1.0.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.30.0", "express": "^5.2.1", - "zod": "^4.3.6" + "zod": "^4.6.2" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^25.5.2", + "@types/node": "^26.5.1", "cors": "^2.8.6", - "tsx": "^4.21.0", - "typescript": "^6.0.2" + "tsx": "^4.23.13", + "typescript": "^7.0.2" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -39,9 +39,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -56,9 +56,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -73,9 +73,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -90,9 +90,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -107,9 +107,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -124,9 +124,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -141,9 +141,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -158,9 +158,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -175,9 +175,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -192,9 +192,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -209,9 +209,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -226,9 +226,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -243,9 +243,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -260,9 +260,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -277,9 +277,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -294,9 +294,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -311,9 +311,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -328,9 +328,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -345,9 +345,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -362,9 +362,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -379,9 +379,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -396,9 +396,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -413,9 +413,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -430,9 +430,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -447,9 +447,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -464,24 +464,24 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -579,13 +579,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/qs": { @@ -623,6 +623,346 @@ "@types/node": "*" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -670,20 +1010,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -693,6 +1033,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -876,9 +1229,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -888,9 +1241,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -901,32 +1254,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escape-html": { @@ -1009,12 +1362,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -1033,9 +1387,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -1147,19 +1501,6 @@ "node": ">= 0.4" } }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1185,9 +1526,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1197,9 +1538,9 @@ } }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1248,9 +1589,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -1461,12 +1802,13 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -1508,16 +1850,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1613,14 +1945,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1632,13 +1964,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1703,14 +2035,13 @@ } }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -1723,37 +2054,75 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" }, @@ -1797,9 +2166,9 @@ "license": "ISC" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz", + "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/integration-test/src/jvmTest/typescript/package.json b/integration-test/src/jvmTest/typescript/package.json index 20249d0..b0147e4 100644 --- a/integration-test/src/jvmTest/typescript/package.json +++ b/integration-test/src/jvmTest/typescript/package.json @@ -9,16 +9,16 @@ "server:http": "tsx server/http-server.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "^1.30.0", "express": "^5.2.1", - "zod": "^4.3.6" + "zod": "^4.6.2" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^25.5.2", + "@types/node": "^26.5.1", "cors": "^2.8.6", - "tsx": "^4.21.0", - "typescript": "^6.0.2" + "tsx": "^4.23.13", + "typescript": "^7.0.2" } } diff --git a/kotlin-sdk-client/api/kotlin-sdk-client.api b/kotlin-sdk-client/api/kotlin-sdk-client.api index da0e752..d856f5b 100644 --- a/kotlin-sdk-client/api/kotlin-sdk-client.api +++ b/kotlin-sdk-client/api/kotlin-sdk-client.api @@ -34,6 +34,7 @@ public class io/modelcontextprotocol/kotlin/sdk/client/Client : io/modelcontextp public final fun removeRoot (Ljava/lang/String;)Z public final fun removeRoots (Ljava/util/List;)I public final fun sendRootsListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun setElicitationCompleteHandler (Lkotlin/jvm/functions/Function1;)V public final fun setElicitationHandler (Lkotlin/jvm/functions/Function1;)V public final fun setLoggingLevel (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun setLoggingLevel$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; @@ -41,6 +42,7 @@ public class io/modelcontextprotocol/kotlin/sdk/client/Client : io/modelcontextp public static synthetic fun subscribeResource$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/types/SubscribeRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun unsubscribeResource (Lio/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun unsubscribeResource$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + protected fun wrapRequestHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;Lkotlin/jvm/functions/Function3;)Lkotlin/jvm/functions/Function3; } public final class io/modelcontextprotocol/kotlin/sdk/client/ClientKt { @@ -50,8 +52,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/client/ClientKt { public final class io/modelcontextprotocol/kotlin/sdk/client/ClientOptions : io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions { public fun ()V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;Z)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZLkotlin/coroutines/CoroutineContext;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZLkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; } @@ -100,8 +102,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/client/StdioClientTranspor } public final class io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport { - public fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;ILkotlin/jvm/functions/Function1;)V + public synthetic fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;ILkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getProtocolVersion ()Ljava/lang/String; diff --git a/kotlin-sdk-client/build.gradle.kts b/kotlin-sdk-client/build.gradle.kts index f50266c..fbb1300 100644 --- a/kotlin-sdk-client/build.gradle.kts +++ b/kotlin-sdk-client/build.gradle.kts @@ -4,6 +4,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { id("mcp.multiplatform") + id("mcp.detekt") id("mcp.publishing") id("mcp.dokka") alias(libs.plugins.kotlinx.binary.compatibility.validator) @@ -14,10 +15,8 @@ kotlin { iosArm64() iosX64() iosSimulatorArm64() - watchosX64() watchosArm64() watchosSimulatorArm64() - tvosX64() tvosArm64() tvosSimulatorArm64() js { @@ -56,7 +55,6 @@ kotlin { implementation(libs.ktor.client.apache5) implementation(libs.mockk) implementation(libs.junit.jupiter.params) - implementation(libs.mokksy) implementation(dependencies.platform(libs.netty.bom)) runtimeOnly(libs.slf4j.simple) } diff --git a/kotlin-sdk-client/detekt-baseline-commonMainSourceSet.xml b/kotlin-sdk-client/detekt-baseline-commonMainSourceSet.xml deleted file mode 100644 index 5cddbe1..0000000 --- a/kotlin-sdk-client/detekt-baseline-commonMainSourceSet.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - CyclomaticComplexMethod:StdioClientTransport.kt:StdioClientTransport$override suspend fun initialize - LongMethod:StdioClientTransport.kt:StdioClientTransport$override suspend fun initialize - MaxLineLength:StdioClientTransport.kt:StdioClientTransport$* - ThrowsCount:SseClientTransport.kt:SseClientTransport$private suspend fun CoroutineScope.collectMessages - ThrowsCount:StreamableHttpClientTransport.kt:StreamableHttpClientTransport$@Suppress("ReturnCount", "CyclomaticComplexMethod") override suspend fun performSend - TooGenericExceptionCaught:Client.kt:Client$error: Throwable - TooGenericExceptionCaught:SseClientTransport.kt:SseClientTransport$e: Throwable - TooGenericExceptionCaught:StdioClientTransport.kt:StdioClientTransport$e: Throwable - - diff --git a/kotlin-sdk-client/detekt-baseline-commonTestSourceSet.xml b/kotlin-sdk-client/detekt-baseline-commonTestSourceSet.xml deleted file mode 100644 index cab4322..0000000 --- a/kotlin-sdk-client/detekt-baseline-commonTestSourceSet.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - MaxLineLength:StreamableHttpClientTransportTest.kt:StreamableHttpClientTransportTest$"""data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""" - MaxLineLength:StreamableHttpClientTransportTest.kt:StreamableHttpClientTransportTest$"""data: {"jsonrpc":"2.0","method":"notifications/resumed","params":{"fromToken":"$lastEventIdSent"}}""" - - diff --git a/kotlin-sdk-client/detekt-baseline-main.xml b/kotlin-sdk-client/detekt-baseline-main.xml index 8411ab0..c34e75c 100644 --- a/kotlin-sdk-client/detekt-baseline-main.xml +++ b/kotlin-sdk-client/detekt-baseline-main.xml @@ -2,18 +2,6 @@ - CyclomaticComplexMethod:StdioClientTransport.kt:StdioClientTransport$override suspend fun initialize - InjectDispatcher:StdioClientTransport.kt:StdioClientTransport$Default - InjectDispatcher:StreamableHttpClientTransport.kt:StreamableHttpClientTransport$Default - LongMethod:StdioClientTransport.kt:StdioClientTransport$override suspend fun initialize - MaxLineLength:StdioClientTransport.kt:StdioClientTransport$* - NoNameShadowing:StdioClientTransport.kt:StdioClientTransport$source - ThrowsCount:SseClientTransport.kt:SseClientTransport$private suspend fun CoroutineScope.collectMessages - ThrowsCount:StreamableHttpClientTransport.kt:StreamableHttpClientTransport$@Suppress("ReturnCount", "CyclomaticComplexMethod") override suspend fun performSend - TooGenericExceptionCaught:Client.kt:Client$error: Throwable - TooGenericExceptionCaught:SseClientTransport.kt:SseClientTransport$e: Throwable - TooGenericExceptionCaught:StdioClientTransport.kt:StdioClientTransport$e: Throwable - UseCheckOrError:Client.kt:Client$throw IllegalStateException( "Server does not support resource subscriptions (required for $method)", ) - UseCheckOrError:Client.kt:Client$throw IllegalStateException("Client does not support roots capability.") + LoopWithTooManyJumpStatements:StreamableHttpClientTransport.kt:StreamableHttpClientTransport$while diff --git a/kotlin-sdk-client/detekt-baseline-test.xml b/kotlin-sdk-client/detekt-baseline-test.xml deleted file mode 100644 index dd6adfa..0000000 --- a/kotlin-sdk-client/detekt-baseline-test.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - AbstractClassCanBeConcreteClass:AbstractStreamableHttpClientTest.kt:AbstractStreamableHttpClientTest$AbstractStreamableHttpClientTest - ForbiddenComment:StreamableHttpClientTest.kt:StreamableHttpClientTest$// TODO: how to get notifications via Client API? - LongParameterList:MockMcp.kt:MockMcp$fun handleJSONRPCRequest - LongParameterList:MockMcp.kt:MockMcp$fun handleWithResult - LongParameterList:MockMcp.kt:MockMcp$fun onInitialize - MaxLineLength:StreamableHttpClientTransportTest.kt:StreamableHttpClientTransportTest$"""data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""" - MaxLineLength:StreamableHttpClientTransportTest.kt:StreamableHttpClientTransportTest$"""data: {"jsonrpc":"2.0","method":"notifications/resumed","params":{"fromToken":"$lastEventIdSent"}}""" - UseOrEmpty:ClientMetaParameterTest.kt:ClientMetaParameterTest$exception.message ?: "" - - 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 34b9a5b..3352104 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt @@ -4,6 +4,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import io.modelcontextprotocol.kotlin.sdk.shared.Protocol import io.modelcontextprotocol.kotlin.sdk.shared.ProtocolOptions +import io.modelcontextprotocol.kotlin.sdk.shared.RequestHandlerExtra import io.modelcontextprotocol.kotlin.sdk.shared.RequestOptions import io.modelcontextprotocol.kotlin.sdk.shared.Transport import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema @@ -13,10 +14,12 @@ import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities import io.modelcontextprotocol.kotlin.sdk.types.CompleteRequest import io.modelcontextprotocol.kotlin.sdk.types.CompleteResult +import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest 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 @@ -45,7 +48,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.PingRequest import io.modelcontextprotocol.kotlin.sdk.types.PrimitiveSchemaDefinition import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult +import io.modelcontextprotocol.kotlin.sdk.types.Request import io.modelcontextprotocol.kotlin.sdk.types.RequestMeta +import io.modelcontextprotocol.kotlin.sdk.types.RequestResult import io.modelcontextprotocol.kotlin.sdk.types.Root import io.modelcontextprotocol.kotlin.sdk.types.RootsListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.SUPPORTED_PROTOCOL_VERSIONS @@ -59,6 +64,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.supportsUrl import io.modelcontextprotocol.kotlin.sdk.types.toJson import kotlinx.atomicfu.atomic import kotlinx.atomicfu.getAndUpdate @@ -66,11 +72,14 @@ import kotlinx.atomicfu.update import kotlinx.collections.immutable.minus import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray +import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException private val logger = KotlinLogging.logger {} @@ -79,12 +88,17 @@ private val logger = KotlinLogging.logger {} * Options for configuring the MCP client. * * @property capabilities The capabilities this client supports. - * @property enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with the server. + * @param enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with the server. + * @param handlerCoroutineContext Coroutine context for inbound handlers. See [ProtocolOptions.handlerCoroutineContext]. */ public class ClientOptions( public val capabilities: ClientCapabilities = ClientCapabilities(), enforceStrictCapabilities: Boolean = true, -) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) + handlerCoroutineContext: CoroutineContext = Dispatchers.Default, +) : ProtocolOptions( + enforceStrictCapabilities = enforceStrictCapabilities, + handlerCoroutineContext = handlerCoroutineContext, +) /** * Initializes and connects an MCP client using the provided clientInfo [Implementation], client options, @@ -121,7 +135,6 @@ public suspend fun mcpClient( * @param clientInfo Information about the client implementation (name, version). * @param options Configuration options for this client. */ -@Suppress("TooManyFunctions") public open class Client(private val clientInfo: Implementation, options: ClientOptions = ClientOptions()) : Protocol(options) { @@ -171,6 +184,7 @@ public open class Client(private val clientInfo: Implementation, options: Client "prompts" -> caps?.prompts != null "resources" -> caps?.resources != null "tools" -> caps?.tools != null + "tasks" -> caps?.tasks != null else -> true } @@ -209,6 +223,7 @@ public open class Client(private val clientInfo: Implementation, options: Client serverInstructions = result.instructions notification(InitializedNotification()) + enableConcurrentDispatch() } catch (error: Throwable) { logger.error(error) { "Failed to initialize client: ${error.message}" } close() @@ -235,10 +250,15 @@ public open class Client(private val clientInfo: Implementation, options: Client Method.Defined.PromptsGet, Method.Defined.PromptsList, - Method.Defined.CompletionComplete, -> { checkNotNull(serverCapabilities?.prompts) { - throw IllegalStateException("Server does not support prompts (required for $method)") + "Server does not support prompts (required for $method)" + } + } + + Method.Defined.CompletionComplete -> { + checkNotNull(serverCapabilities?.completions) { + "Server does not support completions (required for $method)" } } @@ -251,10 +271,10 @@ public open class Client(private val clientInfo: Implementation, options: Client val resCaps = serverCapabilities?.resources ?: error("Server does not support resources (required for $method)") - if (method == Method.Defined.ResourcesSubscribe && resCaps.subscribe != true) { - throw IllegalStateException( - "Server does not support resource subscriptions (required for $method)", - ) + if (method == Method.Defined.ResourcesSubscribe) { + check(resCaps.subscribe == true) { + "Server does not support resource subscriptions (required for $method)" + } } } @@ -264,6 +284,12 @@ public open class Client(private val clientInfo: Implementation, options: Client } } + Method.Defined.TasksGet, + Method.Defined.TasksResult, + Method.Defined.TasksList, + Method.Defined.TasksCancel, + -> assertTasksCapabilityForMethod(method) + Method.Defined.Initialize, Method.Defined.Ping -> { // No specific capability required } @@ -274,6 +300,24 @@ public open class Client(private val clientInfo: Implementation, options: Client } } + private fun assertTasksCapabilityForMethod(method: Method) { + val tasks = serverCapabilities?.tasks + ?: error("Server does not support tasks (required for $method)") + when (method) { + Method.Defined.TasksList -> checkNotNull(tasks.list) { + "Server does not support listing tasks (required for $method)" + } + + Method.Defined.TasksCancel -> checkNotNull(tasks.cancel) { + "Server does not support cancelling tasks (required for $method)" + } + + else -> { + // TasksGet, TasksResult: base tasks capability suffices. + } + } + } + override fun assertNotificationCapability(method: Method) { when (method) { Method.Defined.NotificationsRootsListChanged -> { @@ -282,6 +326,12 @@ public open class Client(private val clientInfo: Implementation, options: Client } } + Method.Defined.NotificationsTasksStatus -> { + checkNotNull(capabilities.tasks) { + "Client does not support tasks (required for $method)" + } + } + Method.Defined.NotificationsInitialized, Method.Defined.NotificationsCancelled, Method.Defined.NotificationsProgress, @@ -323,6 +373,26 @@ public open class Client(private val clientInfo: Implementation, options: Client } } + /** + * Wraps incoming-request handlers with SEP-1577 client-side enforcement. + * + * For `sampling/createMessage`: if the incoming request carries `tools` or + * `toolChoice` but this client did not advertise [ClientCapabilities.Sampling.tools], + * the wrapper throws an [McpException] with JSON-RPC error code `InvalidParams` + * before the user-supplied handler runs. Matches the TypeScript SDK wrapper in + * `Client.setRequestHandler`. + */ + override fun wrapRequestHandler( + method: Method, + block: suspend (T, RequestHandlerExtra) -> RequestResult?, + ): suspend (T, RequestHandlerExtra) -> RequestResult? { + if (method != Method.Defined.SamplingCreateMessage) return block + return { request, extra -> + (request as? CreateMessageRequest)?.let { validateSamplingToolsCapability(it, capabilities) } + block(request, extra) + } + } + /** * Sends a ping request to the server to check connectivity. * @@ -337,7 +407,7 @@ public open class Client(private val clientInfo: Implementation, options: Client * @param params The completion request parameters. * @param options Optional request options. * @return The completion result returned by the server, or `null` if none. - * @throws IllegalStateException If the server does not support prompts or completion. + * @throws IllegalStateException If the server does not support completions. */ public suspend fun complete(params: CompleteRequest, options: RequestOptions? = null): CompleteResult = request(params, options) @@ -532,9 +602,8 @@ public open class Client(private val clientInfo: Implementation, options: Client * @throws IllegalStateException If the client does not support roots. */ public fun removeRoot(uri: String): Boolean { - if (capabilities.roots == null) { - logger.error { "Failed to remove root '$uri': Client does not support roots capability" } - throw IllegalStateException("Client does not support roots capability.") + checkNotNull(capabilities.roots) { + "Client does not support roots capability." } logger.info { "Removing root: $uri" } val oldMap = roots.getAndUpdate { current -> current.remove(uri) } @@ -590,9 +659,18 @@ public open class Client(private val clientInfo: Implementation, options: Client /** * Sets the elicitation handler. * - * When the handler returns [ElicitResult.Action.Accept], any properties missing from + * The handler receives both form-mode ([ElicitRequestFormParams]) and URL-mode + * ([io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams]) requests; + * branch on `request.params` to tell them apart. For URL mode, + * the host application must obtain explicit user consent and display the target domain before + * navigating — the SDK never opens or validates the URL — and should return + * [ElicitResult.Action.Decline] or [ElicitResult.Action.Cancel] when it cannot or will not proceed. + * A URL-mode [ElicitResult.Action.Accept] only signals consent; the outcome arrives out-of-band via + * [setElicitationCompleteHandler]. + * + * When a form-mode handler returns [ElicitResult.Action.Accept], any properties missing from * [ElicitResult.content] are automatically populated with default values defined in the - * elicitation schema. + * elicitation schema. URL-mode responses carry no content. * * @param handler The elicitation handler. * @throws IllegalStateException if the client does not support elicitation. @@ -610,9 +688,37 @@ 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 --- - @Suppress("ReturnCount") private fun applyElicitationDefaults(request: ElicitRequest, result: ElicitResult): ElicitResult { if (result.action != ElicitResult.Action.Accept) return result val formParams = request.params as? ElicitRequestFormParams ?: return result diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SamplingValidation.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SamplingValidation.kt new file mode 100644 index 0000000..1290cb0 --- /dev/null +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SamplingValidation.kt @@ -0,0 +1,24 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.RPCError + +/** + * Validates SEP-1577 client-side enforcement: an incoming `sampling/createMessage` + * request that carries `tools` or `toolChoice` requires the client to have advertised + * the `sampling.tools` sub-capability. When the capability is missing, throws an + * [McpException] with JSON-RPC error code `InvalidParams`, matching TypeScript SDK + * client enforcement. + */ +internal fun validateSamplingToolsCapability(request: CreateMessageRequest, capabilities: ClientCapabilities) { + val params = request.params + if (params.tools == null && params.toolChoice == null) return + if (capabilities.sampling?.tools != null) return + val field = if (params.tools != null) "tools" else "toolChoice" + throw McpException( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "Client does not support sampling with tools but request contains $field parameter", + ) +} diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt index 3c9d88b..0295eb9 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt @@ -24,11 +24,12 @@ import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.time.Duration @@ -135,6 +136,7 @@ public class SseClientTransport( throw e } finally { closeResources() + invokeOnCloseCallback() } } @@ -171,13 +173,15 @@ public class SseClientTransport( } override suspend fun closeResources() { - job?.cancelAndJoin() - try { - if (::session.isInitialized) session.cancel() - if (::scope.isInitialized) scope.cancel() - endpoint.cancel() - } catch (e: Throwable) { - _onError(e) + withContext(NonCancellable) { + job?.cancel() + try { + if (::session.isInitialized) session.cancel() + if (::scope.isInitialized) scope.cancel() + endpoint.cancel() + } catch (e: Throwable) { + _onError(e) + } } } } 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 12529cc..5e990ee 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin @@ -31,6 +32,7 @@ import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import kotlinx.io.Buffer import kotlinx.io.IOException @@ -225,7 +227,6 @@ public class StdioClientTransport @JvmOverloads public constructor( } override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) { - @Suppress("SwallowedException") try { sendChannel.send(message) } catch (e: CancellationException) { @@ -241,8 +242,20 @@ public class StdioClientTransport @JvmOverloads public constructor( } override suspend fun closeResources() { - scope.stopProcessing("Closed") - scope.coroutineContext[Job]?.join() // Wait for all coroutines to complete + 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) { @@ -264,6 +277,8 @@ public class StdioClientTransport @JvmOverloads public constructor( private suspend fun handleJSONRPCMessage(msg: JSONRPCMessage) { try { _onMessage.invoke(msg) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { logger.error(e) { "Error processing message." } runCatching { _onError.invoke(e) } @@ -284,7 +299,7 @@ public class StdioClientTransport @JvmOverloads public constructor( ) { val buffer = Buffer() try { - source.use { source -> + source.use { while (isActive) { val bytesRead = source.readAtMostTo(buffer, BUFFER_SIZE) if (bytesRead == -1L) { 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 999e25a..6df352a 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt @@ -21,8 +21,10 @@ import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.http.isSuccess +import io.ktor.utils.io.charsets.TooLongLineException import io.ktor.utils.io.readUTF8Line import io.modelcontextprotocol.kotlin.sdk.shared.AbstractClientTransport +import io.modelcontextprotocol.kotlin.sdk.shared.TooLongFrameException import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification @@ -41,6 +43,10 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.pow import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -49,11 +55,24 @@ 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. * * @property code HTTP status code associated with the error, or `null` if unavailable + * @param message detailed error description appended to the exception message */ public class StreamableHttpError(public val code: Int? = null, message: String? = null) : Exception("Streamable HTTP error: $message") @@ -65,21 +84,32 @@ private sealed interface ConnectResult { } /** - * Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. - * It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events - * for receiving messages. + * Client transport implementing the MCP Streamable HTTP transport specification. * - * @property sessionId session identifier assigned by the server after initialization, or `null` before connection - * @property protocolVersion MCP protocol version negotiated with the server, or `null` before connection + * Sends messages via HTTP POST and receives messages via HTTP GET with Server-Sent Events. + * Supports automatic SSE reconnection with exponential backoff, stream resumption via the + * `Last-Event-ID` header, and explicit session termination. + * + * @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 */ -@Suppress("TooManyFunctions") 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( @@ -94,12 +124,20 @@ public class StreamableHttpClientTransport( url: String, reconnectionTime: Duration?, requestBuilder: HttpRequestBuilder.() -> Unit = {}, - ) : this(client, url, ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), requestBuilder) + ) : this( + client, + url, + ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), + requestBuilder = requestBuilder, + ) override val logger: KLogger = KotlinLogging.logger {} + /** Session identifier assigned by the server after initialization, or `null` before connection. */ public var sessionId: String? = null private set + + /** MCP protocol version negotiated with the server, or `null` before connection. */ public var protocolVersion: String? = null private var sseJob: Job? = null @@ -121,7 +159,6 @@ public class StreamableHttpClientTransport( /** * Sends a single message with optional resumption support */ - @Suppress("ReturnCount", "CyclomaticComplexMethod", "LongMethod", "TooGenericExceptionCaught", "ThrowsCount") override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) { logger.debug { "Client sending message via POST to $url: ${McpJson.encodeToString(message)}" } @@ -138,6 +175,7 @@ public class StreamableHttpClientTransport( val jsonBody = McpJson.encodeToString(message) val response = client.post(url) { applyCommonHeaders(this) + applyStandardPostHeaders(this, message) headers.append(HttpHeaders.Accept, "${ContentType.Application.Json}, ${ContentType.Text.EventStream}") contentType(ContentType.Application.Json) setBody(jsonBody) @@ -260,7 +298,6 @@ public class StreamableHttpClientTransport( var attempt = 0 var needsDelay = initialServerRetryDelay != null - @Suppress("LoopWithTooManyJumpStatements") while (isActive) { // Delay before (re)connection: skip only for first fresh SSE connection if (needsDelay) { @@ -296,7 +333,6 @@ public class StreamableHttpClientTransport( } } - @Suppress("TooGenericExceptionCaught") private suspend fun connectSse(lastEventId: String?): ConnectResult { logger.debug { "Client attempting to start SSE session at url: $url" } return try { @@ -363,7 +399,36 @@ public class StreamableHttpClientTransport( } } - @Suppress("TooGenericExceptionCaught") + 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?, @@ -408,7 +473,6 @@ public class StreamableHttpClientTransport( return SseStreamResult(hasPrimingEvent, receivedResponse, localLastEventId, localServerRetryDelay) } - @Suppress("CyclomaticComplexMethod") private suspend fun handleInlineSse( response: HttpResponse, replayMessageId: RequestId?, @@ -455,9 +519,15 @@ public class StreamableHttpClientTransport( } } - @Suppress("LoopWithTooManyJumpStatements") while (!channel.isClosedForRead) { - val line = channel.readUTF8Line() ?: break + // Bound each line so a server that streams a line without ever terminating it cannot + // exhaust client memory; readUTF8Line returns null at the end of the stream. + val line = try { + channel.readUTF8Line(maxInlineSseEventSize) + } catch (_: TooLongLineException) { + throw TooLongFrameException(maxInlineSseEventSize.toLong() + 1, maxInlineSseEventSize) + } + if (line == null) break if (line.isEmpty()) { dispatch(id = id, eventName = eventName, data = sb.toString()) // reset @@ -471,7 +541,13 @@ public class StreamableHttpClientTransport( line.startsWith("event:") -> eventName = line.substringAfter("event:").trim() - line.startsWith("data:") -> sb.append(line.substringAfter("data:").trim()) + line.startsWith("data:") -> { + sb.append(line.substringAfter("data:").trim()) + // Cap an event assembled from many data: lines that never sees a terminating blank line. + if (sb.length > maxInlineSseEventSize) { + throw TooLongFrameException(sb.length.toLong(), maxInlineSseEventSize) + } + } line.startsWith("retry:") -> line.substringAfter("retry:").trim().toLongOrNull()?.let { localServerRetryDelay = it.milliseconds diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientAssertCapabilityTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientAssertCapabilityTest.kt new file mode 100644 index 0000000..e9379a7 --- /dev/null +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientAssertCapabilityTest.kt @@ -0,0 +1,171 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.shared.Transport +import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFailsWith + +/** + * Tests for the protected helper [Client.assertCapability]. + * + * `assertCapability(capability, method)` is `protected`, so this suite uses + * a small subclass [TestClient] that re-exports it via [TestClient.exposedAssertCapability]. + * Server capabilities are seeded by completing the initialization handshake through + * [CapabilitiesTransport], which replays a configurable [ServerCapabilities] in its + * `initialize` response. + */ +class ClientAssertCapabilityTest { + + @Test + fun `assertCapability tasks throws when server has no tasks capability`() = runTest { + val client = newTestClient(serverCapabilities = ServerCapabilities()) + val ex = assertFailsWith { + client.exposedAssertCapability("tasks", "tasks/list") + } + ex.message.orEmpty() shouldContain "Server does not support tasks" + } + + @Test + fun `TasksGet throws when server has no tasks capability`() = runTest { + val client = newTestClient(serverCapabilities = ServerCapabilities()) + val ex = assertFailsWith { + client.exposedAssertCapabilityForMethod(Method.Defined.TasksGet) + } + ex.message.orEmpty() shouldContain "Server does not support tasks" + } + + @Test + fun `TasksGet does not throw when server declared tasks`() = runTest { + val client = newTestClient( + serverCapabilities = ServerCapabilities(tasks = ServerCapabilities.Tasks()), + ) + client.exposedAssertCapabilityForMethod(Method.Defined.TasksGet) + } + + @Test + fun `TasksList throws when server tasks list is null`() = runTest { + val client = newTestClient( + serverCapabilities = ServerCapabilities(tasks = ServerCapabilities.Tasks()), + ) + val ex = assertFailsWith { + client.exposedAssertCapabilityForMethod(Method.Defined.TasksList) + } + ex.message.orEmpty() shouldContain "Server does not support listing tasks" + } + + @Test + fun `TasksCancel throws when server tasks cancel is null`() = runTest { + val client = newTestClient( + serverCapabilities = ServerCapabilities(tasks = ServerCapabilities.Tasks()), + ) + val ex = assertFailsWith { + client.exposedAssertCapabilityForMethod(Method.Defined.TasksCancel) + } + ex.message.orEmpty() shouldContain "Server does not support cancelling tasks" + } + + @Test + fun `NotificationsTasksStatus throws when client has no tasks capability`() = runTest { + val client = newTestClient(clientCapabilities = ClientCapabilities()) + val ex = assertFailsWith { + client.exposedAssertNotificationCapability(Method.Defined.NotificationsTasksStatus) + } + ex.message.orEmpty() shouldContain "Client does not support tasks" + } + + @Test + fun `CompletionComplete does not throw when server declared completions`() = runTest { + val client = newTestClient( + serverCapabilities = ServerCapabilities(completions = ServerCapabilities.Completions), + ) + client.exposedAssertCapabilityForMethod(Method.Defined.CompletionComplete) + } + + @Test + fun `CompletionComplete throws when server has no completions capability`() = runTest { + val client = newTestClient( + serverCapabilities = ServerCapabilities(prompts = ServerCapabilities.Prompts()), + ) + val ex = assertFailsWith { + client.exposedAssertCapabilityForMethod(Method.Defined.CompletionComplete) + } + ex.message.orEmpty() shouldContain "Server does not support completions" + } + + private suspend fun newTestClient( + serverCapabilities: ServerCapabilities = ServerCapabilities(), + clientCapabilities: ClientCapabilities = ClientCapabilities(), + ): TestClient { + val client = TestClient( + Implementation("test-client", "1.0.0"), + ClientOptions(capabilities = clientCapabilities), + ) + val transport = CapabilitiesTransport(serverCapabilities) + client.connect(transport) + return client + } + + /** + * Test-only [Client] subclass that exposes the protected + * [Client.assertCapability], [Client.assertCapabilityForMethod], and + * [Client.assertNotificationCapability] helpers to the test code. + */ + private class TestClient(clientInfo: Implementation, options: ClientOptions = ClientOptions()) : + Client(clientInfo, options) { + fun exposedAssertCapability(capability: String, method: String): Unit = assertCapability(capability, method) + fun exposedAssertCapabilityForMethod(method: Method): Unit = assertCapabilityForMethod(method) + fun exposedAssertNotificationCapability(method: Method): Unit = assertNotificationCapability(method) + } + + /** + * Minimal in-memory [Transport] that responds to `initialize` with a configurable + * [ServerCapabilities]. Other JSON-RPC traffic (e.g. the `notifications/initialized` + * sent by [Client.connect]) is silently consumed. + */ + private class CapabilitiesTransport(private val serverCapabilities: ServerCapabilities) : Transport { + private var onMessageBlock: (suspend (JSONRPCMessage) -> Unit)? = null + private var onCloseBlock: (() -> Unit)? = null + + override suspend fun start() = Unit + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + if (message is JSONRPCRequest && message.method == "initialize") { + onMessageBlock?.invoke( + JSONRPCResponse( + id = message.id, + result = InitializeResult( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = serverCapabilities, + serverInfo = Implementation("mock-server", "1.0.0"), + ), + ), + ) + } + } + + override suspend fun close() { + onCloseBlock?.invoke() + } + + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { + onMessageBlock = block + } + + override fun onClose(block: () -> Unit) { + onCloseBlock = block + } + + override fun onError(block: (Throwable) -> Unit) = Unit + } +} diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/MockSseClientEngine.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/MockSseClientEngine.kt index 3627b6c..14af546 100644 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/MockSseClientEngine.kt +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/MockSseClientEngine.kt @@ -53,6 +53,14 @@ internal open class MockSseClientEngine( job.cancel() } + /** + * Closes the SSE byte channel without disposing the engine, simulating a server-side + * disconnect of the SSE stream. + */ + fun disconnectSseStream() { + channel.close() + } + private fun responseContext(): CoroutineContext = dispatcher + Job() private suspend fun respondWithSseEndpointEvent(data: HttpRequestData): HttpResponseData { diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt index 6cc7f6e..319ba2a 100644 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt @@ -1,5 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.client.sse +import io.kotest.assertions.nondeterministic.eventually import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.ktor.client.HttpClient @@ -9,6 +10,7 @@ import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds class SseClientTransportTest { @@ -90,6 +92,31 @@ class SseClientTransportTest { engine.close() } + @Test + fun `onClose callback fires when the SSE stream is disconnected by the server`() = runTest { + // Given + val sseUrl = "http://example.com/api/mcp/sse" + + // And + val engine = CapturingSseClientEngine(endpoint = "/messages?sessionId=abc") + val transport = sseTransport(sseUrl, engine) + var onCloseFired = false + transport.onClose { onCloseFired = true } + + // When + transport.start() + engine.disconnectSseStream() + + // Then + eventually(2.seconds) { + onCloseFired shouldBe true + } + + // Cleanup + transport.close() + engine.close() + } + private fun sseTransport(sseUrl: String, engine: CapturingSseClientEngine) = SseClientTransport(HttpClient(engine) { install(SSE) }, sseUrl) diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/streamable/http/StreamableHttpClientTransportTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/streamable/http/StreamableHttpClientTransportTest.kt index 67b2f07..d9aaf7a 100644 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/streamable/http/StreamableHttpClientTransportTest.kt +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/streamable/http/StreamableHttpClientTransportTest.kt @@ -19,6 +19,7 @@ import io.ktor.http.headersOf import io.ktor.utils.io.ByteReadChannel import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport +import io.modelcontextprotocol.kotlin.sdk.shared.TooLongFrameException import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification @@ -43,11 +44,15 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class StreamableHttpClientTransportTest { - private fun createTransport(handler: MockRequestHandler): StreamableHttpClientTransport { + private fun createTransport( + maxInlineSseEventSize: Int = 16 * 1024 * 1024, + handler: MockRequestHandler, + ): StreamableHttpClientTransport { val mockEngine = MockEngine(handler) val httpClient = HttpClient(mockEngine) { install(SSE) { @@ -55,7 +60,11 @@ class StreamableHttpClientTransportTest { } } - return StreamableHttpClientTransport(httpClient, url = "http://localhost:8080/mcp") + return StreamableHttpClientTransport( + httpClient, + url = "http://localhost:8080/mcp", + maxInlineSseEventSize = maxInlineSseEventSize, + ) } private fun buildSseMessage(id: String, method: String, params: String): String = buildString { @@ -237,6 +246,100 @@ class StreamableHttpClientTransportTest { transport.close() } + @Test + fun `should send MCP method and name headers from name`() = runTest { + val transport = createTransport { request -> + assertEquals("tools/call", request.headers["Mcp-Method"]) + assertEquals("read_file", request.headers["Mcp-Name"]) + respond(content = "", status = HttpStatusCode.Accepted) + } + + transport.start() + transport.send( + JSONRPCRequest( + id = "test-id", + method = "tools/call", + params = buildJsonObject { + put("name", JsonPrimitive("read_file")) + put("arguments", buildJsonObject { put("path", JsonPrimitive("README.md")) }) + }, + ), + ) + transport.close() + } + + @Test + fun `should send MCP name header from URI`() = runTest { + val transport = createTransport { request -> + assertEquals("resources/read", request.headers["Mcp-Method"]) + assertEquals("file:///README.md", request.headers["Mcp-Name"]) + respond(content = "", status = HttpStatusCode.Accepted) + } + + transport.start() + transport.send( + JSONRPCRequest( + id = "test-id", + method = "resources/read", + params = buildJsonObject { put("uri", JsonPrimitive("file:///README.md")) }, + ), + ) + transport.close() + } + + @Test + fun `should encode MCP name header values when required`() = runTest { + val cases = listOf( + "Hello, 世界" to "=?base64?SGVsbG8sIOS4lueVjA==?=", + " padded " to "=?base64?IHBhZGRlZCA=?=", + "line1\nline2" to "=?base64?bGluZTEKbGluZTI=?=", + "=?base64?literal?=" to "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=", + ) + + cases.forEachIndexed { index, (name, expectedHeader) -> + val transport = createTransport { request -> + assertEquals(expectedHeader, request.headers["Mcp-Name"]) + respond(content = "", status = HttpStatusCode.Accepted) + } + + transport.start() + transport.send( + JSONRPCRequest( + id = "test-id-$index", + method = "tools/call", + params = buildJsonObject { put("name", JsonPrimitive(name)) }, + ), + ) + transport.close() + } + } + + @Test + fun `should send MCP method header for notifications`() = runTest { + val transport = createTransport { request -> + assertEquals("notifications/tools/list_changed", request.headers["Mcp-Method"]) + assertNull(request.headers["Mcp-Name"]) + respond(content = "", status = HttpStatusCode.Accepted) + } + + transport.start() + transport.send(JSONRPCNotification(method = "notifications/tools/list_changed")) + transport.close() + } + + @Test + fun `should omit MCP standard headers for responses`() = runTest { + val transport = createTransport { request -> + assertNull(request.headers["Mcp-Method"]) + assertNull(request.headers["Mcp-Name"]) + respond(content = "", status = HttpStatusCode.Accepted) + } + + transport.start() + transport.send(JSONRPCResponse(id = RequestId.StringId("test-id"))) + transport.close() + } + // Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support @Ignore @Test @@ -478,6 +581,170 @@ class StreamableHttpClientTransportTest { } } + @Test + fun testSkipEmptySSE() = runTest { + val transport = createTransport { request -> + if (request.method == HttpMethod.Post) { + val sseContent = buildString { + // Event 1: empty data — must be skipped without error + appendLine("id: a") + appendLine("data:") + appendLine() + // Event 2: whitespace-only data — must be skipped without error + appendLine("id: b") + appendLine("data: \t ") + appendLine() + // Event 3: multi-line data lines that together form a valid JSON-RPC response + appendLine("id: c") + appendLine("event: message") + appendLine("data: {") + appendLine("""data: "jsonrpc":"2.0",""") + appendLine("""data: "id":"test-1",""") + appendLine("""data: "result":{""") + appendLine("""data: "protocolVersion":"2025-06-18",""") + appendLine("""data: "capabilities":{},""") + appendLine("""data: "serverInfo":{"name":"server","version":"1.0.0"}""") + appendLine("data: }") + appendLine("data: }") + appendLine() + } + + respond( + content = ByteReadChannel(sseContent), + status = HttpStatusCode.OK, + headers = headersOf( + HttpHeaders.ContentType, + ContentType.Text.EventStream.toString(), + ), + ) + } else { + respond("", HttpStatusCode.OK) + } + } + + val receivedMessages = mutableListOf() + val messageReceived = CompletableDeferred() + + transport.onMessage { message -> + receivedMessages.add(message) + if (!messageReceived.isCompleted) { + messageReceived.complete(Unit) + } + } + + transport.start() + + transport.send( + JSONRPCRequest( + id = "test-1", + method = "initialize", + params = buildJsonObject { }, + ), + ) + + eventually { + messageReceived.await() + } + + receivedMessages shouldHaveSize 1 + val response = receivedMessages[0] + response.shouldBeInstanceOf() + response.id shouldBe RequestId.StringId("test-1") + + transport.close() + } + + @Test + fun testInlineSseRejectsEventExceedingMaxSize() = runTest { + // A malicious server streams an endless single event: many `data:` lines that accumulate + // past the cap and never send the blank-line terminator that would flush the buffer. + val maxInlineSseEventSize = 64 + val transport = createTransport(maxInlineSseEventSize) { request -> + if (request.method == HttpMethod.Post) { + val sseContent = buildString { + appendLine("event: message") + // 20 lines × 16 chars = 320 chars of accumulated data, no terminating blank line. + repeat(20) { appendLine("data: ${"A".repeat(16)}") } + } + respond( + content = ByteReadChannel(sseContent), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.EventStream.toString()), + ) + } else { + respond("", HttpStatusCode.OK) + } + } + + val receivedMessages = mutableListOf() + val receivedErrors = mutableListOf() + transport.onMessage { receivedMessages.add(it) } + transport.onError { receivedErrors.add(it) } + transport.start() + + val error = assertFailsWith { + transport.send(JSONRPCRequest(id = "req-1", method = "test", params = buildJsonObject { })) + } + + error.cause.shouldBeInstanceOf() + receivedErrors.filterIsInstance() shouldHaveSize 1 + receivedMessages shouldHaveSize 0 + transport.close() + } + + @Test + fun testInlineSseEventExactlyAtMaxSizeIsAccepted() = runTest { + // An event whose assembled data length equals the cap must still be accepted and dispatched: + // the guard rejects only sizes strictly greater than the cap (parity with ReadBuffer). + val part1 = """{"jsonrpc":"2.0",""" + val part2 = """"method":"notifications/tools/list_changed"}""" + val maxInlineSseEventSize = (part1 + part2).length + + val transport = createTransport(maxInlineSseEventSize) { request -> + if (request.method == HttpMethod.Post) { + val sseContent = buildString { + appendLine("event: message") + appendLine("data: $part1") + appendLine("data: $part2") + appendLine() + } + respond( + content = ByteReadChannel(sseContent), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Text.EventStream.toString()), + ) + } else { + respond("", HttpStatusCode.OK) + } + } + + val receivedMessages = mutableListOf() + val receivedErrors = mutableListOf() + val messageReceived = CompletableDeferred() + transport.onMessage { + receivedMessages.add(it) + if (!messageReceived.isCompleted) messageReceived.complete(Unit) + } + transport.onError { receivedErrors.add(it) } + transport.start() + + transport.send(JSONRPCRequest(id = "req-1", method = "test", params = buildJsonObject { })) + + eventually { messageReceived.await() } + + receivedMessages shouldHaveSize 1 + (receivedMessages[0] as JSONRPCNotification).method shouldBe "notifications/tools/list_changed" + receivedErrors shouldHaveSize 0 + transport.close() + } + + @Test + fun testNonPositiveMaxInlineSseEventSizeThrows() { + assertFailsWith { + createTransport(maxInlineSseEventSize = 0) { respond("", HttpStatusCode.OK) } + } + } + @Test fun testInlineSSEInResponse() = runTest { val transport = createTransport { request -> @@ -643,7 +910,7 @@ class StreamableHttpClientTransportTest { eventually { while (receivedMessages.isEmpty()) { - delay(10) + delay(10.milliseconds) } } @@ -834,7 +1101,6 @@ class StreamableHttpClientTransportTest { transport.close() } - @Suppress("DEPRECATION") @Test fun testDeprecatedConstructorStillWorks() = runTest { val mockEngine = MockEngine { _ -> diff --git a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractStreamableHttpClientTest.kt b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractStreamableHttpClientTest.kt deleted file mode 100644 index 4a04e81..0000000 --- a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractStreamableHttpClientTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.client - -import io.ktor.client.HttpClient -import io.ktor.client.engine.apache5.Apache5 -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logging -import io.ktor.client.plugins.sse.SSE -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.TestInstance - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -internal abstract class AbstractStreamableHttpClientTest { - - // start mokksy on random port - protected val mockMcp: MockMcp = MockMcp(verbose = true) - - @AfterEach - fun afterEach() { - mockMcp.verifyNoUnexpectedRequests() - } - - @AfterAll - fun afterAll() { - mockMcp.verifyNoUnmatchedStubs() - mockMcp.close() - } - - protected suspend fun connect(client: Client) { - client.connect( - StreamableHttpClientTransport( - url = mockMcp.url, - client = HttpClient(Apache5) { - install(SSE) - install(Logging) { - level = LogLevel.ALL - } - }, - ), - ) - } -} diff --git a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientSamplingValidationTest.kt b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientSamplingValidationTest.kt new file mode 100644 index 0000000..7775a08 --- /dev/null +++ b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientSamplingValidationTest.kt @@ -0,0 +1,98 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +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.EmptyJsonObject +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.Role +import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.Tool +import io.modelcontextprotocol.kotlin.sdk.types.ToolChoice +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import kotlinx.serialization.json.buildJsonObject +import org.junit.jupiter.api.assertDoesNotThrow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ClientSamplingValidationTest { + + private val dummyTool = Tool( + name = "t", + inputSchema = ToolSchema(properties = buildJsonObject { }, required = emptyList()), + ) + + private val minimalMessages = listOf(SamplingMessage(Role.User, TextContent("hi"))) + + private val noToolsCaps = ClientCapabilities(sampling = ClientCapabilities.sampling) + private val withToolsCaps = ClientCapabilities( + sampling = ClientCapabilities.Sampling(tools = EmptyJsonObject), + ) + + @Test + fun `request without tools or toolChoice is always accepted`() { + val request = CreateMessageRequest( + CreateMessageRequestParams(maxTokens = 10, messages = minimalMessages), + ) + assertDoesNotThrow { validateSamplingToolsCapability(request, noToolsCaps) } + } + + @Test + fun `tools without sampling tools capability throws InvalidParams`() { + val request = CreateMessageRequest( + CreateMessageRequestParams( + maxTokens = 10, + messages = minimalMessages, + tools = listOf(dummyTool), + ), + ) + val exception = assertFailsWith { + validateSamplingToolsCapability(request, noToolsCaps) + } + assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code) + check(exception.message?.contains("tools") == true) + } + + @Test + fun `toolChoice without sampling tools capability throws InvalidParams`() { + val request = CreateMessageRequest( + CreateMessageRequestParams( + maxTokens = 10, + messages = minimalMessages, + toolChoice = ToolChoice(mode = ToolChoice.Mode.Required), + ), + ) + val exception = assertFailsWith { + validateSamplingToolsCapability(request, noToolsCaps) + } + assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code) + check(exception.message?.contains("toolChoice") == true) + } + + @Test + fun `tools with sampling tools capability is accepted`() { + val request = CreateMessageRequest( + CreateMessageRequestParams( + maxTokens = 10, + messages = minimalMessages, + tools = listOf(dummyTool), + ), + ) + assertDoesNotThrow { validateSamplingToolsCapability(request, withToolsCaps) } + } + + @Test + fun `toolChoice with sampling tools capability is accepted`() { + val request = CreateMessageRequest( + CreateMessageRequestParams( + maxTokens = 10, + messages = minimalMessages, + toolChoice = ToolChoice(mode = ToolChoice.Mode.Auto), + ), + ) + assertDoesNotThrow { validateSamplingToolsCapability(request, withToolsCaps) } + } +} diff --git a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockMcp.kt b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockMcp.kt deleted file mode 100644 index fb71904..0000000 --- a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockMcp.kt +++ /dev/null @@ -1,241 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.client - -import dev.mokksy.mokksy.BuildingStep -import dev.mokksy.mokksy.Mokksy -import dev.mokksy.mokksy.StubConfiguration -import dev.mokksy.mokksy.shutdown -import dev.mokksy.mokksy.start -import io.ktor.http.ContentType -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.sse.ServerSentEvent -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest -import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import kotlinx.coroutines.flow.Flow -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.putJsonObject - -const val MCP_SESSION_ID_HEADER = "MCP-Session-Id" - -internal class MockMcp(verbose: Boolean = false) : AutoCloseable { - - private val mokksy: Mokksy = Mokksy(verbose = verbose).apply { - start() - } - - fun verifyNoUnexpectedRequests() { - mokksy.verifyNoUnexpectedRequests() - } - - fun verifyNoUnmatchedStubs() { - mokksy.verifyNoUnmatchedStubs() - } - - val url = "${mokksy.baseUrl()}/mcp" - - @Suppress("LongParameterList") - fun onInitialize( - clientName: String? = null, - sessionId: String, - protocolVersion: String = "2025-03-26", - serverName: String = "Mock MCP Server", - serverVersion: String = "1.0.0", - capabilities: JsonObject = buildJsonObject { - putJsonObject("tools") { - put("listChanged", JsonPrimitive(false)) - } - }, - ) { - val predicates = if (clientName != null) { - arrayOf<(JSONRPCRequest?) -> Boolean>({ - it?.params?.jsonObject - ?.get("clientInfo")?.jsonObject - ?.get("name")?.jsonPrimitive - ?.contentOrNull == clientName - }) - } else { - emptyArray() - } - - handleWithResult( - jsonRpcMethod = "initialize", - sessionId = sessionId, - bodyPredicates = predicates, - // language=json - result = """ - { - "capabilities": $capabilities, - "protocolVersion": "$protocolVersion", - "serverInfo": { - "name": "$serverName", - "version": "$serverVersion" - }, - "_meta": { - "foo": "bar" - } - } - """.trimIndent(), - ) - } - - fun onJSONRPCRequest( - httpMethod: HttpMethod = HttpMethod.Post, - jsonRpcMethod: String, - expectedSessionId: String? = null, - vararg bodyPredicates: (JSONRPCRequest) -> Boolean, - ): BuildingStep = mokksy.method( - configuration = StubConfiguration(removeAfterMatch = true), - httpMethod = httpMethod, - requestType = JSONRPCRequest::class, - ) { - path("/mcp") - expectedSessionId?.let { - containsHeader(MCP_SESSION_ID_HEADER, it) - } - bodyMatchesPredicate( - description = "JSON-RPC version is '2.0'", - predicate = - { - it!!.jsonrpc == "2.0" - }, - ) - bodyMatchesPredicate( - description = "JSON-RPC Method should be '$jsonRpcMethod'", - predicate = - { - it!!.method == jsonRpcMethod - }, - ) - bodyPredicates.forEach { predicate -> - bodyMatchesPredicate(predicate = { predicate.invoke(it!!) }) - } - } - - @Suppress("LongParameterList") - fun handleWithResult( - httpMethod: HttpMethod = HttpMethod.Post, - jsonRpcMethod: String, - expectedSessionId: String? = null, - sessionId: String, - contentType: ContentType = ContentType.Application.Json, - statusCode: HttpStatusCode = HttpStatusCode.OK, - vararg bodyPredicates: (JSONRPCRequest) -> Boolean, - result: () -> JsonObject, - ) { - onJSONRPCRequest( - httpMethod = httpMethod, - jsonRpcMethod = jsonRpcMethod, - expectedSessionId = expectedSessionId, - bodyPredicates = bodyPredicates, - ) respondsWith { - val requestId = when (val id = request.body().id) { - is RequestId.NumberId -> id.value.toString() - is RequestId.StringId -> "\"${id.value}\"" - } - val resultObject = result.invoke() - // language=json - body = """ - { - "jsonrpc": "2.0", - "id": $requestId, - "result": $resultObject - } - """.trimIndent() - this.contentType = contentType - headers += MCP_SESSION_ID_HEADER to sessionId - httpStatus = statusCode - } - } - - @Suppress("LongParameterList") - fun handleWithResult( - httpMethod: HttpMethod = HttpMethod.Post, - jsonRpcMethod: String, - expectedSessionId: String? = null, - sessionId: String, - contentType: ContentType = ContentType.Application.Json, - statusCode: HttpStatusCode = HttpStatusCode.OK, - vararg bodyPredicates: (JSONRPCRequest) -> Boolean, - result: String, - ) { - handleWithResult( - httpMethod = httpMethod, - jsonRpcMethod = jsonRpcMethod, - expectedSessionId = expectedSessionId, - sessionId = sessionId, - contentType = contentType, - statusCode = statusCode, - bodyPredicates = bodyPredicates, - result = { - Json.parseToJsonElement(result).jsonObject - }, - ) - } - - @Suppress("LongParameterList") - fun handleJSONRPCRequest( - httpMethod: HttpMethod = HttpMethod.Post, - jsonRpcMethod: String, - expectedSessionId: String? = null, - sessionId: String, - contentType: ContentType = ContentType.Application.Json, - statusCode: HttpStatusCode = HttpStatusCode.OK, - vararg bodyPredicates: (JSONRPCRequest?) -> Boolean, - bodyBuilder: () -> String = { "" }, - ) { - onJSONRPCRequest( - httpMethod = httpMethod, - jsonRpcMethod = jsonRpcMethod, - expectedSessionId = expectedSessionId, - bodyPredicates = bodyPredicates, - ) respondsWith { - body = bodyBuilder.invoke() - this.contentType = contentType - headers += MCP_SESSION_ID_HEADER to sessionId - httpStatus = statusCode - } - } - - fun onSubscribe(httpMethod: HttpMethod = HttpMethod.Post, sessionId: String): BuildingStep = mokksy.method( - httpMethod = httpMethod, - name = "MCP GETs", - requestType = Any::class, - ) { - path("/mcp") - containsHeader(MCP_SESSION_ID_HEADER, sessionId) - containsHeader("Accept", "application/json,text/event-stream") - containsHeader("Cache-Control", "no-store") - } - - fun handleSubscribeWithGet(sessionId: String, block: () -> Flow) { - onSubscribe( - httpMethod = HttpMethod.Get, - sessionId = sessionId, - ) respondsWithSseStream { - headers += MCP_SESSION_ID_HEADER to sessionId - this.flow = block.invoke() - } - } - - fun mockUnsubscribeRequest(sessionId: String) { - mokksy.delete( - configuration = StubConfiguration(removeAfterMatch = true), - requestType = JSONRPCRequest::class, - ) { - path("/mcp") - containsHeader(MCP_SESSION_ID_HEADER, sessionId) - } respondsWith { - body = null - } - } - - override fun close() { - mokksy.shutdown() - } -} diff --git a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt deleted file mode 100644 index a1fe89e..0000000 --- a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt +++ /dev/null @@ -1,293 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.client - -import io.kotest.matchers.collections.shouldContain -import io.kotest.matchers.shouldBe -import io.ktor.http.ContentType -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.sse.ServerSentEvent -import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.Tool -import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonObject -import org.junit.jupiter.api.TestInstance -import kotlin.test.Test -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -@OptIn(ExperimentalUuidApi::class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -internal class StreamableHttpClientTest : AbstractStreamableHttpClientTest() { - - @Test - fun `Should skip empty SSE`(): Unit = runBlocking { - val client = Client( - clientInfo = Implementation( - name = "client1", - version = "1.0.0", - ), - options = ClientOptions( - capabilities = ClientCapabilities(), - ), - ) - val sessionId = Uuid.random().toString() - - mockMcp.onJSONRPCRequest( - httpMethod = HttpMethod.Post, - jsonRpcMethod = "initialize", - ).respondsWithStream { - headers += MCP_SESSION_ID_HEADER to sessionId - flow = flowOf( - "id: ${Uuid.random()}\n", - "data:\n", // empty data - "\n", - "id: ${Uuid.random()}\n", - "data: \t \n", // tabs and spaces - "\n", - "id: ${Uuid.random()}\n", - "event: message\n", - // multiline data - "data: {\n", - "data: \"result\":{\n" + - "data: \"protocolVersion\":\"2025-06-18\",\n" + - "data: \"capabilities\":{},\n" + - "data: \"serverInfo\":{\"name\":\"simple-streamable-http-server\",\"version\":\"1.0.0\"}\n" + - "data: },\n" + - "data: \"jsonrpc\":\"2.0\",\n" + - "data: \"id\":\"7ce065b0678f49e5b04ce5a0fcc7d518\"\n" + - "data: }\n", - "\n", - ) - } - - mockMcp.handleJSONRPCRequest( - jsonRpcMethod = "notifications/initialized", - expectedSessionId = sessionId, - sessionId = sessionId, - statusCode = HttpStatusCode.Accepted, - ) - - mockMcp.handleSubscribeWithGet(sessionId) { - emptyFlow() - } - - connect(client) - } - - @Test - fun `test streamableHttpClient`() = runBlocking { - val client = Client( - clientInfo = Implementation( - name = "client1", - version = "1.0.0", - ), - options = ClientOptions( - capabilities = ClientCapabilities(), - ), - ) - - val sessionId = Uuid.random().toString() - - mockMcp.onInitialize( - clientName = "client1", - sessionId = sessionId, - ) - - mockMcp.handleJSONRPCRequest( - jsonRpcMethod = "notifications/initialized", - expectedSessionId = sessionId, - sessionId = sessionId, - statusCode = HttpStatusCode.Accepted, - ) - - mockMcp.handleSubscribeWithGet(sessionId) { - flow { - delay(500.milliseconds) - emit( - ServerSentEvent( - event = "message", - id = "1", - data = @Suppress("MaxLineLength") - //language=json - """{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""", - ), - ) - delay(200.milliseconds) - emit( - ServerSentEvent( - data = @Suppress("MaxLineLength") - //language=json - """{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""", - ), - ) - } - } - - // TODO: how to get notifications via Client API? - - mockMcp.handleWithResult( - jsonRpcMethod = "tools/list", - sessionId = sessionId, - // language=json - result = """ - { - "tools": [ - { - "name": "get_weather", - "title": "Weather Information Provider", - "description": "Get current weather information for a location", - "inputSchema": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "City name or zip code" - } - }, - "required": ["location"] - }, - "outputSchema": { - "type": "object", - "properties": { - "temperature": { - "type": "number", - "description": "Temperature, Celsius" - } - }, - "required": ["temperature"] - }, - "_meta": {} - } - ] - } - """.trimIndent(), - ) - - connect(client) - - val listToolsResult = client.listTools() - - listToolsResult.tools shouldContain Tool( - name = "get_weather", - title = "Weather Information Provider", - description = "Get current weather information for a location", - inputSchema = ToolSchema( - properties = buildJsonObject { - putJsonObject("location") { - put("type", "string") - put("description", "City name or zip code") - } - }, - required = listOf("location"), - ), - outputSchema = ToolSchema( - properties = buildJsonObject { - putJsonObject("temperature") { - put("type", "number") - put("description", "Temperature, Celsius") - } - }, - required = listOf("temperature"), - ), - annotations = null, - meta = EmptyJsonObject, - ) - - client.close() - } - - @Test - fun `terminateSession sends DELETE request`() = runBlocking { - val client = Client( - clientInfo = Implementation(name = "client1", version = "1.0.0"), - options = ClientOptions(capabilities = ClientCapabilities()), - ) - val sessionId = Uuid.random().toString() - - mockMcp.onInitialize(clientName = "client1", sessionId = sessionId) - mockMcp.handleJSONRPCRequest( - jsonRpcMethod = "notifications/initialized", - expectedSessionId = sessionId, - sessionId = sessionId, - statusCode = HttpStatusCode.Accepted, - ) - mockMcp.handleSubscribeWithGet(sessionId) { emptyFlow() } - - connect(client) - - mockMcp.mockUnsubscribeRequest(sessionId = sessionId) - (client.transport as StreamableHttpClientTransport).terminateSession() - (client.transport as StreamableHttpClientTransport).sessionId shouldBe null - - client.close() - } - - @Test - fun `handle MethodNotAllowed`() = runBlocking { - checkSupportNonStreamingResponse( - ContentType.Text.EventStream, - HttpStatusCode.MethodNotAllowed, - ) - } - - @Test - fun `handle non-streaming response`() = runBlocking { - checkSupportNonStreamingResponse( - ContentType.Application.Json, - HttpStatusCode.OK, - ) - } - - private suspend fun checkSupportNonStreamingResponse(contentType: ContentType, statusCode: HttpStatusCode) { - val sessionId = "SID_${Uuid.random().toHexString()}" - val clientName = "client-${Uuid.random().toHexString()}" - val client = Client( - clientInfo = Implementation(name = clientName, version = "1.0.0"), - options = ClientOptions( - capabilities = ClientCapabilities(), - ), - ) - - mockMcp.onInitialize(clientName = clientName, sessionId = sessionId) - - mockMcp.handleJSONRPCRequest( - jsonRpcMethod = "notifications/initialized", - expectedSessionId = sessionId, - sessionId = sessionId, - statusCode = HttpStatusCode.Accepted, - ) - - mockMcp.onSubscribe( - httpMethod = HttpMethod.Get, - sessionId = sessionId, - ) respondsWith { - headers += MCP_SESSION_ID_HEADER to sessionId - body = null - httpStatus = statusCode - this.contentType = contentType - } - - mockMcp.handleWithResult(jsonRpcMethod = "ping", sessionId = sessionId) { - buildJsonObject {} - } - - connect(client) - - delay(1.seconds) - - client.ping() // connection is still alive - - client.close() - } -} diff --git a/kotlin-sdk-core/.openapi-generator-ignore b/kotlin-sdk-core/.openapi-generator-ignore deleted file mode 100644 index dbdf930..0000000 --- a/kotlin-sdk-core/.openapi-generator-ignore +++ /dev/null @@ -1,29 +0,0 @@ -# OpenAPI Generator Ignore -# Generated by openapi-generator https://github.com/openapitools/openapi-generator - -# Use this file to prevent files from being overwritten by the generator. -# The patterns follow closely to .gitignore or .dockerignore. - -# As an example, the C# client generator defines ApiClient.cs. -# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: -**/auth/** -**/infrastructure/** -!**/Base64ByteArray.kt -!**/Bytes.kt - - -## Exclude Models which are causing problems -**/JSONRPCResponse.kt -**/Result.kt -**/ListToolsResult.kt -**/InitializeRequest.kt -**/InitializeResult.kt -**/ServerCapabilities.kt -**/InitializeRequestParams.kt -**/CreateMessageRequest.kt -**/CreateMessageRequestParams.kt -**/ClientCapabilities.kt -**/Tool.kt -**/ToolInputSchema.kt -**/ToolOutputSchema.kt - diff --git a/kotlin-sdk-core/api/kotlin-sdk-core.api b/kotlin-sdk-core/api/kotlin-sdk-core.api index d4f88ba..b3e88d8 100644 --- a/kotlin-sdk-core/api/kotlin-sdk-core.api +++ b/kotlin-sdk-core/api/kotlin-sdk-core.api @@ -59,6 +59,7 @@ public abstract class io/modelcontextprotocol/kotlin/sdk/shared/Protocol { public abstract fun assertRequestHandlerCapability (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun connect (Lio/modelcontextprotocol/kotlin/sdk/shared/Transport;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + protected final fun enableConcurrentDispatch ()V public final fun getFallbackNotificationHandler ()Lkotlin/jvm/functions/Function2; public final fun getFallbackRequestHandler ()Lkotlin/jvm/functions/Function3; public final fun getNotificationHandlers ()Ljava/util/Map; @@ -71,6 +72,7 @@ public abstract class io/modelcontextprotocol/kotlin/sdk/shared/Protocol { public static synthetic fun notification$default (Lio/modelcontextprotocol/kotlin/sdk/shared/Protocol;Lio/modelcontextprotocol/kotlin/sdk/types/Notification;Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun onClose ()V public fun onError (Ljava/lang/Throwable;)V + protected fun onInitializedNotification ()V public final fun removeNotificationHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public final fun removeRequestHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public final fun request (Lio/modelcontextprotocol/kotlin/sdk/types/Request;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -79,19 +81,23 @@ public abstract class io/modelcontextprotocol/kotlin/sdk/shared/Protocol { public final fun setFallbackRequestHandler (Lkotlin/jvm/functions/Function3;)V public final fun setNotificationHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;Lkotlin/jvm/functions/Function1;)V public final fun setRequestHandlerInternal (Lio/modelcontextprotocol/kotlin/sdk/types/Method;Lkotlin/jvm/functions/Function3;)V + protected fun wrapRequestHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;Lkotlin/jvm/functions/Function3;)Lkotlin/jvm/functions/Function3; } public final class io/modelcontextprotocol/kotlin/sdk/shared/ProtocolKt { public static final field IMPLEMENTATION_NAME Ljava/lang/String; + public static final fun currentRequestHandlerExtra (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun getDEFAULT_REQUEST_TIMEOUT ()J } public class io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions { - public synthetic fun (ZJILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (ZJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZJLkotlin/coroutines/CoroutineContext;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZJLkotlin/coroutines/CoroutineContext;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getEnforceStrictCapabilities ()Z + public final fun getHandlerCoroutineContext ()Lkotlin/coroutines/CoroutineContext; public final fun getTimeout-UwyO8pc ()J public final fun setEnforceStrictCapabilities (Z)V + public final fun setHandlerCoroutineContext (Lkotlin/coroutines/CoroutineContext;)V public final fun setTimeout-LRDsOJo (J)V } @@ -106,8 +112,16 @@ public final class io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferKt { public static final fun serializeMessage (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;)Ljava/lang/String; } -public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra { - public fun ()V +public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra : kotlin/coroutines/AbstractCoroutineContextElement { + public static final field Key Lio/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra$Key; + public final fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; + public final fun getRequestId ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestId; + public final fun sendNotification (Lio/modelcontextprotocol/kotlin/sdk/types/Notification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun sendRequest (Lio/modelcontextprotocol/kotlin/sdk/types/Request;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun sendRequest$default (Lio/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra;Lio/modelcontextprotocol/kotlin/sdk/types/Request;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra$Key : kotlin/coroutines/CoroutineContext$Key { } public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestOptions : io/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions { @@ -124,6 +138,12 @@ public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestOptions : io public fun toString ()Ljava/lang/String; } +public final class io/modelcontextprotocol/kotlin/sdk/shared/TooLongFrameException : java/io/IOException { + public fun (JI)V + public final fun getFrameSize ()J + public final fun getMaxFrameSize ()I +} + public abstract interface class io/modelcontextprotocol/kotlin/sdk/shared/Transport { public abstract fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onClose (Lkotlin/jvm/functions/Function0;)V @@ -370,6 +390,19 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/BooleanSchema$Compan public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/CacheScope : java/lang/Enum { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope$Companion; + public static final field Private Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; + public static final field Public Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; + public static fun values ()[Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/CacheScope$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequest$Companion; public fun (Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams;)V @@ -383,6 +416,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolRequest : io public final fun getName ()Ljava/lang/String; public fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestParams; + public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -407,24 +441,26 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolRequestBuild public final fun arguments (Lkotlin/jvm/functions/Function1;)V public final fun arguments (Lkotlinx/serialization/json/JsonObject;)V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getName ()Ljava/lang/String; public final fun setName (Ljava/lang/String;)V } public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams$Companion; - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Lkotlinx/serialization/json/JsonObject; - public final fun component3-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun copy-EDEVuBg (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; - public static synthetic fun copy-EDEVuBg$default (Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; + public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; + public final fun component4-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; + public final fun copy-6R815Ik (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; + public static synthetic fun copy-6R815Ik$default (Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; public fun equals (Ljava/lang/Object;)Z public final fun getArguments ()Lkotlinx/serialization/json/JsonObject; public fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; public final fun getName ()Ljava/lang/String; + public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -603,22 +639,32 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CancelledNotificatio public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesKt { + public static final fun getSupportsUrl (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;)Z +} + public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Companion; public fun ()V - public fun (Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)V - public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling; public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots; - public final fun component3 ()Lkotlinx/serialization/json/JsonObject; - public final fun component4 ()Lkotlinx/serialization/json/JsonObject; - public final fun copy (Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; + public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; + public final fun component4 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks; + public final fun component5 ()Lkotlinx/serialization/json/JsonObject; + public final fun component6 ()Ljava/util/Map; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; public fun equals (Ljava/lang/Object;)Z - public final fun getElicitation ()Lkotlinx/serialization/json/JsonObject; + public final fun getElicitation ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; public final fun getExperimental ()Lkotlinx/serialization/json/JsonObject; + public final fun getExtensions ()Ljava/util/Map; public final fun getRoots ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots; - public final fun getSampling ()Lkotlinx/serialization/json/JsonObject; + public final fun getSampling ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling; + public final fun getTasks ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -635,8 +681,39 @@ public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapa } public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Companion { - public final fun getElicitation ()Lkotlinx/serialization/json/JsonObject; - public final fun getSampling ()Lkotlinx/serialization/json/JsonObject; + public final fun getElicitation ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; + public final fun getSampling ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling; + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun component2 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; + public fun equals (Ljava/lang/Object;)Z + public final fun getForm ()Lkotlinx/serialization/json/JsonObject; + public final fun getUrl ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } @@ -669,17 +746,169 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$R public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun component2 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling; + public fun equals (Ljava/lang/Object;)Z + public final fun getContext ()Lkotlinx/serialization/json/JsonObject; + public final fun getTools ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun component2 ()Lkotlinx/serialization/json/JsonObject; + public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests; + public final fun copy (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks; + public fun equals (Ljava/lang/Object;)Z + public final fun getCancel ()Lkotlinx/serialization/json/JsonObject; + public final fun getList ()Lkotlinx/serialization/json/JsonObject; + public final fun getRequests ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Companion; + public fun ()V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling; + public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests; + public fun equals (Ljava/lang/Object;)Z + public final fun getElicitation ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation; + public final fun getSampling ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation; + public fun equals (Ljava/lang/Object;)Z + public final fun getCreate ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Elicitation$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling; + public fun equals (Ljava/lang/Object;)Z + public final fun getCreateMessage ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks$Requests$Sampling$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilitiesBuilder { public fun ()V public final fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; - public final fun elicitation (Lkotlin/jvm/functions/Function1;)V - public final fun elicitation (Lkotlinx/serialization/json/JsonObject;)V + public final fun elicitation (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;)V public final fun experimental (Lkotlin/jvm/functions/Function1;)V public final fun experimental (Lkotlinx/serialization/json/JsonObject;)V + public final fun extensions (Ljava/util/Map;)V public final fun roots (Ljava/lang/Boolean;)V public static synthetic fun roots$default (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilitiesBuilder;Ljava/lang/Boolean;ILjava/lang/Object;)V - public final fun sampling (Lkotlin/jvm/functions/Function1;)V - public final fun sampling (Lkotlinx/serialization/json/JsonObject;)V + public final fun sampling (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;)V } public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/ClientNotification : io/modelcontextprotocol/kotlin/sdk/types/Notification { @@ -751,7 +980,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CompleteRequestBuild public fun ()V public final fun argument (Ljava/lang/String;Ljava/lang/String;)V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun context (Ljava/util/Map;)V public final fun context (Lkotlin/jvm/functions/Function1;)V public final fun ref (Lio/modelcontextprotocol/kotlin/sdk/types/Reference;)V @@ -931,6 +1160,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ContentTypes : java/ public static final field IMAGE Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; public static final field RESOURCE_LINK Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; public static final field TEXT Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; + public static final field TOOL_RESULT Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; + public static final field TOOL_USE Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; public static fun getEntries ()Lkotlin/enums/EnumEntries; public final fun getValue ()Ljava/lang/String; public static fun valueOf (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; @@ -959,6 +1190,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestParams; public final fun getStopSequences ()Ljava/util/List; public final fun getSystemPrompt ()Ljava/lang/String; + public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; public final fun getTemperature ()Ljava/lang/Double; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -982,7 +1214,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getContext ()Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext; public final fun getMaxTokens ()Ljava/lang/Integer; public final fun getStopSequences ()Ljava/util/List; @@ -1003,9 +1235,12 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams$Companion; - public synthetic fun (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;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;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()I + public final fun component10 ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice; + public final fun component11 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; + public final fun component12-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; public final fun component2 ()Ljava/util/List; public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences; public final fun component4 ()Ljava/lang/String; @@ -1013,9 +1248,9 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public final fun component6 ()Ljava/lang/Double; public final fun component7 ()Ljava/util/List; public final fun component8 ()Lkotlinx/serialization/json/JsonObject; - public final fun component9-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun copy-qtvSJzw (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;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams; - public static synthetic fun copy-qtvSJzw$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;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams; + 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 fun equals (Ljava/lang/Object;)Z public final fun getIncludeContext ()Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext; public final fun getMaxTokens ()I @@ -1025,7 +1260,10 @@ 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; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -1047,17 +1285,19 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageResult : io/modelcontextprotocol/kotlin/sdk/types/ClientResult { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageResult$Companion; - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessageContent;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessageContent;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/Role; - public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent; + public final fun component2 ()Ljava/util/List; public final fun component3 ()Ljava/lang/String; public final fun component4-6olV-UY ()Ljava/lang/String; public final fun component5 ()Lkotlinx/serialization/json/JsonObject; - public final fun copy-ctVX1tw (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageResult; - public static synthetic fun copy-ctVX1tw$default (Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageResult;Lio/modelcontextprotocol/kotlin/sdk/types/Role;Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageResult; + public final fun copy-ctVX1tw (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageResult; + public static synthetic fun copy-ctVX1tw$default (Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageResult;Lio/modelcontextprotocol/kotlin/sdk/types/Role;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageResult; public fun equals (Ljava/lang/Object;)Z - public final fun getContent ()Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent; + public final fun getContent ()Ljava/util/List; public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getModel ()Ljava/lang/String; public final fun getRole ()Lio/modelcontextprotocol/kotlin/sdk/types/Role; @@ -1168,6 +1408,104 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CustomRequest$Compan public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest$Companion; + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;)V + public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest;Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest; + public fun equals (Ljava/lang/Object;)Z + public final fun getMeta--Khlwt8 ()Lkotlinx/serialization/json/JsonObject; + public fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; + public fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; + public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestParams; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams$Companion; + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1--Khlwt8 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy-hVVZdjo (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; + public static synthetic fun copy-hVVZdjo$default (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; + public fun equals (Ljava/lang/Object;)Z + public fun getMeta--Khlwt8 ()Lkotlinx/serialization/json/JsonObject; + public synthetic fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverResult : io/modelcontextprotocol/kotlin/sdk/types/ServerResult { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult$Companion; + public fun (Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Ljava/lang/String;Ljava/lang/String;JLio/modelcontextprotocol/kotlin/sdk/types/CacheScope;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Ljava/lang/String;Ljava/lang/String;JLio/modelcontextprotocol/kotlin/sdk/types/CacheScope;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()J + public final fun component6 ()Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; + public final fun component7 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Ljava/lang/String;Ljava/lang/String;JLio/modelcontextprotocol/kotlin/sdk/types/CacheScope;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Ljava/lang/String;Ljava/lang/String;JLio/modelcontextprotocol/kotlin/sdk/types/CacheScope;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult; + public fun equals (Ljava/lang/Object;)Z + public final fun getCacheScope ()Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; + public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; + public final fun getInstructions ()Ljava/lang/String; + public fun getMeta ()Lkotlinx/serialization/json/JsonObject; + public final fun getResultType ()Ljava/lang/String; + public final fun getSupportedVersions ()Ljava/util/List; + public final fun getTtlMs ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/DiscoverResult$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverResult$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/DoubleSchema : io/modelcontextprotocol/kotlin/sdk/types/NumberSchemaDefinition { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/DoubleSchema$Companion; public fun ()V @@ -1241,7 +1579,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequest$Compan public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getMessage ()Ljava/lang/String; public final fun requestedSchema (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;)V public final fun requestedSchema (Lkotlin/jvm/functions/Function1;)V @@ -1619,7 +1957,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/GetPromptRequest$Com public final class io/modelcontextprotocol/kotlin/sdk/types/GetPromptRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/GetPromptRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getArguments ()Ljava/util/Map; public final fun getName ()Ljava/lang/String; public final fun setArguments (Ljava/util/Map;)V @@ -2064,7 +2402,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/InitializeRequest$Co public final class io/modelcontextprotocol/kotlin/sdk/types/InitializeRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/InitializeRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun capabilities (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;)V public final fun capabilities (Lkotlin/jvm/functions/Function1;)V public final fun getProtocolVersion ()Ljava/lang/String; @@ -2454,7 +2792,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequest$C public final class io/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListPromptsResult : io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { @@ -2525,7 +2863,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplate public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplatesRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplatesRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplatesResult : io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { @@ -2596,7 +2934,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequest public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourcesResult : io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { @@ -2666,7 +3004,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest$Com public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsResult : io/modelcontextprotocol/kotlin/sdk/types/ClientResult { @@ -2800,7 +3138,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListToolsRequest$Com public final class io/modelcontextprotocol/kotlin/sdk/types/ListToolsRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListToolsRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListToolsResult : io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { @@ -2924,7 +3262,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Logging_dslKt { public abstract interface annotation class io/modelcontextprotocol/kotlin/sdk/types/McpDsl : java/lang/annotation/Annotation { } -public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception { +public class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception { public fun (I)V public fun (ILjava/lang/String;)V public fun (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;)V @@ -2934,7 +3272,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/ public final fun getData ()Lkotlinx/serialization/json/JsonElement; } -public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/MediaContent : io/modelcontextprotocol/kotlin/sdk/types/ContentBlock { +public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/MediaContent : io/modelcontextprotocol/kotlin/sdk/types/ContentBlock, io/modelcontextprotocol/kotlin/sdk/types/SamplingMessageContent { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent$Companion; } @@ -3018,6 +3356,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Method$Defined : jav public static final field ResourcesUnsubscribe Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field RootsList Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field SamplingCreateMessage Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public static final field ServerDiscover Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field TasksCancel Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field TasksGet Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field TasksList Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; @@ -3226,7 +3565,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/PingRequest$Companio public final class io/modelcontextprotocol/kotlin/sdk/types/PingRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/PingRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/PingRequest_dslKt { @@ -3540,6 +3879,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/RPCError$ErrorCode { public static final field PARSE_ERROR I public static final field REQUEST_TIMEOUT I public static final field RESOURCE_NOT_FOUND I + public static final field UNSUPPORTED_PROTOCOL_VERSION I + public static final field URL_ELICITATION_REQUIRED I } public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { @@ -3576,7 +3917,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest$ public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getUri ()Ljava/lang/String; public final fun setUri (Ljava/lang/String;)V } @@ -3784,8 +4125,12 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/RequestMeta { public static fun equals-impl (Lkotlinx/serialization/json/JsonObject;Ljava/lang/Object;)Z public static final fun equals-impl0 (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Z public static final fun get-impl (Lkotlinx/serialization/json/JsonObject;Ljava/lang/String;)Lkotlinx/serialization/json/JsonElement; + public static final fun getClientCapabilities-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; + public static final fun getClientInfo-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/Implementation; public final fun getJson ()Lkotlinx/serialization/json/JsonObject; + public static final fun getLogLevel-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; public static final fun getProgressToken-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/RequestId; + public static final fun getProtocolVersion-impl (Lkotlinx/serialization/json/JsonObject;)Ljava/lang/String; public static final fun getRelatedTask-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/RelatedTaskMetadata; public fun hashCode ()I public static fun hashCode-impl (Lkotlinx/serialization/json/JsonObject;)I @@ -4196,13 +4541,18 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Roots_dslKt { public final class io/modelcontextprotocol/kotlin/sdk/types/SamplingMessage { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessage$Companion; - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessageContent;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessageContent;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/Role; - public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent;)Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessage; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessage;Lio/modelcontextprotocol/kotlin/sdk/types/Role;Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessage; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessage; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessage;Lio/modelcontextprotocol/kotlin/sdk/types/Role;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessage; public fun equals (Ljava/lang/Object;)Z - public final fun getContent ()Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent; + public final fun getContent ()Ljava/util/List; + public final fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getRole ()Lio/modelcontextprotocol/kotlin/sdk/types/Role; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -4230,6 +4580,15 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/SamplingMessageBuild public final fun user (Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent;)V } +public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/SamplingMessageContent : io/modelcontextprotocol/kotlin/sdk/types/WithMeta { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessageContent$Companion; + public abstract fun getType ()Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/SamplingMessageContent$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/Sampling_dslKt { public static final fun assistant (Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessageBuilder;Lkotlin/jvm/functions/Function0;)V public static final fun assistantAudio (Lio/modelcontextprotocol/kotlin/sdk/types/SamplingMessageBuilder;Lkotlin/jvm/functions/Function1;)V @@ -4245,22 +4604,26 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Sampling_dslKt { public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Companion; public fun ()V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools; public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources; public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts; public final fun component4 ()Lkotlinx/serialization/json/JsonObject; public final fun component5 ()Lkotlinx/serialization/json/JsonObject; - public final fun component6 ()Lkotlinx/serialization/json/JsonObject; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; + public final fun component6 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks; + public final fun component7 ()Lkotlinx/serialization/json/JsonObject; + public final fun component8 ()Ljava/util/Map; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Ljava/util/Map;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; public fun equals (Ljava/lang/Object;)Z public final fun getCompletions ()Lkotlinx/serialization/json/JsonObject; public final fun getExperimental ()Lkotlinx/serialization/json/JsonObject; + public final fun getExtensions ()Ljava/util/Map; public final fun getLogging ()Lkotlinx/serialization/json/JsonObject; public final fun getPrompts ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts; public final fun getResources ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources; + public final fun getTasks ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks; public final fun getTools ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -4343,6 +4706,97 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$R public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun component2 ()Lkotlinx/serialization/json/JsonObject; + public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests; + public final fun copy (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks; + public fun equals (Ljava/lang/Object;)Z + public final fun getCancel ()Lkotlinx/serialization/json/JsonObject; + public final fun getList ()Lkotlinx/serialization/json/JsonObject; + public final fun getRequests ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Companion; + public fun ()V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests; + public fun equals (Ljava/lang/Object;)Z + public final fun getTools ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools$Companion; + public fun ()V + public fun (Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools; + public fun equals (Ljava/lang/Object;)Z + public final fun getCall ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tasks$Requests$Tools$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools$Companion; public fun ()V @@ -4430,7 +4884,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/SetLevelRequest$Comp public final class io/modelcontextprotocol/kotlin/sdk/types/SetLevelRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/SetLevelRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getLoggingLevel ()Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; public final fun setLoggingLevel (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;)V } @@ -4503,6 +4957,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/StopReason$Companion public final fun getEndTurn--MeDoko ()Ljava/lang/String; public final fun getMaxTokens--MeDoko ()Ljava/lang/String; public final fun getStopSequence--MeDoko ()Ljava/lang/String; + public final fun getToolUse--MeDoko ()Ljava/lang/String; public final fun serializer ()Lkotlinx/serialization/KSerializer; } @@ -4595,7 +5050,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/SubscribeRequest$Com public final class io/modelcontextprotocol/kotlin/sdk/types/SubscribeRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/SubscribeRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getUri ()Ljava/lang/String; public final fun setUri (Ljava/lang/String;)V } @@ -5072,6 +5527,49 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations$Comp public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ToolChoice { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Companion; + public fun ()V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice; + public fun equals (Ljava/lang/Object;)Z + public final fun getMode ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ToolChoice$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice; + 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/ToolChoice;)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/ToolChoice$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode : java/lang/Enum { + public static final field Auto Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode; + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode$Companion; + public static final field None Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode; + public static final field Required Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode; + public static fun values ()[Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/ToolChoice$Mode$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/ToolExecution { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution$Companion; public fun ()V @@ -5132,20 +5630,59 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ToolListChangedNotif public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ToolResultContent : io/modelcontextprotocol/kotlin/sdk/types/SamplingMessageContent { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ToolResultContent$Companion; + public fun (Ljava/lang/String;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/lang/Boolean;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Ljava/lang/String;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/lang/Boolean;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Lkotlinx/serialization/json/JsonObject; + public final fun component4 ()Ljava/lang/Boolean; + public final fun component5 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Ljava/lang/String;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/lang/Boolean;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolResultContent; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ToolResultContent;Ljava/lang/String;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/lang/Boolean;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolResultContent; + public fun equals (Ljava/lang/Object;)Z + public final fun getContent ()Ljava/util/List; + public fun getMeta ()Lkotlinx/serialization/json/JsonObject; + public final fun getStructuredContent ()Lkotlinx/serialization/json/JsonObject; + public final fun getToolUseId ()Ljava/lang/String; + public fun getType ()Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; + public fun hashCode ()I + public final fun isError ()Ljava/lang/Boolean; + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ToolResultContent$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ToolResultContent$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolResultContent; + 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/ToolResultContent;)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/ToolResultContent$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/ToolSchema { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema$Companion; public fun ()V - public fun (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;)V - public synthetic fun (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Lkotlinx/serialization/json/JsonObject; - public final fun component2 ()Ljava/util/List; - public final fun component3 ()Lkotlinx/serialization/json/JsonObject; - public final fun copy (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; + public fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lkotlinx/serialization/json/JsonObject; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; public fun equals (Ljava/lang/Object;)Z public final fun getDefs ()Lkotlinx/serialization/json/JsonObject; public final fun getProperties ()Lkotlinx/serialization/json/JsonObject; public final fun getRequired ()Ljava/util/List; + public final fun getSchema ()Ljava/lang/String; public final fun getType ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -5166,6 +5703,41 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ToolSchema$Companion public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ToolUseContent : io/modelcontextprotocol/kotlin/sdk/types/SamplingMessageContent { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ToolUseContent$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Lkotlinx/serialization/json/JsonObject; + public final fun component4 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolUseContent; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ToolUseContent;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolUseContent; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ljava/lang/String; + public final fun getInput ()Lkotlinx/serialization/json/JsonObject; + public fun getMeta ()Lkotlinx/serialization/json/JsonObject; + public final fun getName ()Ljava/lang/String; + public fun getType ()Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ToolUseContent$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ToolUseContent$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolUseContent; + 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/ToolUseContent;)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/ToolUseContent$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/ToolsKt { public static final fun error (Lio/modelcontextprotocol/kotlin/sdk/types/CallToolResult$Companion;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolResult; public static synthetic fun error$default (Lio/modelcontextprotocol/kotlin/sdk/types/CallToolResult$Companion;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolResult; @@ -5246,7 +5818,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequest$C public final class io/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequest; - public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getUri ()Ljava/lang/String; public final fun setUri (Ljava/lang/String;)V } @@ -5281,6 +5853,35 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequestPa public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData$Companion; + public fun (Ljava/util/List;Ljava/lang/String;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/util/List;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData; + public fun equals (Ljava/lang/Object;)Z + public final fun getRequested ()Ljava/lang/String; + public final fun getSupported ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema : io/modelcontextprotocol/kotlin/sdk/types/MultiSelectEnumSchema { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items;Ljava/util/List;)V @@ -5383,6 +5984,39 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/UntitledSingleSelect public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$Companion; + public fun (Ljava/util/List;)V + public final fun component1 ()Ljava/util/List; + public final fun copy (Ljava/util/List;)Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData; + public fun equals (Ljava/lang/Object;)Z + public final fun getElicitations ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredException : io/modelcontextprotocol/kotlin/sdk/types/McpException { + public fun (Ljava/util/List;Ljava/lang/String;)V + public synthetic fun (Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getElicitations ()Ljava/util/List; +} + public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/WithMeta { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/WithMeta$Companion; public abstract fun getMeta ()Lkotlinx/serialization/json/JsonObject; diff --git a/kotlin-sdk-core/build.gradle.kts b/kotlin-sdk-core/build.gradle.kts index 0e702c4..1d19f5d 100644 --- a/kotlin-sdk-core/build.gradle.kts +++ b/kotlin-sdk-core/build.gradle.kts @@ -4,65 +4,10 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { id("mcp.multiplatform") + id("mcp.detekt") id("mcp.publishing") id("mcp.dokka") alias(libs.plugins.kotlinx.binary.compatibility.validator) - alias(libs.plugins.openapi.generator) -} - -/* -tasks.withType>().configureEach { - dependsOn(tasks.openApiGenerate) -} - -tasks.named("runKtlintCheckOverCommonMainSourceSet") { - dependsOn(tasks.openApiGenerate) -} - -// Also ensure it runs before other relevant tasks -tasks.withType().configureEach { - dependsOn(tasks.openApiGenerate) -} - -tasks.withType().configureEach { - if (name.lowercase().contains("sourcesjar")) { - dependsOn(tasks.openApiGenerate) - } -} - */ - -openApiGenerate { - val schemaVersion = "2025-03-26" // or "2025-06-18" or "draft" - val schemaUrl = -// "https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/$schemaVersion/schema.json" - "https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/tags/$schemaVersion/schema/$schemaVersion/schema.json" - generatorName = "kotlin" - remoteInputSpec = schemaUrl - outputDir = layout.buildDirectory.dir("generated-sources/openapi").get().asFile.absolutePath - packageName = "io.modelcontextprotocol.kotlin.sdk.models" - modelPackage = "io.modelcontextprotocol.kotlin.sdk.models" - apiPackage = "io.modelcontextprotocol.kotlin.sdk.api" - generateModelTests = false - generateModelDocumentation = false - cleanupOutput = false - skipValidateSpec = true // do not validate spec - library.set("multiplatform") - ignoreFileOverride.set("${layout.projectDirectory}/.openapi-generator-ignore") - globalProperties.set( - mapOf( - "supportingFiles" to "", - "models" to "", // or generate all models - ), - ) - configOptions.set( - mapOf( - "omitGradleWrapper" to "true", - "enumPropertyNaming" to "UPPERCASE", - "dateLibrary" to "kotlinx-datetime", - "explicitApi" to "true", - "modelMutable" to "false", - ), - ) } // Generation library versions @@ -91,10 +36,8 @@ kotlin { iosArm64() iosX64() iosSimulatorArm64() - watchosX64() watchosArm64() watchosSimulatorArm64() - tvosX64() tvosArm64() tvosSimulatorArm64() js { diff --git a/kotlin-sdk-core/detekt-baseline-commonMainSourceSet.xml b/kotlin-sdk-core/detekt-baseline-commonMainSourceSet.xml deleted file mode 100644 index d774a5b..0000000 --- a/kotlin-sdk-core/detekt-baseline-commonMainSourceSet.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - CyclomaticComplexMethod:jsonUtils.kt:@OptIn(ExperimentalUnsignedTypes::class, ExperimentalSerializationApi::class) private fun convertToJsonElement: JsonElement - LongMethod:Protocol.kt:Protocol$public suspend fun <T : RequestResult> request: T - MagicNumber:completion.kt:CompleteResult.Completion$100 - MatchingDeclarationName:capabilities.dsl.kt:ClientCapabilitiesBuilder - MatchingDeclarationName:methods.kt:Method - MaxLineLength:AbstractClientTransport.kt:AbstractClientTransport$* - MaxLineLength:ClientTransportState.kt:ClientTransportState.Initializing$* - MaxLineLength:Protocol.kt:Protocol$"Received progress notification: token=${notification.params.progressToken}, progress=${notification.params.progress}/${notification.params.total}" - MaxLineLength:Protocol.kt:Protocol$* - MaxLineLength:Protocol.kt:RequestOptions$* - MaxLineLength:Transport.kt:Transport$* - MaxLineLength:TransportSendOptions.kt:TransportSendOptions$* - ReturnCount:ReadBuffer.kt:ReadBuffer$public fun readMessage: JSONRPCMessage? - ThrowsCount:AbstractClientTransport.kt:AbstractClientTransport$public override suspend fun send - TooGenericExceptionCaught:AbstractClientTransport.kt:AbstractClientTransport$e: Exception - TooGenericExceptionCaught:Protocol.kt:Protocol$cause: Throwable - TooGenericExceptionCaught:Protocol.kt:Protocol$error: Throwable - TooGenericExceptionCaught:Protocol.kt:Protocol$sendError: Throwable - TooGenericExceptionCaught:ReadBuffer.kt:ReadBuffer$e: Exception - TooGenericExceptionCaught:WebSocketMcpTransport.kt:WebSocketMcpTransport$e: Exception - TooManyFunctions:Protocol.kt:Protocol - - diff --git a/kotlin-sdk-core/detekt-baseline-main.xml b/kotlin-sdk-core/detekt-baseline-main.xml index a22fbcd..d483d7e 100644 --- a/kotlin-sdk-core/detekt-baseline-main.xml +++ b/kotlin-sdk-core/detekt-baseline-main.xml @@ -2,31 +2,6 @@ - CyclomaticComplexMethod:jsonUtils.kt:@OptIn(ExperimentalUnsignedTypes::class, ExperimentalSerializationApi::class) private fun convertToJsonElement: JsonElement - InjectDispatcher:utils.jvm.kt:IO - InstanceOfCheckForException:AbstractClientTransport.kt:AbstractClientTransport$e is McpException - LongMethod:Protocol.kt:Protocol$public suspend fun <T : RequestResult> request: T - MagicNumber:completion.kt:CompleteResult.Completion$100 - MatchingDeclarationName:capabilities.dsl.kt:ClientCapabilitiesBuilder - MatchingDeclarationName:methods.kt:Method - MaxLineLength:AbstractClientTransport.kt:AbstractClientTransport$* - MaxLineLength:ClientTransportState.kt:ClientTransportState.Initializing$* - MaxLineLength:Protocol.kt:Protocol$"Received progress notification: token=${notification.params.progressToken}, progress=${notification.params.progress}/${notification.params.total}" - MaxLineLength:Protocol.kt:Protocol$* - MaxLineLength:Protocol.kt:RequestOptions$* - MaxLineLength:Transport.kt:Transport$* - MaxLineLength:TransportSendOptions.kt:TransportSendOptions$* - NoNameShadowing:Protocol.kt:Protocol$error - ReturnCount:ReadBuffer.kt:ReadBuffer$public fun readMessage: JSONRPCMessage? - ThrowsCount:AbstractClientTransport.kt:AbstractClientTransport$public override suspend fun send - TooGenericExceptionCaught:AbstractClientTransport.kt:AbstractClientTransport$e: Exception - TooGenericExceptionCaught:Protocol.kt:Protocol$cause: Throwable - TooGenericExceptionCaught:Protocol.kt:Protocol$error: Throwable - TooGenericExceptionCaught:Protocol.kt:Protocol$sendError: Throwable - TooGenericExceptionCaught:ReadBuffer.kt:ReadBuffer$e: Exception - TooGenericExceptionCaught:WebSocketMcpTransport.kt:WebSocketMcpTransport$e: Exception - TooManyFunctions:Protocol.kt:Protocol UnsafeCallOnNullableType:Protocol.kt:Protocol$response!! - UseCheckNotNull:Protocol.kt:Protocol$check(error != null) diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport.kt index 5f67999..27013d3 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport.kt @@ -99,8 +99,10 @@ public abstract class AbstractClientTransport : AbstractTransport() { * proceeding with further operations. It is often used to enforce state invariants within the transport's * lifecycle mechanics, enabling robust error handling and debugging in case of unexpected state transitions. * - * @param expected The expected transport state. An exception will be thrown if the actual state does not match this value. - * @throws IllegalStateException If the current state does not match the expected state. + * @param expected The expected transport state. + * An exception will be thrown if the actual state does not match this value. + * @param lazyMessage Function that builds the exception message from the actual state when the check fails. + * Defaults to a message that compares the expected state against the observed one. */ protected fun checkState( expected: ClientTransportState, diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport.kt index 7b88100..a738289 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport.kt @@ -6,11 +6,13 @@ import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi /** - * Implements [onClose], [onError] and [onMessage] functions of [Transport] providing - * corresponding [_onClose], [_onError] and [_onMessage] properties to use for an implementation. + * Base class for [Transport] implementations that handles the bookkeeping for [onClose], [onError] + * and [onMessage] callback registration, exposing the composed callbacks to subclasses for invocation. + * + * The callbacks are composed: each call to [onClose], [onError], or [onMessage] chains the new block + * after any previously registered ones. */ @OptIn(ExperimentalAtomicApi::class) -@Suppress("PropertyName") public abstract class AbstractTransport : Transport { private val onCloseCalled = AtomicBoolean(false) private var _onClose: (() -> Unit) = {} 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 43c0ae5..908e524 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt @@ -27,15 +27,34 @@ import kotlinx.atomicfu.AtomicRef import kotlinx.atomicfu.atomic import kotlinx.atomicfu.getAndUpdate import kotlinx.atomicfu.update +import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.encodeToJsonElement +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.ContinuationInterceptor +import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -53,23 +72,26 @@ 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 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( - /** - * Whether to restrict emitted requests to only those that the remote side has indicated - * that they can handle, through their advertised capabilities. - * - * Note that this DOES NOT affect checking of _local_ side capabilities, as it is - * considered a logic error to mis-specify those. - * - * Currently, this defaults to false, for backwards compatibility with SDK versions - * that did not advertise capabilities correctly. - * In the future, this will default to true. - */ 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, ) /** @@ -77,21 +99,27 @@ 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. * - * @property relatedRequestId if present, - * `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. - * @property resumptionToken the resumption token used to continue long-running requests that were interrupted. - * This allows clients to reconnect and continue from where they left off, if supported by the transport. - * @property onResumptionToken a callback that is invoked when the resumption token changes, if supported by the transport. - * This allows clients to persist the latest token for potential reconnection. + * @param relatedRequestId if present, used to indicate to the transport which incoming request to + * associate this outgoing message with. + * @param resumptionToken the resumption token used to continue long-running requests that were interrupted. + * Allows clients to reconnect and continue from where they left off, if supported by the transport. + * @param onResumptionToken callback invoked when the resumption token changes, if supported by the transport. + * Allows clients to persist the latest token for potential reconnection. * @property onProgress callback for progress notifications. - * If set, requests progress notifications from the remote end (if supported). - * When progress notifications are received, this callback will be invoked. + * If set, requests progress notifications from the remote end (if supported); + * when progress notifications are received, this callback is invoked. * @property timeout a timeout for this request. - * If exceeded, a McpException with code `RequestTimeout` will be raised from request(). - * If not specified, `DEFAULT_REQUEST_TIMEOUT` will be used as the timeout. + * If exceeded, a [McpException] with code `RequestTimeout` is raised from [Protocol.request]. + * If not specified, [DEFAULT_REQUEST_TIMEOUT] is used. */ public class RequestOptions( relatedRequestId: RequestId? = null, @@ -127,7 +155,7 @@ public class RequestOptions( override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (onProgress?.hashCode() ?: 0) + result = 31 * result + onProgress.hashCode() result = 31 * result + timeout.hashCode() return result } @@ -138,28 +166,88 @@ public class RequestOptions( /** * Extra data given to request handlers. + * + * One instance exists per in-flight inbound request. It is passed as the second parameter to + * low-level handlers registered via [Protocol.setRequestHandler] **and** installed into the + * handler coroutine's [CoroutineContext], so code nested anywhere under the handler can reach it + * via [currentRequestHandlerExtra] without parameter plumbing. + * + * @property requestId the JSON-RPC id of the request being handled + * @property method the request's method */ -public class RequestHandlerExtra +public class RequestHandlerExtra internal constructor( + public val requestId: RequestId, + public val method: Method, + private val protocol: Protocol, + internal val capturedTransport: Transport, +) : AbstractCoroutineContextElement(Key) { + + /** Context key for retrieving the extra from a handler coroutine's context. */ + public companion object Key : CoroutineContext.Key + + /** + * Sends a notification tagged with `relatedRequestId = `[requestId], letting transports + * associate it with the request being handled (e.g. route it onto the request's SSE stream). + */ + public suspend fun sendNotification(notification: Notification) { + protocol.notification(notification, relatedRequestId = requestId) + } + + /** + * Sends a request to the peer tagged with `relatedRequestId = `[requestId]. + * + * Mirrors [Protocol.request]; cancellation of the handler coroutine propagates into this call. + */ + public suspend fun sendRequest(request: Request, options: RequestOptions? = null): T { + val base = options ?: RequestOptions() + return protocol.request( + request, + RequestOptions( + relatedRequestId = requestId, + resumptionToken = base.resumptionToken, + onResumptionToken = base.onResumptionToken, + onProgress = base.onProgress, + timeout = base.timeout, + ), + ) + } +} + +/** The [RequestHandlerExtra] of the MCP request being handled by the current coroutine, or `null` outside a handler. */ +public suspend fun currentRequestHandlerExtra(): RequestHandlerExtra? = currentCoroutineContext()[RequestHandlerExtra] internal val COMPLETED = CompletableDeferred(Unit).also { it.complete(Unit) } +/** Cap on how many recently-cancelled outbound request ids are remembered to quiet late responses/progress. */ +private const val CANCELLED_REQUEST_IDS_REMEMBERED = 256 + +// Bypass set: exempt from both bound tiers so the connection cannot self-block. +// Responses are not listed — they are handled inline before dispatch. +private val CONTROL_METHODS: Set = setOf( + Method.Defined.Ping.value, + Method.Defined.NotificationsCancelled.value, + Method.Defined.NotificationsProgress.value, + Method.Defined.NotificationsInitialized.value, +) + /** * Implements MCP protocol framing on top of a pluggable transport, including * features like request/response linking, notifications, and progress. * - * @property transport the active transport, or `null` if not connected - * @property requestHandlers registered request handlers keyed by method name - * @property notificationHandlers registered notification handlers keyed by method name - * @property responseHandlers pending response handlers keyed by request ID - * @property progressHandlers registered progress callbacks keyed by progress token + * @property options protocol-level configuration; `null` falls back to defaults */ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptions?) { - public var transport: Transport? = null - private set + private val connectionRef: AtomicRef = atomic(null) + + /** The active transport, or `null` if not connected. */ + public val transport: Transport? + get() = connectionRef.value?.transport private val _requestHandlers: AtomicRef RequestResult?>> = atomic(persistentMapOf()) + + /** Registered request handlers keyed by method name. */ public val requestHandlers: Map< String, suspend ( @@ -171,20 +259,41 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio private val _notificationHandlers = atomic(persistentMapOf Unit>()) + + /** Registered notification handlers keyed by method name. */ public val notificationHandlers: Map Unit> get() = _notificationHandlers.value private val _responseHandlers: AtomicRef Unit>> = atomic(persistentMapOf()) + + /** Pending response handlers keyed by request ID. */ public val responseHandlers: Map Unit> get() = _responseHandlers.value private val _progressHandlers: AtomicRef> = atomic(persistentMapOf()) + + /** Registered progress callbacks keyed by progress token. */ 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. * @@ -214,6 +323,11 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio public var fallbackNotificationHandler: (suspend (notification: JSONRPCNotification) -> Unit)? = null init { + setNotificationHandler(Method.Defined.NotificationsCancelled) { notification -> + handleCancelledNotification(notification) + COMPLETED + } + setNotificationHandler(Method.Defined.NotificationsProgress) { notification -> onProgress(notification) COMPLETED @@ -224,15 +338,64 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } } + /** + * Enables concurrent dispatch of inbound messages on the current connection. + * + * Called by subclasses when the MCP initialization phase completes (the client after the + * initialize exchange; the server session on receiving `notifications/initialized`). Until + * then, inbound messages are handled inline in arrival order. Reset by [connect]. + */ + protected fun enableConcurrentDispatch() { + val connection = connectionRef.value ?: return + connection.concurrentDispatchEnabled.value = true + } + + /** + * Router hook invoked whenever `notifications/initialized` is received, before handler lookup. + * + * Runs regardless of which notification handler is registered for the method, so replacing the + * handler via [setNotificationHandler] cannot disable subclass initialization logic. + */ + protected open fun onInitializedNotification() {} + /** * Attaches to the given transport, starts it, and starts listening for messages. * - * The Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward. + * The Protocol object assumes ownership of the Transport, replacing any callbacks that have + * already been set, and expects that it is the only user of the Transport instance going forward. + * + * @throws IllegalStateException if this Protocol is already connected; call [close] first. */ public open suspend fun connect(transport: Transport) { - this.transport = transport + val maxConcurrent = options?.maxConcurrentHandlers ?: DEFAULT_MAX_CONCURRENT_HANDLERS + val maxInFlight = options?.maxInFlightHandlers ?: DEFAULT_MAX_IN_FLIGHT_HANDLERS + require(maxConcurrent > 0) { "maxConcurrentHandlers must be positive, but was $maxConcurrent" } + require(maxInFlight >= maxConcurrent) { + "maxInFlightHandlers ($maxInFlight) must be >= maxConcurrentHandlers ($maxConcurrent)" + } + val handlerContext = (options?.handlerCoroutineContext ?: Dispatchers.Default).minusKey(Job) + val interceptor = handlerContext[ContinuationInterceptor] + if (interceptor === Dispatchers.Unconfined || + interceptor?.let { it::class.simpleName?.contains("Unconfined") } == true + ) { + logger.warn { + "handlerCoroutineContext uses an unconfined dispatcher ($interceptor); " + + "handler resumptions will run on the transport read loop and may block unrelated messages" + } + } + val connection = Connection( + transport = transport, + handlerScope = CoroutineScope(SupervisorJob() + handlerContext + CoroutineName("McpProtocol")), + executionSemaphore = Semaphore(maxConcurrent), + maxInFlightHandlers = maxInFlight, + ) + if (!connectionRef.compareAndSet(null, connection)) { + connection.handlerScope.cancel() + error("Protocol is already connected; close() the current connection before connecting a new transport") + } + transport.onClose { - doClose() + doClose(connection) } transport.onError { @@ -240,24 +403,43 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } transport.onMessage { message -> + if (connectionRef.value !== connection) { + logger.trace { "Dropping message received after close: ${message::class.simpleName}" } + return@onMessage + } when (message) { is JSONRPCResponse -> onResponse(message, null) - is JSONRPCRequest -> onRequest(message) - is JSONRPCNotification -> onNotification(message) + is JSONRPCRequest -> dispatchRequest(connection, message) + is JSONRPCNotification -> dispatchNotification(connection, message) is JSONRPCError -> onResponse(null, message) is JSONRPCEmptyMessage -> Unit } } logger.info { "Starting transport" } - transport.start() + try { + transport.start() + } catch (cause: Throwable) { + // Roll back so the Protocol can be reconnected after a failed start; idempotent with + // a transport-initiated doClose (both CAS on the same connection). + if (connectionRef.compareAndSet(connection, null)) { + connection.handlerScope.cancel() + } + throw cause + } } - private fun doClose() { - val handlersToNotify = _responseHandlers.value.values.toList() - _responseHandlers.getAndSet(persistentMapOf()) + private fun doClose(connection: Connection) { + // A stale onClose from a previous transport must not tear down the successor connection. + if (!connectionRef.compareAndSet(connection, null)) { + logger.trace { "Ignoring close signal from a stale transport" } + return + } + val handlersToNotify = _responseHandlers.getAndSet(persistentMapOf()).values.toList() _progressHandlers.getAndSet(persistentMapOf()) - transport = null + recentlyCancelledRequestIds.getAndSet(persistentListOf()) + connection.inFlightRequestJobs.getAndSet(persistentMapOf()) + connection.handlerScope.cancel() onClose() val error = McpException(RPCError.ErrorCode.CONNECTION_CLOSED, "Connection closed") @@ -266,6 +448,110 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } } + private suspend fun dispatchRequest(connection: Connection, request: JSONRPCRequest) { + if (!connection.concurrentDispatchEnabled.value) { + // Serial phase (before the peer completes MCP initialization): inline, in arrival + // order — the delivering coroutine awaits the handler. + onRequest(request, connection, trackCancellation = false) + return + } + val bypass = request.method in CONTROL_METHODS + if (!bypass && !tryAdmit(connection)) { + rejectOverloadedRequest(connection, request) + return + } + // UNDISPATCHED starts the handler synchronously in the delivering coroutine, preserving + // arrival order, then moves to handlerCoroutineContext at its first suspension. + connection.handlerScope.launch(start = CoroutineStart.UNDISPATCHED) { + // Register before any suspension point so a following notifications/cancelled can find + // and cancel even a handler still parked on admission. + registerInFlight(connection, request.id, coroutineContext.job) + try { + if (bypass) { + onRequest(request, connection, trackCancellation = true) + } else { + connection.executionSemaphore.withPermit { + onRequest(request, connection, trackCancellation = true) + } + } + } finally { + if (!bypass) connection.inFlightCount.decrementAndGet() + } + } + } + + private suspend fun dispatchNotification(connection: Connection, notification: JSONRPCNotification) { + if (notification.method == Method.Defined.NotificationsInitialized.value) { + onInitializedNotification() + } + if (!connection.concurrentDispatchEnabled.value) { + onNotification(notification) + return + } + val bypass = notification.method in CONTROL_METHODS + if (!bypass && !tryAdmit(connection)) { + val dropped = IllegalStateException( + "Dropped notification ${notification.method}: too many in-flight messages", + ) + logger.warn { dropped.message } + onError(dropped) + return + } + connection.handlerScope.launch(start = CoroutineStart.UNDISPATCHED) { + try { + if (bypass) { + onNotification(notification) + } else { + connection.executionSemaphore.withPermit { onNotification(notification) } + } + } finally { + if (!bypass) connection.inFlightCount.decrementAndGet() + } + } + } + + private fun tryAdmit(connection: Connection): Boolean { + while (true) { + val current = connection.inFlightCount.value + if (current >= connection.maxInFlightHandlers) return false + if (connection.inFlightCount.compareAndSet(current, current + 1)) return true + } + } + + private suspend fun rejectOverloadedRequest(connection: Connection, request: JSONRPCRequest) { + logger.warn { + "Rejecting request ${request.method} (id: ${request.id}): " + + "in-flight handler limit (${connection.maxInFlightHandlers}) reached" + } + try { + connection.transport.send( + JSONRPCError( + id = request.id, + error = RPCError( + code = RPCError.ErrorCode.INTERNAL_ERROR, + message = "Server is busy: too many in-flight messages", + ), + ), + ) + } catch (e: CancellationException) { + throw e + } catch (cause: Throwable) { + logger.error(cause) { "Failed to send busy rejection for request id: ${request.id}" } + onError(cause) + } + } + + private fun handleCancelledNotification(notification: CancelledNotification) { + val connection = connectionRef.value ?: return + val requestId = notification.params.requestId + val job = connection.inFlightRequestJobs.value[requestId] + if (job == null) { + logger.trace { "Ignoring cancellation for unknown or already-completed request: $requestId" } + return + } + job.cancel("Cancelled by peer: ${notification.params.reason ?: "unknown"}") + } + private suspend fun onNotification(notification: JSONRPCNotification) { logger.trace { "Received notification: ${notification.method}" } @@ -277,21 +563,24 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } try { handler(notification) + } catch (e: CancellationException) { + throw e } catch (cause: Throwable) { logger.error(cause) { "Error handling notification: ${notification.method}" } onError(cause) } } - private suspend fun onRequest(request: JSONRPCRequest) { + private suspend fun onRequest(request: JSONRPCRequest, connection: Connection, trackCancellation: Boolean) { logger.trace { "Received request: ${request.method} (id: ${request.id})" } val handler = requestHandlers[request.method] ?: fallbackRequestHandler + val capturedTransport = connection.transport if (handler === null) { logger.trace { "No handler found for request: ${request.method}" } try { - transport?.send( + capturedTransport.send( JSONRPCError( id = request.id, error = RPCError( @@ -300,6 +589,8 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio ), ), ) + } catch (e: CancellationException) { + throw e } catch (cause: Throwable) { logger.error(cause) { "Error sending method not found response" } onError(cause) @@ -307,37 +598,77 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio return } - @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException") - try { - val result = handler(request, RequestHandlerExtra()) - logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" } + val extra = RequestHandlerExtra( + requestId = request.id, + method = Method.from(request.method), + protocol = this, + capturedTransport = capturedTransport, + ) - transport?.send( - JSONRPCResponse( - id = request.id, - result = result ?: EmptyResult(), - ), - ) + // Registration in the in-flight registry already happened in dispatchRequest's launch + // block (synchronously, pre-suspension). Here we only need the job for suppression checks. + val handlerJob = if (trackCancellation) currentCoroutineContext().job else null + + withContext(extra) { + try { + val result = handler(request, extra) + logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" } + if (handlerJob?.isCancelled == true) { + logger.trace { "Suppressing response for request cancelled by peer (id: ${request.id})" } + return@withContext + } + capturedTransport.send( + JSONRPCResponse( + id = request.id, + result = result ?: EmptyResult(), + ), + ) + } catch (e: CancellationException) { + // Intentional exception to the always-rethrow-CE convention, only at this boundary. + if (handlerJob == null) throw e // serial phase: inline dispatch is not response-suppressed + if (handlerJob.isCancelled) throw e // genuine peer or close cancel: suppress the response + // CE escaped a live handler job (e.g. a leaked inner withTimeout). Answer + // INTERNAL_ERROR so the peer does not hang until its own timeout. + respondWithError(capturedTransport, request, e) + } catch (cause: Throwable) { + if (handlerJob?.isCancelled == true) { + logger.trace { "Suppressing error response for request cancelled by peer (id: ${request.id})" } + return@withContext + } + respondWithError(capturedTransport, request, cause) + } + } + } + + private fun registerInFlight(connection: Connection, id: RequestId, job: Job) { + val previous = connection.inFlightRequestJobs.getAndUpdate { current -> current.putting(id, job) }[id] + if (previous != null) { + logger.warn { "Duplicate in-flight request id $id; replacing the previous handler job" } + } + job.invokeOnCompletion { + // Identity guard: never evict a successor job registered under a reused id. + connection.inFlightRequestJobs.update { current -> + if (current[id] === job) current.removing(id) else current + } + } + } + + private suspend fun respondWithError(transport: Transport, request: JSONRPCRequest, cause: Throwable) { + logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" } + try { + val rpcError = if (cause is McpException) { + RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data) + } else { + RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error") + } + transport.send(JSONRPCError(id = request.id, error = rpcError)) } catch (e: CancellationException) { throw e - } catch (cause: Throwable) { - logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" } - - try { - val rpcError = if (cause is McpException) { - RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data) - } else { - RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error") - } - transport?.send(JSONRPCError(id = request.id, error = rpcError)) - } catch (e: CancellationException) { - throw e - } catch (sendError: Throwable) { - logger.error(sendError) { - "Failed to send error response for request: ${request.method} (id: ${request.id})" - } - // Optionally implement fallback behavior here + } catch (sendError: Throwable) { + logger.error(sendError) { + "Failed to send error response for request: ${request.method} (id: ${request.id})" } + onError(sendError) } } @@ -352,6 +683,10 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val handler = _progressHandlers.value[progressToken] if (handler == null) { + if (isRecentlyCancelled(progressToken)) { + logger.trace { "Ignoring progress for a locally cancelled request: $progressToken" } + return + } val error = Error( "Received a progress notification for an unknown token: ${McpJson.encodeToString(notification)}", ) @@ -368,7 +703,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val oldResponseHandlers = _responseHandlers.getAndUpdate { current -> if (messageId != null && messageId in current) { - current.remove(messageId) + current.removing(messageId) } else { current } @@ -377,8 +712,12 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val handler = oldResponseHandlers[messageId] if (handler != null) { - messageId?.let { msg -> _progressHandlers.update { it.remove(msg) } } + messageId?.let { msg -> _progressHandlers.update { it.removing(msg) } } } else { + if (isRecentlyCancelled(messageId)) { + logger.trace { "Ignoring response for a locally cancelled request: $messageId" } + return + } onError( IllegalStateException( "Received a response for an unknown message ID: ${McpJson.encodeToString(error ?: response)}", @@ -390,8 +729,8 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio if (response != null) { handler(response, null) } else { - check(error != null) - val mcpException = McpException( + checkNotNull(error) + val mcpException = McpException.fromError( code = error.error.code, message = error.error.message, data = error.error.data, @@ -436,7 +775,8 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio public suspend fun request(request: Request, options: RequestOptions? = null): T { logger.trace { "Sending request: ${request.method}" } val result = CompletableDeferred() - val transport = transport ?: error("Not connected") + val connection = connectionRef.value ?: error("Not connected") + val transport = connection.transport if (this@Protocol.options?.enforceStrictCapabilities == true) { assertCapabilityForMethod(request.method) @@ -446,7 +786,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio options?.onProgress?.let { progressHandler -> logger.trace { "Registering progress handler for request id: $id" } _progressHandlers.update { current -> - current.put(id, progressHandler) + current.putting(id, progressHandler) } val paramsObject = (this.params as? JsonObject) ?: JsonObject(emptyMap()) @@ -465,57 +805,99 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val jsonRpcRequestId = jsonRpcRequest.id _responseHandlers.update { current -> - current.put(jsonRpcRequestId) { response, error -> + current.putting(jsonRpcRequestId) { response, error -> if (error != null) { result.completeExceptionally(error) - return@put + return@putting } try { @Suppress("UNCHECKED_CAST") result.complete(response!!.result as T) - } catch (error: Throwable) { - result.completeExceptionally(error) + } catch (e: Throwable) { + result.completeExceptionally(e) } } } - val cancel: suspend (Throwable) -> Unit = { reason: Throwable -> - _responseHandlers.update { current -> current.remove(jsonRpcRequestId) } - _progressHandlers.update { current -> current.remove(jsonRpcRequestId) } + if (connectionRef.value !== connection) { + // Connection closed or replaced during registration; nothing would complete this handler. + _responseHandlers.update { it.removing(jsonRpcRequestId) } + _progressHandlers.update { it.removing(jsonRpcRequestId) } + throw McpException(RPCError.ErrorCode.CONNECTION_CLOSED, "Connection closed") + } - val notification = CancelledNotification( - params = CancelledNotificationParams( - requestId = jsonRpcRequestId, - reason = reason.message ?: "Unknown", - ), - ) + val cancelPending: suspend (reason: Throwable, notifyPeer: Boolean) -> Unit = { reason, notifyPeer -> + // Remember before removing: a racing onResponse/onProgress that sees the handler gone + // must also see the id, or it reports a late message as unknown. Order is load-bearing. + rememberCancelledRequestId(jsonRpcRequestId) + _responseHandlers.update { current -> current.removing(jsonRpcRequestId) } + _progressHandlers.update { current -> current.removing(jsonRpcRequestId) } - val jsonRpcNotification = notification.toJSON() - - transport.send(jsonRpcNotification, options) + if (notifyPeer) { + val notification = CancelledNotification( + params = CancelledNotificationParams( + requestId = jsonRpcRequestId, + reason = reason.message ?: "Unknown", + ), + ) + transport.send(notification.toJSON(), options) + } result.completeExceptionally(reason) } + // The MCP spec forbids cancelling `initialize`; local cleanup still runs, + // but no notifications/cancelled goes on the wire (timeout and cancel paths alike). + val notifyPeerOnCancel = request.method != Method.Defined.Initialize + val timeout = options?.timeout ?: DEFAULT_REQUEST_TIMEOUT try { - withTimeout(timeout) { + val response = withTimeoutOrNull(timeout) { logger.trace { "Sending request message with id: $jsonRpcRequestId" } - this@Protocol.transport?.send(jsonRpcRequest, options) + transport.send(jsonRpcRequest, options) + result.await() } - return result.await() - } catch (cause: TimeoutCancellationException) { - logger.error { "Request timed out after ${timeout.inWholeMilliseconds}ms: ${request.method}" } - cancel( - McpException( + if (response == null) { + // Our own request timeout expired. An outer withTimeout's cancellation propagates + // through withTimeoutOrNull instead of returning null and is handled by the catch below. + logger.error { "Request timed out after ${timeout.inWholeMilliseconds}ms: ${request.method}" } + val timeoutError = McpException( code = RPCError.ErrorCode.REQUEST_TIMEOUT, message = "Request timed out", data = JsonObject(mutableMapOf("timeout" to JsonPrimitive(timeout.inWholeMilliseconds))), - ), - ) - result.cancel(cause) + ) + withContext(NonCancellable) { + try { + cancelPending(timeoutError, notifyPeerOnCancel) + } catch (e: Throwable) { + logger.warn(e) { "Failed to notify peer about timed-out request" } + onError(e) + } + } + result.cancel() + throw timeoutError + } + return response + } catch (cause: CancellationException) { + // Plain caller cancellation, or an outer withTimeout that propagated through + // withTimeoutOrNull (only our own deadline returns null there). NonCancellable so the + // cancellation notification still goes out even though the caller's job is cancelled. + // A secondary send failure must never replace the original CancellationException. + withContext(NonCancellable) { + try { + cancelPending(cause, notifyPeerOnCancel) + } catch (e: Throwable) { + logger.warn(e) { "Failed to notify peer about cancelled request" } + onError(e) + } + } throw cause + } finally { + // Backstop for the failed-send path. The success, timeout and cancellation paths already + // removed these handlers, so this only bites when the outbound send failed. + _responseHandlers.update { it.removing(jsonRpcRequestId) } + _progressHandlers.update { it.removing(jsonRpcRequestId) } } } @@ -536,6 +918,11 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio * Registers a handler to invoke when this protocol object receives a request with the given method. * * Note that this will replace any previous request handler for the same method. + * + * Handlers may run concurrently after initialization and are cancelled cooperatively, so always + * re-throw [CancellationException]. Replacing a built-in control-method handler (`ping`, + * `notifications/cancelled`, `notifications/progress`, `notifications/initialized`) with slow + * code degrades connection liveness. */ public inline fun setRequestHandler( method: Method, @@ -551,31 +938,60 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio block: suspend (T, RequestHandlerExtra) -> RequestResult?, ) { assertRequestHandlerCapability(method) + val wrapped = wrapRequestHandler(method, block) _requestHandlers.update { current -> - current.put(method.value) { jSONRPCRequest, extraHandler -> - val request = jSONRPCRequest.fromJSON() - val response = block(request as T, extraHandler) + current.putting(method.value) { jSONRPCRequest, extraHandler -> + val request = try { + jSONRPCRequest.fromJSON() + } catch (cause: SerializationException) { + throw McpException( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "Invalid params", + cause = cause, + ) + } + val response = wrapped(request as T, extraHandler) response } } } + /** + * Subclass hook to wrap an incoming-request handler before it is registered. + * + * Called once by [setRequestHandler] during registration. Subclasses may return a + * new function that, when invoked, performs additional checks (capability gates, + * schema validation, etc.) before delegating to [block]. The default implementation + * is the identity. + */ + @Suppress("UNUSED_PARAMETER") + protected open fun wrapRequestHandler( + method: Method, + block: suspend (T, RequestHandlerExtra) -> RequestResult?, + ): suspend (T, RequestHandlerExtra) -> RequestResult? = block + /** * Removes the request handler for the given method. */ public fun removeRequestHandler(method: Method) { - _requestHandlers.update { current -> current.remove(method.value) } + _requestHandlers.update { current -> current.removing(method.value) } } /** * Registers a handler to invoke when this protocol object receives a notification with the given method. * * Note that this will replace any previous notification handler for the same method. + * + * Handlers may run concurrently after initialization and are cancelled cooperatively, so always + * re-throw [CancellationException]. Replacing a built-in control-method handler (`ping`, + * `notifications/cancelled`, `notifications/progress`, `notifications/initialized`) with slow + * code degrades connection liveness. Replacing `notifications/cancelled` in particular disables + * inbound request cancellation entirely, since that handler is what cancels the running job. */ public fun setNotificationHandler(method: Method, handler: (notification: T) -> Deferred) { _notificationHandlers.update { current -> - current.put(method.value) { + current.putting(method.value) { @Suppress("UNCHECKED_CAST") handler(it.fromJSON() as T) } @@ -586,6 +1002,22 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio * Removes the notification handler for the given method. */ public fun removeNotificationHandler(method: Method) { - _notificationHandlers.update { current -> current.remove(method.value) } + _notificationHandlers.update { current -> current.removing(method.value) } + } + + private class Connection( + val transport: Transport, + val handlerScope: CoroutineScope, + val executionSemaphore: Semaphore, + val maxInFlightHandlers: Int, + ) { + /** Init gate: inbound dispatch is inline/serial until this flips. */ + val concurrentDispatchEnabled = atomic(false) + + /** Admission tier: launched-but-not-completed handler jobs (running + parked). */ + val inFlightCount = atomic(0) + + /** In-flight request handler jobs, for `notifications/cancelled`. */ + val inFlightRequestJobs: AtomicRef> = atomic(persistentMapOf()) } } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt index d4c8b43..8102cf6 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt @@ -4,13 +4,24 @@ import io.github.oshai.kotlinlogging.KotlinLogging import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.McpJson import kotlinx.io.Buffer +import kotlinx.io.IOException import kotlinx.io.indexOf import kotlinx.io.readString /** * Buffers a continuous stdio stream into discrete JSON-RPC messages. + * + * A single newline-terminated frame may not exceed an internal size cap (16 MiB): a peer that streams + * bytes without ever sending a newline triggers a [TooLongFrameException] from [readMessage] instead + * of growing the buffer without bound. A non-positive cap disables the check. */ -public class ReadBuffer { +public class ReadBuffer internal constructor(private val maxFrameSize: Int) { + + public constructor() : this(DEFAULT_MAX_FRAME_SIZE) + + internal companion object { + internal const val DEFAULT_MAX_FRAME_SIZE: Int = 16 * 1024 * 1024 + } private val logger = KotlinLogging.logger { } @@ -39,7 +50,6 @@ public class ReadBuffer { val line = readNextLine() ?: return null if (line.isBlank()) continue - @Suppress("TooGenericExceptionCaught") val message = try { deserializeMessage(line) } catch (e: Exception) { @@ -54,7 +64,11 @@ public class ReadBuffer { private fun readNextLine(): String? { val lfIndex = if (buffer.exhausted()) -1L else buffer.indexOf('\n'.code.toByte()) - if (lfIndex == -1L) return null + if (lfIndex == -1L) { + failIfFrameTooLong(buffer.size) + return null + } + failIfFrameTooLong(lfIndex) return if (lfIndex == 0L) { buffer.skip(1) @@ -72,13 +86,20 @@ public class ReadBuffer { } } + private fun failIfFrameTooLong(frameSize: Long) { + if (maxFrameSize in 1..(line) /** Serializes a [JSONRPCMessage] to its JSON string representation with a trailing newline. */ diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Transport.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Transport.kt index cb57547..a89c102 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Transport.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Transport.kt @@ -21,9 +21,9 @@ public interface Transport { /** * Sends a JSON-RPC message (request or response). * - * @property message The JSON-RPC message to send, either a request or a response. - * @property options Optional transport-specific options that control sending behavior. - * Different transport implementations may support different options. + * @param message the JSON-RPC message to send, either a request or a response + * @param options optional transport-specific options that control sending behavior; + * different transport implementations may support different options */ public suspend fun send(message: JSONRPCMessage, options: TransportSendOptions? = null) diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/WebSocketMcpTransport.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/WebSocketMcpTransport.kt index 5f8bf9d..baf3723 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/WebSocketMcpTransport.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/WebSocketMcpTransport.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.coroutines.cancellation.CancellationException /** WebSocket subprotocol identifier for MCP connections. */ public const val MCP_SUBPROTOCOL: String = "mcp" @@ -74,6 +75,8 @@ public abstract class WebSocketMcpTransport : AbstractTransport() { try { val message = McpJson.decodeFromString(message.readText()) _onMessage.invoke(message) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { _onError.invoke(e) throw e 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 6b12492..6a9015b 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/McpException.kt @@ -6,15 +6,32 @@ import kotlin.jvm.JvmOverloads /** * Represents an error specific to the MCP protocol. * - * @property code The MCP/JSON‑RPC error code. - * @property data Optional additional error payload as a JSON element; `null` when not provided. - * @param message The error message. Used verbatim as [Exception.message] — no error code prefix is prepended. - * Defaults to `"MCP error $code"` when not provided. - * @param cause The original cause. + * @property code the MCP/JSON-RPC error code + * @param message the error message; used verbatim as [Exception.message] without an error-code prefix. + * Defaults to `"MCP error $code"` when not provided. + * @property data optional additional error payload as a JSON element + * @param cause the original cause */ -public class McpException @JvmOverloads public constructor( +public open class McpException @JvmOverloads public constructor( public val code: Int, message: String = "MCP error $code", public val data: JsonElement? = null, cause: Throwable? = null, -) : Exception(message, cause) +) : Exception(message, cause) { + internal companion object { + /** + * Reconstructs the most specific [McpException] subtype for a JSON-RPC error. + * + * Recognizes [RPCError.ErrorCode.URL_ELICITATION_REQUIRED] errors and returns a + * [UrlElicitationRequiredException] when [data] carries valid URL-mode elicitations; + * otherwise returns a plain [McpException]. Never throws — a malformed payload simply + * degrades to a plain [McpException]. + */ + fun fromError(code: Int, message: String, data: JsonElement?): McpException { + if (code == RPCError.ErrorCode.URL_ELICITATION_REQUIRED && data != null) { + UrlElicitationRequiredException.fromDataOrNull(message, data)?.let { return it } + } + return McpException(code = code, message = message, data = data) + } + } +} diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PingRequest.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PingRequest.kt index c45ed30..8280404 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PingRequest.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PingRequest.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable /** * Represents a ping request used to check if the connection is alive. * - * @property meta optional request metadata + * @property params optional request parameters carrying metadata */ @Serializable public data class PingRequest(override val params: BaseRequestParams? = null) : @@ -17,6 +17,7 @@ public data class PingRequest(override val params: BaseRequestParams? = null) : @EncodeDefault override val method: Method = Method.Defined.Ping + /** Convenience accessor for [params]'s metadata. */ public val meta: RequestMeta? get() = params?.meta } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredException.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredException.kt new file mode 100644 index 0000000..f3da768 --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredException.kt @@ -0,0 +1,65 @@ +package io.modelcontextprotocol.kotlin.sdk.types + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement + +/** + * Payload carried by the [data][RPCError.data] of a [RPCError.ErrorCode.URL_ELICITATION_REQUIRED] error. + * + * @property elicitations The URL-mode elicitations that must be completed before the original request + * can succeed. Each entry is equivalent to an `elicitation/create` request. + */ +@Serializable +public data class UrlElicitationRequiredData(val elicitations: List) + +/** + * Signals that one or more URL-mode elicitations must be completed before a request can be processed. + * + * Serialized over the wire as a JSON-RPC error with code [RPCError.ErrorCode.URL_ELICITATION_REQUIRED] + * and [data][RPCError.data] holding the [elicitations]. A server throws this from a request handler to + * tell the client that a URL-mode elicitation is required; the client receives it from any request as a + * typed exception, equivalent to receiving an `elicitation/create` request. + * + * The client is responsible for obtaining explicit user consent and surfacing each [ElicitRequestURLParams] + * before navigation — the SDK never opens or validates URLs. + * + * Example client-side handling: + * ```kotlin + * try { + * client.callTool(name = "create_repo", arguments = args) + * } catch (e: UrlElicitationRequiredException) { + * for (elicitation in e.elicitations) { + * // Show elicitation.message and the target domain, then ask the user for consent. + * if (userConsents(elicitation.url)) openInBrowser(elicitation.url) + * // Await completion via Client.setElicitationCompleteHandler, then optionally retry the call. + * } + * } + * ``` + * + * @property elicitations The required URL-mode elicitations (always non-empty). + */ +public class UrlElicitationRequiredException( + public val elicitations: List, + message: String = defaultMessage(elicitations), +) : McpException( + code = RPCError.ErrorCode.URL_ELICITATION_REQUIRED, + message = message, + data = McpJson.encodeToJsonElement(UrlElicitationRequiredData(elicitations)), +) { + internal companion object { + private fun defaultMessage(elicitations: List): String = + if (elicitations.size > 1) "URL elicitations are required" else "URL elicitation is required" + + /** + * Reconstructs a [UrlElicitationRequiredException] from JSON-RPC error [data], or `null` when the + * payload is absent, malformed, or carries no elicitations. Never throws. + */ + fun fromDataOrNull(message: String, data: JsonElement): UrlElicitationRequiredException? = + runCatching { McpJson.decodeFromJsonElement(data).elicitations } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + ?.let { UrlElicitationRequiredException(it, message) } + } +} diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.dsl.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.dsl.kt index 146eabf..0d8b2b7 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.dsl.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.dsl.kt @@ -21,7 +21,7 @@ import kotlinx.serialization.json.buildJsonObject * val request = buildInitializeRequest { * protocolVersion = "1.0" * capabilities { - * sampling(ClientCapabilities.sampling) + * sampling(ClientCapabilities.Sampling()) * roots(listChanged = true) * experimental { * put("customFeature", JsonPrimitive(true)) @@ -36,45 +36,32 @@ import kotlinx.serialization.json.buildJsonObject */ @McpDsl public class ClientCapabilitiesBuilder @PublishedApi internal constructor() { - private var sampling: JsonObject? = null + private var sampling: ClientCapabilities.Sampling? = null private var roots: ClientCapabilities.Roots? = null - private var elicitation: JsonObject? = null + private var elicitation: ClientCapabilities.Elicitation? = null + private var extensions: Map? = null private var experimental: JsonObject? = null /** - * Indicates that the client supports sampling from an LLM. + * Sampling capability configuration. See [ClientCapabilities.Sampling]. * - * Use [ClientCapabilities.sampling] for default empty configuration. + * Pass `ClientCapabilities.Sampling()` to enable base sampling with no sub-capabilities. + * Construct `ClientCapabilities.Sampling(tools = EmptyJsonObject, context = EmptyJsonObject)` + * directly to enable tools/context sub-capabilities. * * Example: * ```kotlin * capabilities { - * sampling(ClientCapabilities.sampling) + * sampling(ClientCapabilities.Sampling(tools = EmptyJsonObject)) * } * ``` * * @param value The sampling capability configuration */ - public fun sampling(value: JsonObject) { + public fun sampling(value: ClientCapabilities.Sampling) { this.sampling = value } - /** - * Indicates that the client supports sampling from an LLM with custom configuration. - * - * Example: - * ```kotlin - * capabilities { - * sampling { - * put("temperature", JsonPrimitive(0.7)) - * } - * } - * ``` - * - * @param block Lambda for building the sampling configuration - */ - public fun sampling(block: JsonObjectBuilder.() -> Unit): Unit = sampling(buildJsonObject(block)) - /** * Indicates that the client supports listing roots. * @@ -112,25 +99,33 @@ public class ClientCapabilitiesBuilder @PublishedApi internal constructor() { * * @param value The elicitation capability configuration */ - public fun elicitation(value: JsonObject) { + public fun elicitation(value: ClientCapabilities.Elicitation) { this.elicitation = value } /** - * Indicates that the client supports elicitation from the server with custom configuration. + * Defines extensions that the client supports. + * + * Extension identifiers use the format `{vendor-prefix}/{extension-name}`, + * e.g., `"io.modelcontextprotocol/ui"`. Each value is an extension-specific + * settings object; an empty [JsonObject] indicates no settings. * * Example: * ```kotlin * capabilities { - * elicitation { - * put("mode", JsonPrimitive("interactive")) - * } + * extensions(mapOf( + * "io.modelcontextprotocol/ui" to buildJsonObject { + * put("mimeTypes", JsonArray(listOf(JsonPrimitive("text/html")))) + * } + * )) * } * ``` * - * @param block Lambda for building the elicitation configuration + * @param value The map of extension identifiers to their settings */ - public fun elicitation(block: JsonObjectBuilder.() -> Unit): Unit = elicitation(buildJsonObject(block)) + public fun extensions(value: Map) { + this.extensions = value + } /** * Defines experimental, non-standard capabilities that the client supports. @@ -177,6 +172,7 @@ public class ClientCapabilitiesBuilder @PublishedApi internal constructor() { sampling = sampling, roots = roots, elicitation = elicitation, + extensions = extensions, experimental = experimental, ) } 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 fae24c3..ecb8c48 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.kt @@ -41,26 +41,84 @@ public data class Implementation( * supports that capability. * * @property sampling Present if the client supports sampling from an LLM. + * Use [ClientCapabilities.Sampling] to configure tools/context sub-capabilities. * @property roots Present if the client supports listing roots. * @property elicitation Present if the client supports elicitation from the server. + * Use [ClientCapabilities.Elicitation] to configure form/url mode sub-capabilities. + * @property tasks Present if the client supports task-augmented requests, listing, or cancellation. * @property experimental Experimental, non-standard capabilities that the client supports. * Keys are capability names, values are capability-specific configuration objects. + * @property extensions Optional extensions that the client supports. + * Keys are extension identifiers (e.g., `"io.modelcontextprotocol/ui"`), + * values are extension-specific settings objects. */ @Serializable public data class ClientCapabilities( - public val sampling: JsonObject? = null, + public val sampling: Sampling? = null, public val roots: Roots? = null, - public val elicitation: JsonObject? = null, + public val elicitation: Elicitation? = null, + public val tasks: Tasks? = null, public val experimental: JsonObject? = null, + public val extensions: Map? = null, ) { /** - * @property sampling convenience value to enable the sampling capability - * @property elicitation convenience value to enable the elicitation capability + * Source-compatibility constructor retaining the older `sampling: JsonObject?` / + * `elicitation: JsonObject?` shapes. Any non-null [sampling] is converted to an + * empty [Sampling], and any non-null [elicitation] is converted to an empty + * [Elicitation] (sub-capabilities cannot be recovered from the old opaque + * `JsonObject`). + */ + @Deprecated( + "ClientCapabilities.sampling and ClientCapabilities.elicitation are now typed. " + + "Pass typed ClientCapabilities.Sampling? / ClientCapabilities.Elicitation? " + + "instead of JsonObject?.", + ReplaceWith( + "ClientCapabilities(" + + "sampling?.let { ClientCapabilities.Sampling() }, " + + "roots, " + + "elicitation?.let { ClientCapabilities.Elicitation() }, " + + "tasks = null, " + + "experimental = experimental, " + + "extensions = extensions)", + ), + level = DeprecationLevel.WARNING, + ) + public constructor( + sampling: JsonObject?, + roots: Roots? = null, + elicitation: JsonObject? = null, + experimental: JsonObject? = null, + extensions: Map? = null, + ) : this( + sampling = sampling?.let { Sampling() }, + roots = roots, + elicitation = elicitation?.let { Elicitation() }, + tasks = null, + experimental = experimental, + extensions = extensions, + ) + + /** + * sub-capabilities for sampling. + * + * @property context Present if the client supports context inclusion via + * [CreateMessageRequestParams.includeContext] with values other than [IncludeContext.None]. + * Servers SHOULD avoid non-`none` values when this field is absent. + * @property tools Present if the client supports tool use via + * [CreateMessageRequestParams.tools] / [CreateMessageRequestParams.toolChoice]. + * Servers MUST NOT send `tools`/`toolChoice` when this field is absent. + */ + @Serializable + public data class Sampling(public val context: JsonObject? = null, public val tools: JsonObject? = null) + + /** + * @property sampling convenience value to enable the base sampling capability with no sub-capabilities + * @property elicitation convenience value to enable the elicitation capability with default form mode */ public companion object { - public val sampling: JsonObject = EmptyJsonObject - public val elicitation: JsonObject = EmptyJsonObject + public val sampling: Sampling = Sampling() + public val elicitation: Elicitation = Elicitation() } /** @@ -75,8 +133,71 @@ public data class ClientCapabilities( */ @Serializable public data class Roots(val listChanged: Boolean? = null) + + /** + * Sub-capabilities for elicitation. + * + * An empty [Elicitation] (both fields null) is equivalent to declaring `form` mode only. + * + * @property form Present if the client supports form-mode elicitation + * (in-band structured data collection). + * @property url Present if the client supports url-mode elicitation + * (out-of-band interaction via URL navigation). + */ + @Serializable + public data class Elicitation(public val form: JsonObject? = null, public val url: JsonObject? = null) + + /** + * Sub-capabilities for tasks (client side). + * + * Declares which client-side requests can be augmented with task execution, + * and whether the client supports listing and cancelling tasks. + * + * @property list Present if the client supports the `tasks/list` operation. + * @property cancel Present if the client supports the `tasks/cancel` operation. + * @property requests Present if the client supports task-augmented sampling and/or elicitation requests. + */ + @Serializable + public data class Tasks( + public val list: JsonObject? = null, + public val cancel: JsonObject? = null, + public val requests: Requests? = null, + ) { + /** + * Task-augmentable client-side request categories. + * + * @property sampling Present if the client supports task-augmented `sampling/createMessage` requests. + * @property elicitation Present if the client supports task-augmented `elicitation/create` requests. + */ + @Serializable + public data class Requests( + public val sampling: Sampling? = null, + public val elicitation: Elicitation? = null, + ) { + /** + * @property createMessage Present if the client supports task-augmented `sampling/createMessage` requests. + */ + @Serializable + public data class Sampling(public val createMessage: JsonObject? = null) + + /** + * @property create Present if the client supports task-augmented `elicitation/create` requests. + */ + @Serializable + public data class Elicitation(public val create: JsonObject? = null) + } + } } +/** + * Whether the client supports url-mode elicitation (out-of-band interaction via URL navigation). + * + * `false` when the client declared no elicitation capability at all, or only form mode (an empty + * [ClientCapabilities.Elicitation] is treated as form mode only). + */ +public val ClientCapabilities.Elicitation?.supportsUrl: Boolean + get() = this?.url != null + /** * Capabilities that a server may support. * @@ -92,7 +213,11 @@ public data class ClientCapabilities( * @property logging Present if the server supports sending log messages to the client. * @property completions Present if the server supports argument autocompletion suggestions. * Keys are capability names, values are capability-specific configuration objects. + * @property tasks Present if the server supports task-augmented requests, listing, or cancellation. * @property experimental Experimental, non-standard capabilities that the server supports. + * @property extensions Optional extensions that the server supports. + * Keys are extension identifiers (e.g., `"io.modelcontextprotocol/ui"`), + * values are extension-specific settings objects. */ @Serializable public data class ServerCapabilities( @@ -101,7 +226,9 @@ public data class ServerCapabilities( val prompts: Prompts? = null, val logging: JsonObject? = null, val completions: JsonObject? = null, + val tasks: Tasks? = null, val experimental: JsonObject? = null, + val extensions: Map? = null, ) { /** @@ -150,4 +277,35 @@ public data class ServerCapabilities( */ @Serializable public data class Prompts(val listChanged: Boolean? = null) + + /** + * Sub-capabilities for tasks (server side). + * + * Declares which server-side requests can be augmented with task execution, + * and whether the server supports listing and cancelling tasks. + * + * @property list Present if the server supports the `tasks/list` operation. + * @property cancel Present if the server supports the `tasks/cancel` operation. + * @property requests Present if the server supports task-augmented tool call requests. + */ + @Serializable + public data class Tasks( + public val list: JsonObject? = null, + public val cancel: JsonObject? = null, + public val requests: Requests? = null, + ) { + /** + * Task-augmentable server-side request categories. + * + * @property tools Present if the server supports task-augmented `tools/call` requests. + */ + @Serializable + public data class Requests(public val tools: Tools? = null) { + /** + * @property call Present if the server supports task-augmented `tools/call` requests. + */ + @Serializable + public data class Tools(public val call: JsonObject? = null) + } + } } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/common.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/common.kt index ac867af..f65b1ed 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/common.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/common.kt @@ -12,7 +12,7 @@ import kotlinx.serialization.json.JsonObject public const val LATEST_PROTOCOL_VERSION: String = "2025-11-25" /** The default protocol version used when negotiation is not performed. */ -public const val DEFAULT_NEGOTIATED_PROTOCOL_VERSION: String = "2025-06-18" +public const val DEFAULT_NEGOTIATED_PROTOCOL_VERSION: String = "2025-03-26" /** All MCP protocol versions supported by this SDK. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( @@ -28,11 +28,10 @@ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( /** * Represents an entity that includes additional metadata in its responses. - * - * @property meta optional metadata attached to this entity */ @Serializable public sealed interface WithMeta { + /** Optional metadata attached to this entity. */ @SerialName("_meta") public val meta: JsonObject? } @@ -91,9 +90,6 @@ public data class Icon( ) { /** * The theme context for which an icon is designed. - * - * @property Light Icon designed for use with a light background (typically darker icon). - * @property Dark Icon designed for use with a dark background (typically lighter icon). */ @Serializable public enum class Theme { @@ -128,11 +124,10 @@ public enum class Role { * * References are used to point to other entities (prompts, resources, etc.) * without including their full definitions. - * - * @property type discriminator identifying the reference subtype */ @Serializable(with = ReferencePolymorphicSerializer::class) public sealed interface Reference { + /** Discriminator identifying the reference subtype. */ public val type: ReferenceType } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/completion.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/completion.kt index c782e3e..1eb5329 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/completion.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/completion.kt @@ -12,7 +12,6 @@ import kotlinx.serialization.json.JsonObject * A request from the client to the server to ask for completion options. * * @property params The request parameters containing the argument to complete and its context. - * @property meta optional request metadata */ @Serializable public data class CompleteRequest(override val params: CompleteRequestParams) : ClientRequest { @@ -38,6 +37,7 @@ public data class CompleteRequest(override val params: CompleteRequestParams) : public val context: Context? get() = params.context + /** Convenience accessor for [params]'s metadata. */ public val meta: RequestMeta? get() = params.meta } @@ -48,6 +48,8 @@ public data class CompleteRequest(override val params: CompleteRequestParams) : * @property argument The argument's information for which completion options are requested. * @property ref A reference to either a prompt or resource template to complete within. * @property context Additional, optional context for generating completions. + * @property meta Optional metadata for this request. May include a progressToken for + * out-of-band progress notifications. */ @Serializable public data class CompleteRequestParams( diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/content.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/content.kt index a1c9e4f..815e5f6 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/content.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/content.kt @@ -29,23 +29,45 @@ public enum class ContentTypes(public val value: String) { @SerialName("resource") EMBEDDED_RESOURCE("resource"), + + @SerialName("tool_use") + TOOL_USE("tool_use"), + + @SerialName("tool_result") + TOOL_RESULT("tool_result"), } /** * Base interface for all content blocks in the protocol. - * - * @property type discriminator identifying the content block subtype */ @Serializable(with = ContentBlockPolymorphicSerializer::class) public sealed interface ContentBlock : WithMeta { + /** Discriminator identifying the content block subtype. */ public val type: ContentTypes } /** * Content block that carries media data such as text, images, or audio. + * + * Every [MediaContent] is also a valid [SamplingMessageContent]; the sampling content + * hierarchy additionally admits [ToolUseContent] and [ToolResultContent]. */ @Serializable(with = MediaContentPolymorphicSerializer::class) -public sealed interface MediaContent : ContentBlock +public sealed interface MediaContent : + ContentBlock, + SamplingMessageContent + +/** + * Content block that can appear inside a [SamplingMessage] or [CreateMessageResult]. + * + * Implemented by [TextContent], [ImageContent], [AudioContent], [ToolUseContent], + * and [ToolResultContent]. + */ +@Serializable(with = SamplingMessageContentPolymorphicSerializer::class) +public sealed interface SamplingMessageContent : WithMeta { + /** discriminator identifying the content block subtype */ + public val type: ContentTypes +} /** * Text provided to or from an LLM. @@ -174,3 +196,50 @@ public data class EmbeddedResource( @EncodeDefault public override val type: ContentTypes = ContentTypes.EMBEDDED_RESOURCE } + +/** + * A request from the assistant to invoke a tool during sampling. + * + * @property id Unique identifier for this tool use; matches a subsequent + * [ToolResultContent.toolUseId] that reports the result. + * @property name The tool name (must match a tool declared in the sampling request's tools list). + * @property input The arguments to pass to the tool, conforming to the tool's input schema. + * @property meta property/parameter is reserved by MCP to allow clients and servers + * to attach additional metadata to their interactions. + */ +@Serializable +public data class ToolUseContent( + val id: String, + val name: String, + val input: JsonObject, + @SerialName("_meta") + override val meta: JsonObject? = null, +) : SamplingMessageContent { + @EncodeDefault + public override val type: ContentTypes = ContentTypes.TOOL_USE +} + +/** + * The result of a tool call previously requested via [ToolUseContent], supplied back + * to the assistant on the next sampling turn. + * + * @property toolUseId The id of the [ToolUseContent] this result corresponds to. + * @property content The unstructured result, following the same shape as [CallToolResult.content]. + * @property structuredContent Optional structured result; if the tool declared an output schema, + * this SHOULD conform to it. + * @property isError Whether the tool call ended in error. Defaults to absent (treated as false). + * @property meta property/parameter is reserved by MCP to allow clients and servers + * to attach additional metadata to their interactions. + */ +@Serializable +public data class ToolResultContent( + val toolUseId: String, + val content: List = emptyList(), + val structuredContent: JsonObject? = null, + val isError: Boolean? = null, + @SerialName("_meta") + override val meta: JsonObject? = null, +) : SamplingMessageContent { + @EncodeDefault + public override val type: ContentTypes = ContentTypes.TOOL_RESULT +} diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/discovery.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/discovery.kt new file mode 100644 index 0000000..d486b1f --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/discovery.kt @@ -0,0 +1,107 @@ +package io.modelcontextprotocol.kotlin.sdk.types + +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Required +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonObject + +/** + * Parameters for a `server/discover` request. + * + * Discovery belongs to the request-scoped lifecycle and therefore requires a protocol version and + * client capabilities in [meta]. Client information is recommended by the protocol but optional. + * Unknown metadata entries are preserved by [RequestMeta]. + * + * @property meta request-scoped protocol and client metadata + * @throws IllegalArgumentException if required request-scoped metadata is absent + * @throws SerializationException if required metadata is malformed + */ +@Serializable +@ExperimentalMcpApi +public data class DiscoverRequestParams( + @SerialName("_meta") + override val meta: RequestMeta, +) : RequestParams { + init { + requireNotNull(meta.protocolVersion) { "${RequestMetaKeys.PROTOCOL_VERSION} is required" } + requireNotNull(meta.clientCapabilities) { "${RequestMetaKeys.CLIENT_CAPABILITIES} is required" } + } +} + +/** + * Requests the protocol versions, capabilities, and metadata advertised by a server. + * + * @property params request metadata required by the request-scoped lifecycle + */ +@Serializable +@ExperimentalMcpApi +public data class DiscoverRequest(override val params: DiscoverRequestParams) : ClientRequest { + @OptIn(ExperimentalSerializationApi::class) + @EncodeDefault + override val method: Method = Method.Defined.ServerDiscover + + /** Metadata for this request. */ + public val meta: RequestMeta + get() = params.meta +} + +/** Defines where a cached discovery result may be reused. */ +@Serializable +@ExperimentalMcpApi +public enum class CacheScope { + /** The result may only be reused within the same authorization context. */ + @SerialName("private") + Private, + + /** The result may be shared across authorization contexts. */ + @SerialName("public") + Public, +} + +/** + * Result returned by `server/discover`. + * + * @property supportedVersions protocol versions supported by the server + * @property capabilities capabilities advertised by the server + * @property instructions optional guidance for clients using the server + * @property resultType discriminator for the result representation + * @property ttlMs number of milliseconds clients may treat this result as fresh + * @property cacheScope authorization boundary within which the result may be reused + * @property meta optional response metadata, including optional server information + * @throws IllegalArgumentException if [ttlMs] is negative + */ +@Serializable +@ExperimentalMcpApi +public data class DiscoverResult( + val supportedVersions: List, + val capabilities: ServerCapabilities, + val instructions: String? = null, + @EncodeDefault + val resultType: String = COMPLETE_RESULT_TYPE, + @Required + val ttlMs: Long = 0, + @Required + val cacheScope: CacheScope = CacheScope.Private, + @SerialName("_meta") + override val meta: JsonObject? = null, +) : ServerResult { + init { + require(ttlMs >= 0) { "ttlMs must be non-negative, but was $ttlMs" } + } +} + +/** + * Data returned when a request selects an unsupported protocol version. + * + * @property supported protocol versions supported by the receiver + * @property requested protocol version selected by the request + */ +@Serializable +@ExperimentalMcpApi +public data class UnsupportedProtocolVersionData(val supported: List, val requested: String) + +internal const val COMPLETE_RESULT_TYPE: String = "complete" diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.kt index bb303f0..1bb73e6 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.kt @@ -46,11 +46,10 @@ public data class ElicitRequest(override val params: ElicitRequestParams) : Serv * Represents the parameters for an `elicitation/create` request. * * Implementations: [ElicitRequestFormParams], [ElicitRequestURLParams]. - * - * @property message The message to present to the user describing what information is being requested. */ @Serializable(with = ElicitRequestParamsSerializer::class) public sealed interface ElicitRequestParams : RequestParams { + /** The message to present to the user describing what information is being requested. */ public val message: String /** @@ -64,7 +63,6 @@ public sealed interface ElicitRequestParams : RequestParams { * Each property must be a primitive type (string, number, boolean). * @property required Optional list of property names that must be provided by the user. * If omitted, all fields are considered optional. - * @property type Always "object" for elicitation schemas. */ @Serializable public data class RequestedSchema( @@ -73,6 +71,7 @@ public sealed interface ElicitRequestParams : RequestParams { val properties: Map, val required: List? = null, ) { + /** Always "object" for elicitation schemas. */ @EncodeDefault val type: String = "object" } @@ -91,7 +90,7 @@ public sealed interface ElicitRequestParams : RequestParams { ReplaceWith("ElicitRequestFormParams(message, requestedSchema = requestedSchema, meta = meta)"), DeprecationLevel.WARNING, ) -@Suppress("FunctionNaming", "FunctionName") +@Suppress("FunctionName") public fun ElicitRequestParams( message: String, requestedSchema: ElicitRequestParams.RequestedSchema, @@ -113,7 +112,6 @@ public fun ElicitRequestParams( * later via `tasks/result`. * @property requestedSchema A restricted subset of JSON Schema. Only top-level properties * are allowed, without nesting. - * @property mode The elicitation mode discriminator, always `"form"`. * @property meta Optional metadata. May include a progressToken for out-of-band progress notifications. */ @Serializable @@ -124,6 +122,7 @@ public data class ElicitRequestFormParams( @SerialName("_meta") override val meta: RequestMeta? = null, ) : ElicitRequestParams { + /** The elicitation mode discriminator, always `"form"`. */ @EncodeDefault public val mode: String = "form" } @@ -134,6 +133,11 @@ public data class ElicitRequestFormParams( * Directs the user to an external URL for out-of-band interactions (e.g., OAuth flows, * payment processing, or entering sensitive credentials) that must not pass through the MCP client. * + * Handling the [url] is the responsibility of the host application: it should require HTTPS, clearly + * display the target domain, obtain explicit user consent, and open the URL in a secure browser context. + * The SDK neither opens, fetches, nor validates the URL. URLs must only appear in URL-mode requests — + * never render a URL from a form-mode request as a clickable link. + * * @property message The message explaining why the interaction is needed. * @property elicitationId A unique identifier for this elicitation. The client MUST treat * this ID as an opaque value. @@ -141,7 +145,6 @@ public data class ElicitRequestFormParams( * @property task If specified, the caller is requesting task-augmented execution. The request * will return a [CreateTaskResult] immediately, and the actual result can be retrieved * later via `tasks/result`. - * @property mode The elicitation mode discriminator, always `"url"`. * @property meta Optional metadata. May include a progressToken for out-of-band progress notifications. */ @Serializable @@ -153,6 +156,7 @@ public data class ElicitRequestURLParams( @SerialName("_meta") override val meta: RequestMeta? = null, ) : ElicitRequestParams { + /** The elicitation mode discriminator, always `"url"`. */ @EncodeDefault public val mode: String = "url" } @@ -182,10 +186,6 @@ public data class ElicitResult( /** * The user's response action to an elicitation request. - * - * @property Accept User submitted the form/confirmed the action. Content will be provided. - * @property Decline User explicitly declined the action. No content provided. - * @property Cancel User dismissed the dialog without making an explicit choice. No content provided. */ @Serializable public enum class Action { diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/initialize.dsl.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/initialize.dsl.kt index 2cb58b7..f3e1575 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/initialize.dsl.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/initialize.dsl.kt @@ -21,7 +21,7 @@ import kotlin.contracts.contract * val request = buildInitializeRequest { * protocolVersion = "2024-11-05" * capabilities { - * sampling(ClientCapabilities.sampling) + * sampling(ClientCapabilities.Sampling()) * roots(listChanged = true) * } * info("MyClient", "1.0.0") @@ -33,7 +33,7 @@ import kotlin.contracts.contract * val request = buildInitializeRequest { * protocolVersion = "2024-11-05" * capabilities { - * sampling(ClientCapabilities.sampling) + * sampling(ClientCapabilities.Sampling()) * experimental { * put("feature", JsonPrimitive(true)) * } @@ -94,7 +94,7 @@ public class InitializeRequestBuilder @PublishedApi internal constructor() : Req * Example: * ```kotlin * capabilities(ClientCapabilities( - * sampling = ClientCapabilities.sampling, + * sampling = ClientCapabilities.Sampling(), * roots = ClientCapabilities.Roots(listChanged = true) * )) * ``` @@ -113,7 +113,7 @@ public class InitializeRequestBuilder @PublishedApi internal constructor() : Req * Example: * ```kotlin * capabilities { - * sampling(ClientCapabilities.sampling) + * sampling(ClientCapabilities.Sampling()) * roots(listChanged = true) * elicitation(ClientCapabilities.elicitation) * } 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 1a9ea2e..7e10904 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonRpc.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonRpc.kt @@ -104,11 +104,10 @@ internal fun JSONRPCNotification.fromJSON(): Notification = * Base interface for all JSON-RPC 2.0 messages. * * All messages in the MCP protocol follow the JSON-RPC 2.0 specification. - * - * @property jsonrpc the JSON-RPC protocol version, always `"2.0"` */ @Serializable(with = JSONRPCMessagePolymorphicSerializer::class) public sealed interface JSONRPCMessage { + /** The JSON-RPC protocol version, always `"2.0"`. */ public val jsonrpc: String } @@ -131,7 +130,6 @@ public data object JSONRPCEmptyMessage : JSONRPCMessage { * The server or client (depending on a direction) must respond with either a * [JSONRPCResponse] or [JSONRPCError] that has the same [id]. * - * @property jsonrpc Always "2.0" to indicate JSON-RPC 2.0 protocol. * @property id A unique identifier for this request. The response will include the same ID. * Can be a string or number. UUID string is used by default. * @property method The name of the method to invoke (e.g., "tools/list", "resources/read"). @@ -144,6 +142,7 @@ public data class JSONRPCRequest @JvmOverloads public constructor( val method: String, val params: JsonElement? = null, ) : JSONRPCMessage { + /** Always `"2.0"` to indicate JSON-RPC 2.0 protocol. */ @EncodeDefault override val jsonrpc: String = JSONRPC_VERSION @@ -186,13 +185,13 @@ public data class JSONRPCRequest @JvmOverloads public constructor( * * Examples: progress updates, resource change notifications, log messages. * - * @property jsonrpc Always "2.0" to indicate JSON-RPC 2.0 protocol. * @property method The name of the notification method (e.g., "notifications/progress", * "notifications/resources/updated"). * @property params Optional parameters for the notification. Structure depends on the specific method. */ @Serializable public data class JSONRPCNotification(val method: String, val params: JsonElement? = null) : JSONRPCMessage { + /** Always `"2.0"` to indicate JSON-RPC 2.0 protocol. */ @EncodeDefault override val jsonrpc: String = JSONRPC_VERSION } @@ -207,12 +206,12 @@ public data class JSONRPCNotification(val method: String, val params: JsonElemen * Sent in response to a [JSONRPCRequest] when the method execution succeeds. * The [id] must match the [id] of the original request. * - * @property jsonrpc Always "2.0" to indicate JSON-RPC 2.0 protocol. * @property id The identifier from the original request. Used to match responses to requests. * @property result The result of the method execution. Structure depends on the method that was called. */ @Serializable public data class JSONRPCResponse(val id: RequestId, val result: RequestResult = EmptyResult()) : JSONRPCMessage { + /** Always `"2.0"` to indicate JSON-RPC 2.0 protocol. */ @EncodeDefault override val jsonrpc: String = JSONRPC_VERSION } @@ -227,12 +226,12 @@ public data class JSONRPCResponse(val id: RequestId, val result: RequestResult = * Sent in response to a [JSONRPCRequest] when the method execution fails. * The [id] must match the [id] of the original request. * - * @property jsonrpc Always "2.0" to indicate JSON-RPC 2.0 protocol. * @property id The identifier from the original request. Used to match error responses to requests. * @property error Details about the error that occurred, including error code and message. */ @Serializable public data class JSONRPCError(val id: RequestId?, val error: RPCError) : JSONRPCMessage { + /** Always `"2.0"` to indicate JSON-RPC 2.0 protocol. */ @EncodeDefault override val jsonrpc: String = JSONRPC_VERSION } @@ -270,6 +269,16 @@ public data class RPCError(val code: Int, val message: String, val data: JsonEle /** Resource not found */ public const val RESOURCE_NOT_FOUND: Int = -32002 + /** The request selected a protocol version that the receiver does not support. */ + public const val UNSUPPORTED_PROTOCOL_VERSION: Int = -32022 + + /** + * A URL-mode elicitation must be completed before the request can be processed. + * The error [data][RPCError.data] carries the required URL-mode elicitations + * (see [UrlElicitationRequiredException]). + */ + public const val URL_ELICITATION_REQUIRED: Int = -32042 + // Standard JSON-RPC 2.0 error codes /** Invalid JSON was received */ diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/methods.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/methods.kt index dae2fc7..1824ad0 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/methods.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/methods.kt @@ -4,11 +4,10 @@ import kotlinx.serialization.Serializable /** * Represents a method in the protocol, which can be predefined or custom. - * - * @property value the string representation of this method name */ @Serializable(with = MethodSerializer::class) public sealed interface Method { + /** The string representation of this method name. */ public val value: String /** @@ -17,6 +16,7 @@ public sealed interface Method { @Serializable public enum class Defined(override val value: String) : Method { Initialize("initialize"), + ServerDiscover("server/discover"), Ping("ping"), ResourcesList("resources/list"), ResourcesTemplatesList("resources/templates/list"), @@ -54,4 +54,13 @@ public sealed interface Method { */ @Serializable public data class Custom(override val value: String) : Method + + public companion object { + private val definedByValue: Map by lazy { + Defined.entries.associateBy { it.value } + } + + /** Resolves a wire method string to a [Defined] entry when known, [Custom] otherwise. */ + internal fun from(value: String): Method = definedByValue[value] ?: Custom(value) + } } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/notification.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/notification.kt index 24be040..3d33c21 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/notification.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/notification.kt @@ -11,13 +11,13 @@ import kotlinx.serialization.json.JsonObject /** * Represents a notification in the protocol. - * - * @property method the notification method identifier - * @property params optional notification parameters */ @Serializable(with = NotificationPolymorphicSerializer::class) public sealed interface Notification { + /** The notification method identifier. */ public val method: Method + + /** Optional notification parameters. */ public val params: NotificationParams? } @@ -34,9 +34,7 @@ public sealed interface ClientNotification : Notification public sealed interface ServerNotification : Notification /** - * Interface for notification parameter types. - * - * @property meta Optional metadata for the notification. + * Interface for notification parameter types. Inherits the optional `_meta` field from [WithMeta]. */ @Serializable public sealed interface NotificationParams : WithMeta @@ -76,13 +74,13 @@ public class Progress( * @property method The custom method name. By convention, custom methods often contain * organization-specific prefixes (e.g., "mycompany/custom_event"). * @property params Raw JSON parameters for the custom notification, if present. - * @property meta optional metadata for this notification */ @Serializable public data class CustomNotification(override val method: Method, override val params: BaseNotificationParams? = null) : ClientNotification, ServerNotification { + /** Convenience accessor for [params]'s metadata. */ public val meta: JsonObject? get() = params?.meta } 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 9f52ec9..7d885fb 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/request.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/request.kt @@ -1,8 +1,10 @@ package io.modelcontextprotocol.kotlin.sdk.types +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -11,15 +13,22 @@ 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. * * @property json the raw JSON object containing the metadata - * @property progressToken optional progress token for tracking request progress */ @JvmInline @Serializable public value class RequestMeta(public val json: JsonObject) { + /** Optional progress token for tracking request progress. */ public val progressToken: ProgressToken? get() = json["progressToken"]?.let { element -> when { @@ -40,6 +49,88 @@ public value class RequestMeta(public val json: JsonObject) { McpJson.decodeFromJsonElement(element) } + /** + * The MCP protocol version selected for this request, or `null` when absent. + * + * This field is required by the request-scoped lifecycle introduced in protocol version + * `2026-07-28`, but remains optional here so older requests retain their wire shape. + * + * @throws SerializationException if the field is present but is not a string + */ + @ExperimentalMcpApi + public val protocolVersion: String? + get() = json[RequestMetaKeys.PROTOCOL_VERSION]?.let { element -> + if (element !is JsonPrimitive || !element.isString) { + throw SerializationException("${RequestMetaKeys.PROTOCOL_VERSION} must be a JSON string") + } + element.content + } + + /** + * Information about the client making this request, or `null` when absent. + * + * Request-scoped clients should include this field unless specifically configured not to do + * so. The self-reported value is intended for display, logging, and debugging; servers should + * not use it to change behavior or rely on it for security decisions. + * + * @throws SerializationException if the field is present but is not a valid [Implementation] + */ + @ExperimentalMcpApi + public val clientInfo: Implementation? + get() = json[RequestMetaKeys.CLIENT_INFO]?.let { element -> + try { + McpJson.decodeFromJsonElement(element) + } catch (cause: SerializationException) { + throw SerializationException( + "${RequestMetaKeys.CLIENT_INFO} must be a valid client implementation", + cause, + ) + } + } + + /** + * Capabilities declared by the client for this request, or `null` when absent. + * + * Servers using the request-scoped lifecycle must not infer capabilities from an earlier + * request. An empty object means the client supports no optional capabilities. + * + * @throws SerializationException if the field is present but is not valid [ClientCapabilities] + */ + @ExperimentalMcpApi + public val clientCapabilities: ClientCapabilities? + get() = json[RequestMetaKeys.CLIENT_CAPABILITIES]?.let { element -> + try { + McpJson.decodeFromJsonElement(element) + } catch (cause: SerializationException) { + throw SerializationException( + "${RequestMetaKeys.CLIENT_CAPABILITIES} must be a valid client capabilities object", + cause, + ) + } + } + + /** + * Minimum severity of request-associated log notifications, or `null` when absent. + * + * In the request-scoped lifecycle, absence means that the server must not emit log + * notifications for this request. + * + * @throws SerializationException if the field is present but is not a valid [LoggingLevel] + */ + @Deprecated("Per-request log levels are deprecated as of MCP protocol version 2026-07-28 (SEP-2577).") + @ExperimentalMcpApi + public val logLevel: LoggingLevel? + get() = json[RequestMetaKeys.LOG_LEVEL]?.let { element -> + try { + McpJson.decodeFromJsonElement(element) + } catch (cause: SerializationException) { + throw SerializationException( + "${RequestMetaKeys.LOG_LEVEL} must be a valid logging level", + cause, + ) + } + } + /** * Retrieves the value associated with the specified key from the JSON object. * @@ -72,14 +163,14 @@ public data class BaseRequestParams(@SerialName("_meta") override val meta: Requ /** * Represents a request in the protocol. - * - * @property method the request method identifier - * @property params optional request parameters */ @OptIn(ExperimentalSerializationApi::class) @Serializable(with = RequestPolymorphicSerializer::class) public sealed interface Request { + /** The request method identifier. */ public val method: Method + + /** Optional request parameters. */ public val params: RequestParams? } @@ -142,7 +233,7 @@ public sealed interface ServerResult : RequestResult /** * An empty result for a request containing optional metadata. * - * @param meta Additional metadata for the response. Defaults to an empty JSON object. + * @property meta Additional metadata for the response. Defaults to an empty JSON object. */ @Serializable public data class EmptyResult(@SerialName("_meta") override val meta: JsonObject? = null) : @@ -151,11 +242,10 @@ public data class EmptyResult(@SerialName("_meta") override val meta: JsonObject /** * Represents a paginated result. - * - * @property nextCursor opaque token for retrieving the next page of results, or `null` if no more results */ @Serializable public sealed interface PaginatedResult : RequestResult { + /** Opaque token for retrieving the next page of results, or `null` if no more results. */ public val nextCursor: String? } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/resources.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/resources.kt index 86ce3ce..1afa0e6 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/resources.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/resources.kt @@ -117,15 +117,14 @@ public data class ResourceTemplateReference(val uri: String) : Reference { } /** - * The contents of a specific resource or sub-resource. - * - * @property uri The URI of this resource. - * @property mimeType The MIME type of this resource, if known. - * @property meta Optional metadata for this response. + * The contents of a specific resource or sub-resource. Carries the optional `_meta` field via [WithMeta]. */ @Serializable(with = ResourceContentsPolymorphicSerializer::class) public sealed interface ResourceContents : WithMeta { + /** The URI of this resource. */ public val uri: String + + /** The MIME type of this resource, if known. */ public val mimeType: String? } @@ -136,6 +135,7 @@ public sealed interface ResourceContents : WithMeta { * This must only be set if the item can actually be represented as text (not binary data). * @property uri The URI of this resource. * @property mimeType The MIME type of this resource, if known. + * @property meta Optional metadata for these contents. */ @Serializable public data class TextResourceContents( @@ -152,6 +152,7 @@ public data class TextResourceContents( * @property blob A base64-encoded string representing the binary data of the item. * @property uri The URI of this resource. * @property mimeType The MIME type of this resource, if known. + * @property meta Optional metadata for these contents. */ @Serializable public data class BlobResourceContents( @@ -167,6 +168,7 @@ public data class BlobResourceContents( * * @property uri The URI of this resource. * @property mimeType The MIME type of this resource, if known. + * @property meta Optional metadata for these contents. */ @Serializable public data class UnknownResourceContents( diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/roots.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/roots.kt index 194ea4f..7ee4f9e 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/roots.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/roots.kt @@ -53,7 +53,6 @@ public data class Root( * `listChanged = true` to receive these requests. * * @property params Optional request parameters containing metadata. - * @property meta optional request metadata */ @Serializable public data class ListRootsRequest(override val params: BaseRequestParams? = null) : ServerRequest { @@ -61,6 +60,7 @@ public data class ListRootsRequest(override val params: BaseRequestParams? = nul @EncodeDefault override val method: Method = Method.Defined.RootsList + /** Convenience accessor for [params]'s metadata. */ public val meta: RequestMeta? get() = params?.meta } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/sampling.dsl.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/sampling.dsl.kt index 1bc00e2..4752ff5 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/sampling.dsl.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/sampling.dsl.kt @@ -112,8 +112,8 @@ public class CreateMessageRequestBuilder @PublishedApi internal constructor() : * Example: * ```kotlin * messages(listOf( - * SamplingMessage(Role.User, TextContent("Hello")), - * SamplingMessage(Role.Assistant, TextContent("Hi!")) + * SamplingMessage(Role.User, listOf(TextContent("Hello"))), + * SamplingMessage(Role.Assistant, listOf(TextContent("Hi!"))) * )) * ``` */ @@ -246,7 +246,7 @@ public class SamplingMessageBuilder @PublishedApi internal constructor() { * ``` */ public fun user(content: MediaContent) { - messages.add(SamplingMessage(Role.User, content)) + messages.add(SamplingMessage(Role.User, listOf(content))) } /** @@ -258,7 +258,7 @@ public class SamplingMessageBuilder @PublishedApi internal constructor() { * ``` */ public fun assistant(content: MediaContent) { - messages.add(SamplingMessage(Role.Assistant, content)) + messages.add(SamplingMessage(Role.Assistant, listOf(content))) } @PublishedApi 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 97fc0c0..7cc2685 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/sampling.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/sampling.kt @@ -75,11 +75,37 @@ public data class ModelPreferences( * * Used in sampling requests to provide conversation context and history to the LLM. * + * Under SEP-1577, [content] is a list of [SamplingMessageContent] blocks rather than a + * single block. On the wire, a list of size 1 is serialised as a single object (wire-compatible + * with pre-SEP single-block content); a list of size 2+ is serialised as a JSON array. + * * @property role The role of the message sender (user, assistant, or system). - * @property content The content of the message. Can be text, image, or audio content. + * @property content The content blocks of the message; MUST contain at least one block. + * @property meta Optional metadata reserved by MCP for clients and servers. */ @Serializable -public data class SamplingMessage(val role: Role, val content: MediaContent) +public data class SamplingMessage( + val role: Role, + @Serializable(with = SamplingContentSerializer::class) + val content: List, + @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"))` + * without the explicit `listOf(...)`. + */ + public constructor( + role: Role, + content: SamplingMessageContent, + meta: JsonObject? = null, + ) : this(role, listOf(content), meta) +} // ============================================================================ // sampling/createMessage @@ -156,6 +182,12 @@ public data class CreateMessageRequest(override val params: CreateMessageRequest public val metadata: JsonObject? get() = params.metadata + /** + * Task metadata, if the caller is requesting task-augmented execution. + */ + public val task: TaskMetadata? + get() = params.task + /** * Metadata for this request. May include a progressToken for out-of-band progress notifications. */ @@ -184,6 +216,14 @@ public data class CreateMessageRequest(override val params: CreateMessageRequest * @property stopSequences Optional list of sequences that will stop generation if encountered. * @property metadata Optional metadata to pass through to the LLM provider. * The format of this metadata is provider-specific. + * @property tools Optional list of tools the model may use during generation. + * The client MUST return an error if this field is present but the client did not advertise + * [ClientCapabilities.Sampling.tools]. + * @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. */ @@ -197,6 +237,9 @@ public data class CreateMessageRequestParams( val temperature: Double? = null, val stopSequences: List? = null, val metadata: JsonObject? = null, + val tools: List? = null, + val toolChoice: ToolChoice? = null, + val task: TaskMetadata? = null, @SerialName("_meta") override val meta: RequestMeta? = null, ) : RequestParams @@ -226,22 +269,41 @@ public enum class IncludeContext { * to inspect the response (human in the loop) and decide whether to allow the server to see it. * * @property role The role of the message sender. Typically [Role.Assistant] for LLM-generated responses. - * @property content The generated content. Can be text, image, or audio content. - * @property model The name of the model that generated the message (e.g., "claude-3-opus-20240229", - * "gpt-4-turbo-preview"). This helps the server understand which model was used. + * @property content The generated content blocks; at least one block is required. + * @property model The name of the model that generated the message (e.g., "claude-3-opus-20240229"). * @property stopReason The reason why sampling stopped, if known. - * Common values: "end_turn", "max_tokens", "stop_sequence", "content_filter" + * Common values: [StopReason.EndTurn], [StopReason.StopSequence], [StopReason.MaxTokens], + * [StopReason.ToolUse]. * @property meta Optional metadata for this response. */ @Serializable public data class CreateMessageResult( val role: Role, - val content: MediaContent, + @Serializable(with = SamplingContentSerializer::class) + val content: List, val model: String, val stopReason: StopReason? = null, @SerialName("_meta") override val meta: JsonObject? = null, -) : ClientResult +) : 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 + * `CreateMessageResult(Role.Assistant, TextContent("ok"), "model-name")` + * without the explicit `listOf(...)`. + */ + public constructor( + role: Role, + content: SamplingMessageContent, + model: String, + stopReason: StopReason? = null, + meta: JsonObject? = null, + ) : this(role, listOf(content), model, stopReason, meta) +} /** * The reason why the LLM stopped generating tokens. @@ -255,10 +317,38 @@ public value class StopReason(public val value: String) { * @property EndTurn generation ended naturally * @property StopSequence a stop sequence was encountered * @property MaxTokens the maximum token limit was reached + * @property ToolUse the assistant issued a tool call (see SEP-1577) */ public companion object { public val EndTurn: StopReason = StopReason("endTurn") public val StopSequence: StopReason = StopReason("stopSequence") public val MaxTokens: StopReason = StopReason("maxTokens") + public val ToolUse: StopReason = StopReason("toolUse") + } +} + +/** + * Controls tool-selection behaviour for [CreateMessageRequest] under SEP-1577. + * + * @property mode Tool-use policy: + * - [Mode.Auto]: the model decides whether to use tools (default when [mode] is absent) + * - [Mode.Required]: the model MUST use at least one tool before completing + * - [Mode.None]: the model MUST NOT use any tool + */ +@Serializable +public data class ToolChoice(public val mode: Mode? = null) { + /** + * Allowed values of [ToolChoice.mode] per SEP-1577. + */ + @Serializable + public enum class Mode { + @SerialName("auto") + Auto, + + @SerialName("required") + Required, + + @SerialName("none") + None, } } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/schema.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/schema.kt index 479cc66..0982500 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/schema.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/schema.kt @@ -24,7 +24,6 @@ public sealed interface PrimitiveSchemaDefinition * @property minLength Minimum string length. * @property maxLength Maximum string length. * @property format Optional format constraint (e.g., email, URI, date). - * @property type JSON Schema type discriminator, always `"string"`. * @property default Optional default value. */ @Serializable @@ -36,6 +35,7 @@ public data class StringSchema( val format: StringSchemaFormat? = null, val default: String? = null, ) : PrimitiveSchemaDefinition { + /** JSON Schema type discriminator, always `"string"`. */ @EncodeDefault public val type: String = "string" } @@ -73,7 +73,6 @@ public sealed interface NumberSchemaDefinition : PrimitiveSchemaDefinition * @property description Optional description for the field. * @property minimum Minimum allowed value. * @property maximum Maximum allowed value. - * @property type JSON Schema type discriminator, always `"integer"`. * @property default Optional default value. */ @Serializable @@ -84,6 +83,7 @@ public data class IntegerSchema( val maximum: Int? = null, val default: Int? = null, ) : NumberSchemaDefinition { + /** JSON Schema type discriminator, always `"integer"`. */ @EncodeDefault val type: String = "integer" } @@ -95,7 +95,6 @@ public data class IntegerSchema( * @property description Optional description for the field. * @property minimum Minimum allowed value. * @property maximum Maximum allowed value. - * @property type JSON Schema type discriminator, always `"number"`. * @property default Optional default value. */ @Serializable @@ -106,6 +105,7 @@ public data class DoubleSchema( val maximum: Double? = null, val default: Double? = null, ) : NumberSchemaDefinition { + /** JSON Schema type discriminator, always `"number"`. */ @EncodeDefault val type: String = "number" } @@ -115,7 +115,6 @@ public data class DoubleSchema( * * @property title Optional display title for the field. * @property description Optional description for the field. - * @property type JSON Schema type discriminator, always `"boolean"`. * @property default Optional default value. */ @Serializable @@ -124,6 +123,7 @@ public data class BooleanSchema( val description: String? = null, val default: Boolean? = null, ) : PrimitiveSchemaDefinition { + /** JSON Schema type discriminator, always `"boolean"`. */ @EncodeDefault val type: String = "boolean" } @@ -161,7 +161,6 @@ public sealed interface SingleSelectEnumSchema : EnumSchemaDefinition * @property title Optional display title for the field. * @property description Optional description for the field. * @property enumValues Array of enum values to choose from. - * @property type JSON Schema type discriminator, always `"string"`. * @property default Optional default value. */ @Serializable @@ -172,6 +171,7 @@ public data class UntitledSingleSelectEnumSchema( val enumValues: List, val default: String? = null, ) : SingleSelectEnumSchema { + /** JSON Schema type discriminator, always `"string"`. */ @EncodeDefault val type: String = "string" } @@ -182,7 +182,6 @@ public data class UntitledSingleSelectEnumSchema( * @property title Optional display title for the field. * @property description Optional description for the field. * @property oneOf Array of enum options with values and display labels. - * @property type JSON Schema type discriminator, always `"string"`. * @property default Optional default value. */ @Serializable @@ -192,6 +191,7 @@ public data class TitledSingleSelectEnumSchema( val oneOf: List, val default: String? = null, ) : SingleSelectEnumSchema { + /** JSON Schema type discriminator, always `"string"`. */ @EncodeDefault val type: String = "string" } @@ -205,7 +205,6 @@ public data class TitledSingleSelectEnumSchema( * @property description Optional description for the field. * @property enumValues Array of enum values to choose from. * @property enumNames Display names for enum values. Non-standard according to JSON Schema 2020-12. - * @property type JSON Schema type discriminator, always `"string"`. * @property default Optional default value. */ @Deprecated("Use TitledSingleSelectEnumSchema instead") @@ -218,6 +217,7 @@ public data class LegacyTitledEnumSchema( val enumNames: List? = null, val default: String? = null, ) : EnumSchemaDefinition { + /** JSON Schema type discriminator, always `"string"`. */ @EncodeDefault val type: String = "string" } @@ -238,7 +238,6 @@ public sealed interface MultiSelectEnumSchema : EnumSchemaDefinition * @property minItems Minimum number of items to select. * @property maxItems Maximum number of items to select. * @property items Schema for the array items. - * @property type JSON Schema type discriminator, always `"array"`. * @property default Optional default value. */ @Serializable @@ -250,13 +249,13 @@ public data class UntitledMultiSelectEnumSchema( val items: Items, val default: List? = null, ) : MultiSelectEnumSchema { + /** JSON Schema type discriminator, always `"array"`. */ @EncodeDefault val type: String = "array" /** * Schema for the array items with plain enum values. * - * @property type JSON Schema type discriminator, always `"string"`. * @property enumValues Array of enum values to choose from. */ @Serializable @@ -264,6 +263,7 @@ public data class UntitledMultiSelectEnumSchema( @SerialName("enum") val enumValues: List, ) { + /** JSON Schema type discriminator, always `"string"`. */ @EncodeDefault val type: String = "string" } @@ -277,7 +277,6 @@ public data class UntitledMultiSelectEnumSchema( * @property minItems Minimum number of items to select. * @property maxItems Maximum number of items to select. * @property items Schema for array items with enum options and display labels. - * @property type JSON Schema type discriminator, always `"array"`. * @property default Optional default value. */ @Serializable @@ -289,6 +288,7 @@ public data class TitledMultiSelectEnumSchema( val items: Items, val default: List? = null, ) : MultiSelectEnumSchema { + /** JSON Schema type discriminator, always `"array"`. */ @EncodeDefault val type: String = "array" 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 3e25957..36b7e37 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt @@ -1,16 +1,22 @@ 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 +import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject @@ -54,18 +60,11 @@ internal object MethodSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("io.modelcontextprotocol.kotlin.sdk.types.Method", PrimitiveKind.STRING) - private val definedMethods: Map by lazy { - Method.Defined.entries.associateBy { it.value } - } - override fun serialize(encoder: Encoder, value: Method) { encoder.encodeString(value.value) } - override fun deserialize(decoder: Decoder): Method { - val decodedString = decoder.decodeString() - return definedMethods[decodedString] ?: Method.Custom(decodedString) - } + override fun deserialize(decoder: Decoder): Method = Method.from(decoder.decodeString()) } // ============================================================================ @@ -121,6 +120,101 @@ internal object MediaContentPolymorphicSerializer : } } +/** + * Polymorphic serializer for [SamplingMessageContent] types. + * Determines the concrete type based on the "type" field in JSON. + */ +internal object SamplingMessageContentPolymorphicSerializer : + JsonContentPolymorphicSerializer(SamplingMessageContent::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy = + when (element.getType()) { + ContentTypes.TEXT.value -> TextContent.serializer() + + ContentTypes.IMAGE.value -> ImageContent.serializer() + + ContentTypes.AUDIO.value -> AudioContent.serializer() + + ContentTypes.TOOL_USE.value -> ToolUseContent.serializer() + + ContentTypes.TOOL_RESULT.value -> ToolResultContent.serializer() + + else -> throw SerializationException( + "Unknown sampling message content type: ${element.getTypeOrNull()}", + ) + } +} + +/** + * Wire-format serializer for `List` honouring SEP-1577's + * single-object-or-array content shape. + * + * **Why this exists.** SEP-1577 widens the `content` field of [SamplingMessage] and + * [CreateMessageResult] from a single content block to either a single block or an + * array of blocks. Pre-SEP peers only understand a single object on the wire; post-SEP + * peers may send arrays when a turn carries multiple blocks (e.g. an assistant reply + * containing `[TextContent, ToolUseContent]` during a tool-loop step). + * + * The Kotlin API uses `List` uniformly to avoid branching in + * caller code. This serializer bridges the type difference: + * + * - **Read:** accepts both shapes; a single JSON object becomes `listOf(block)`, an + * array becomes the decoded list. + * - **Write:** size-1 list → single object (wire-compatible with pre-SEP peers); + * size≥2 → array; empty list throws (sampling content has no valid empty meaning + * per spec). + */ +internal object SamplingContentSerializer : KSerializer> { + + private val listSerializer = ListSerializer(SamplingMessageContentPolymorphicSerializer) + + override val descriptor: SerialDescriptor = listSerializer.descriptor + + override fun serialize(encoder: Encoder, value: List) { + check(value.isNotEmpty()) { "content must contain at least one block" } + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("SamplingContentSerializer requires a Json encoder") + if (value.size == 1) { + jsonEncoder.encodeJsonElement( + jsonEncoder.json.encodeToJsonElement( + SamplingMessageContentPolymorphicSerializer, + value[0], + ), + ) + } else { + jsonEncoder.encodeJsonElement( + jsonEncoder.json.encodeToJsonElement(listSerializer, value), + ) + } + } + + override fun deserialize(decoder: Decoder): List { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("SamplingContentSerializer requires a Json decoder") + return decodeFromElement(jsonDecoder, jsonDecoder.decodeJsonElement()) + } + + private fun decodeFromElement(jsonDecoder: JsonDecoder, element: JsonElement): List = + when (element) { + is JsonArray -> { + if (element.isEmpty()) { + throw SerializationException("content must contain at least one block") + } + jsonDecoder.json.decodeFromJsonElement(listSerializer, element) + } + + is JsonObject -> listOf( + jsonDecoder.json.decodeFromJsonElement( + SamplingMessageContentPolymorphicSerializer, + element, + ), + ) + + else -> throw SerializationException( + "content must be a JSON object or array of objects, got $element", + ) + } +} + // ============================================================================ // Resource Serializers // ============================================================================ @@ -145,9 +239,11 @@ internal object ResourceContentsPolymorphicSerializer : // Request Serializers // ============================================================================ +@OptIn(ExperimentalMcpApi::class) private val clientRequestDeserializers: Map> by lazy { mapOf( Method.Defined.CompletionComplete.value to CompleteRequest.serializer(), + Method.Defined.ServerDiscover.value to DiscoverRequest.serializer(), Method.Defined.Initialize.value to InitializeRequest.serializer(), Method.Defined.Ping.value to PingRequest.serializer(), Method.Defined.LoggingSetLevel.value to SetLevelRequest.serializer(), @@ -324,10 +420,16 @@ private fun selectClientResultDeserializer(element: JsonElement): Deserializatio * Selects the appropriate deserializer for server results based on JSON content. * Returns null if the structure doesn't match any known server result type. */ -@Suppress("CyclomaticComplexMethod") +@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() @@ -430,7 +532,6 @@ internal object RequestIdPolymorphicSerializer : JsonContentPolymorphicSerialize internal object ElicitRequestParamsSerializer : JsonContentPolymorphicSerializer( ElicitRequestParams::class, ) { - @Suppress("ThrowsCount") override fun selectDeserializer(element: JsonElement): DeserializationStrategy { val mode = when (val modeElement = element.jsonObject["mode"]) { null -> null @@ -459,7 +560,6 @@ internal object ElicitRequestParamsSerializer : JsonContentPolymorphicSerializer internal object PrimitiveSchemaDefinitionSerializer : JsonContentPolymorphicSerializer( PrimitiveSchemaDefinition::class, ) { - @Suppress("ThrowsCount") override fun selectDeserializer(element: JsonElement): DeserializationStrategy { val obj = element.jsonObject val typeElement = obj["type"] @@ -489,7 +589,6 @@ internal object PrimitiveSchemaDefinitionSerializer : JsonContentPolymorphicSeri } } - @Suppress("DEPRECATION") private fun selectStringTypeDeserializer( obj: Map, ): DeserializationStrategy = when { diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tasks.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tasks.kt index 0f154e5..7363561 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tasks.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tasks.kt @@ -17,22 +17,27 @@ public const val RELATED_TASK_META_KEY: String = "io.modelcontextprotocol/relate /** * Common fields shared by all types representing the task state. - * - * @property taskId the task identifier - * @property status current task state - * @property statusMessage optional human-readable message describing the current task state - * @property createdAt ISO 8601 timestamp when the task was created - * @property lastUpdatedAt ISO 8601 timestamp when the task was last updated - * @property ttl actual retention duration from creation in milliseconds, or `null` for unlimited - * @property pollInterval suggested polling interval in milliseconds */ public sealed interface TaskFields { + /** The task identifier. */ public val taskId: String + + /** Current task state. */ public val status: TaskStatus + + /** Optional human-readable message describing the current task state. */ public val statusMessage: String? + + /** ISO 8601 timestamp when the task was created. */ public val createdAt: String + + /** ISO 8601 timestamp when the task was last updated. */ public val lastUpdatedAt: String + + /** Actual retention duration from creation in milliseconds, or `null` for unlimited. */ public val ttl: Long? + + /** Suggested polling interval in milliseconds. */ public val pollInterval: Long? } @@ -237,13 +242,13 @@ public data class GetTaskPayloadRequestParams( * For example, a tools/call task would return the [CallToolResult] structure. * * @property json the raw JSON object containing the result payload - * @property meta Optional metadata for this response. */ @JvmInline @Serializable public value class GetTaskPayloadResult(public val json: JsonObject) : ClientResult, ServerResult { + /** Optional metadata for this response, extracted from the `_meta` field of [json]. */ override val meta: JsonObject? get() = json["_meta"]?.takeIf { it is JsonObject } as? JsonObject 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 4f642f4..1f0f54e 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tools.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tools.kt @@ -39,12 +39,14 @@ public fun CallToolResult.Companion.error(content: String, meta: JsonObject? = n * if both [title] and [annotations].[title] are not provided. * @property inputSchema A JSON Schema object defining the expected parameters for the tool. * Must be an object type schema. Defines what arguments the tool accepts. + * Defaults to the JSON Schema 2020-12 dialect when no explicit `$schema` is provided. * @property description A human-readable description of the tool and when to use it. * Clients can use this to improve the LLM's understanding of available tools. * It can be thought of like a "hint" to the model. * @property outputSchema An optional JSON Schema object defining the structure of the tool's output * returned in the [structuredContent][CallToolResult.structuredContent] field of a [CallToolResult]. * Must be an object type schema if provided. + * Defaults to the JSON Schema 2020-12 dialect when no explicit `$schema` is provided. * @property title Optional human-readable display name for this tool. * Intended for UI and end-user contexts, optimized to be easily understood * even by those unfamiliar with domain-specific terminology. @@ -77,19 +79,25 @@ public data class Tool( * A JSON Schema for tool input or output parameters. * * This is a simplified schema structure that must be of type "object". + * Defaults to the JSON Schema 2020-12 dialect when no explicit [schema] (`$schema`) is provided. * - * @property type Always "object" for tool schemas. + * @property schema Optional URI identifying the JSON Schema dialect (e.g., + * `https://json-schema.org/draft/2020-12/schema`). Serialized as `$schema`. + * When absent, JSON Schema 2020-12 is assumed by default. * @property properties Optional map of property names to their schema definitions. * @property required Optional list of property names that are required. * @property defs Optional schema definitions available to references in [properties]. Serialized as `$defs`. */ @Serializable public data class ToolSchema( + @SerialName("\$schema") + val schema: String? = null, val properties: JsonObject? = null, val required: List? = null, @SerialName("\$defs") val defs: JsonObject? = null, ) { + /** Always `"object"` for tool schemas. */ @OptIn(ExperimentalSerializationApi::class) @EncodeDefault val type: String = "object" @@ -195,6 +203,12 @@ public data class CallToolRequest(override val params: CallToolRequestParams) : public val arguments: JsonObject? get() = params.arguments + /** + * Task metadata, if the caller is requesting task-augmented execution. + */ + public val task: TaskMetadata? + get() = params.task + /** * Metadata for this request. May include a progressToken for out-of-band progress notifications. */ @@ -208,6 +222,8 @@ public data class CallToolRequest(override val params: CallToolRequestParams) : * @property name The name of the tool to invoke. * @property arguments Arguments to pass to the tool. Keys are argument names, values are the argument values. * The structure must match the tool's input schema. + * @property task If set, requests task-augmented execution. A receiver that supports it returns + * a [CreateTaskResult] (result via `tasks/result`); otherwise it returns a [CallToolResult] directly. * @property meta Optional metadata for this request. May include a progressToken for * out-of-band progress notifications. */ @@ -215,6 +231,7 @@ public data class CallToolRequest(override val params: CallToolRequestParams) : public data class CallToolRequestParams( val name: String, val arguments: JsonObject? = null, + val task: TaskMetadata? = null, @SerialName("_meta") override val meta: RequestMeta? = null, ) : RequestParams diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher.kt index c5619d5..aa2abb9 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher.kt @@ -66,11 +66,9 @@ private const val VARIABLE_MATCH_SCORE = 1 * no normalizer is available, so dot-segment traversal is **not** mitigated at this * layer — handlers on those targets must normalize paths themselves. * - * @param resourceTemplate The resource template to match against. Must follow RFC 6570 Level 1 syntax. - * @param maxUriLength Maximum allowed length for incoming URIs. Defaults to 2048. - * @param maxDepth Maximum allowed segment count for the template/uri. Defaults to 50. - * - * @property resourceTemplate The resource template against which resource URIs will be matched. + * @property resourceTemplate the resource template to match against; must follow RFC 6570 Level 1 syntax + * @param maxDepth maximum allowed segment count for the template/uri (defaults to 50) + * @param maxUriLength maximum allowed length for incoming URIs (defaults to 2048) */ public class PathSegmentTemplateMatcher @JvmOverloads constructor( override val resourceTemplate: ResourceTemplate, @@ -118,7 +116,6 @@ public class PathSegmentTemplateMatcher @JvmOverloads constructor( variableIndices = vars.toImmutableMap() } - @Suppress("ReturnCount") override fun match(resourceUri: String): MatchResult? { if (resourceUri.length > maxUriLength) { logger.debug { "URL is too long (max=$maxUriLength)" } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher.kt index 867cc9f..d99bfda 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher.kt @@ -32,11 +32,10 @@ public class MatchResult(public val variables: Map, public val s * candidate URIs against that template via [match]. The returned [MatchResult.score] * must reflect match specificity so that a selection algorithm can prefer the most * specific template when multiple templates match the same URI. - * - * @property resourceTemplate the template to match URIs against */ public interface ResourceTemplateMatcher { + /** The template to match URIs against. */ public val resourceTemplate: ResourceTemplate /** diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyTest.kt new file mode 100644 index 0000000..b3c95ac --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyTest.kt @@ -0,0 +1,524 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult +import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.PingRequest +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +class ProtocolConcurrencyTest { + + private fun TestScope.newProtocol(): TestProtocol = + TestProtocol(ProtocolOptions(handlerCoroutineContext = StandardTestDispatcher(testScheduler))) + + @Test + fun `a suspended slow handler does not block a later fast request`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/slow") delay(10.seconds) + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) + transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) + runCurrent() + + responsesOn(transport).map { it.id } shouldBe listOf(RequestId(2L)) + + advanceTimeBy(11.seconds) + runCurrent() + responsesOn(transport).map { it.id } shouldBe listOf(RequestId(2L), RequestId(1L)) + } + + @Test + fun `dispatch is serial until concurrent dispatch is enabled`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + // gate NOT flipped + + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/slow") delay(10.seconds) + EmptyResult() + } + + val delivery = launch { + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) + transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) + } + runCurrent() + // inline handling: the delivering coroutine is parked inside the slow handler + delivery.isCompleted shouldBe false + responsesOn(transport) shouldBe emptyList() + + advanceTimeBy(11.seconds) + runCurrent() + delivery.isCompleted shouldBe true + responsesOn(transport).map { it.id } shouldBe listOf(RequestId(1L), RequestId(2L)) + } + + // the hook fires before handler lookup and cannot be disabled by a user handler + @Test + fun `initialized notification invokes the router hook even when a user handler is registered`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + var userHandlerCalled = false + protocol.setNotificationHandler(Method.Defined.NotificationsInitialized) { + userHandlerCalled = true + COMPLETED + } + transport.deliver(JSONRPCNotification(method = Method.Defined.NotificationsInitialized.value)) + runCurrent() + + protocol.initializedNotificationCount shouldBe 1 + userHandlerCalled shouldBe true + } + + @Test + fun `cancelled notification cancels the in-flight handler and suppresses the response`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val entered = CompletableDeferred() + val sawCancellation = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + entered.complete(Unit) + try { + delay(10.seconds) + } catch (e: CancellationException) { + sawCancellation.complete(Unit) + throw e + } + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(5L), method = "test/slow")) + runCurrent() + entered.await() + + transport.deliver(cancelledNotification(requestId = RequestId(5L), reason = "user gave up")) + runCurrent() + sawCancellation.await() + advanceUntilIdle() + + responsesOn(transport) shouldBe emptyList() + errorsOn(transport) shouldBe emptyList() // JSONRPCError messages on the wire + protocol.errors shouldBe emptyList() + } + + @Test + fun `cancelled notification for an unknown id is a silent no-op`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + transport.deliver(cancelledNotification(requestId = RequestId(404L), reason = "nope")) + advanceUntilIdle() + + protocol.errors shouldBe emptyList() + } + + @Test + fun `handler leaking CancellationException without being cancelled produces INTERNAL_ERROR`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + protocol.fallbackRequestHandler = { _, _ -> throw CancellationException("leaked") } + + transport.deliver(JSONRPCRequest(id = RequestId(6L), method = "test/leak")) + advanceUntilIdle() + + val error = errorsOn(transport).single() + error.id shouldBe RequestId(6L) + error.error.code shouldBe RPCError.ErrorCode.INTERNAL_ERROR + } + + // suppression race variants + @Test + fun `handler that swallows cancellation and returns normally still gets no response`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val entered = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + entered.complete(Unit) + try { + delay(10.seconds) + } catch (_: CancellationException) { + // swallow (anti-pattern) and return normally + } + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(7L), method = "test/swallow")) + runCurrent() + entered.await() + transport.deliver(cancelledNotification(requestId = RequestId(7L), reason = "stop")) + advanceUntilIdle() + + responsesOn(transport) shouldBe emptyList() + } + + @Test + fun `handler throwing non-CE while cancelled gets no error response`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val entered = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + entered.complete(Unit) + try { + delay(10.seconds) + } catch (_: CancellationException) { + throw IllegalStateException("boom") + } + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(8L), method = "test/throw")) + runCurrent() + entered.await() + transport.deliver(cancelledNotification(requestId = RequestId(8L), reason = "stop")) + advanceUntilIdle() + + errorsOn(transport) shouldBe emptyList() + protocol.errors shouldBe emptyList() + } + + @Test + fun `suspension-free notification handlers observe strict arrival order`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val seen = mutableListOf() + protocol.fallbackNotificationHandler = { notification -> + seen.add((notification.params as JsonObject).getValue("i").jsonPrimitive.int) + } + + repeat(20) { i -> + transport.deliver( + JSONRPCNotification(method = "test/event", params = buildJsonObject { put("i", i) }), + ) + } + advanceUntilIdle() + + seen shouldBe (0 until 20).toList() + } + + @Test + fun `reconnect resets the dispatch gate to serial`() = runTest { + val protocol = newProtocol() + val transport1 = RecordingTransport() + protocol.connect(transport1) + protocol.enableConcurrency() + protocol.close() + + val transport2 = RecordingTransport() + protocol.connect(transport2) + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/slow") delay(10.seconds) + EmptyResult() + } + + val delivery = launch { + transport2.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) + transport2.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) + } + runCurrent() + delivery.isCompleted shouldBe false // serial again: gate reset + advanceTimeBy(11.seconds) + runCurrent() + responsesOn(transport2).map { it.id } shouldBe listOf(RequestId(1L), RequestId(2L)) + } + + @Test + fun `close cancels in-flight handlers suppresses responses and fails pending outbound requests`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val entered = CompletableDeferred() + val sawCancellation = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + entered.complete(Unit) + try { + delay(10.seconds) + } catch (e: CancellationException) { + sawCancellation.complete(Unit) + throw e + } + EmptyResult() + } + transport.deliver(JSONRPCRequest(id = RequestId(9L), method = "test/slow")) + runCurrent() + entered.await() + + // shouldThrow inside the async so its failure does not cancel the (non-supervisor) test scope. + val pending = async { shouldThrow { protocol.request(PingRequest()) } } + transport.awaitRequest() // outbound ping on the wire + + protocol.close() + runCurrent() + + sawCancellation.await() + responsesOn(transport) shouldBe emptyList() + val thrown = pending.await() + thrown.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED + } + + @Test + fun `message delivered after close is dropped without crash or response`() = runTest { + val protocol = newProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + protocol.close() + + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/anything")) + advanceUntilIdle() + + responsesOn(transport) shouldBe emptyList() + errorsOn(transport) shouldBe emptyList() + } + + @Test + fun `at most maxConcurrentHandlers handlers run concurrently while control messages bypass`() = runTest { + val protocol = TestProtocol( + ProtocolOptions( + handlerCoroutineContext = StandardTestDispatcher(testScheduler), + maxConcurrentHandlers = 2, + ), + ) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + var entered = 0 + val release = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + entered += 1 + release.await() + EmptyResult() + } + + repeat(4) { i -> transport.deliver(JSONRPCRequest(id = RequestId(i.toLong()), method = "test/slow")) } + runCurrent() + entered shouldBe 2 // exactly two running, two parked on the semaphore + + // ping (bypass) is answered while saturated + transport.deliver(JSONRPCRequest(id = RequestId(100L), method = Method.Defined.Ping.value)) + runCurrent() + responsesOn(transport).map { it.id } shouldBe listOf(RequestId(100L)) + + // responses (bypass, inline) are processed while saturated + val pending = async { protocol.request(PingRequest()) } + runCurrent() + val outbound = transport.awaitRequest() + transport.deliver(JSONRPCResponse(id = outbound.id, result = EmptyResult())) + runCurrent() + pending.isCompleted shouldBe true + + release.complete(Unit) + advanceUntilIdle() + entered shouldBe 4 + responsesOn(transport) shouldHaveSize 5 + } + + @Test + fun `flood beyond maxInFlightHandlers is rejected fail-fast and the read loop never suspends`() = runTest { + val protocol = TestProtocol( + ProtocolOptions( + handlerCoroutineContext = StandardTestDispatcher(testScheduler), + maxConcurrentHandlers = 1, + maxInFlightHandlers = 4, + ), + ) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val release = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + release.await() + EmptyResult() + } + + // 10 requests delivered back-to-back; delivery must complete without advancing + // virtual time — i.e. the read loop was never suspended on admission. + val delivery = launch { + repeat(10) { i -> transport.deliver(JSONRPCRequest(id = RequestId(i.toLong()), method = "test/slow")) } + } + runCurrent() + delivery.isCompleted shouldBe true + + // 4 admitted (1 running + 3 parked), 6 rejected immediately + val busyErrors = errorsOn(transport) + busyErrors shouldHaveSize 6 + busyErrors.forEach { + it.error.code shouldBe RPCError.ErrorCode.INTERNAL_ERROR + it.error.message shouldBe "Server is busy: too many in-flight messages" + } + + // ping still answered at full saturation + transport.deliver(JSONRPCRequest(id = RequestId(100L), method = Method.Defined.Ping.value)) + runCurrent() + responsesOn(transport).map { it.id } shouldBe listOf(RequestId(100L)) + + release.complete(Unit) + advanceUntilIdle() + responsesOn(transport) shouldHaveSize 5 // ping + the 4 admitted + } + + @Test + fun `overflowing notifications are dropped and reported via onError`() = runTest { + val protocol = TestProtocol( + ProtocolOptions( + handlerCoroutineContext = StandardTestDispatcher(testScheduler), + maxConcurrentHandlers = 1, + maxInFlightHandlers = 2, + ), + ) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + // parks one handler with a request: holds the sole execution permit and one in-flight slot + val release = CompletableDeferred() + protocol.fallbackRequestHandler = { _, _ -> + release.await() + EmptyResult() + } + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/hog")) + runCurrent() + + // notification 1 is admitted (parked on the semaphore, behind the hogged permit); + // notification 2 overflows the admission cap and is dropped instead of parked. + val notificationRelease = CompletableDeferred() + protocol.fallbackNotificationHandler = { notificationRelease.await() } + transport.deliver(JSONRPCNotification(method = "test/event")) + transport.deliver(JSONRPCNotification(method = "test/event")) + runCurrent() + + protocol.errors shouldHaveSize 1 + protocol.errors.single().message.orEmpty() shouldContain "too many in-flight messages" + errorsOn(transport) shouldBe emptyList() // no wire error for dropped notifications + + release.complete(Unit) + notificationRelease.complete(Unit) + advanceUntilIdle() + } + + @Test + fun `cancelling saturating handlers releases permits and lets parked handlers run`() = runTest { + val protocol = TestProtocol( + ProtocolOptions( + handlerCoroutineContext = StandardTestDispatcher(testScheduler), + maxConcurrentHandlers = 1, + ), + ) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val secondCompleted = CompletableDeferred() + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/hog") { + delay(1000.seconds) // cancellable park + error("unreachable") + } + secondCompleted.complete(Unit) + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/hog")) // holds the permit + transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/next")) // parked on the semaphore + runCurrent() + secondCompleted.isCompleted shouldBe false + + // cancel the hog THROUGH the bypass while saturated + transport.deliver(cancelledNotification(requestId = RequestId(1L), reason = "make room")) + advanceUntilIdle() + + secondCompleted.isCompleted shouldBe true + responsesOn(transport).map { it.id } shouldBe listOf(RequestId(2L)) // hog suppressed, next answered + } + + @Test + fun `a parked handler can itself be cancelled before it ever runs`() = runTest { + val protocol = TestProtocol( + ProtocolOptions( + handlerCoroutineContext = StandardTestDispatcher(testScheduler), + maxConcurrentHandlers = 1, + ), + ) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/hog") { + delay(1000.seconds) // cancellable park + error("unreachable") + } + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/hog")) // holds the permit + transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/next")) // parked on the semaphore + runCurrent() + + // cancel the parked handler before it ever acquires the permit, then cancel the hog too + transport.deliver(cancelledNotification(requestId = RequestId(2L), reason = "give up while parked")) + transport.deliver(cancelledNotification(requestId = RequestId(1L), reason = "make room")) + advanceUntilIdle() + + // no response for id=2 at all: its withPermit acquire was cancelled before onRequest ever ran + responsesOn(transport) shouldBe emptyList() + errorsOn(transport) shouldBe emptyList() + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptionsTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptionsTest.kt new file mode 100644 index 0000000..81bf24a --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptionsTest.kt @@ -0,0 +1,16 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers +import kotlin.test.Test + +class ProtocolOptionsTest { + + @Test + fun `defaults expose concurrency knobs`() { + val options = ProtocolOptions() + options.handlerCoroutineContext shouldBe Dispatchers.Default + options.maxConcurrentHandlers shouldBe 64 + options.maxInFlightHandlers shouldBe 256 + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOutboundCancellationTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOutboundCancellationTest.kt new file mode 100644 index 0000000..38ad556 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOutboundCancellationTest.kt @@ -0,0 +1,167 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.PingRequest +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +class ProtocolOutboundCancellationTest { + + @Test + fun `request times out awaiting the response and sends CancelledNotification with timeout reason`() = runTest { + val protocol = TestProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + val inFlight = async { + shouldThrow { + protocol.request(PingRequest(), RequestOptions(timeout = 5.seconds)) + } + } + val sent = transport.awaitRequest() // request is on the wire; peer never responds + advanceTimeBy(6.seconds) + runCurrent() + + val thrown = inFlight.await() + thrown.code shouldBe RPCError.ErrorCode.REQUEST_TIMEOUT + thrown.data?.jsonObject?.get("timeout")?.jsonPrimitive?.long shouldBe 5.seconds.inWholeMilliseconds + + val cancelledJson = transport.sentWithOptions + .map { it.first } + .filterIsInstance() + .single { it.method == Method.Defined.NotificationsCancelled.value } + val cancelledParams = McpJson.decodeFromJsonElement(cancelledJson.params!!) + cancelledParams.requestId shouldBe sent.id + cancelledParams.reason.shouldNotBeNull() shouldContain "timed out" + } + + @Test + fun `outer withTimeout around request propagates the original timeout exception and notifies the peer`() = runTest { + val protocol = TestProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + val inFlight = async { + shouldThrow { + withTimeout(1.seconds) { + protocol.request(PingRequest()) + } + } + } + transport.awaitRequest() // request is on the wire; peer never responds + advanceTimeBy(2.seconds) + runCurrent() + inFlight.await() + + transport.sentWithOptions.map { it.first }.filterIsInstance() + .filter { it.method == Method.Defined.NotificationsCancelled.value } shouldHaveSize 1 + } + + @Test + fun `caller cancellation sends CancelledNotification and rethrows the original exception`() = runTest { + val protocol = TestProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + val cancelled = CompletableDeferred() + val job = launch { + try { + protocol.request(PingRequest()) + } catch (e: CancellationException) { + cancelled.complete(e) + throw e + } + } + transport.awaitRequest() + job.cancelAndJoin() + + cancelled.await() + val wire = transport.sentWithOptions.map { it.first }.filterIsInstance() + .filter { it.method == Method.Defined.NotificationsCancelled.value } + wire shouldHaveSize 1 + } + + @Test + fun `cancelling the initialize request performs local cleanup but sends no CancelledNotification`() = runTest { + val protocol = TestProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + val job = launch { + protocol.request( + InitializeRequest( + InitializeRequestParams( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = ClientCapabilities(), + clientInfo = Implementation(name = "t", version = "1"), + ), + ), + ) + } + val sent = transport.awaitRequest() + sent.method shouldBe Method.Defined.Initialize.value + job.cancelAndJoin() + + transport.sentWithOptions.map { it.first }.filterIsInstance() + .filter { it.method == Method.Defined.NotificationsCancelled.value } shouldHaveSize 0 + protocol.responseHandlers shouldBe emptyMap() + } + + @Test + fun `late response for a cancelled request is ignored silently`() = runTest { + val protocol = TestProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + val job = launch { protocol.request(PingRequest()) } + val sent = transport.awaitRequest() + job.cancelAndJoin() + + transport.deliver(JSONRPCResponse(id = sent.id, result = EmptyResult())) + + protocol.errors shouldHaveSize 0 // no "unknown message ID" onError + } + + @Test + fun `response for a genuinely unknown id still reports onError`() = runTest { + val protocol = TestProtocol() + val transport = RecordingTransport() + protocol.connect(transport) + + transport.deliver(JSONRPCResponse(id = RequestId(999L), result = EmptyResult())) + + protocol.errors shouldHaveSize 1 + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTest.kt index b228071..2846ccc 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTest.kt @@ -1,20 +1,31 @@ package io.modelcontextprotocol.kotlin.sdk.shared +import io.kotest.assertions.throwables.shouldThrow 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 +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.PingRequest +import io.modelcontextprotocol.kotlin.sdk.types.ProgressNotification +import io.modelcontextprotocol.kotlin.sdk.types.ProgressNotificationParams +import io.modelcontextprotocol.kotlin.sdk.types.ProgressToken import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.RequestId import io.modelcontextprotocol.kotlin.sdk.types.RequestMeta import kotlinx.coroutines.async -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObjectBuilder @@ -24,8 +35,10 @@ import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject 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 @@ -126,6 +139,36 @@ class ProtocolTest { inFlight.await() } + @Test + fun `should propagate CancellationException from notification handler without calling onError`() = runTest { + protocol.connect(transport) + + protocol.fallbackNotificationHandler = { + throw CancellationException("test cancellation") + } + + shouldThrow { + transport.deliver(JSONRPCNotification(method = "test/notification")) + } + + protocol.errors shouldHaveSize 0 + } + + @Test + fun `should report non-cancellation exception from notification handler via onError`() = runTest { + protocol.connect(transport) + + protocol.fallbackNotificationHandler = { + throw IllegalStateException("handler failed") + } + + // Non-CE exceptions are caught and reported, not propagated + transport.deliver(JSONRPCNotification(method = "test/notification")) + + protocol.errors shouldHaveSize 1 + protocol.errors[0].message shouldBe "handler failed" + } + @Test fun `should create params object when request params are null`() = runTest { protocol.connect(transport) @@ -151,58 +194,174 @@ class ProtocolTest { transport.deliver(JSONRPCResponse(sent.id, EmptyResult())) inFlight.await() } -} -private class TestProtocol : Protocol(null) { - override fun assertCapabilityForMethod(method: Method) { - // noop - } - override fun assertNotificationCapability(method: Method) { - // noop - } - override fun assertRequestHandlerCapability(method: Method) { - // noop - } -} + @Test + fun `request handler receives enriched extra and ambient context element`() = runTest { + protocol.connect(transport) -private class RecordingTransport : Transport { - private val sentMessages = Channel(Channel.UNLIMITED) - private var onMessageCallback: (suspend (JSONRPCMessage) -> Unit)? = null - private var onCloseCallback: (() -> Unit)? = null + var seenByParameter: RequestHandlerExtra? = null + var seenAmbient: RequestHandlerExtra? = null + protocol.fallbackRequestHandler = { _, extra -> + seenByParameter = extra + seenAmbient = currentRequestHandlerExtra() + EmptyResult() + } - override suspend fun start() { - // noop + 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 suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { - sentMessages.send(message) + @Test + fun `extra method resolves defined methods to Defined entries`() = runTest { + protocol.connect(transport) + + var seenMethod: Method? = null + protocol.setRequestHandler(Method.Defined.Ping) { _, extra -> + seenMethod = extra.method + EmptyResult() + } + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "ping")) + + seenMethod shouldBe Method.Defined.Ping } - override suspend fun close() { - onCloseCallback?.invoke() + @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 fun onClose(block: () -> Unit) { - onCloseCallback = block + @Test + fun `extra sendRequest stamps relatedRequestId and preserves caller options`() = runTest { + protocol.connect(transport) + + protocol.fallbackRequestHandler = { _, extra -> + extra.sendRequest( + PingRequest(), + RequestOptions(resumptionToken = "tok", onProgress = { }, timeout = 30.seconds), + ) + EmptyResult() + } + + // The serial phase runs the handler inline inside deliver(), and the handler suspends + // awaiting the nested request's response — drive delivery from a child coroutine. + val delivery = launch { + transport.deliver(JSONRPCRequest(id = RequestId(42L), method = "custom/nested")) + } + + val outbound = transport.awaitRequest() + val recorded = transport.sentWithOptions + .first { (it.first as? JSONRPCRequest)?.id == outbound.id } + .second + val options = recorded.shouldBeInstanceOf() + options.relatedRequestId shouldBe RequestId(42L) + options.resumptionToken shouldBe "tok" + options.timeout shouldBe 30.seconds + options.onProgress shouldNotBe null + + transport.deliver(JSONRPCResponse(id = outbound.id, result = EmptyResult())) + delivery.join() } - override fun onError(block: (Throwable) -> Unit) { - // noop + @Test + fun `currentRequestHandlerExtra returns null outside handlers`() = runTest { + currentRequestHandlerExtra() shouldBe null } - override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { - onMessageCallback = block + @Test + fun `connect while already connected throws IllegalStateException`() = runTest { + protocol.connect(transport) + shouldThrow { + protocol.connect(RecordingTransport()) + } } - suspend fun awaitRequest(): JSONRPCRequest { - val message = sentMessages.receive() - return message as? JSONRPCRequest - ?: error("Expected JSONRPCRequest but received ${message::class.simpleName}") + @Test + fun `stale onClose from a previous transport does not tear down the successor connection`() = runTest { + protocol.connect(transport) + val staleCloseCallback = transport.closeCallback ?: error("onClose not registered") + + protocol.close() // disconnect first transport (fires its own doClose) + val second = RecordingTransport() + protocol.connect(second) // reconnect + + staleCloseCallback() // late duplicate close signal from transport #1 + + // successor connection must still be alive + protocol.transport shouldBe second } - suspend fun deliver(message: JSONRPCMessage) { - val callback = onMessageCallback ?: error("onMessage callback not registered") - callback(message) + @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 + } + + @Test + fun `request cleans up its handlers when the transport send fails`() = runTest { + // Send fails without closing the connection, so the leak is not masked by doClose(). + val failingSend = object : Transport { + override suspend fun start() {} + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?): Unit = + throw IllegalStateException("send failed") + override suspend fun close() {} + override fun onClose(block: () -> Unit) {} + override fun onError(block: (Throwable) -> Unit) {} + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {} + } + protocol.connect(failingSend) + + shouldThrow { + protocol.request(PingRequest(), RequestOptions(onProgress = {})) + } + + protocol.responseHandlers.size shouldBe 0 + protocol.progressHandlers.size shouldBe 0 + } + + @Test + fun `connect rejects non-positive maxConcurrentHandlers`() = runTest { + val error = shouldThrow { + TestProtocol(ProtocolOptions(maxConcurrentHandlers = 0)).connect(RecordingTransport()) + } + error.message.shouldNotBeNull() shouldContain "maxConcurrentHandlers" + } + + @Test + fun `connect rejects maxInFlightHandlers below maxConcurrentHandlers`() = runTest { + val error = shouldThrow { + TestProtocol(ProtocolOptions(maxConcurrentHandlers = 4, maxInFlightHandlers = 0)) + .connect(RecordingTransport()) + } + error.message.shouldNotBeNull() shouldContain "maxInFlightHandlers" } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTestFakes.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTestFakes.kt new file mode 100644 index 0000000..9e8dd60 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTestFakes.kt @@ -0,0 +1,112 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import kotlinx.coroutines.channels.Channel +import kotlinx.serialization.json.encodeToJsonElement + +/** + * Shared [Protocol] test double: records [onError] calls, counts `notifications/initialized` + * router-hook invocations, and no-ops all capability assertions. + * + * The concurrency gate is flipped only via [enableConcurrency]; nothing in this double flips it + * implicitly, so tests keep today's serial semantics unless they opt in. + */ +internal class TestProtocol(options: ProtocolOptions? = null) : Protocol(options) { + val errors = mutableListOf() + var initializedNotificationCount = 0 + + fun enableConcurrency() { + enableConcurrentDispatch() + } + + override fun onInitializedNotification() { + initializedNotificationCount++ + // deliberately does NOT flip the gate — tests control the flip explicitly + } + + override fun onError(error: Throwable) { + errors.add(error) + } + + override fun assertCapabilityForMethod(method: Method) { + // noop + } + override fun assertNotificationCapability(method: Method) { + // noop + } + public override fun assertRequestHandlerCapability(method: Method) { + // noop + } +} + +/** Shared [Transport] test double: records every sent message (with its options) and replays inbound ones. */ +internal class RecordingTransport : Transport { + val sentMessages = Channel(Channel.UNLIMITED) + private var onMessageCallback: (suspend (JSONRPCMessage) -> Unit)? = null + private var onCloseCallback: (() -> Unit)? = null + + val sentWithOptions = mutableListOf>() + + var closeCallback: (() -> Unit)? = null + private set + + override suspend fun start() { + // noop + } + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + sentWithOptions.add(message to options) + sentMessages.send(message) + } + + override suspend fun close() { + onCloseCallback?.invoke() + } + + override fun onClose(block: () -> Unit) { + closeCallback = block + onCloseCallback = block + } + + override fun onError(block: (Throwable) -> Unit) { + // noop + } + + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { + onMessageCallback = block + } + + suspend fun awaitRequest(): JSONRPCRequest { + while (true) { + val message = sentMessages.receive() + if (message is JSONRPCRequest) return message + } + } + + /** Cross-thread-safe reader used by real-dispatcher tests; [sentWithOptions] is single-thread only. */ + suspend fun awaitSent(): JSONRPCMessage = sentMessages.receive() + + suspend fun deliver(message: JSONRPCMessage) { + val callback = onMessageCallback ?: error("onMessage callback not registered") + callback(message) + } +} + +internal fun responsesOn(transport: RecordingTransport): List = + transport.sentWithOptions.map { it.first }.filterIsInstance() + +internal fun errorsOn(transport: RecordingTransport): List = + transport.sentWithOptions.map { it.first }.filterIsInstance() + +internal fun cancelledNotification(requestId: RequestId, reason: String): JSONRPCNotification = JSONRPCNotification( + method = Method.Defined.NotificationsCancelled.value, + params = McpJson.encodeToJsonElement(CancelledNotificationParams(requestId = requestId, reason = reason)), +) diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferTest.kt index 7b4f053..e5e50f8 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferTest.kt @@ -6,7 +6,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification import kotlinx.serialization.json.Json import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull class ReadBufferTest { @@ -75,4 +77,35 @@ class ReadBufferTest { val message = readBuffer.readMessage() assertEquals(testMessage, message) } + + @Test + fun `should fail when an unframed blob exceeds the cap`() { + val readBuffer = ReadBuffer(maxFrameSize = 64) + // No newline ever arrives: the memory-exhaustion vector. + readBuffer.append(ByteArray(100) { 'a'.code.toByte() }) + val ex = assertFailsWith { readBuffer.readMessage() } + assertContains(ex.message.orEmpty(), "maximum size") + } + + @Test + fun `should fail when a completed line exceeds the cap`() { + val readBuffer = ReadBuffer(maxFrameSize = 64) + readBuffer.append(ByteArray(100) { 'a'.code.toByte() } + '\n'.code.toByte()) + assertFailsWith { readBuffer.readMessage() } + } + + @Test + fun `should not enforce a cap when maxFrameSize is non-positive`() { + val readBuffer = ReadBuffer(maxFrameSize = 0) + // Well beyond any small cap and still no newline — must not throw when disabled. + readBuffer.append(ByteArray(8192) { 'a'.code.toByte() }) + assertNull(readBuffer.readMessage()) + } + + @Test + fun `should parse a message that fits under the cap`() { + val readBuffer = ReadBuffer(maxFrameSize = 1024) + readBuffer.append(serializeMessage(testMessage).encodeToByteArray()) + assertEquals(testMessage, readBuffer.readMessage()) + } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesTest.kt index 33d5653..3468aae 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesTest.kt @@ -1,6 +1,7 @@ package io.modelcontextprotocol.kotlin.sdk.types import io.kotest.assertions.json.shouldEqualJson +import io.kotest.matchers.shouldBe import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization import io.modelcontextprotocol.kotlin.test.utils.verifySerialization import io.modelcontextprotocol.kotlin.test.utils.verifySerializationRoundTrip @@ -8,6 +9,7 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull class CapabilitiesTest { @@ -119,7 +121,7 @@ class CapabilitiesTest { @Test fun `should serialize ClientCapabilities with sampling`() { val capabilities = ClientCapabilities( - sampling = ClientCapabilities.sampling, + sampling = ClientCapabilities.Sampling(), ) verifySerialization( capabilities, @@ -182,6 +184,53 @@ class CapabilitiesTest { ) } + @Test + fun `should serialize ClientCapabilities with extensions`() { + val extensions = mapOf( + "io.modelcontextprotocol/ui" to buildJsonObject { + put("mimeTypes", buildJsonObject { put("text/html", true) }) + }, + ) + val capabilities = ClientCapabilities( + extensions = extensions, + ) + verifySerialization( + capabilities, + McpJson, + """ + { + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": { + "text/html": true + } + } + } + } + """.trimIndent(), + ) + } + + @Test + fun `should serialize ClientCapabilities with empty extensions settings`() { + val capabilities = ClientCapabilities( + extensions = mapOf( + "io.modelcontextprotocol/ui" to EmptyJsonObject, + ), + ) + verifySerialization( + capabilities, + McpJson, + """ + { + "extensions": { + "io.modelcontextprotocol/ui": {} + } + } + """.trimIndent(), + ) + } + @Test fun `should serialize ClientCapabilities with experimental`() { val experimental = buildJsonObject { @@ -215,11 +264,15 @@ class CapabilitiesTest { val experimental = buildJsonObject { put("feature1", buildJsonObject { put("enabled", true) }) } + val extensions = mapOf( + "io.modelcontextprotocol/ui" to EmptyJsonObject, + ) val capabilities = ClientCapabilities( - sampling = ClientCapabilities.sampling, + sampling = ClientCapabilities.Sampling(), roots = ClientCapabilities.Roots(listChanged = true), elicitation = ClientCapabilities.elicitation, experimental = experimental, + extensions = extensions, ) verifySerialization( capabilities, @@ -235,6 +288,9 @@ class CapabilitiesTest { "feature1": { "enabled": true } + }, + "extensions": { + "io.modelcontextprotocol/ui": {} } } """.trimIndent(), @@ -255,9 +311,9 @@ class CapabilitiesTest { val capabilities = verifyDeserialization(McpJson, json) - assertEquals(EmptyJsonObject, capabilities.sampling) + assertEquals(ClientCapabilities.Sampling(), capabilities.sampling) assertEquals(true, capabilities.roots?.listChanged) - assertEquals(EmptyJsonObject, capabilities.elicitation) + assertEquals(ClientCapabilities.Elicitation(), capabilities.elicitation) } @Test @@ -270,14 +326,20 @@ class CapabilitiesTest { assertNull(capabilities.roots) assertNull(capabilities.elicitation) assertNull(capabilities.experimental) + assertNull(capabilities.extensions) } @Test fun `should serialize and deserialize ClientCapabilities round trip`() { val original = ClientCapabilities( - sampling = ClientCapabilities.sampling, + sampling = ClientCapabilities.Sampling(), roots = ClientCapabilities.Roots(listChanged = false), elicitation = ClientCapabilities.elicitation, + extensions = mapOf( + "io.modelcontextprotocol/ui" to buildJsonObject { + put("mimeTypes", buildJsonObject { put("text/html", true) }) + }, + ), ) verifySerializationRoundTrip(original, McpJson) @@ -466,6 +528,32 @@ class CapabilitiesTest { ) } + @Test + fun `should serialize ServerCapabilities with extensions`() { + val capabilities = ServerCapabilities( + extensions = mapOf( + "io.modelcontextprotocol/ui" to EmptyJsonObject, + "com.example/custom" to buildJsonObject { + put("setting", "value") + }, + ), + ) + verifySerialization( + capabilities, + McpJson, + """ + { + "extensions": { + "io.modelcontextprotocol/ui": {}, + "com.example/custom": { + "setting": "value" + } + } + } + """.trimIndent(), + ) + } + @Test fun `should serialize ServerCapabilities with experimental`() { val experimental = buildJsonObject { @@ -499,6 +587,9 @@ class CapabilitiesTest { val experimental = buildJsonObject { put("feature1", buildJsonObject { put("enabled", true) }) } + val extensions = mapOf( + "io.modelcontextprotocol/ui" to EmptyJsonObject, + ) val capabilities = ServerCapabilities( tools = ServerCapabilities.Tools(listChanged = true), resources = ServerCapabilities.Resources( @@ -509,6 +600,7 @@ class CapabilitiesTest { logging = ServerCapabilities.Logging, completions = ServerCapabilities.Completions, experimental = experimental, + extensions = extensions, ) verifySerialization( capabilities, @@ -531,6 +623,9 @@ class CapabilitiesTest { "feature1": { "enabled": true } + }, + "extensions": { + "io.modelcontextprotocol/ui": {} } } """.trimIndent(), @@ -578,6 +673,7 @@ class CapabilitiesTest { assertNull(capabilities.logging) assertNull(capabilities.completions) assertNull(capabilities.experimental) + assertNull(capabilities.extensions) } @Test @@ -590,6 +686,9 @@ class CapabilitiesTest { ), prompts = ServerCapabilities.Prompts(listChanged = true), logging = ServerCapabilities.Logging, + extensions = mapOf( + "io.modelcontextprotocol/ui" to EmptyJsonObject, + ), ) verifySerializationRoundTrip(original, McpJson) @@ -637,10 +736,11 @@ class CapabilitiesTest { } """.trimIndent() - val capabilities = verifyDeserialization(McpJson, json) + // Sampling is now a typed struct; unknown fields are ignored on deserialization + val capabilities = McpJson.decodeFromString(json) - // Should not fail - additionalProperties are allowed - assertEquals("customValue", capabilities.sampling?.get("customProperty")?.toString()?.trim('"')) + // Should not fail - additionalProperties are allowed (unknown fields are ignored by Sampling) + assertNotNull(capabilities.sampling) } @Test @@ -658,4 +758,189 @@ class CapabilitiesTest { // Should not fail - additionalProperties are allowed assertEquals("debug", capabilities.logging?.get("level")?.toString()?.trim('"')) } + + // ============================================================================ + // ClientCapabilities.Sampling sub-capabilities (SEP-1577) + // ============================================================================ + + @Test + fun `empty sampling serialises as empty object`() { + val caps = ClientCapabilities(sampling = ClientCapabilities.Sampling()) + val json = McpJson.encodeToString(ClientCapabilities.serializer(), caps) + check("\"sampling\":{}" in json) { json } + } + + @Test + fun `sampling with tools sub-capability serialises tools`() { + val caps = ClientCapabilities(sampling = ClientCapabilities.Sampling(tools = EmptyJsonObject)) + val json = McpJson.encodeToString(ClientCapabilities.serializer(), caps) + check("\"tools\":{}" in json) { json } + } + + @Test + fun `sampling with context sub-capability serialises context`() { + val caps = ClientCapabilities(sampling = ClientCapabilities.Sampling(context = EmptyJsonObject)) + val json = McpJson.encodeToString(ClientCapabilities.serializer(), caps) + check("\"context\":{}" in json) { json } + } + + @Test + fun `sampling with combined tools and context round-trips`() { + val original = ClientCapabilities( + sampling = ClientCapabilities.Sampling( + context = EmptyJsonObject, + tools = EmptyJsonObject, + ), + ) + val json = McpJson.encodeToString(ClientCapabilities.serializer(), original) + val decoded = McpJson.decodeFromString(ClientCapabilities.serializer(), json) + decoded shouldBe original + decoded.sampling?.tools shouldBe EmptyJsonObject + decoded.sampling?.context shouldBe EmptyJsonObject + } + + @Test + fun `absent sampling means unsupported`() { + val caps = ClientCapabilities() + caps.sampling shouldBe null + } + + @Test + fun `should serialize ServerCapabilities with tasks`() { + val capabilities = ServerCapabilities( + tasks = ServerCapabilities.Tasks( + list = EmptyJsonObject, + cancel = EmptyJsonObject, + requests = ServerCapabilities.Tasks.Requests( + tools = ServerCapabilities.Tasks.Requests.Tools(call = EmptyJsonObject), + ), + ), + ) + verifySerialization( + capabilities, + McpJson, + """ + { + "tasks": { + "list": {}, + "cancel": {}, + "requests": { + "tools": { + "call": {} + } + } + } + } + """.trimIndent(), + ) + } + + @Test + fun `should deserialize ServerCapabilities with tasks`() { + val json = """ + { + "tasks": { + "list": {}, + "requests": {"tools": {"call": {}}} + } + } + """.trimIndent() + val capabilities = verifyDeserialization(McpJson, json) + assertEquals(EmptyJsonObject, capabilities.tasks?.list) + assertNull(capabilities.tasks?.cancel) + assertEquals(EmptyJsonObject, capabilities.tasks?.requests?.tools?.call) + } + + @Test + fun `should serialize ClientCapabilities with tasks`() { + val capabilities = ClientCapabilities( + tasks = ClientCapabilities.Tasks( + list = EmptyJsonObject, + cancel = EmptyJsonObject, + requests = ClientCapabilities.Tasks.Requests( + sampling = ClientCapabilities.Tasks.Requests.Sampling(createMessage = EmptyJsonObject), + elicitation = ClientCapabilities.Tasks.Requests.Elicitation(create = EmptyJsonObject), + ), + ), + ) + verifySerialization( + capabilities, + McpJson, + """ + { + "tasks": { + "list": {}, + "cancel": {}, + "requests": { + "sampling": {"createMessage": {}}, + "elicitation": {"create": {}} + } + } + } + """.trimIndent(), + ) + } + + @Test + fun `should serialize ClientCapabilities with structured elicitation form and url`() { + val capabilities = ClientCapabilities( + elicitation = ClientCapabilities.Elicitation(form = EmptyJsonObject, url = EmptyJsonObject), + ) + verifySerialization( + capabilities, + McpJson, + """ + { + "elicitation": { + "form": {}, + "url": {} + } + } + """.trimIndent(), + ) + } + + @Test + fun `should deserialize ClientCapabilities with elicitation and tasks`() { + val json = """ + { + "elicitation": {"form": {}, "url": {}}, + "tasks": { + "list": {}, + "cancel": {}, + "requests": { + "sampling": {"createMessage": {}}, + "elicitation": {"create": {}} + } + } + } + """.trimIndent() + val capabilities = verifyDeserialization(McpJson, json) + assertEquals(EmptyJsonObject, capabilities.elicitation?.form) + assertEquals(EmptyJsonObject, capabilities.elicitation?.url) + assertEquals(EmptyJsonObject, capabilities.tasks?.list) + assertEquals(EmptyJsonObject, capabilities.tasks?.cancel) + assertEquals(EmptyJsonObject, capabilities.tasks?.requests?.sampling?.createMessage) + assertEquals(EmptyJsonObject, capabilities.tasks?.requests?.elicitation?.create) + } + + // ── Elicitation supportsUrl predicate ─────────────────────────────── + + @Test + fun `supportsUrl is false when no elicitation capability is declared`() { + val elicitation: ClientCapabilities.Elicitation? = null + elicitation.supportsUrl shouldBe false + } + + @Test + fun `supportsUrl is false for an empty form-only capability`() { + ClientCapabilities.Elicitation().supportsUrl shouldBe false + ClientCapabilities.Elicitation(form = EmptyJsonObject).supportsUrl shouldBe false + } + + @Test + fun `supportsUrl is true when url mode is declared`() { + ClientCapabilities.Elicitation(url = EmptyJsonObject).supportsUrl shouldBe true + ClientCapabilities.Elicitation(form = EmptyJsonObject, url = EmptyJsonObject).supportsUrl shouldBe true + } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ContentTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ContentTest.kt index f110326..c6dee5d 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ContentTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ContentTest.kt @@ -1,17 +1,26 @@ package io.modelcontextprotocol.kotlin.sdk.types +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization import io.modelcontextprotocol.kotlin.test.utils.verifySerialization +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject 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.assertNull class ContentTest { + private val samplingContentSerializer = ListSerializer(SamplingMessageContent.serializer()) + @Test fun `should serialize TextContent with minimal fields`() { val content = TextContent(text = "Hello, MCP!") @@ -308,4 +317,133 @@ class ContentTest { assertIs(content[1]) assertIs(content[2]) } + + // ============================================================================ + // SamplingMessageContent hierarchy (SEP-1577) + // ============================================================================ + + @Test + fun `TextContent implements SamplingMessageContent`() { + val c: SamplingMessageContent = TextContent("hi") + c.shouldBeInstanceOf() + } + + @Test + fun `ImageContent implements SamplingMessageContent`() { + val c: SamplingMessageContent = ImageContent(data = "AA==", mimeType = "image/png") + c.shouldBeInstanceOf() + } + + @Test + fun `AudioContent implements SamplingMessageContent`() { + val c: SamplingMessageContent = AudioContent(data = "AA==", mimeType = "audio/wav") + c.shouldBeInstanceOf() + } + + // ============================================================================ + // ToolUseContent + // ============================================================================ + + @Test + fun `ToolUseContent round-trips through McpJson`() { + val input = buildJsonObject { put("location", JsonPrimitive("London")) } + val original = ToolUseContent(id = "call_1", name = "get_weather", input = input) + val encoded = McpJson.encodeToString(ToolUseContent.serializer(), original) + val decoded = McpJson.decodeFromString(ToolUseContent.serializer(), encoded) + decoded shouldBe original + } + + @Test + fun `ToolUseContent serialises with type discriminator tool_use`() { + val c = ToolUseContent(id = "x", name = "n", input = JsonObject(emptyMap())) + val json = McpJson.encodeToString(ToolUseContent.serializer(), c) + check("\"type\":\"tool_use\"" in json) { "missing discriminator in: $json" } + } + + @Test + fun `ToolUseContent preserves meta when present`() { + val meta = buildJsonObject { put("cacheControl", JsonPrimitive("ephemeral")) } + val c = ToolUseContent(id = "x", name = "n", input = JsonObject(emptyMap()), meta = meta) + val json = McpJson.encodeToString(ToolUseContent.serializer(), c) + val decoded = McpJson.decodeFromString(ToolUseContent.serializer(), json) + decoded.meta shouldBe meta + } + + // ============================================================================ + // ToolResultContent + // ============================================================================ + + @Test + fun `ToolResultContent round-trips with text content block`() { + val original = ToolResultContent( + toolUseId = "call_1", + content = listOf(TextContent("20°C sunny")), + ) + val encoded = McpJson.encodeToString(ToolResultContent.serializer(), original) + val decoded = McpJson.decodeFromString(ToolResultContent.serializer(), encoded) + decoded shouldBe original + } + + @Test + fun `ToolResultContent round-trips with mixed content including resource_link`() { + val original = ToolResultContent( + toolUseId = "call_2", + content = listOf( + TextContent("see file"), + ResourceLink(name = "log", uri = "file:///tmp/a.log"), + ), + ) + val encoded = McpJson.encodeToString(ToolResultContent.serializer(), original) + val decoded = McpJson.decodeFromString(ToolResultContent.serializer(), encoded) + decoded shouldBe original + } + + @Test + fun `ToolResultContent serialises with discriminator and preserves structuredContent and isError`() { + val original = ToolResultContent( + toolUseId = "call_3", + content = emptyList(), + structuredContent = buildJsonObject { put("temp", JsonPrimitive(20)) }, + isError = true, + ) + val json = McpJson.encodeToString(ToolResultContent.serializer(), original) + check("\"type\":\"tool_result\"" in json) { "missing discriminator in: $json" } + val decoded = McpJson.decodeFromString(ToolResultContent.serializer(), json) + decoded shouldBe original + } + + // ============================================================================ + // SamplingMessageContentPolymorphicSerializer + // ============================================================================ + + @Test + fun `SamplingMessageContent polymorphic serializer decodes text`() { + val json = """[{"type":"text","text":"hi"}]""" + val list = McpJson.decodeFromString(samplingContentSerializer, json) + list[0].shouldBeInstanceOf().text shouldBe "hi" + } + + @Test + fun `SamplingMessageContent polymorphic serializer decodes tool_use`() { + val json = """[{"type":"tool_use","id":"a","name":"n","input":{}}]""" + val list = McpJson.decodeFromString(samplingContentSerializer, json) + val use = list[0].shouldBeInstanceOf() + use.id shouldBe "a" + use.name shouldBe "n" + } + + @Test + fun `SamplingMessageContent polymorphic serializer decodes tool_result`() { + val json = """[{"type":"tool_result","toolUseId":"a","content":[]}]""" + val list = McpJson.decodeFromString(samplingContentSerializer, json) + list[0].shouldBeInstanceOf().toolUseId shouldBe "a" + } + + @Test + fun `SamplingMessageContent polymorphic serializer throws on unknown discriminator`() { + val json = """[{"type":"bogus","x":1}]""" + assertFailsWith { + McpJson.decodeFromString(samplingContentSerializer, json) + } + } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/DiscoveryTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/DiscoveryTest.kt new file mode 100644 index 0000000..691af45 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/DiscoveryTest.kt @@ -0,0 +1,324 @@ +package io.modelcontextprotocol.kotlin.sdk.types + +import io.kotest.assertions.json.shouldEqualJson +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalMcpApi::class) +class DiscoveryTest { + + @Test + fun `should decode server discover request from its wire shape`() { + val request = McpJson.decodeFromString( + """ + { + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "test-client", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + } + """.trimIndent(), + ) + + val discover = assertIs(request) + assertEquals(Method.Defined.ServerDiscover, discover.method) + assertEquals("2026-07-28", discover.meta.protocolVersion) + assertEquals("test-client", discover.meta.clientInfo?.name) + assertEquals(ClientCapabilities(), discover.meta.clientCapabilities) + } + + @Test + fun `should decode discovery request without optional client info`() { + val request = McpJson.decodeFromString( + """ + { + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "com.example/traceId": "trace-123" + } + } + } + """.trimIndent(), + ) + + val discover = assertIs(request) + assertEquals("2026-07-28", discover.meta.protocolVersion) + assertEquals(ClientCapabilities(), discover.meta.clientCapabilities) + assertNull(discover.meta.clientInfo) + assertNotNull(discover.meta["com.example/traceId"]) + McpJson.encodeToString(discover) shouldEqualJson """ + { + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "com.example/traceId": "trace-123" + } + } + } + """.trimIndent() + } + + @Test + fun `should reject discovery requests without required request metadata`() { + val invalidParams = listOf( + "{}" to "_meta", + """{"_meta": {}}""" to RequestMetaKeys.PROTOCOL_VERSION, + """ + { + "_meta": { + "${RequestMetaKeys.PROTOCOL_VERSION}": "2026-07-28", + "${RequestMetaKeys.CLIENT_INFO}": { + "name": "test-client", + "version": "1.0.0" + } + } + } + """.trimIndent() to RequestMetaKeys.CLIENT_CAPABILITIES, + ) + + invalidParams.forEach { (params, missingField) -> + val failure = assertFails { + McpJson.decodeFromString( + """{"method": "server/discover", "params": $params}""", + ) + } + + assertTrue( + failure.message.orEmpty().contains(missingField), + "Expected failure to identify missing field $missingField, but was: $failure", + ) + } + } + + @Test + fun `should serialize every required discovery result field`() { + val result = DiscoverResult( + supportedVersions = listOf("2026-07-28"), + capabilities = ServerCapabilities( + tools = ServerCapabilities.Tools(listChanged = true), + ), + instructions = "Use tools intentionally.", + ) + + McpJson.encodeToString(result) shouldEqualJson """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": { + "tools": {"listChanged": true} + }, + "instructions": "Use tools intentionally.", + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private" + } + """.trimIndent() + } + + @Test + fun `should encode optional server info in result metadata`() { + val result = DiscoverResult( + supportedVersions = listOf("2026-07-28"), + capabilities = ServerCapabilities(), + meta = Json.parseToJsonElement( + """ + { + "io.modelcontextprotocol/serverInfo": { + "name": "test-server", + "version": "2.0.0" + }, + "com.example/source": "edge" + } + """.trimIndent(), + ).jsonObject, + ) + + McpJson.encodeToString(result) shouldEqualJson """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "test-server", + "version": "2.0.0" + }, + "com.example/source": "edge" + } + } + """.trimIndent() + assertEquals( + Json.parseToJsonElement("""{"name":"test-server","version":"2.0.0"}"""), + result.meta?.get("io.modelcontextprotocol/serverInfo"), + ) + } + + @Test + fun `should encode required defaults independently of McpJson`() { + val result = DiscoverResult( + supportedVersions = listOf("2026-07-28"), + capabilities = ServerCapabilities(), + ) + + Json.encodeToString(result) shouldEqualJson """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private" + } + """.trimIndent() + } + + @Test + fun `should reject discovery results without required cache fields`() { + val invalidResults = listOf( + """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "cacheScope": "private" + } + """.trimIndent(), + """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0 + } + """.trimIndent(), + ) + + invalidResults.forEach { wire -> + assertFails { McpJson.decodeFromString(wire) } + assertFails { McpJson.decodeFromString(wire) } + } + } + + @Test + fun `should use complete when a discovery result omits resultType`() { + val result = McpJson.decodeFromString( + """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "ttlMs": 0, + "cacheScope": "private" + } + """.trimIndent(), + ) + + assertEquals(COMPLETE_RESULT_TYPE, assertIs(result).resultType) + } + + @Test + fun `should deserialize discovery results polymorphically`() { + val wire = + """ + { + "supportedVersions": ["2026-07-28"], + "capabilities": {}, + "resultType": "complete", + "ttlMs": 250, + "cacheScope": "public", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "server", + "version": "1" + }, + "com.example/source": "edge" + } + } + """.trimIndent() + val result = McpJson.decodeFromString(wire) + + val discover = assertIs(result) + assertEquals(250, discover.ttlMs) + assertEquals(CacheScope.Public, discover.cacheScope) + assertEquals( + Json.parseToJsonElement("""{"name":"server","version":"1"}"""), + discover.meta?.get("io.modelcontextprotocol/serverInfo"), + ) + assertNotNull(discover.meta?.get("com.example/source")) + McpJson.encodeToString(discover) shouldEqualJson wire + } + + @Test + fun `should not treat supportedVersions alone as a discovery discriminator`() { + val result = McpJson.decodeFromString( + """ + { + "tools": [], + "supportedVersions": ["extension-value"] + } + """.trimIndent(), + ) + + assertIs(result) + } + + @Test + fun `should not treat an initialize extension as a discovery result`() { + val result = McpJson.decodeFromString( + """ + { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "serverInfo": { + "name": "test-server", + "version": "1.0.0" + }, + "supportedVersions": ["extension-value"] + } + """.trimIndent(), + ) + + assertIs(result) + } + + @Test + fun `should reject a negative discovery ttl`() { + assertFailsWith { + DiscoverResult( + supportedVersions = listOf("2026-07-28"), + capabilities = ServerCapabilities(), + ttlMs = -1, + ) + } + } + + @Test + fun `unsupported version data should round trip without losing versions`() { + val data = UnsupportedProtocolVersionData( + supported = listOf("2026-07-28", "2025-11-25"), + requested = "2099-01-01", + ) + + val encoded = McpJson.encodeToString(data) + assertEquals(data, McpJson.decodeFromString(encoded)) + assertEquals(-32022, RPCError.ErrorCode.UNSUPPORTED_PROTOCOL_VERSION) + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ElicitationTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ElicitationTest.kt index 5392890..bd5ef06 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ElicitationTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ElicitationTest.kt @@ -10,6 +10,7 @@ import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization import io.modelcontextprotocol.kotlin.test.utils.verifySerialization import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put @@ -176,7 +177,6 @@ class ElicitationTest { // ── Deprecated compat ─────────────────────────────────────────────── @Test - @Suppress("DEPRECATION") fun `deprecated factory function should create ElicitRequestFormParams`() { val schema = ElicitRequestParams.RequestedSchema( properties = mapOf("name" to StringSchema()), @@ -188,7 +188,6 @@ class ElicitationTest { } @Test - @Suppress("DEPRECATION") fun `deprecated requestedSchema should return schema for form mode`() { val schema = ElicitRequestParams.RequestedSchema( properties = mapOf("x" to BooleanSchema()), @@ -198,7 +197,6 @@ class ElicitationTest { } @Test - @Suppress("DEPRECATION") fun `deprecated requestedSchema should return null for URL mode`() { val request = ElicitRequest( ElicitRequestURLParams(message = "m", elicitationId = "id", url = "https://example.com"), @@ -276,4 +274,86 @@ class ElicitationTest { ) } } + + // ── URLElicitationRequired error (-32042) ─────────────────────────── + + @Test + fun `UrlElicitationRequiredException carries code and serialized elicitations`() { + val elicitation = ElicitRequestURLParams( + message = "Authorize access to continue", + elicitationId = "550e8400-e29b-41d4-a716-446655440000", + url = "https://oauth.example.com/authorize", + ) + val exception = UrlElicitationRequiredException(listOf(elicitation)) + + exception.code shouldBe RPCError.ErrorCode.URL_ELICITATION_REQUIRED + exception.elicitations shouldBe listOf(elicitation) + + val data = exception.data.shouldNotBeNull() + McpJson.encodeToString(data) shouldEqualJson """ + { + "elicitations": [ + { + "message": "Authorize access to continue", + "elicitationId": "550e8400-e29b-41d4-a716-446655440000", + "url": "https://oauth.example.com/authorize", + "mode": "url" + } + ] + } + """.trimIndent() + } + + @Test + fun `fromError reconstructs typed exception for -32042 with valid data`() { + val data = McpJson.encodeToJsonElement( + UrlElicitationRequiredData( + listOf(ElicitRequestURLParams(message = "m", elicitationId = "id-1", url = "https://example.com/a")), + ), + ) + + val exception = McpException.fromError( + code = RPCError.ErrorCode.URL_ELICITATION_REQUIRED, + message = "needs auth", + data = data, + ) + + val typed = exception.shouldBeInstanceOf() + typed.message shouldBe "needs auth" + typed.elicitations.single().elicitationId shouldBe "id-1" + } + + @Test + fun `fromError returns plain McpException for unrelated code`() { + val exception = McpException.fromError( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "bad params", + data = null, + ) + (exception is UrlElicitationRequiredException) shouldBe false + exception.code shouldBe RPCError.ErrorCode.INVALID_PARAMS + } + + @Test + fun `fromError degrades to plain McpException for -32042 with malformed data`() { + val malformed = buildJsonObject { put("unexpected", "shape") } + + val exception = McpException.fromError( + code = RPCError.ErrorCode.URL_ELICITATION_REQUIRED, + message = "x", + data = malformed, + ) + + (exception is UrlElicitationRequiredException) shouldBe false + exception.code shouldBe RPCError.ErrorCode.URL_ELICITATION_REQUIRED + } + + @Test + fun `fromError degrades to plain McpException when elicitations empty`() { + val data = McpJson.encodeToJsonElement(UrlElicitationRequiredData(emptyList())) + + val exception = McpException.fromError(RPCError.ErrorCode.URL_ELICITATION_REQUIRED, "x", data) + + (exception is UrlElicitationRequiredException) shouldBe false + } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/InitializeTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/InitializeTest.kt index 3da2a8b..e5797b9 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/InitializeTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/InitializeTest.kt @@ -15,13 +15,12 @@ import kotlin.test.assertNull class InitializeTest { @Test - @Suppress("LongMethod") fun `should serialize InitializeRequest with capabilities and meta`() { val request = InitializeRequest( InitializeRequestParams( protocolVersion = "2024-11-05", capabilities = ClientCapabilities( - sampling = ClientCapabilities.sampling, + sampling = ClientCapabilities.Sampling(), roots = ClientCapabilities.Roots(listChanged = true), elicitation = ClientCapabilities.elicitation, experimental = buildJsonObject { diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PrimitiveSchemaTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PrimitiveSchemaTest.kt index 954fb1e..904454f 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PrimitiveSchemaTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PrimitiveSchemaTest.kt @@ -397,7 +397,6 @@ class PrimitiveSchemaTest { // ── LegacyTitledEnumSchema ──────────────────────────────────────────── - @Suppress("DEPRECATION") @Test fun `should serialize LegacyTitledEnumSchema`() { verifySerialization( @@ -420,7 +419,6 @@ class PrimitiveSchemaTest { ) } - @Suppress("DEPRECATION") @Test fun `should deserialize LegacyTitledEnumSchema from JSON`() { val json = """ diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RequestTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RequestTest.kt index 1d6da19..6087b20 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RequestTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RequestTest.kt @@ -2,20 +2,120 @@ package io.modelcontextprotocol.kotlin.sdk.types import io.kotest.assertions.json.shouldEqualJson import io.kotest.matchers.shouldBe +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization import io.modelcontextprotocol.kotlin.test.utils.verifySerialization +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.double import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue class RequestTest { + @OptIn(ExperimentalMcpApi::class) + @Suppress("DEPRECATION") + @Test + fun `should decode typed request metadata without discarding extensions`() { + val request = McpJson.decodeFromString( + """ + { + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "wire-client", + "version": "1.2.3" + }, + "io.modelcontextprotocol/clientCapabilities": { + "sampling": {}, + "com.example/custom-capability": {"enabled": true} + }, + "io.modelcontextprotocol/logLevel": "warning", + "com.example/traceId": "trace-123" + } + } + } + """.trimIndent(), + ) + + val listTools = assertIs(request) + val meta = assertNotNull(listTools.params?.meta) + meta.protocolVersion shouldBe "2026-07-28" + meta.clientInfo shouldBe Implementation(name = "wire-client", version = "1.2.3") + assertNotNull(meta.clientCapabilities?.sampling) + meta.logLevel shouldBe LoggingLevel.Warning + meta["com.example/traceId"]?.jsonPrimitive?.content shouldBe "trace-123" + assertNotNull(meta.json[RequestMetaKeys.CLIENT_CAPABILITIES]?.let { it as? JsonObject }) + } + + @OptIn(ExperimentalMcpApi::class) + @Test + fun `empty client capabilities should mean no optional capabilities`() { + val json = Json.parseToJsonElement( + """{"${RequestMetaKeys.CLIENT_CAPABILITIES}": {}}""", + ) as JsonObject + + RequestMeta(json).clientCapabilities shouldBe ClientCapabilities() + } + + @OptIn(ExperimentalMcpApi::class) + @Suppress("DEPRECATION") + @Test + fun `typed request metadata should reject malformed fields`() { + val malformedValues = listOf( + RequestMetaKeys.PROTOCOL_VERSION to "{}", + RequestMetaKeys.PROTOCOL_VERSION to "null", + RequestMetaKeys.CLIENT_INFO to "\"not-an-implementation\"", + RequestMetaKeys.CLIENT_INFO to "{\"name\":\"missing-version\"}", + RequestMetaKeys.CLIENT_INFO to "null", + RequestMetaKeys.CLIENT_CAPABILITIES to "\"not-capabilities\"", + RequestMetaKeys.CLIENT_CAPABILITIES to "[]", + RequestMetaKeys.CLIENT_CAPABILITIES to "null", + RequestMetaKeys.LOG_LEVEL to "\"verbose\"", + RequestMetaKeys.LOG_LEVEL to "7", + RequestMetaKeys.LOG_LEVEL to "null", + ) + + malformedValues.forEach { (key, value) -> + val json = Json.parseToJsonElement("""{"$key":$value}""") as JsonObject + val meta = RequestMeta(json) + + val failure = assertFailsWith { + when (key) { + RequestMetaKeys.PROTOCOL_VERSION -> meta.protocolVersion + RequestMetaKeys.CLIENT_INFO -> meta.clientInfo + RequestMetaKeys.CLIENT_CAPABILITIES -> meta.clientCapabilities + else -> meta.logLevel + } + } + assertTrue(failure.message.orEmpty().contains(key)) + } + } + + @OptIn(ExperimentalMcpApi::class) + @Test + fun `legacy requests should not gain request scoped metadata`() { + val request = ListToolsRequest() + + McpJson.encodeToString(request) shouldEqualJson """ + { + "method": "tools/list" + } + """.trimIndent() + assertNull(request.params?.meta?.protocolVersion) + } + @Test fun `should expose progress token from RequestMeta`() { val stringTokenMeta = RequestMeta(buildJsonObject { put("progressToken", "sync-1") }) diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/SamplingTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/SamplingTest.kt index 696f322..b7bc5cf 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/SamplingTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/SamplingTest.kt @@ -1,7 +1,13 @@ package io.modelcontextprotocol.kotlin.sdk.types +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization import io.modelcontextprotocol.kotlin.test.utils.verifySerialization +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.double import kotlinx.serialization.json.jsonPrimitive @@ -14,6 +20,21 @@ import kotlin.test.assertNotNull class SamplingTest { + @Serializable + private data class Holder( + @Serializable(with = SamplingContentSerializer::class) + val content: List, + ) + + private val dummyTool = Tool( + name = "get_weather", + description = "returns weather", + inputSchema = ToolSchema( + properties = buildJsonObject { }, + required = emptyList(), + ), + ) + @Test fun `should serialize ModelHint`() { val hint = ModelHint(name = "claude-3-5-sonnet") @@ -66,7 +87,7 @@ class SamplingTest { fun `should serialize SamplingMessage`() { val message = SamplingMessage( role = Role.User, - content = TextContent(text = "Summarize the latest release."), + content = listOf(TextContent(text = "Summarize the latest release.")), ) verifySerialization( @@ -85,7 +106,6 @@ class SamplingTest { } @Test - @Suppress("LongMethod") fun `should serialize CreateMessageRequest with all fields`() { val request = CreateMessageRequest( CreateMessageRequestParams( @@ -93,11 +113,11 @@ class SamplingTest { messages = listOf( SamplingMessage( role = Role.User, - content = TextContent(text = "You are a helpful assistant."), + content = listOf(TextContent(text = "You are a helpful assistant.")), ), SamplingMessage( role = Role.User, - content = TextContent(text = "Provide a short summary."), + content = listOf(TextContent(text = "Provide a short summary.")), ), ), modelPreferences = ModelPreferences( @@ -202,7 +222,7 @@ class SamplingTest { val message = params.messages.first() assertEquals(Role.User, message.role) - val content = assertIs(message.content) + val content = assertIs(message.content.single()) assertEquals("Draft a project update.", content.text) val preferences = assertNotNull(params.modelPreferences) @@ -259,7 +279,7 @@ class SamplingTest { val result = verifyDeserialization(McpJson, json) assertEquals(Role.Assistant, result.role) - val text = assertIs(result.content) + val text = assertIs(result.content.single()) assertEquals("Summary complete.", text.text) assertEquals("gpt-4o", result.model) assertEquals(StopReason.StopSequence, result.stopReason) @@ -267,4 +287,249 @@ class SamplingTest { assertNotNull(meta) assertEquals(1200.5, meta["latencyMs"]?.jsonPrimitive?.double) } + + // ============================================================================ + // SamplingMessage shape (SEP-1577) + // ============================================================================ + + @Test + fun `SamplingMessage content is a list of SamplingMessageContent`() { + val m = SamplingMessage( + role = Role.User, + content = listOf(TextContent("hi")), + ) + m.content.size shouldBe 1 + (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"))) + val json = McpJson.encodeToString(SamplingMessage.serializer(), m) + check("""{"role":"user","content":""" in json) { "expected role/content prefix, got $json" } + check("\"type\":\"text\"" in json && "\"text\":\"hi\"" in json) { + "expected single-object content with text discriminator, got $json" + } + check("\"content\":[" !in json) { "expected single-object wire, but array form found: $json" } + } + + @Test + fun `SamplingMessage multi-element content serialises as array`() { + val m = SamplingMessage( + role = Role.Assistant, + content = listOf( + TextContent("Let me use a tool"), + ToolUseContent(id = "c1", name = "get_weather", input = JsonObject(emptyMap())), + ), + ) + val json = McpJson.encodeToString(SamplingMessage.serializer(), m) + check("\"content\":[" in json) { "expected array wire form, got $json" } + } + + @Test + fun `SamplingMessage _meta round-trips`() { + val meta = buildJsonObject { put("k", JsonPrimitive("v")) } + val m = SamplingMessage(role = Role.User, content = listOf(TextContent("hi")), meta = meta) + val json = McpJson.encodeToString(SamplingMessage.serializer(), m) + val decoded = McpJson.decodeFromString(SamplingMessage.serializer(), json) + decoded.meta shouldBe meta + } + + // ============================================================================ + // CreateMessageRequestParams: tools / toolChoice + StopReason.ToolUse + // ============================================================================ + + @Test + fun `CreateMessageRequestParams tools and toolChoice default to null`() { + val params = CreateMessageRequestParams(maxTokens = 100, messages = emptyList()) + params.tools shouldBe null + params.toolChoice shouldBe null + } + + @Test + fun `CreateMessageRequestParams round-trips tools and toolChoice`() { + val original = CreateMessageRequestParams( + maxTokens = 100, + messages = emptyList(), + tools = listOf(dummyTool), + toolChoice = ToolChoice(mode = ToolChoice.Mode.Required), + ) + val encoded = McpJson.encodeToString(CreateMessageRequestParams.serializer(), original) + val decoded = McpJson.decodeFromString(CreateMessageRequestParams.serializer(), encoded) + decoded.tools?.single()?.name shouldBe "get_weather" + 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" + } + + // ============================================================================ + // CreateMessageResult shape (SEP-1577) + // ============================================================================ + + @Test + fun `CreateMessageResult single-block content serialises as single object`() { + val r = CreateMessageResult( + role = Role.Assistant, + content = TextContent("42"), + model = "test-model", + stopReason = StopReason.EndTurn, + ) + val json = McpJson.encodeToString(CreateMessageResult.serializer(), r) + check("\"content\":{" in json) { "expected single-object content wire form, got $json" } + check("\"content\":[" !in json) { "expected NOT to use array wire form for size-1, got $json" } + } + + @Test + fun `CreateMessageResult multi-block content with ToolUse stopReason round-trips`() { + val r = CreateMessageResult( + role = Role.Assistant, + content = listOf( + TextContent("Let me use a tool"), + ToolUseContent(id = "c1", name = "get_weather", input = JsonObject(emptyMap())), + ), + model = "test-model", + stopReason = StopReason.ToolUse, + ) + val json = McpJson.encodeToString(CreateMessageResult.serializer(), r) + val decoded = McpJson.decodeFromString(CreateMessageResult.serializer(), json) + decoded shouldBe r + decoded.stopReason shouldBe StopReason.ToolUse + decoded.content.size shouldBe 2 + } + + @Test + fun `CreateMessageResult pre-SEP single-object wire decodes correctly`() { + val json = """{"role":"assistant","content":{"type":"text","text":"hi"},"model":"m"}""" + val decoded = McpJson.decodeFromString(CreateMessageResult.serializer(), json) + decoded.content.size shouldBe 1 + (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) + // ============================================================================ + + @Test + fun `SamplingContentSerializer decodes single object into list of one`() { + val json = """{"content":{"type":"text","text":"hi"}}""" + val h = McpJson.decodeFromString(Holder.serializer(), json) + h.content.size shouldBe 1 + h.content[0].shouldBeInstanceOf().text shouldBe "hi" + } + + @Test + fun `SamplingContentSerializer decodes array into list`() { + val json = """{"content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]}""" + val h = McpJson.decodeFromString(Holder.serializer(), json) + h.content.size shouldBe 2 + } + + @Test + fun `SamplingContentSerializer encodes list of size one as a single object`() { + val h = Holder(content = listOf(TextContent("hi"))) + val json = McpJson.encodeToString(Holder.serializer(), h) + json shouldBe """{"content":{"text":"hi","type":"text"}}""" + } + + @Test + fun `SamplingContentSerializer encodes list of size two as an array`() { + val h = Holder(content = listOf(TextContent("a"), TextContent("b"))) + val json = McpJson.encodeToString(Holder.serializer(), h) + json shouldBe """{"content":[{"text":"a","type":"text"},{"text":"b","type":"text"}]}""" + } + + @Test + fun `SamplingContentSerializer encoding an empty list throws`() { + val h = Holder(content = emptyList()) + assertFailsWith { + McpJson.encodeToString(Holder.serializer(), h) + } + } + + @Test + fun `SamplingContentSerializer decoding an empty array throws`() { + val json = """{"content":[]}""" + assertFailsWith { + McpJson.decodeFromString(Holder.serializer(), json) + } + } + + // ============================================================================ + // ToolChoice + // ============================================================================ + + @Test + fun `ToolChoice round-trips auto mode`() { + val original = ToolChoice(mode = ToolChoice.Mode.Auto) + val json = McpJson.encodeToString(ToolChoice.serializer(), original) + json shouldBe """{"mode":"auto"}""" + McpJson.decodeFromString(ToolChoice.serializer(), json) shouldBe original + } + + @Test + fun `ToolChoice round-trips required mode`() { + val original = ToolChoice(mode = ToolChoice.Mode.Required) + McpJson.decodeFromString( + ToolChoice.serializer(), + McpJson.encodeToString(ToolChoice.serializer(), original), + ) shouldBe original + } + + @Test + fun `ToolChoice round-trips none mode`() { + val original = ToolChoice(mode = ToolChoice.Mode.None) + McpJson.decodeFromString( + ToolChoice.serializer(), + McpJson.encodeToString(ToolChoice.serializer(), original), + ) shouldBe original + } + + @Test + fun `ToolChoice absent mode serialises as empty object and deserialises to null mode`() { + val original = ToolChoice() + val json = McpJson.encodeToString(ToolChoice.serializer(), original) + json shouldBe """{}""" + McpJson.decodeFromString(ToolChoice.serializer(), json) shouldBe ToolChoice(mode = null) + } } 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 543e12d..358866a 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ToolsTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ToolsTest.kt @@ -37,7 +37,6 @@ class ToolsTest { } @Test - @Suppress("LongMethod") fun `should serialize Tool with annotations and schemas`() { val inputSchema = ToolSchema( properties = buildJsonObject { @@ -158,6 +157,61 @@ class ToolsTest { assertEquals("language", tool.meta?.get("category")?.jsonPrimitive?.content) } + @Test + fun `should serialize ToolSchema with schema field`() { + val tool = Tool( + name = "typed-tool", + inputSchema = ToolSchema( + schema = "https://json-schema.org/draft/2020-12/schema", + properties = buildJsonObject { + put("name", buildJsonObject { put("type", "string") }) + }, + ), + ) + + verifySerialization( + tool, + McpJson, + $$""" + { + "name": "typed-tool", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + """.trimIndent(), + ) + } + + @Test + fun `should deserialize ToolSchema with schema field`() { + val json = $$""" + { + "name": "typed-tool", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + """.trimIndent() + + val tool = verifyDeserialization(McpJson, json) + + assertEquals("https://json-schema.org/draft/2020-12/schema", tool.inputSchema.schema) + assertNotNull(tool.inputSchema.properties) + } + @Test fun `should serialize ToolSchema with defs`() { val tool = toolWithDefs() @@ -265,6 +319,57 @@ class ToolsTest { assertEquals(ProgressToken(77), params.meta?.progressToken) } + @Test + fun `should serialize CallToolRequest with task augmentation`() { + val request = CallToolRequest( + CallToolRequestParams( + name = "long-running", + arguments = buildJsonObject { put("query", "MCP protocol") }, + task = TaskMetadata(ttl = 60_000L), + ), + ) + + verifySerialization( + request, + McpJson, + """ + { + "method": "tools/call", + "params": { + "name": "long-running", + "arguments": { + "query": "MCP protocol" + }, + "task": { + "ttl": 60000 + } + } + } + """.trimIndent(), + ) + + assertEquals(TaskMetadata(ttl = 60_000L), request.task) + } + + @Test + fun `should omit task from CallToolRequest when null`() { + val request = CallToolRequest(CallToolRequestParams(name = "plain")) + + assertEquals(null, request.task) + verifySerialization( + request, + McpJson, + """ + { + "method": "tools/call", + "params": { + "name": "plain" + } + } + """.trimIndent(), + ) + } + @Test fun `should serialize CallToolResult with structured content`() { val result = CallToolResult( diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt index e4a949f..25d9ee6 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt @@ -4,8 +4,10 @@ import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.buildInitializeRequest -import kotlinx.serialization.json.double +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlin.test.Test @@ -31,6 +33,7 @@ class CapabilitiesDslTest { roots.shouldBeNull() elicitation.shouldBeNull() experimental.shouldBeNull() + extensions.shouldBeNull() } } @@ -39,40 +42,36 @@ class CapabilitiesDslTest { val request = buildInitializeRequest { protocolVersion = "2024-11-05" capabilities { - sampling { - put("temperature", 0.7) - put("maxTokens", 1000) - put("topP", 0.95) - } + sampling(ClientCapabilities.Sampling(tools = EmptyJsonObject)) roots(listChanged = true) - elicitation { - put("mode", "interactive") - put("timeout", 30) - put("retries", 3) - } + elicitation(ClientCapabilities.Elicitation(form = EmptyJsonObject, url = EmptyJsonObject)) experimental { put("customFeature", true) put("version", "1.0") put("beta", false) put("maxConcurrency", 10) } + extensions( + mapOf( + "io.modelcontextprotocol/ui" to buildJsonObject { + put("mimeTypes", "text/html") + }, + ), + ) } info("Test", "1.0") } request.params.capabilities.shouldNotBeNull { sampling shouldNotBeNull { - get("temperature")?.jsonPrimitive?.double shouldBe 0.7 - get("maxTokens")?.jsonPrimitive?.content shouldBe "1000" - get("topP")?.jsonPrimitive?.double shouldBe 0.95 + tools shouldBe EmptyJsonObject } roots shouldNotBeNull { listChanged shouldBe true } elicitation shouldNotBeNull { - get("mode")?.jsonPrimitive?.content shouldBe "interactive" - get("timeout")?.jsonPrimitive?.content shouldBe "30" - get("retries")?.jsonPrimitive?.content shouldBe "3" + form shouldBe EmptyJsonObject + url shouldBe EmptyJsonObject } experimental shouldNotBeNull { get("customFeature")?.jsonPrimitive?.content shouldBe "true" @@ -80,6 +79,11 @@ class CapabilitiesDslTest { get("beta")?.jsonPrimitive?.content shouldBe "false" get("maxConcurrency")?.jsonPrimitive?.content shouldBe "10" } + extensions shouldNotBeNull { + get("io.modelcontextprotocol/ui") shouldNotBeNull { + get("mimeTypes")?.jsonPrimitive?.content shouldBe "text/html" + } + } } } @@ -110,13 +114,57 @@ class CapabilitiesDslTest { requestNull.params.capabilities.roots?.listChanged.shouldBeNull() } + @Test + fun `capabilities should build with extensions containing empty settings`() { + val request = buildInitializeRequest { + protocolVersion = "2024-11-05" + capabilities { + extensions( + mapOf( + "io.modelcontextprotocol/ui" to EmptyJsonObject, + "com.example/custom" to EmptyJsonObject, + ), + ) + } + info("Test", "1.0") + } + + request.params.capabilities.shouldNotBeNull { + extensions shouldNotBeNull { + size shouldBe 2 + get("io.modelcontextprotocol/ui") shouldBe EmptyJsonObject + get("com.example/custom") shouldBe EmptyJsonObject + } + } + } + + @Test + fun `capabilities should overwrite extensions when set multiple times`() { + val request = buildInitializeRequest { + protocolVersion = "2024-11-05" + capabilities { + extensions(mapOf("io.modelcontextprotocol/ui" to EmptyJsonObject)) + extensions(mapOf("com.example/custom" to EmptyJsonObject)) // Should overwrite + } + info("Test", "1.0") + } + + request.params.capabilities.shouldNotBeNull { + extensions shouldNotBeNull { + size shouldBe 1 + get("com.example/custom") shouldBe EmptyJsonObject + get("io.modelcontextprotocol/ui").shouldBeNull() + } + } + } + @Test fun `capabilities should overwrite when same field set multiple times`() { val request = buildInitializeRequest { protocolVersion = "2024-11-05" capabilities { - sampling { put("temperature", 0.5) } - sampling { put("temperature", 0.9) } // Should overwrite + sampling(ClientCapabilities.Sampling()) + sampling(ClientCapabilities.Sampling(tools = EmptyJsonObject)) // Should overwrite experimental { put("feature", "v1") } experimental { put("feature", "v2") } // Should overwrite } @@ -125,7 +173,7 @@ class CapabilitiesDslTest { request.params.capabilities.shouldNotBeNull { sampling shouldNotBeNull { - get("temperature")?.jsonPrimitive?.double shouldBe 0.9 + tools shouldBe EmptyJsonObject } experimental shouldNotBeNull { get("feature")?.jsonPrimitive?.content shouldBe "v2" diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ContentDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ContentDslTest.kt index 180a308..4801d02 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ContentDslTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ContentDslTest.kt @@ -42,7 +42,7 @@ class ContentDslTest { } } - (request.params.messages[0].content as TextContent).shouldNotBeNull { + (request.params.messages[0].content.single() as TextContent).shouldNotBeNull { text shouldBe "Hello, world!" annotations.shouldBeNull() meta.shouldBeNull() @@ -71,7 +71,7 @@ class ContentDslTest { } } - (request.params.messages[0].content as TextContent).shouldNotBeNull { + (request.params.messages[0].content.single() as TextContent).shouldNotBeNull { text shouldBe "Analyze the quarterly sales report for Q4 2025" annotations shouldNotBeNull { audience shouldBe listOf(Role.User, Role.Assistant) @@ -98,7 +98,7 @@ class ContentDslTest { } } - (request.params.messages[0].content as TextContent).text shouldBe "Hello 🌍! Ça va? Москва 北京 مرحبا" + (request.params.messages[0].content.single() as TextContent).text shouldBe "Hello 🌍! Ça va? Москва 北京 مرحبا" } @Test @@ -112,7 +112,7 @@ class ContentDslTest { } } - (request.params.messages[0].content as TextContent).text shouldBe "" + (request.params.messages[0].content.single() as TextContent).text shouldBe "" } @Test @@ -144,7 +144,7 @@ class ContentDslTest { } } - (request.params.messages[0].content as ImageContent).shouldNotBeNull { + (request.params.messages[0].content.single() as ImageContent).shouldNotBeNull { data shouldBe "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" mimeType shouldBe "image/png" @@ -177,7 +177,7 @@ class ContentDslTest { } } - (request.params.messages[0].content as ImageContent).shouldNotBeNull { + (request.params.messages[0].content.single() as ImageContent).shouldNotBeNull { mimeType shouldBe "image/jpeg" annotations shouldNotBeNull { audience shouldBe listOf(Role.Assistant) @@ -208,7 +208,7 @@ class ContentDslTest { } } } - (request.params.messages[0].content as ImageContent).mimeType shouldBe mime + (request.params.messages[0].content.single() as ImageContent).mimeType shouldBe mime } } @@ -256,7 +256,7 @@ class ContentDslTest { } } - (request.params.messages[0].content as AudioContent).shouldNotBeNull { + (request.params.messages[0].content.single() as AudioContent).shouldNotBeNull { data shouldBe "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==" mimeType shouldBe "audio/wav" annotations.shouldBeNull() @@ -288,7 +288,7 @@ class ContentDslTest { } } - (request.params.messages[0].content as AudioContent).shouldNotBeNull { + (request.params.messages[0].content.single() as AudioContent).shouldNotBeNull { mimeType shouldBe "audio/mpeg" annotations shouldNotBeNull { audience shouldBe listOf(Role.User) @@ -349,7 +349,7 @@ class ContentDslTest { } } } - (requestMin.params.messages[0].content as TextContent).annotations?.priority shouldBe 0.0 + (requestMin.params.messages[0].content.single() as TextContent).annotations?.priority shouldBe 0.0 // Priority = 1.0 (maximum) val requestMax = buildCreateMessageRequest { @@ -361,7 +361,7 @@ class ContentDslTest { } } } - (requestMax.params.messages[0].content as TextContent).annotations?.priority shouldBe 1.0 + (requestMax.params.messages[0].content.single() as TextContent).annotations?.priority shouldBe 1.0 } @Test @@ -376,7 +376,7 @@ class ContentDslTest { } } - (request.params.messages[0].content as TextContent).annotations shouldNotBeNull { + (request.params.messages[0].content.single() as TextContent).annotations shouldNotBeNull { priority shouldBe 0.5 audience shouldBe listOf(Role.User) lastModified.shouldBeNull() diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/InitializeDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/InitializeDslTest.kt index cb35115..da2a4cb 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/InitializeDslTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/InitializeDslTest.kt @@ -5,10 +5,10 @@ import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.buildInitializeRequest import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlin.test.Test @@ -20,16 +20,17 @@ class InitializeDslTest { val request = buildInitializeRequest { protocolVersion = "2024-11-05" capabilities { - sampling { - put("maxTokens", 100) - } + sampling(ClientCapabilities.Sampling()) roots(listChanged = true) - elicitation { - put("mode", "interactive") - } + elicitation(ClientCapabilities.Elicitation(form = EmptyJsonObject)) experimental { put("custom", true) } + extensions( + mapOf( + "io.modelcontextprotocol/ui" to EmptyJsonObject, + ), + ) } info( name = "TestClient", @@ -41,10 +42,13 @@ class InitializeDslTest { request.params.protocolVersion shouldBe "2024-11-05" request.params.capabilities.shouldNotBeNull { - sampling?.get("maxTokens")?.jsonPrimitive?.int shouldBe 100 + sampling shouldNotBeNull { } roots?.listChanged shouldBe true - elicitation?.get("mode")?.jsonPrimitive?.content shouldBe "interactive" + elicitation?.form shouldBe EmptyJsonObject experimental?.get("custom")?.jsonPrimitive?.content shouldBe "true" + extensions shouldNotBeNull { + get("io.modelcontextprotocol/ui") shouldBe EmptyJsonObject + } } request.params.clientInfo.shouldNotBeNull { name shouldBe "TestClient" @@ -70,25 +74,30 @@ class InitializeDslTest { } @Test - fun `capabilities DSL should support direct JsonObject values`() { - val samplingObj = buildJsonObject { put("key", "value") } - val elicitationObj = buildJsonObject { put("key", "value") } + fun `capabilities DSL should support direct typed values`() { + val samplingValue = ClientCapabilities.Sampling(tools = EmptyJsonObject) + val elicitationValue = ClientCapabilities.Elicitation(form = EmptyJsonObject, url = EmptyJsonObject) val experimentalObj = buildJsonObject { put("key", "value") } + val extensionsMap = mapOf( + "io.modelcontextprotocol/ui" to buildJsonObject { put("key", "value") }, + ) val request = buildInitializeRequest { protocolVersion = "1.0" capabilities { - sampling(samplingObj) - elicitation(elicitationObj) + sampling(samplingValue) + elicitation(elicitationValue) experimental(experimentalObj) + extensions(extensionsMap) } info("Test", "1.0") } request.params.capabilities.shouldNotBeNull { - sampling shouldBe samplingObj - elicitation shouldBe elicitationObj + sampling shouldBe samplingValue + elicitation shouldBe elicitationValue experimental shouldBe experimentalObj + extensions shouldBe extensionsMap } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/SamplingDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/SamplingDslTest.kt index c53a400..98817c1 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/SamplingDslTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/SamplingDslTest.kt @@ -28,7 +28,6 @@ import kotlin.test.Test @OptIn(ExperimentalMcpApi::class) class SamplingDslTest { @Test - @Suppress("LongMethod") fun `buildCreateMessageRequest should build with all fields`() { val request = buildCreateMessageRequest { maxTokens = 1000 @@ -71,7 +70,7 @@ class SamplingDslTest { request.params.stopSequences shouldBe listOf("STOP") request.params.messages shouldHaveSize 5 - (request.params.messages[2].content as TextContent).shouldNotBeNull { + (request.params.messages[2].content.single() as TextContent).shouldNotBeNull { text shouldBe "Text with annotations" annotations shouldNotBeNull { audience shouldBe listOf(Role.User) @@ -81,13 +80,13 @@ class SamplingDslTest { meta?.get("key")?.jsonPrimitive?.content shouldBe "value" } - (request.params.messages[3].content as ImageContent).shouldNotBeNull { + (request.params.messages[3].content.single() as ImageContent).shouldNotBeNull { data shouldBe "base64image" mimeType shouldBe "image/png" annotations?.priority shouldBe 0.5 } - (request.params.messages[4].content as AudioContent).shouldNotBeNull { + (request.params.messages[4].content.single() as AudioContent).shouldNotBeNull { data shouldBe "base64audio" mimeType shouldBe "audio/wav" } @@ -102,7 +101,7 @@ class SamplingDslTest { @Test fun `buildCreateMessageRequest should support direct assignments`() { - val messages = listOf(SamplingMessage(Role.User, TextContent("Hello"))) + val messages = listOf(SamplingMessage(Role.User, listOf(TextContent("Hello")))) val preferences = ModelPreferences(costPriority = 0.1) val request = buildCreateMessageRequest { maxTokens = 100 @@ -126,8 +125,8 @@ class SamplingDslTest { assistant(content) } } - request.params.messages[0].content shouldBe content - request.params.messages[1].content shouldBe content + request.params.messages[0].content.single() shouldBe content + request.params.messages[1].content.single() shouldBe content } @Test diff --git a/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyJvmTest.kt b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyJvmTest.kt new file mode 100644 index 0000000..60658f8 --- /dev/null +++ b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyJvmTest.kt @@ -0,0 +1,114 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.kotest.matchers.shouldBe +import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +/** + * Real-dispatcher counterparts to [ProtocolConcurrencyTest]. + * + * These run under [runBlocking] (real time, real threads on [Dispatchers.Default]) rather than + * `runTest`: a virtual-time scheduler auto-advances past the `withTimeout` guards and cannot + * observe genuine cross-thread parallelism, so the concurrency assertions could pass vacuously. + */ +class ProtocolConcurrencyJvmTest { + + @Test + fun `fast request completes while a slow handler is parked on Dispatchers Default`() = runBlocking { + val protocol = TestProtocol(ProtocolOptions(handlerCoroutineContext = Dispatchers.Default)) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + val slowEntered = CompletableDeferred() + val releaseSlow = CompletableDeferred() + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/slow") { + slowEntered.complete(Unit) + releaseSlow.await() + } + EmptyResult() + } + + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) + withTimeout(5.seconds) { slowEntered.await() } + transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) + + val fast = withTimeout(5.seconds) { transport.awaitSent() } + (fast as JSONRPCResponse).id shouldBe RequestId(2L) + + releaseSlow.complete(Unit) + val slow = withTimeout(5.seconds) { transport.awaitSent() } + (slow as JSONRPCResponse).id shouldBe RequestId(1L) + Unit + } + + @Test + fun `dispatch stays serial before the gate flips on Dispatchers Default`() = runBlocking { + val protocol = TestProtocol(ProtocolOptions(handlerCoroutineContext = Dispatchers.Default)) + val transport = RecordingTransport() + protocol.connect(transport) + // gate NOT flipped + + val slowEntered = CompletableDeferred() + val releaseSlow = CompletableDeferred() + protocol.fallbackRequestHandler = { request, _ -> + if (request.method == "test/slow") { + slowEntered.complete(Unit) + releaseSlow.await() + } + EmptyResult() + } + + // A single delivering coroutine models the transport read loop: the second deliver cannot + // start until the first returns, so a parked slow handler blocks the fast request. + val delivery = launch { + transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) + transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) + } + withTimeout(5.seconds) { slowEntered.await() } + + // The delivering coroutine is parked inside the slow handler; fast was never delivered. + transport.sentMessages.tryReceive().isFailure shouldBe true + + releaseSlow.complete(Unit) + val first = withTimeout(5.seconds) { transport.awaitSent() } + (first as JSONRPCResponse).id shouldBe RequestId(1L) + val second = withTimeout(5.seconds) { transport.awaitSent() } + (second as JSONRPCResponse).id shouldBe RequestId(2L) + delivery.join() + } + + @Test + fun `suspension-free notification handlers keep arrival order on Dispatchers Default`() = runBlocking { + val protocol = TestProtocol(ProtocolOptions(handlerCoroutineContext = Dispatchers.Default)) + val transport = RecordingTransport() + protocol.connect(transport) + protocol.enableConcurrency() + + // safe: appended only from the delivering coroutine (UNDISPATCHED, suspension-free) + val seen = mutableListOf() + protocol.fallbackNotificationHandler = { notification -> + seen.add((notification.params as JsonObject).getValue("i").jsonPrimitive.int) + } + repeat(100) { i -> + transport.deliver(JSONRPCNotification(method = "test/event", params = buildJsonObject { put("i", i) })) + } + seen shouldBe (0 until 100).toList() + } +} diff --git a/kotlin-sdk-server/api/kotlin-sdk-server.api b/kotlin-sdk-server/api/kotlin-sdk-server.api index 80da5b5..d8e4c7a 100644 --- a/kotlin-sdk-server/api/kotlin-sdk-server.api +++ b/kotlin-sdk-server/api/kotlin-sdk-server.api @@ -32,20 +32,37 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/ClientConnection$De public static synthetic fun ping$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Lio/modelcontextprotocol/kotlin/sdk/types/PingRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } +public final class io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionConfig { + public fun ()V + public final fun getAllowedHosts ()Ljava/util/List; + public final fun getAllowedOrigins ()Ljava/util/List; + public final fun setAllowedHosts (Ljava/util/List;)V + public final fun setAllowedOrigins (Ljava/util/List;)V +} + public abstract interface class io/modelcontextprotocol/kotlin/sdk/server/EventStore { public abstract fun getStreamIdForEventId (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun replayEventsAfter (Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun storeEvent (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class io/modelcontextprotocol/kotlin/sdk/server/HostValidationKt { + public static final fun getDnsRebindingProtection ()Lio/ktor/server/application/RouteScopedPlugin; +} + public final class io/modelcontextprotocol/kotlin/sdk/server/KtorServerKt { - public static final fun mcp (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)V - public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V - public static final fun mcp (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function1;)V + public static final fun mcp (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;)V + public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;)V + public static final fun mcp (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;)V + public static synthetic fun mcp$default (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static synthetic fun mcp$default (Lio/ktor/server/routing/Route;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static synthetic fun mcp$default (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V + public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;)V public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V - public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V - public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V } public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt : io/modelcontextprotocol/kotlin/sdk/server/Feature { @@ -154,8 +171,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/ServerOptions : io/ public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;)V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Z)V public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;Lkotlin/coroutines/CoroutineContext;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;Lkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; public final fun getResourceTemplateMatcherFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory; } @@ -183,6 +200,7 @@ public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelc public fun onClose ()V public final fun onClose (Lkotlin/jvm/functions/Function0;)V public final fun onInitialized (Lkotlin/jvm/functions/Function0;)V + protected fun onInitializedNotification ()V public final fun ping (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun sendElicitationComplete (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun sendLoggingMessage (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingMessageNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -193,7 +211,8 @@ public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelc } public final class io/modelcontextprotocol/kotlin/sdk/server/SseServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport { - public fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;)V + public fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;J)V + public synthetic fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;JILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getSessionId ()Ljava/lang/String; public final fun handleMessage (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -204,11 +223,22 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/SseServerTransport public final class io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport { public fun (Lkotlinx/io/Source;Lkotlinx/io/Sink;)V + public fun (Lkotlinx/io/Source;Lkotlinx/io/Sink;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lkotlinx/io/Source;Lkotlinx/io/Sink;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun send (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lio/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport$Builder { + public final fun getHandlerDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; + public final fun getIoDispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; + public final fun getScope ()Lkotlinx/coroutines/CoroutineScope; + public final fun setHandlerDispatcher (Lkotlinx/coroutines/CoroutineDispatcher;)V + public final fun setIoDispatcher (Lkotlinx/coroutines/CoroutineDispatcher;)V + public final fun setScope (Lkotlinx/coroutines/CoroutineScope;)V +} + public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport { public static final field STANDALONE_SSE_STREAM_ID Ljava/lang/String; public fun ()V diff --git a/kotlin-sdk-server/build.gradle.kts b/kotlin-sdk-server/build.gradle.kts index 126c516..193eb90 100644 --- a/kotlin-sdk-server/build.gradle.kts +++ b/kotlin-sdk-server/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("mcp.multiplatform") + id("mcp.detekt") id("mcp.publishing") id("mcp.dokka") alias(libs.plugins.kotlinx.binary.compatibility.validator) diff --git a/kotlin-sdk-server/detekt-baseline-commonMainSourceSet.xml b/kotlin-sdk-server/detekt-baseline-commonMainSourceSet.xml deleted file mode 100644 index d383ec9..0000000 --- a/kotlin-sdk-server/detekt-baseline-commonMainSourceSet.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - MagicNumber:StdioServerTransport.kt:StdioServerTransport$8192 - MaxLineLength:SSEServerTransport.kt:SseServerTransport$"SSEServerTransport already started! If using Server class, note that connect() calls start() automatically." - MaxLineLength:SSEServerTransport.kt:SseServerTransport$* - ReturnCount:KtorServer.kt:private suspend fun existingStreamableTransport: StreamableHttpServerTransport? - TooGenericExceptionCaught:SSEServerTransport.kt:SseServerTransport$e: Exception - TooGenericExceptionCaught:StdioServerTransport.kt:StdioServerTransport$e: Throwable - TooManyFunctions:KtorServer.kt:io.modelcontextprotocol.kotlin.sdk.server.KtorServer.kt - - diff --git a/kotlin-sdk-server/detekt-baseline-main.xml b/kotlin-sdk-server/detekt-baseline-main.xml index a27f38a..aad11fa 100644 --- a/kotlin-sdk-server/detekt-baseline-main.xml +++ b/kotlin-sdk-server/detekt-baseline-main.xml @@ -2,29 +2,6 @@ - InjectDispatcher:FeatureNotificationService.kt:FeatureNotificationService$Default - LongParameterList:KtorServer.kt:private suspend fun RoutingContext.streamableTransport: StreamableHttpServerTransport? - LongParameterList:StreamableHttpServerTransport.kt:StreamableHttpServerTransport.Configuration - MagicNumber:StdioServerTransport.kt:StdioServerTransport$8192 - MaxLineLength:SSEServerTransport.kt:SseServerTransport$"SSEServerTransport already started! If using Server class, note that connect() calls start() automatically." - MaxLineLength:SSEServerTransport.kt:SseServerTransport$* - NoNameShadowing:FeatureNotificationService.kt:FeatureNotificationService${ it.remove(job) } - ReturnCount:KtorServer.kt:private suspend fun existingStreamableTransport: StreamableHttpServerTransport? - TooGenericExceptionCaught:SSEServerTransport.kt:SseServerTransport$e: Exception - TooGenericExceptionCaught:StdioServerTransport.kt:StdioServerTransport$e: Throwable - TooManyFunctions:KtorServer.kt:io.modelcontextprotocol.kotlin.sdk.server.KtorServer.kt UnsafeCallOnNullableType:StreamableHttpServerTransport.kt:StreamableHttpServerTransport$responseRequestId!! - UseCheckNotNull:Server.kt:Server$check(options.capabilities.prompts != null) { logger.error { "Failed to add prompt '${prompt.name}': Server does not support prompts capability" } "Server does not support prompts capability." } - UseCheckNotNull:Server.kt:Server$check(options.capabilities.prompts != null) { logger.error { "Failed to add prompts: Server does not support prompts capability" } "Server does not support prompts capability." } - UseCheckNotNull:Server.kt:Server$check(options.capabilities.prompts != null) { logger.error { "Failed to remove prompt '$name': Server does not support prompts capability" } "Server does not support prompts capability." } - UseCheckNotNull:Server.kt:Server$check(options.capabilities.prompts != null) { logger.error { "Failed to remove prompts: Server does not support prompts capability" } "Server does not support prompts capability." } - UseCheckNotNull:Server.kt:Server$check(options.capabilities.resources != null) { logger.error { "Failed to add resource '$name': Server does not support resources capability" } "Server does not support resources capability." } - UseCheckNotNull:Server.kt:Server$check(options.capabilities.resources != null) { logger.error { "Failed to add resources: Server does not support resources capability" } "Server does not support resources capability." } - UseCheckNotNull:Server.kt:Server$check(options.capabilities.resources != null) { logger.error { "Failed to remove resource '$uri': Server does not support resources capability" } "Server does not support resources capability." } - UseCheckNotNull:Server.kt:Server$check(options.capabilities.resources != null) { logger.error { "Failed to remove resources: Server does not support resources capability" } "Server does not support resources capability." } - UseCheckNotNull:Server.kt:Server$check(options.capabilities.tools != null) { logger.error { "Failed to add tool '${tool.name}': Server does not support tools capability" } "Server does not support tools capability. Enable it in ServerOptions." } - UseCheckNotNull:Server.kt:Server$check(options.capabilities.tools != null) { logger.error { "Failed to add tools: Server does not support tools capability" } "Server does not support tools capability." } - UseCheckNotNull:Server.kt:Server$check(options.capabilities.tools != null) { logger.error { "Failed to remove tool '$name': Server does not support tools capability" } "Server does not support tools capability." } - UseCheckNotNull:Server.kt:Server$check(options.capabilities.tools != null) { logger.error { "Failed to remove tools: Server does not support tools capability" } "Server does not support tools capability." } 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 23dbe89..21e0925 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt @@ -10,11 +10,14 @@ import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification +import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult +import io.modelcontextprotocol.kotlin.sdk.types.IncludeContext import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest import io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification +import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.PingRequest import io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.Request @@ -24,6 +27,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification import io.modelcontextprotocol.kotlin.sdk.types.ServerNotification import io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification +import io.modelcontextprotocol.kotlin.sdk.types.supportsUrl private val logger = KotlinLogging.logger {} @@ -32,7 +36,6 @@ private val logger = KotlinLogging.logger {} * communication through notifications, requests, and other operations. * This interface defines various methods to facilitate the interaction. */ -@Suppress("TooManyFunctions") public interface ClientConnection { /** @@ -94,12 +97,17 @@ public interface ClientConnection { * Sends a message to the client requesting elicitation. * This typically results in a form being displayed to the end user. * + * Accepted content is validated against [requestedSchema]. If content is absent, an empty + * object is used for validation only, so declared `required` properties still apply. The + * returned [ElicitResult] is not modified. + * * @param message The message for the elicitation to display. - * @param requestedSchema The schema requested by the client for the elicitation result. + * @param requestedSchema The schema the server requests for the elicitation result. * Influences the form displayed to the user. * @param options Optional request options. * @return The result of the elicitation request. * @throws IllegalStateException If the server or client does not support elicitation. + * @throws McpException If an accepted response does not match [requestedSchema]. */ public suspend fun createElicitation( message: String, @@ -129,10 +137,15 @@ public interface ClientConnection { * Sends a message to the client requesting elicitation. * This typically results in a form being displayed to the user. * + * For form-mode requests, accepted content is validated against the requested schema. If + * content is absent, an empty object is used for validation only, so declared `required` + * properties still apply. The returned [ElicitResult] is not modified. + * * @param request The elicitation request parameters. * @param options Optional request options. * @return The result of the elicitation request. * @throws IllegalStateException If the server or client does not support elicitation. + * @throws McpException If an accepted form-mode response does not match its requested schema. */ public suspend fun createElicitation(request: ElicitRequest, options: RequestOptions? = null): ElicitResult @@ -175,7 +188,6 @@ public interface ClientConnection { public suspend fun sendElicitationComplete(notification: ElicitationCompleteNotification) } -@Suppress("TooManyFunctions") internal class ClientConnectionImpl(private val session: ServerSession) : ClientConnection { override val sessionId: String get() = session.sessionId @@ -200,13 +212,34 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client } override suspend fun createMessage(request: CreateMessageRequest, options: RequestOptions?): CreateMessageResult { - with(request.params) { - logger.debug { - "Creating message with ${messages.size} messages, maxTokens=$maxTokens, " + - "temperature=$temperature, " + - "systemPrompt=${if (systemPrompt != null) "present" else "absent"}" + val caps = session.clientCapabilities + val params = request.params + + if (params.tools != null || params.toolChoice != null) { + requireNotNull(caps?.sampling?.tools) { + "Client did not advertise sampling.tools capability; cannot send " + + "tools/toolChoice in sampling/createMessage request." } } + + if (params.includeContext != null && params.includeContext != IncludeContext.None) { + if (caps?.sampling?.context == null) { + logger.warn { + "Client did not advertise sampling.context capability but server requested " + + "includeContext=${params.includeContext}. This is soft-deprecated and may be " + + "rejected by future spec versions." + } + } + } + + validateSamplingMessages(params.messages) + + logger.debug { + "Creating message with ${params.messages.size} messages, maxTokens=${params.maxTokens}, " + + "temperature=${params.temperature}, " + + "systemPrompt=${if (params.systemPrompt != null) "present" else "absent"}, " + + "tools=${params.tools?.size ?: 0}, toolChoice=${params.toolChoice?.mode}" + } logger.trace { "Full createMessage params: $request" } return request(request, options) } @@ -218,6 +251,11 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client override suspend fun createElicitation(request: ElicitRequest, options: RequestOptions?): ElicitResult { val params = request.params + if (params is ElicitRequestURLParams) { + require(session.clientCapabilities?.elicitation.supportsUrl) { + "Client did not advertise elicitation.url capability; cannot send a URL-mode elicitation." + } + } logger.debug { when (params) { is ElicitRequestFormParams -> @@ -230,7 +268,13 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client } } logger.trace { "ElicitRequest: $request" } - return request(request, options) + val result: ElicitResult = request(request, options) + if (params is ElicitRequestFormParams && result.action == ElicitResult.Action.Accept) { + // Peer-response backstop: accepted form content is client-supplied. Absent content is + // validated as an empty object so declared `required` properties still apply. + validateElicitationContent(params.requestedSchema, result.content ?: EmptyJsonObject) + } + return result } override suspend fun createElicitation( @@ -279,6 +323,9 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client } override suspend fun sendElicitationComplete(notification: ElicitationCompleteNotification) { + require(session.clientCapabilities?.elicitation.supportsUrl) { + "Client did not advertise elicitation.url capability; cannot send an elicitation completion notification." + } logger.debug { "Sending elicitation complete notification for: ${notification.params.elicitationId}" } notification(notification) } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt new file mode 100644 index 0000000..d976d34 --- /dev/null +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt @@ -0,0 +1,174 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema +import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema +import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema +import io.modelcontextprotocol.kotlin.sdk.types.LegacyTitledEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.PrimitiveSchemaDefinition +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.StringSchema +import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull +import kotlin.math.floor + +/** + * Validates accepted form-mode elicitation [content] against the supported constraints in + * [requestedSchema]: types, `required`, numeric bounds, string lengths, enum membership, and item + * counts. Undeclared properties are permitted. + * [StringSchema.format] values are treated as annotations and are not asserted. + * + * @throws McpException with [RPCError.ErrorCode.INVALID_PARAMS] listing the detected violations + * (at most one per property). + */ +internal fun validateElicitationContent(requestedSchema: ElicitRequestParams.RequestedSchema, content: JsonObject) { + val errors = buildList { + for (name in requestedSchema.required.orEmpty()) { + if (name !in content) add("must have required property '$name'") + } + for ((name, schema) in requestedSchema.properties) { + val value = content[name] ?: continue + checkProperty(name, schema, value)?.let(::add) + } + } + if (errors.isNotEmpty()) { + throw McpException( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "Elicitation response content does not match requested schema: ${errors.joinToString(", ")}", + ) + } +} + +private fun checkProperty(name: String, schema: PrimitiveSchemaDefinition, value: JsonElement): String? = + when (schema) { + is StringSchema -> { + val string = stringValue(value) ?: return "'$name' must be string" + val minLength = schema.minLength + val maxLength = schema.maxLength + val length = codePointCount(string) + when { + minLength != null && length < minLength -> + "'$name' must NOT have fewer than $minLength characters" + + maxLength != null && length > maxLength -> + "'$name' must NOT have more than $maxLength characters" + + else -> null + } + } + + is IntegerSchema -> { + val integer = integerValue(value) ?: return "'$name' must be integer" + checkRange(name, integer, schema.minimum?.toLong(), schema.maximum?.toLong()) + } + + is DoubleSchema -> { + val number = doubleValue(value) ?: return "'$name' must be number" + checkRange(name, number, schema.minimum, schema.maximum) + } + + is BooleanSchema -> if (booleanValue(value) == null) "'$name' must be boolean" else null + + is UntitledSingleSelectEnumSchema -> checkEnum(name, value, schema.enumValues) + + is TitledSingleSelectEnumSchema -> checkEnum(name, value, schema.oneOf.map { it.const }) + + is LegacyTitledEnumSchema -> checkEnum(name, value, schema.enumValues) + + is UntitledMultiSelectEnumSchema -> + checkMultiSelect(name, value, schema.items.enumValues, schema.minItems, schema.maxItems) + + is TitledMultiSelectEnumSchema -> + checkMultiSelect(name, value, schema.items.anyOf.map { it.const }, schema.minItems, schema.maxItems) + } + +// Keep Double statically typed so comparisons use IEEE 754 semantics; generic Comparable ordering +// treats -0.0 as less than 0.0. +private fun checkRange( + name: String, + value: Long, + minimum: Long?, + maximum: Long?, +): String? = when { + minimum != null && value < minimum -> "'$name' must be >= $minimum" + maximum != null && value > maximum -> "'$name' must be <= $maximum" + else -> null +} + +private fun checkRange( + name: String, + value: Double, + minimum: Double?, + maximum: Double?, +): String? = when { + minimum != null && value < minimum -> "'$name' must be >= $minimum" + maximum != null && value > maximum -> "'$name' must be <= $maximum" + else -> null +} + +private fun checkEnum(name: String, value: JsonElement, allowed: List): String? { + val string = stringValue(value) ?: return "'$name' must be string" + return if (string in allowed) null else "'$name' must be equal to one of the allowed values" +} + +private fun checkMultiSelect( + name: String, + value: JsonElement, + allowed: List, + minItems: Int?, + maxItems: Int?, +): String? { + val array = value as? JsonArray ?: return "'$name' must be array" + if (minItems != null && array.size < minItems) return "'$name' must NOT have fewer than $minItems items" + if (maxItems != null && array.size > maxItems) return "'$name' must NOT have more than $maxItems items" + for (element in array) { + val item = stringValue(element) ?: return "'$name' items must be string" + if (item !in allowed) return "'$name' items must be equal to one of the allowed values" + } + return null +} + +// JSON distinguishes the string "42" from the number 42; kotlinx's JsonPrimitive accessors parse +// string content too, so every helper checks isString to preserve that distinction. + +private fun stringValue(value: JsonElement): String? = (value as? JsonPrimitive)?.takeIf { it.isString }?.content + +private fun integerValue(value: JsonElement): Long? { + val primitive = (value as? JsonPrimitive)?.takeIf { !it.isString } ?: return null + primitive.longOrNull?.let { return it } + // JSON Schema's "integer" also matches numbers with a zero fractional part (e.g. 5.0, 1e2). + // Values that do not parse as Long are evaluated using their finite Double representation. + val number = primitive.doubleOrNull ?: return null + return if (number.isFinite() && number == floor(number)) number.toLong() else null +} + +private fun doubleValue(value: JsonElement): Double? = + (value as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() } + +private fun booleanValue(value: JsonElement): Boolean? = + (value as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull + +// JSON Schema string lengths count Unicode code points (RFC 8259 characters), not UTF-16 units, +// so a surrogate pair is one character. +private fun codePointCount(string: String): Int { + var count = 0 + var index = 0 + while (index < string.length) { + if (string[index].isHighSurrogate() && index + 1 < string.length && string[index + 1].isLowSurrogate()) { + index++ + } + index++ + count++ + } + return count +} diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt index ce55f3e..a892c6c 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt @@ -64,6 +64,7 @@ public data class RegisteredResource( * A registered resource template with its associated read handler. * * @property resourceTemplate The [ResourceTemplate] definition (RFC 6570 URI template). + * @property matcher Pre-built matcher used to test incoming URIs against [resourceTemplate]. * @property readHandler A suspend function invoked when a client reads a URI that matches * this template. The second parameter contains the URI variables extracted from the match. */ diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureNotificationService.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureNotificationService.kt index d996b2d..42c2b8f 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureNotificationService.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureNotificationService.kt @@ -38,6 +38,7 @@ private sealed class NotificationEvent(open val timestamp: Long) /** * Represents an event for a notification. * + * @property timestamp Timestamp at which this event was created. * @property notification The notification associated with the event. */ private class SendEvent(override val timestamp: Long, val notification: Notification) : NotificationEvent(timestamp) @@ -340,7 +341,7 @@ internal class FeatureNotificationService( emitJobs.getAndUpdate { it.add(job) } // Register completion - job.invokeOnCompletion { + job.invokeOnCompletion { _ -> emitJobs.getAndUpdate { it.remove(job) } } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistry.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistry.kt index 0ed6558..142d59c 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistry.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistry.kt @@ -20,9 +20,12 @@ internal interface FeatureListener { * A generic registry for managing features of a specified type. This class provides thread-safe * operations for adding, removing, and retrieving features from the registry. * + * Feature keys are unique: [add] and [addAll] reject keys that are already registered. + * To replace a feature, [remove] it first and add the new one. + * * @param T The type of the feature, constrained to implement the [Feature] interface. * @param featureType A string description of the type of feature being managed. - * Used primarily for logging purposes. + * Used for logging and error messages. */ internal class FeatureRegistry(private val featureType: String) { @@ -44,11 +47,11 @@ internal class FeatureRegistry(private val featureType: String) { private val listeners = atomic(persistentListOf()) internal fun addListener(listener: FeatureListener) { - listeners.update { it.add(listener) } + listeners.update { it.adding(listener) } } internal fun removeListener(listener: FeatureListener) { - listeners.update { it.remove(listener) } + listeners.update { it.removing(listener) } } /** @@ -58,27 +61,42 @@ internal class FeatureRegistry(private val featureType: String) { */ internal fun add(feature: T) { logger.info { "Adding $featureType: \"${feature.key}\"" } - val oldMap = registry.getAndUpdate { current -> current.put(feature.key, feature) } - val oldFeature = oldMap[feature.key] + registry.update { current -> + require(!current.containsKey(feature.key)) { + "$featureType \"${feature.key}\" is already registered. Remove it first to replace it." + } + current.putting(feature.key, feature) + } logger.info { "Added $featureType: \"${feature.key}\"" } - notifyFeatureUpdated(oldFeature, feature) + notifyFeatureUpdated(null, feature) } /** * Adds the given list of features to the registry. Each feature is mapped by its key - * and added to the current registry. + * and added to the current registry. The operation is all-or-nothing: if any key is + * rejected, no features are added. * * @param features The list of features to add to the registry. */ internal fun addAll(features: List) { logger.info { "Adding ${featureType}s: ${features.size}" } - val oldMap = registry.getAndUpdate { current -> current.putAll(features.associateBy { it.key }) } + val newEntries = features.associateBy { it.key } + require(newEntries.size == features.size) { + val duplicated = features.groupingBy { it.key }.eachCount().filterValues { it > 1 }.keys + "Duplicate $featureType keys in batch: $duplicated" + } + registry.update { current -> + val conflicting = newEntries.keys.filter { current.containsKey(it) } + require(conflicting.isEmpty()) { + "${featureType}s already registered: $conflicting. Remove them first to replace them." + } + current.puttingAll(newEntries) + } logger.info { "Added ${featureType}s: ${features.size}" } for (feature in features) { - val oldFeature = oldMap[feature.key] - notifyFeatureUpdated(oldFeature, feature) + notifyFeatureUpdated(null, feature) } } @@ -90,7 +108,7 @@ internal class FeatureRegistry(private val featureType: String) { */ internal fun remove(key: FeatureKey): Boolean { logger.info { "Removing $featureType: \"$key\"" } - val oldMap = registry.getAndUpdate { current -> current.remove(key) } + val oldMap = registry.getAndUpdate { current -> current.removing(key) } val removedFeature = oldMap[key] val removed = removedFeature != null diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/HostValidation.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/HostValidation.kt new file mode 100644 index 0000000..ef010fe --- /dev/null +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/HostValidation.kt @@ -0,0 +1,195 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.parseUrl +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.RouteScopedPlugin +import io.ktor.server.application.createRouteScopedPlugin +import io.ktor.server.request.header +import io.ktor.server.response.respondText +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.RPCError + +/** + * Default list of hostnames allowed for localhost DNS rebinding protection. + * Matches the TypeScript SDK's `localhostAllowedHostnames()`. + */ +internal val LOCALHOST_ALLOWED_HOSTS: List = listOf("localhost", "127.0.0.1", "[::1]") + +/** + * Default list of `Origin` values allowed for localhost DNS rebinding protection. + * + * Mirrors [LOCALHOST_ALLOWED_HOSTS] but carries a scheme so the values parse as URLs: + * [extractOriginHost] rejects schemeless input. Comparison is hostname-only and + * scheme-agnostic, so these entries also match `https://` origins on the same host. + */ +internal val LOCALHOST_ALLOWED_ORIGINS: List = + listOf("http://localhost", "http://127.0.0.1", "http://[::1]") + +/** + * Characters that are valid in a URL but must not appear in an HTTP `Host` header. + * Rejecting them prevents the parser from accepting malformed values + * (e.g. `evil.com@localhost`, `host/path`) that a generic URL parser would silently allow. + */ +private val FORBIDDEN_HOST_CHARS: CharArray = charArrayOf('/', '@', '?', '#') + +/** + * Extracts the hostname from a Host header value, stripping the port. + * + * Only accepts the strict `host [ ":" port ]` / `"[" ipv6 "]" [ ":" port ]` + * format defined by RFC 7230. Values containing URL-only characters + * (`/`, `@`, `?`, `#`) or whitespace are rejected. + * + * Examples: + * - `"localhost:3000"` → `"localhost"` + * - `"127.0.0.1:8080"` → `"127.0.0.1"` + * - `"[::1]:3000"` → `"[::1]"` + * - `"example.com"` → `"example.com"` + * - `"evil.com@localhost"` → `null` + * + * @return the hostname, or `null` if the value is blank, malformed, or contains forbidden characters. + */ +internal fun extractHostname(hostHeader: String): String? = when { + hostHeader.isBlank() -> null + + hostHeader.any { it in FORBIDDEN_HOST_CHARS || it.isWhitespace() } -> null + + hostHeader.startsWith("[") -> { + val end = hostHeader.indexOf(']') + when { + end <= 1 -> null + isValidPortSuffix(hostHeader.substring(end + 1)) -> hostHeader.substring(0, end + 1) + else -> null + } + } + + else -> { + val colon = hostHeader.indexOf(':') + when { + colon < 0 -> hostHeader + colon == 0 -> null + isValidPortSuffix(hostHeader.substring(colon)) -> hostHeader.substring(0, colon) + else -> null + } + } +} + +/** + * Checks that [tail] is either empty (no port part) or `":"` followed by zero or more digits, + * matching RFC 7230 §3.2.3 where `port = *DIGIT`. Non-empty non-digit characters are rejected. + */ +private fun isValidPortSuffix(tail: String): Boolean = when { + tail.isEmpty() -> true + tail[0] != ':' -> false + else -> tail.substring(1).all { it.isDigit() } +} + +/** + * Extracts the hostname from an `Origin` header value. + * + * Uses Ktor's [parseUrl] to avoid reimplementing RFC 6454 parsing. + * Scheme, port, userinfo, and path are discarded — only the host matters for + * DNS-rebinding protection. The result is **not** normalized to lower-case; + * callers should apply [String.lowercase] if they need case-insensitive comparison. + * + * @return the hostname, or `null` if [origin] cannot be parsed or has no host part. + */ +internal fun extractOriginHost(origin: String): String? = parseUrl(origin)?.host?.takeIf(String::isNotEmpty) + +/** + * Configuration for the [DnsRebindingProtection] Ktor route-scoped plugin. + * + * @property allowedHosts List of hostnames allowed in the `Host` header. + * Comparison is port-agnostic and case-insensitive. + * Defaults to `localhost`, `127.0.0.1`, `[::1]`. + * An empty list will reject **all** requests. + * @property allowedOrigins Optional list of allowed `Origin` values. Entries are parsed as URLs + * (via [parseUrl]) and compared by **hostname only** — scheme and port are ignored. + * If `null`, origin validation is disabled. + * If configured, requests **with** an `Origin` header whose hostname is not in the list + * are rejected, but requests **without** an `Origin` header are allowed (non-browser clients). + */ +public class DnsRebindingProtectionConfig { + public var allowedHosts: List = LOCALHOST_ALLOWED_HOSTS + public var allowedOrigins: List? = null +} + +/** + * Ktor route-scoped plugin that validates `Host` and `Origin` headers + * to protect against DNS rebinding attacks. + * + * Install on a route to intercept all requests **before** handlers: + * ```kotlin + * route("/mcp") { + * install(DnsRebindingProtection) { + * allowedHosts = listOf("myapp.com", "localhost") + * } + * // handlers... + * } + * ``` + */ +public val DnsRebindingProtection: RouteScopedPlugin = + createRouteScopedPlugin( + "MCP-DnsRebindingProtection", + ::DnsRebindingProtectionConfig, + ) { + val hosts: Set = pluginConfig.allowedHosts.mapTo(mutableSetOf()) { + extractHostname(it)?.lowercase() + ?: error("Invalid host in DnsRebindingProtection.allowedHosts: '$it'") + } + val originHosts: Set? = pluginConfig.allowedOrigins?.mapTo(mutableSetOf()) { + extractOriginHost(it)?.lowercase() + ?: error("Invalid origin in DnsRebindingProtection.allowedOrigins: '$it'") + } + + onCall { call -> + val hostHeader = call.request.header(HttpHeaders.Host) + val hostname = hostHeader?.let { extractHostname(it) }?.lowercase() + + if (hostname == null) { + call.rejectDnsValidation("Invalid Host header: (malformed or missing)") + return@onCall + } + if (hostname !in hosts) { + call.rejectDnsValidation("Invalid Host: $hostname") + return@onCall + } + + if (originHosts != null) { + val originHeader = call.request.header(HttpHeaders.Origin) + // Allow requests without Origin (non-browser clients cannot perform DNS rebinding) + if (originHeader != null) { + val originHost = extractOriginHost(originHeader)?.lowercase() + if (originHost == null) { + call.rejectDnsValidation("Invalid Origin header: (unparseable)") + return@onCall + } + if (originHost !in originHosts) { + call.rejectDnsValidation("Invalid Origin host: $originHost") + return@onCall + } + } + } + } + } + +/** + * Responds with a 403 Forbidden JSON-RPC error without requiring ContentNegotiation. + */ +private suspend fun ApplicationCall.rejectDnsValidation(message: String) { + val error = JSONRPCError( + id = null, + error = RPCError( + code = -32_000, + message = message, + ), + ) + respondText( + McpJson.encodeToString(error), + ContentType.Application.Json, + HttpStatusCode.Forbidden, + ) +} diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt index 806a479..3127499 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt @@ -1,8 +1,7 @@ -@file:Suppress("TooManyFunctions") - 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 @@ -22,8 +21,10 @@ import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.route import io.ktor.server.routing.routing +import io.ktor.server.sse.Heartbeat import io.ktor.server.sse.SSE import io.ktor.server.sse.ServerSSESession +import io.ktor.server.sse.heartbeat import io.ktor.server.sse.sse import io.ktor.utils.io.KtorDsl import io.modelcontextprotocol.kotlin.sdk.types.RPCError @@ -39,14 +40,31 @@ private val logger = KotlinLogging.logger {} * Use [Application.mcp] if you want SSE to be installed automatically. * * @param path the URL path to register the SSE endpoint. + * @param enableDnsRebindingProtection whether to install [DnsRebindingProtection] on this route. Defaults to `true`. + * @param allowedHosts hostnames allowed in the `Host` header. Defaults to `localhost`, `127.0.0.1`, `[::1]`. + * @param allowedOrigins origins allowed in the `Origin` header, compared by hostname only + * (scheme and port are ignored). Requests without an `Origin` header are allowed. + * When `null` while the localhost host defaults are in effect (no custom `allowedHosts`), + * the `Origin` header is validated against `localhost`, `127.0.0.1`, `[::1]`. + * With custom `allowedHosts`, `null` skips origin validation. + * @param maxRequestBodySize maximum allowed size, in bytes, of an incoming POST body; larger requests are + * rejected with `413 Payload Too Large`. Defaults to 4 MiB. * @param block factory block with access to the [ServerSSESession] * that creates and returns the [Server] to handle the connection. * @throws IllegalStateException if the [SSE] plugin is not installed. */ @KtorDsl -public fun Route.mcp(path: String, block: ServerSSESession.() -> Server) { +@Suppress("LongParameterList") +public fun Route.mcp( + path: String, + enableDnsRebindingProtection: Boolean = true, + allowedHosts: List? = null, + allowedOrigins: List? = null, + maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, + block: ServerSSESession.() -> Server, +) { route(path) { - mcp(block) + mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block) } } @@ -57,12 +75,28 @@ public fun Route.mcp(path: String, block: ServerSSESession.() -> Server) { * **Precondition:** the [SSE] plugin must be installed on the application before calling this function. * Use [Application.mcp] if you want SSE to be installed automatically. * + * @param enableDnsRebindingProtection whether to install [DnsRebindingProtection] on this route. Defaults to `true`. + * @param allowedHosts hostnames allowed in the `Host` header. Defaults to `localhost`, `127.0.0.1`, `[::1]`. + * @param allowedOrigins origins allowed in the `Origin` header, compared by hostname only + * (scheme and port are ignored). Requests without an `Origin` header are allowed. + * When `null` while the localhost host defaults are in effect (no custom `allowedHosts`), + * the `Origin` header is validated against `localhost`, `127.0.0.1`, `[::1]`. + * With custom `allowedHosts`, `null` skips origin validation. + * @param maxRequestBodySize maximum allowed size, in bytes, of an incoming POST body; larger requests are + * rejected with `413 Payload Too Large`. Defaults to 4 MiB. * @param block factory block with access to the [ServerSSESession] * that creates and returns the [Server] to handle the connection. * @throws IllegalStateException if the [SSE] plugin is not installed. */ @KtorDsl -public fun Route.mcp(block: ServerSSESession.() -> Server) { +@Suppress("LongParameterList") +public fun Route.mcp( + enableDnsRebindingProtection: Boolean = true, + allowedHosts: List? = null, + allowedOrigins: List? = null, + maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, + block: ServerSSESession.() -> Server, +) { try { plugin(SSE) } catch (e: MissingApplicationPluginException) { @@ -74,10 +108,12 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) { ) } + installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins) + val transportManager = TransportManager() sse { - mcpSseEndpoint("", transportManager, block) + mcpSseEndpoint("", transportManager, maxRequestBodySize, block) } post { @@ -93,22 +129,43 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) { * Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation] * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE]. * + * @param enableDnsRebindingProtection whether to install [DnsRebindingProtection] on this route. Defaults to `true`. + * @param allowedHosts hostnames allowed in the `Host` header. Defaults to `localhost`, `127.0.0.1`, `[::1]`. + * @param allowedOrigins origins allowed in the `Origin` header, compared by hostname only + * (scheme and port are ignored). Requests without an `Origin` header are allowed. + * When `null` while the localhost host defaults are in effect (no custom `allowedHosts`), + * the `Origin` header is validated against `localhost`, `127.0.0.1`, `[::1]`. + * With custom `allowedHosts`, `null` skips origin validation. + * @param maxRequestBodySize maximum allowed size, in bytes, of an incoming POST body; larger requests are + * rejected with `413 Payload Too Large`. Defaults to 4 MiB. * @param block factory block with access to the [ServerSSESession] * that creates and returns the [Server] to handle the connection. */ @KtorDsl -public fun Application.mcp(block: ServerSSESession.() -> Server) { +@Suppress("LongParameterList") +public fun Application.mcp( + enableDnsRebindingProtection: Boolean = true, + allowedHosts: List? = null, + allowedOrigins: List? = null, + maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, + block: ServerSSESession.() -> Server, +) { installMcpContentNegotiation() install(SSE) routing { - mcp(block) + mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block) } } +@Suppress("LongParameterList") private fun Application.mcpStreamableHttp( path: String = "/mcp", + enableDnsRebindingProtection: Boolean, + allowedHosts: List?, + allowedOrigins: List?, configuration: StreamableHttpServerTransport.Configuration, + sseHeartbeatConfig: (Heartbeat.() -> Unit)?, block: RoutingContext.() -> Server, ) { installMcpContentNegotiation() @@ -118,6 +175,8 @@ private fun Application.mcpStreamableHttp( routing { route(path) { + installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins) + // Set Mcp-Session-Id on GET responses before Ktor's sse {} commits headers. intercept(ApplicationCallPipeline.Plugins) { if (context.request.httpMethod == HttpMethod.Get) { @@ -130,6 +189,7 @@ private fun Application.mcpStreamableHttp( sse { val transport = existingStreamableTransport(call, transportManager) ?: return@sse + sseHeartbeatConfig?.let { config -> heartbeat(config) } transport.handleRequest(this, call) } @@ -162,48 +222,59 @@ private fun Application.mcpStreamableHttp( * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE]. * * @param path The base path for the MCP Streamable HTTP endpoint. Defaults to "/mcp". - * @param enableDnsRebindingProtection Enables DNS rebinding attack protection for the endpoint. Defaults to false. - * @param allowedHosts A list of hostnames allowed to access the endpoint. If `null`, no restrictions are applied. - * @param allowedOrigins A list of origins allowed to perform cross-origin requests (CORS). - * If `null`, no restrictions are applied. + * @param enableDnsRebindingProtection Enables DNS rebinding attack protection for the endpoint. Defaults to `true`. + * @param allowedHosts A list of hostnames allowed to access the endpoint. + * If `null` and DNS rebinding protection is enabled, defaults to `localhost`, `127.0.0.1`, `[::1]`. + * @param allowedOrigins A list of allowed `Origin` header values, compared by hostname only + * (scheme and port are ignored). Requests without an `Origin` header are allowed. + * When `null` while the localhost host defaults are in effect (no custom `allowedHosts`), + * the `Origin` header is validated against `localhost`, `127.0.0.1`, `[::1]`. + * With custom `allowedHosts`, `null` skips origin validation. * @param eventStore An optional [EventStore] instance to enable resumable event stream functionality. * Allows storing and replaying events. + * @param sseHeartbeatConfig The heartbeat configuration option for SSE connections. `null` means no heartbeat is sent. * @param block factory block with access to the [RoutingContext] (for reading request headers) * that creates and returns the [Server] to handle the connection. */ @KtorDsl -@Suppress("LongParameterList") public fun Application.mcpStreamableHttp( path: String = "/mcp", - enableDnsRebindingProtection: Boolean = false, + enableDnsRebindingProtection: Boolean = true, allowedHosts: List? = null, allowedOrigins: List? = null, eventStore: EventStore? = null, + sseHeartbeatConfig: (Heartbeat.() -> Unit)? = null, block: RoutingContext.() -> Server, ) { mcpStreamableHttp( path = path, + enableDnsRebindingProtection = enableDnsRebindingProtection, + allowedHosts = allowedHosts, + allowedOrigins = allowedOrigins, configuration = StreamableHttpServerTransport.Configuration( - enableDnsRebindingProtection = enableDnsRebindingProtection, - allowedHosts = allowedHosts, - allowedOrigins = allowedOrigins, eventStore = eventStore, enableJsonResponse = true, ), + sseHeartbeatConfig = sseHeartbeatConfig, block = block, ) } +@Suppress("LongParameterList") private fun Application.mcpStatelessStreamableHttp( path: String = "/mcp", + enableDnsRebindingProtection: Boolean, + allowedHosts: List?, + allowedOrigins: List?, configuration: StreamableHttpServerTransport.Configuration, block: RoutingContext.() -> Server, ) { installMcpContentNegotiation() - install(SSE) routing { route(path) { + installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins) + post { mcpStatelessStreamableHttpEndpoint( configuration = configuration, @@ -211,18 +282,10 @@ private fun Application.mcpStatelessStreamableHttp( ) } get { - call.reject( - HttpStatusCode.MethodNotAllowed, - RPCError.ErrorCode.CONNECTION_CLOSED, - "Method not allowed.", - ) + call.rejectUnsupportedMethod() } delete { - call.reject( - HttpStatusCode.MethodNotAllowed, - RPCError.ErrorCode.CONNECTION_CLOSED, - "Method not allowed.", - ) + call.rejectUnsupportedMethod() } } } @@ -233,48 +296,79 @@ private fun Application.mcpStatelessStreamableHttp( * over _stateless_ [Streamable HTTP Transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) * * Sets up an HTTP POST endpoint at [path]. GET and DELETE requests return 405 Method Not Allowed. - * Simple request/response pairs are returned as JSON (not SSE streams). + * Every request/response pair is returned as JSON, so this endpoint opens no SSE stream and + * offers no resumability. Use [mcpStreamableHttp] when either is required. * * Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation] - * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE]. + * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson]. * * @param path The URL path where the server listens for incoming JSON-RPC requests. Defaults to "/mcp". - * @param enableDnsRebindingProtection Determines whether DNS rebinding protection is enabled. Defaults to `false`. - * @param allowedHosts A list of allowed hostnames. If null, host filtering is disabled. - * @param allowedOrigins A list of allowed origins for CORS. If null, origin filtering is disabled. - * @param eventStore An optional [EventStore] implementation to provide resumability and event replay support. + * @param enableDnsRebindingProtection Determines whether DNS rebinding protection is enabled. Defaults to `true`. + * @param allowedHosts A list of allowed hostnames. If `null` and DNS rebinding protection is enabled, + * defaults to `localhost`, `127.0.0.1`, `[::1]`. + * @param allowedOrigins A list of allowed `Origin` header values, compared by hostname only + * (scheme and port are ignored). Requests without an `Origin` header are allowed. + * If `null`, origin validation is disabled. * @param block factory block with access to the [RoutingContext] (for reading request headers) * that creates and returns the [Server] to handle the connection. */ @KtorDsl -@Suppress("LongParameterList") public fun Application.mcpStatelessStreamableHttp( path: String = "/mcp", - enableDnsRebindingProtection: Boolean = false, + enableDnsRebindingProtection: Boolean = true, allowedHosts: List? = null, allowedOrigins: List? = null, - eventStore: EventStore? = null, block: RoutingContext.() -> Server, ) { mcpStatelessStreamableHttp( path = path, + enableDnsRebindingProtection = enableDnsRebindingProtection, + allowedHosts = allowedHosts, + allowedOrigins = allowedOrigins, configuration = StreamableHttpServerTransport.Configuration( - enableDnsRebindingProtection = enableDnsRebindingProtection, - allowedHosts = allowedHosts, - allowedOrigins = allowedOrigins, - eventStore = eventStore, enableJsonResponse = true, ), block = block, ) } +/** + * Retained only so that existing call sites get a migration hint instead of an unresolved parameter name. + * + * @param eventStore never consulted by a stateless endpoint. Resumption is driven by a `GET` carrying + * `Last-Event-ID`, and this endpoint answers `GET` with `405 Method Not Allowed`, so no stream + * exists to store events on or replay them to. Use [mcpStreamableHttp] when you need resumability. + */ +@Deprecated( + "Use mcpStatelessStreamableHttp without eventStore.", + ReplaceWith("mcpStatelessStreamableHttp(path, enableDnsRebindingProtection, allowedHosts, allowedOrigins, block)"), + DeprecationLevel.ERROR, +) +@Suppress("UnusedParameter") +public fun Application.mcpStatelessStreamableHttp( + path: String = "/mcp", + enableDnsRebindingProtection: Boolean = true, + allowedHosts: List? = null, + allowedOrigins: List? = null, + eventStore: EventStore?, + block: RoutingContext.() -> Server, +) { + mcpStatelessStreamableHttp( + path = path, + enableDnsRebindingProtection = enableDnsRebindingProtection, + allowedHosts = allowedHosts, + allowedOrigins = allowedOrigins, + block = block, + ) +} + private suspend fun ServerSSESession.mcpSseEndpoint( postEndpoint: String, transportManager: TransportManager, + maxRequestBodySize: Long, block: ServerSSESession.() -> Server, ) { - val transport = mcpSseTransport(postEndpoint, transportManager) + val transport = mcpSseTransport(postEndpoint, transportManager, maxRequestBodySize) val server = block() @@ -293,8 +387,9 @@ private suspend fun ServerSSESession.mcpSseEndpoint( private fun ServerSSESession.mcpSseTransport( postEndpoint: String, transportManager: TransportManager, + maxRequestBodySize: Long, ): SseServerTransport { - val transport = SseServerTransport(postEndpoint, this) + val transport = SseServerTransport(postEndpoint, this, maxRequestBodySize) transportManager.addTransport(transport.sessionId, transport) logger.info { "New SSE connection established and stored with sessionId: ${transport.sessionId}" } @@ -312,11 +407,17 @@ private suspend fun RoutingContext.mcpStatelessStreamableHttpEndpoint( logger.info { "New stateless StreamableHttp connection established without sessionId" } val server = block() - server.onClose { logger.info { "Server connection closed without sessionId" } } - server.createSession(transport) + val session = server.createSession(transport) - transport.handleRequest(null, this.call) - logger.debug { "Server connected to transport without sessionId" } + try { + transport.handleRequest(null, this.call) + logger.debug { "Server connected to transport without sessionId" } + } finally { + // A stateless session serves exactly one request: close it so the server's + // session registry and notification subscriptions do not grow unboundedly. + session.close() + logger.debug { "Stateless session closed after request completion" } + } } private suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportManager) { @@ -338,6 +439,16 @@ private suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportMa logger.trace { "Message handled for sessionId: $sessionId" } } +/** A stateless endpoint serves POST only, offering neither an SSE stream to open nor a session to delete. */ +private suspend fun ApplicationCall.rejectUnsupportedMethod() { + response.header(HttpHeaders.Allow, HttpMethod.Post.value) + reject( + HttpStatusCode.MethodNotAllowed, + RPCError.ErrorCode.CONNECTION_CLOSED, + "Method not allowed.", + ) +} + private fun ApplicationRequest.sessionId(): String? = header(MCP_SESSION_ID_HEADER) private suspend fun existingStreamableTransport( @@ -399,3 +510,14 @@ private suspend fun RoutingContext.streamableTransport( return transport } + +private fun Route.installDnsRebindingProtection(enabled: Boolean, hosts: List?, origins: List?) { + if (!enabled) return + install(DnsRebindingProtection) { + allowedHosts = hosts ?: LOCALHOST_ALLOWED_HOSTS + // Secure-by-default: when relying on the localhost host defaults, validate the Origin + // header against localhost too, so a request with a valid Host but a hostile Origin + // (e.g. a DNS-rebinding page) is rejected. Callers with custom hosts opt in explicitly. + allowedOrigins = origins ?: LOCALHOST_ALLOWED_ORIGINS.takeIf { hosts == null } + } +} diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBody.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBody.kt new file mode 100644 index 0000000..0cc4143 --- /dev/null +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBody.kt @@ -0,0 +1,34 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.receiveChannel +import io.ktor.utils.io.readRemaining +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.readString + +/** Default maximum size (in bytes) for an incoming HTTP request body: 4 MiB. */ +internal const val DEFAULT_MAX_REQUEST_BODY_SIZE: Long = 4L * 1024 * 1024 + +/** Signals that an incoming request body exceeded the maximum size a transport is willing to read. */ +internal class RequestBodyTooLargeException(val maxBodySize: Long) : + IOException("Request body exceeds the maximum of $maxBodySize bytes.") + +/** + * Reads the request body as a UTF-8 string, rejecting bodies larger than [maxBytes]. + * + * At most `maxBytes + 1` bytes are ever pulled from the request channel, so an oversized or unframed + * body cannot exhaust memory regardless of the (untrusted) `Content-Length` header. + * + * @throws RequestBodyTooLargeException if the body exceeds [maxBytes] + */ +internal suspend fun ApplicationCall.receiveTextWithLimit(maxBytes: Long): String { + val buffer = Buffer() + val readLimit = if (maxBytes == Long.MAX_VALUE) maxBytes else maxBytes + 1 + val read = receiveChannel().readRemaining(readLimit).transferTo(buffer) + if (read > maxBytes) { + buffer.clear() + throw RequestBodyTooLargeException(maxBytes) + } + return buffer.readString() +} diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt index f8c6581..f525ec2 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt @@ -5,7 +5,6 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.encodeURLPath import io.ktor.server.application.ApplicationCall import io.ktor.server.request.contentType -import io.ktor.server.request.receiveText import io.ktor.server.response.respondText import io.ktor.server.sse.ServerSSESession import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport @@ -27,13 +26,24 @@ internal const val SESSION_ID_PARAM = "sessionId" * * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. * - * @property sessionId unique identifier for this transport session, generated randomly on creation + * @param endpoint relative or absolute URL the client will POST messages to + * @param session active SSE session used to deliver server-to-client events + * @param maxRequestBodySize maximum allowed size, in bytes, of an incoming POST body; larger requests are + * rejected with `413 Payload Too Large` without being buffered in full. Defaults to 4 MiB. */ @OptIn(ExperimentalAtomicApi::class) -public class SseServerTransport(private val endpoint: String, private val session: ServerSSESession) : - AbstractTransport() { +public class SseServerTransport( + private val endpoint: String, + private val session: ServerSSESession, + private val maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, +) : AbstractTransport() { + init { + require(maxRequestBodySize > 0) { "maxRequestBodySize must be greater than 0" } + } + private val initialized: AtomicBoolean = AtomicBoolean(false) + /** Unique identifier for this transport session, generated randomly on creation. */ @OptIn(ExperimentalUuidApi::class) public val sessionId: String = Uuid.random().toString() @@ -75,6 +85,7 @@ public class SseServerTransport(private val endpoint: String, private val sessio val message = "SSE connection not established" call.respondText(message, status = HttpStatusCode.InternalServerError) _onError.invoke(IllegalStateException(message)) + return } val body = try { @@ -83,7 +94,13 @@ public class SseServerTransport(private val endpoint: String, private val sessio error("Unsupported content-type: $ct") } - call.receiveText() + call.receiveTextWithLimit(maxRequestBodySize) + } catch (e: CancellationException) { + throw e + } catch (e: RequestBodyTooLargeException) { + call.respondText(e.message ?: "Request body too large", status = HttpStatusCode.PayloadTooLarge) + _onError.invoke(e) + return } catch (e: Exception) { call.respondText("Invalid message: ${e.message}", status = HttpStatusCode.BadRequest) _onError.invoke(e) @@ -92,6 +109,8 @@ public class SseServerTransport(private val endpoint: String, private val sessio try { handleMessage(body) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { call.respondText("Error handling message $body: ${e.message}", status = HttpStatusCode.BadRequest) return @@ -108,6 +127,8 @@ public class SseServerTransport(private val endpoint: String, private val sessio try { val parsedMessage = McpJson.decodeFromString(message) _onMessage.invoke(parsedMessage) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { _onError.invoke(e) throw 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 new file mode 100644 index 0000000..8243171 --- /dev/null +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingValidation.kt @@ -0,0 +1,54 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage +import io.modelcontextprotocol.kotlin.sdk.types.ToolResultContent +import io.modelcontextprotocol.kotlin.sdk.types.ToolUseContent + +/** + * Validates tool_use / tool_result rules on the last two messages of [messages]. + * + * Only the boundary between the previous message and the final message matters, because + * earlier turns were already validated when they were appended; the sole freshly-built + * portion of a sampling request is its tail. + * + * Rules enforced: + * + * 1. If the last message contains any `tool_result` block, it MUST contain only + * `tool_result` blocks (no mixing with text/image/audio/tool_use). + * 2. If the last message contains any `tool_result`, the previous message MUST contain + * matching `tool_use` blocks. + * 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. + */ +internal fun validateSamplingMessages(messages: List) { + if (messages.isEmpty()) return + + val last = messages.last().content + val hasToolResult = last.any { it is ToolResultContent } + + val previous = messages.getOrNull(messages.size - 2)?.content.orEmpty() + val hasPreviousToolUse = previous.any { it is ToolUseContent } + + if (hasToolResult) { + require(last.all { it is ToolResultContent }) { + "The last message must contain only tool_result content if any is present" + } + require(hasPreviousToolUse) { + "tool_result blocks are not matching any tool_use from the previous message" + } + } + + 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 de219d4..6f7711f 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt @@ -1,6 +1,7 @@ package io.modelcontextprotocol.kotlin.sdk.server import io.github.oshai.kotlinlogging.KotlinLogging +import io.modelcontextprotocol.kotlin.sdk.server.utils.warnIfInvalidToolName import io.modelcontextprotocol.kotlin.sdk.shared.ProtocolOptions import io.modelcontextprotocol.kotlin.sdk.shared.RequestOptions import io.modelcontextprotocol.kotlin.sdk.shared.Transport @@ -45,15 +46,18 @@ import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations import io.modelcontextprotocol.kotlin.sdk.types.ToolExecution import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest +import io.modelcontextprotocol.kotlin.sdk.types.UrlElicitationRequiredException import io.modelcontextprotocol.kotlin.sdk.utils.MatchResult import io.modelcontextprotocol.kotlin.sdk.utils.PathSegmentTemplateMatcher import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcher import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcherFactory import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +import kotlin.coroutines.CoroutineContext import kotlin.jvm.JvmOverloads import kotlin.time.ExperimentalTime @@ -63,15 +67,21 @@ private val logger = KotlinLogging.logger {} * Configuration options for the MCP server. * * @property capabilities The capabilities this server supports. - * @property enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with clients. + * @param enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with clients. + * Forwarded to [ProtocolOptions.enforceStrictCapabilities]. * @property resourceTemplateMatcherFactory The factory used to create [ResourceTemplateMatcher] instances * for matching resource URIs against registered templates. Defaults to [PathSegmentTemplateMatcher.factory]. + * @param handlerCoroutineContext Coroutine context for inbound handlers. See [ProtocolOptions.handlerCoroutineContext]. */ public class ServerOptions( public val capabilities: ServerCapabilities, enforceStrictCapabilities: Boolean = true, public val resourceTemplateMatcherFactory: ResourceTemplateMatcherFactory = PathSegmentTemplateMatcher.factory, -) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) { + handlerCoroutineContext: CoroutineContext = Dispatchers.Default, +) : ProtocolOptions( + enforceStrictCapabilities = enforceStrictCapabilities, + handlerCoroutineContext = handlerCoroutineContext, +) { @JvmOverloads public constructor( capabilities: ServerCapabilities, @@ -91,13 +101,12 @@ public class ServerOptions( * Currently, after subscription to a resource, the server will NOT send the subscription confirmation * as this response schema is not defined in the protocol. * - * @param serverInfo Information about this server implementation (name, version). - * @param options Configuration options for the server. - * @param instructionsProvider Optional provider for instructions from the server to the client about how to use + * @property serverInfo Information about this server implementation (name, version). + * @property options Configuration options for the server. + * @property instructionsProvider Optional provider for instructions from the server to the client about how to use * this server. The provider is called each time a new session is started to support dynamic instructions. * @param block A block to configure the mcp server. */ -@Suppress("TooManyFunctions") public open class Server( protected val serverInfo: Implementation, protected val options: ServerOptions, @@ -303,7 +312,7 @@ public open class Server( logger.error { "Failed to add tool '${tool.name}': Server does not support tools capability" } "Server does not support tools capability. Enable it in ServerOptions." } - + warnIfInvalidToolName(tool.name) toolRegistry.add(RegisteredTool(tool, handler)) } @@ -311,9 +320,9 @@ public open class Server( * Registers a single tool. The client can then call this tool. * * @param name The name of the tool. - * @param title An optional human-readable name of the tool for display purposes. * @param description A human-readable description of what the tool does. * @param inputSchema The expected input schema for the tool. + * @param title An optional human-readable name of the tool for display purposes. * @param outputSchema The optional expected output schema for the tool. * @param toolAnnotations Optional additional tool information. * @param execution Optional execution-related properties, such as task-augmented execution support. @@ -321,7 +330,6 @@ public open class Server( * @param handler A suspend function that handles executing the tool when called by the client. * @throws IllegalStateException If the server does not support tools. */ - @Suppress("LongParameterList") public fun addTool( name: String, description: String, @@ -357,6 +365,7 @@ public open class Server( logger.error { "Failed to add tools: Server does not support tools capability" } "Server does not support tools capability." } + toolsToAdd.forEach { warnIfInvalidToolName(it.tool.name) } toolRegistry.addAll(toolsToAdd) } @@ -643,7 +652,6 @@ public open class Server( } // Execute the tool handler and catch any errors - @Suppress("TooGenericExceptionCaught") return try { logger.trace { "Executing tool ${requestParams.name} with input: ${requestParams.arguments}" } tool.run { @@ -651,6 +659,9 @@ public open class Server( } } catch (e: CancellationException) { throw e + } catch (e: UrlElicitationRequiredException) { + // Surface a required URL-mode elicitation as a JSON-RPC error (-32042) + throw e } catch (e: Exception) { logger.error(e) { "Error executing tool ${requestParams.name}" } CallToolResult( @@ -671,7 +682,10 @@ public open class Server( val prompt = promptRegistry.get(requestParams.name) ?: run { logger.error { "Prompt not found: ${requestParams.name}" } - throw IllegalArgumentException("Prompt not found: ${requestParams.name}") + throw McpException( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "Prompt not found: ${requestParams.name}", + ) } return prompt.run { session.clientConnection.messageProvider(request) diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt index 2f55c76..f20d3e1 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt @@ -39,17 +39,28 @@ import kotlin.uuid.Uuid private val logger = KotlinLogging.logger {} /** - * Represents a server session. + * Represents an active server-side session in the Model Context Protocol. * - * @property sessionId unique identifier for this session, generated randomly on creation + * Owns the server side of a single client connection on top of [Protocol]. Drives the + * initialize/initialized handshake, tracks the negotiated client capabilities and version, + * asserts capability requirements before dispatching requests and notifications, and exposes + * server-to-client operations such as sampling, root listing, elicitation, logging, and + * list-change notifications. + * + * Equality and hashing are based on [sessionId], so a session can be used as a key in + * routing structures. + * + * @property serverInfo server name and version reported to the client during initialization + * @param options server capabilities and protocol options used by [Protocol] + * @property instructions optional human-readable instructions for the client, sent in the initialize result */ -@Suppress("TooManyFunctions") public open class ServerSession( protected val serverInfo: Implementation, options: ServerOptions, protected val instructions: String?, ) : Protocol(options) { + /** Unique identifier for this session, generated on creation. */ @OptIn(ExperimentalUuidApi::class) public val sessionId: String = Uuid.random().toString() @@ -60,14 +71,10 @@ public open class ServerSession( private val _clientCapabilities: AtomicRef = atomic(null) private val _clientVersion: AtomicRef = atomic(null) - /** - * The client's reported capabilities after initialization. - */ + /** Capabilities reported by the client during initialization, or `null` before the handshake completes. */ public val clientCapabilities: ClientCapabilities? get() = _clientCapabilities.value - /** - * The client's version information after initialization. - */ + /** Client implementation information reported during initialization, or `null` before the handshake completes. */ public val clientVersion: Implementation? get() = _clientVersion.value /** @@ -101,10 +108,18 @@ public open class ServerSession( } } + override fun onInitializedNotification() { + // Enable concurrency before the user's onInitialized callback, which runs synchronously. + enableConcurrentDispatch() + } + internal val clientConnection: ClientConnection = ClientConnectionImpl(this) /** * Registers a callback to be invoked when the server has completed initialization. + * + * The callback must be synchronous and fast: it runs on the message-dispatch path for + * `notifications/initialized`, after concurrent dispatch has been enabled for the session. */ public fun onInitialized(block: () -> Unit) { val old = _onInitialized @@ -165,6 +180,12 @@ public open class ServerSession( } } + Defined.TasksGet, + Defined.TasksResult, + Defined.TasksList, + Defined.TasksCancel, + -> assertTasksCapabilityForMethod(method) + Defined.Ping -> { // No specific capability required } @@ -175,6 +196,28 @@ public open class ServerSession( } } + private fun assertTasksCapabilityForMethod(method: Method) { + val tasks = clientCapabilities?.tasks + ?: error("Client does not support tasks (required for ${method.value})") + when (method) { + Defined.TasksList -> { + if (tasks.list == null) { + error("Client does not support listing tasks (required for ${method.value})") + } + } + + Defined.TasksCancel -> { + if (tasks.cancel == null) { + error("Client does not support cancelling tasks (required for ${method.value})") + } + } + + else -> { + // TasksGet, TasksResult: base tasks capability suffices. + } + } + } + /** * Asserts that the server can handle the specified notification method. * @@ -218,6 +261,12 @@ public open class ServerSession( } } + Defined.NotificationsTasksStatus -> { + if (serverCapabilities.tasks == null) { + error("Server does not support tasks (required for ${method.value})") + } + } + Defined.NotificationsCancelled, Defined.NotificationsProgress, -> { @@ -372,12 +421,17 @@ public open class ServerSession( * Sends a message to the client requesting an elicitation. * This typically results in a form being displayed to the end user. * + * Accepted content is validated against [requestedSchema]. If content is absent, an empty + * object is used for validation only, so declared `required` properties still apply. The + * returned [ElicitResult] is not modified. + * * @param message The message for the elicitation to display. - * @param requestedSchema The schema requested by the client for the elicitation result. + * @param requestedSchema The schema the server requests for the elicitation result. * Influences the form displayed to the user. * @param options Optional request options. * @return The result of the elicitation request. * @throws IllegalStateException If the server or client does not support elicitation. + * @throws McpException If an accepted response does not match [requestedSchema]. */ public suspend fun createElicitation( message: String, diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt index 8fd46dd..9261be2 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt @@ -4,16 +4,27 @@ import io.github.oshai.kotlinlogging.KotlinLogging import io.modelcontextprotocol.kotlin.sdk.internal.IODispatcher import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer +import io.modelcontextprotocol.kotlin.sdk.shared.TooLongFrameException import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.McpDsl +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.RPCError import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedSendChannelException +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -23,195 +34,347 @@ import kotlinx.io.Source import kotlinx.io.buffered import kotlinx.io.readByteArray import kotlinx.io.writeString -import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.coroutines.CoroutineContext private const val READ_BUFFER_SIZE = 8192L /** - * A server transport that communicates with a client via standard I/O. + * Server-side MCP transport that exchanges JSON-RPC messages over arbitrary byte streams. * - * Reads from input [Source] and writes to output [Sink]. + * Reads framed messages from the supplied [Source] and writes framed messages to the supplied + * [Sink]. Three internal coroutines drive the pipeline: * - * @constructor Creates a new instance of [StdioServerTransport]. - * @param inputStream The input [Source] used to receive data. - * @param outputStream The output [Sink] used to send data. + * - **reader** — pulls bytes from the input source into the parsing buffer; runs on + * [Builder.ioDispatcher]. + * - **processor** — parses messages out of the buffer and invokes the registered message handler; + * runs on [Builder.handlerDispatcher] (defaults to [Dispatchers.Default]) so blocking handler + * code does not starve the I/O pool. + * - **writer** — serialises outbound messages and flushes them to the output sink; runs on + * [Builder.ioDispatcher]. + * + * Both internal channels are bounded, so a slow handler or slow output naturally back-pressures + * the upstream producer — [send] suspends when the outbound channel is full. + * + * Both explicit [close] and a natural EOF from the input perform a graceful drain: in-flight + * outbound messages are flushed before `onClose` fires, and the input source and output sink are + * released. + * + * Example: + * ```kotlin + * val transport = StdioServerTransport( + * input = System.`in`.asSource().buffered(), + * output = System.out.asSink().buffered(), + * ) { + * scope = myScope + * } + * transport.start() + * ``` */ @OptIn(ExperimentalAtomicApi::class) -public class StdioServerTransport(private val inputStream: Source, outputStream: Sink) : AbstractTransport() { +public class StdioServerTransport private constructor( + private val input: Source, + output: Sink, + private val scope: CoroutineScope?, + private val handlerDispatcher: CoroutineDispatcher, + private val ioDispatcher: CoroutineDispatcher, +) : AbstractTransport() { + + /** + * Creates a [StdioServerTransport] reading from [input] and writing to [output], with + * optional configuration applied through a [Builder] block. + * + * @param input source the transport reads JSON-RPC messages from + * @param output sink the transport writes JSON-RPC messages to + * @param block configuration applied to the underlying [Builder] + */ + public constructor( + input: Source, + output: Sink, + block: Builder.() -> Unit = {}, + ) : this(Builder(input, output).apply(block)) + + private constructor(builder: Builder) : this( + input = builder.input, + output = builder.output, + scope = builder.scope, + handlerDispatcher = builder.handlerDispatcher, + ioDispatcher = builder.ioDispatcher, + ) + + /** + * Creates a [StdioServerTransport] from the given input source and output sink with default + * configuration. Retained for binary compatibility; prefer the [Builder]-based constructor. + * + * @param inputStream source the transport reads JSON-RPC messages from + * @param outputStream sink the transport writes JSON-RPC messages to + */ + @Deprecated( + message = "Use StdioServerTransport(input, output) { ... } instead.", + replaceWith = ReplaceWith("StdioServerTransport(input = inputStream, output = outputStream)"), + level = DeprecationLevel.WARNING, + ) + public constructor(inputStream: Source, outputStream: Sink) : this( + input = inputStream, + output = outputStream, + scope = null, + handlerDispatcher = Dispatchers.Default, + ioDispatcher = IODispatcher.limitedParallelism(2), + ) private val logger = KotlinLogging.logger {} private val readBuffer = ReadBuffer() - private val initialized: AtomicBoolean = AtomicBoolean(false) - private var readingJob: Job? = null - private var sendingJob: Job? = null - private var processingJob: Job? = null + private val readChannel = Channel(Channel.BUFFERED) + private val writeChannel = Channel(Channel.BUFFERED) + private val outputSink = output.buffered() - private val coroutineContext: CoroutineContext = IODispatcher + SupervisorJob() - private val scope = CoroutineScope(coroutineContext) - private val readChannel = Channel(Channel.UNLIMITED) - private val writeChannel = Channel(Channel.UNLIMITED) - private val outputSink = outputStream.buffered() + private enum class State { New, Operational, Stopped } + private val state: AtomicReference = AtomicReference(State.New) + + private var readerJob: Job? = null + private var processorJob: Job? = null + private var writerJob: Job? = null + + private var effectiveScope: CoroutineScope? = null + private val ownsScope: Boolean = (scope == null) + + private val setupComplete = CompletableDeferred() + + /** + * Starts the reader, processor, and writer coroutines. Must be called exactly once before + * messages can be exchanged; subsequent calls throw. + */ override suspend fun start() { - if (!initialized.compareAndSet(expectedValue = false, newValue = true)) { - error("StdioServerTransport already started!") + if (!state.compareAndSet(State.New, State.Operational)) { + when (state.load()) { + State.Stopped -> error("StdioServerTransport is already closed!") + else -> error("StdioServerTransport already started!") + } } - // Launch a coroutine to read from stdin - readingJob = launchReadingJob() + try { + val resolvedScope = scope ?: CoroutineScope( + currentCoroutineContext() + IODispatcher + SupervisorJob(), + ) + effectiveScope = resolvedScope - // Launch a coroutine to process messages from readChannel - processingJob = launchProcessingJob() - - // Launch a coroutine to handle message sending - sendingJob = launchSendingJob() + readerJob = resolvedScope.launch( + ioDispatcher + CoroutineName("StdioServerTransport.reader"), + ) { readerPump() } + processorJob = resolvedScope.launch( + handlerDispatcher + CoroutineName("StdioServerTransport.processor"), + start = CoroutineStart.UNDISPATCHED, + ) { processorPump() } + writerJob = resolvedScope.launch( + ioDispatcher + CoroutineName("StdioServerTransport.writer"), + start = CoroutineStart.UNDISPATCHED, + ) { writerPump() } + } finally { + setupComplete.complete(Unit) + } } - private fun launchReadingJob(): Job { - val job = scope.launch { - val buf = Buffer() - @Suppress("TooGenericExceptionCaught") - try { - while (isActive) { - val bytesRead = inputStream.readAtMostTo(buf, READ_BUFFER_SIZE) - if (bytesRead == -1L) { - // EOF reached - break - } - if (bytesRead > 0) { - val chunk = buf.readByteArray() - readChannel.send(chunk) + private suspend fun readerPump() { + val buf = Buffer() + try { + while (currentCoroutineContext().isActive) { + val bytesRead = input.readAtMostTo(buf, READ_BUFFER_SIZE) + if (bytesRead == -1L) break + if (bytesRead > 0) { + val chunk = buf.readByteArray() + readChannel.send(chunk) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + if (state.load() == State.Stopped) { + logger.debug(e) { "Reader interrupted by close()" } + } else { + logger.error(e) { "Error reading from input source" } + _onError(e) + } + } finally { + readChannel.close() + } + } + + private suspend fun processorPump() { + try { + for (chunk in readChannel) { + readBuffer.append(chunk) + while (true) { + val message = try { + readBuffer.readMessage() + } catch (e: CancellationException) { + throw e + } catch (e: TooLongFrameException) { + throw e + } catch (e: Throwable) { + _onError(e) + null + } ?: break + try { + _onMessage(message) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.error(e) { "Error processing message" } + _onError(e) } } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - logger.error(e) { "Error reading from stdin" } - _onError.invoke(e) - } finally { - // Reached EOF or error, close connection - close() - } - } - job.invokeOnCompletion { cause -> - logJobCompletion("Message reading", cause) - } - return job - } - - private fun launchProcessingJob(): Job { - val job = scope.launch { - @Suppress("TooGenericExceptionCaught") - try { - for (chunk in readChannel) { - readBuffer.append(chunk) - processReadBuffer() - } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - _onError.invoke(e) - } - } - job.invokeOnCompletion { cause -> - logJobCompletion("Processing", cause) - } - return job - } - - private fun launchSendingJob(): Job { - val job = scope.launch { - @Suppress("TooGenericExceptionCaught") - try { - for (message in writeChannel) { - val json = serializeMessage(message) - outputSink.writeString(json) - outputSink.flush() - } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - logger.error(e) { "Error writing to stdout" } - _onError.invoke(e) - } - } - job.invokeOnCompletion { cause -> - logJobCompletion("Message sending", cause) - if (cause is CancellationException) { - readingJob?.cancel(cause) - } - } - return job - } - - private suspend fun processReadBuffer() { - @Suppress("TooGenericExceptionCaught") - while (true) { - val message = try { - readBuffer.readMessage() - } catch (e: Throwable) { - _onError.invoke(e) - null - } - - if (message == null) break - // Async invocation broke delivery order - try { - _onMessage.invoke(message) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - logger.error(e) { "Error processing message" } - _onError.invoke(e) } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + _onError(e) + } finally { + writeChannel.close() } } - private fun logJobCompletion(jobName: String, cause: Throwable?) { - when (cause) { - is CancellationException -> { - } - - null -> { - logger.debug { "$jobName job completed" } - } - - else -> { - logger.debug(cause) { "$jobName job completed exceptionally" } + private suspend fun writerPump() { + try { + for (message in writeChannel) { + val json = serializeMessage(message) + outputSink.writeString(json) + outputSink.flush() } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.error(e) { "Error writing to output sink" } + _onError(e) + } finally { + transitionToStoppedNaturally() } } + /** + * Closes the transport. When called after [start], waits for in-flight outbound messages to be + * flushed, releases the input source and output sink, cancels the internal scope when the + * transport owns it, and invokes `onClose`. When called before [start], transitions directly + * to the closed state without releasing the input/output or invoking `onClose` — the caller + * remains responsible for resources that were never handed off to a running transport. Safe to + * call multiple times and safe to race with [start]. + */ override suspend fun close() { - if (!initialized.compareAndSet(expectedValue = true, newValue = false)) return + var previous: State + do { + previous = state.load() + } while (previous != State.Stopped && !state.compareAndSet(previous, State.Stopped)) + + if (previous == State.New) { + setupComplete.complete(Unit) + return + } withContext(NonCancellable) { - writeChannel.close() - sendingJob?.cancelAndJoin() + setupComplete.await() - runCatching { - inputStream.close() - }.onFailure { logger.warn(it) { "Failed to close stdin" } } + if (previous == State.Stopped) { + writerJob?.join() + return@withContext + } - readingJob?.cancel() + runCatching { input.close() } + .onFailure { logger.warn(it) { "Failed to close input source" } } + readerJob?.cancel() readChannel.close() - - processingJob?.cancelAndJoin() - + processorJob?.cancelAndJoin() + writeChannel.close() + writerJob?.join() + runCatching { outputSink.close() } + .onFailure { logger.warn(it) { "Failed to close output sink" } } readBuffer.clear() - - runCatching { - outputSink.flush() - outputSink.close() - }.onFailure { logger.warn(it) { "Failed to close stdout" } } - + if (ownsScope) { + effectiveScope?.coroutineContext?.get(Job)?.cancel() + } invokeOnCloseCallback() } } + /** + * Queues [message] for the writer coroutine. Suspends when the outbound channel is full, + * applying back-pressure to the caller. Throws [McpException] with + * [RPCError.ErrorCode.CONNECTION_CLOSED] if the transport has not been started or has + * already closed. + * + * @param message JSON-RPC message to send + * @param options transport-specific send options; ignored by this transport + */ override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { - writeChannel.send(message) + when (state.load()) { + State.New -> throw McpException( + code = RPCError.ErrorCode.CONNECTION_CLOSED, + message = "Transport is not started", + ) + + State.Stopped -> throw McpException( + code = RPCError.ErrorCode.CONNECTION_CLOSED, + message = "Transport is closed", + ) + + State.Operational -> Unit + } + try { + writeChannel.send(message) + } catch (e: CancellationException) { + throw e + } catch (e: ClosedSendChannelException) { + throw McpException( + code = RPCError.ErrorCode.CONNECTION_CLOSED, + message = "Transport is closed", + cause = e, + ) + } + } + + private fun transitionToStoppedNaturally() { + if (!state.compareAndSet(State.Operational, State.Stopped)) return + runCatching { input.close() } + .onFailure { logger.warn(it) { "Failed to close input source" } } + runCatching { outputSink.close() } + .onFailure { logger.warn(it) { "Failed to close output sink" } } + readBuffer.clear() + if (ownsScope) { + // Non-null in practice: writer launches after effectiveScope is published. + effectiveScope?.coroutineContext?.get(Job)?.cancel() + } + invokeOnCloseCallback() + } + + /** + * Configuration builder for [StdioServerTransport]. Used via the + * `StdioServerTransport(input, output) { ... }` factory; the I/O endpoints are supplied + * positionally, while [scope], [handlerDispatcher], and [ioDispatcher] are configurable + * inside the block. + * + * Example: + * ```kotlin + * StdioServerTransport(input, output) { + * scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + * handlerDispatcher = myHandlerDispatcher + * ioDispatcher = Dispatchers.IO + * } + * ``` + * + * @property scope Optional caller-supplied [CoroutineScope]. When non-null, the transport's + * pipeline coroutines run as children of this scope and the transport does not tear it + * down on [close]. When `null`, the transport creates and owns an internal scope. + * @property handlerDispatcher Dispatcher for invoking the registered message handler. + * Defaults to [Dispatchers.Default]. + * @property ioDispatcher Dispatcher for the reader and writer coroutines. Must allow at + * least two threads to run concurrently so the reader and writer don't block each other. + * The default is a two-thread view of the platform I/O dispatcher + * (`IODispatcher.limitedParallelism(2)`); pass a different value to share or isolate I/O + * threads with the rest of your application. + */ + @McpDsl + public class Builder internal constructor(internal val input: Source, internal val output: Sink) { + public var scope: CoroutineScope? = null + public var handlerDispatcher: CoroutineDispatcher = Dispatchers.Default + public var ioDispatcher: CoroutineDispatcher = IODispatcher.limitedParallelism(2) } } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt index 17e6a28..dcba848 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt @@ -1,25 +1,28 @@ package io.modelcontextprotocol.kotlin.sdk.server +import io.ktor.http.BadContentTypeFormatException import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode +import io.ktor.http.parseHeaderValue import io.ktor.server.application.ApplicationCall import io.ktor.server.request.contentType import io.ktor.server.request.header import io.ktor.server.request.httpMethod -import io.ktor.server.request.receiveText import io.ktor.server.response.header import io.ktor.server.response.respond -import io.ktor.server.response.respondNullable +import io.ktor.server.response.respondText import io.ktor.server.sse.ServerSSESession import io.ktor.util.collections.ConcurrentMap import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions +import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams import io.modelcontextprotocol.kotlin.sdk.types.DEFAULT_NEGOTIATED_PROTOCOL_VERSION import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCEmptyMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse import io.modelcontextprotocol.kotlin.sdk.types.McpJson @@ -28,15 +31,19 @@ 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 import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.uuid.ExperimentalUuidApi @@ -45,7 +52,6 @@ import kotlin.uuid.Uuid internal const val MCP_SESSION_ID_HEADER = "mcp-session-id" private const val MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version" private const val MCP_RESUMPTION_TOKEN_HEADER = "Last-Event-ID" -private const val DEFAULT_MAX_REQUEST_BODY_SIZE: Long = 4L * 1024 * 1024 // 4 MB private const val MIN_PRIMING_EVENT_PROTOCOL_VERSION = "2025-11-25" /** @@ -71,10 +77,8 @@ private data class SessionContext(val session: ServerSSESession?, val call: Appl * - No session validation is performed * * @param configuration Transport configuration. See [Configuration] for available options. - * @property sessionId session identifier assigned after initialization, or `null` in stateless mode */ @OptIn(ExperimentalUuidApi::class, ExperimentalAtomicApi::class) -@Suppress("TooManyFunctions") public class StreamableHttpServerTransport(private val configuration: Configuration) : AbstractTransport() { @Deprecated("Use default constructor with explicit Configuration()") @@ -97,7 +101,6 @@ public class StreamableHttpServerTransport(private val configuration: Configurat * @param retryIntervalMillis Retry interval in milliseconds for event handling or reconnection attempts. * Defaults to `null`. */ - @Suppress("MaxLineLength") @Deprecated( "Use constructor with Configuration: StreamableHttpServerTransport(Configuration(enableJsonResponse = ...))", replaceWith = ReplaceWith( @@ -136,8 +139,20 @@ public class StreamableHttpServerTransport(private val configuration: Configurat */ public class Configuration( public val enableJsonResponse: Boolean = false, + @Deprecated( + message = "Use install(DnsRebindingProtection) on your Ktor route instead", + level = DeprecationLevel.WARNING, + ) public val enableDnsRebindingProtection: Boolean = false, + @Deprecated( + message = "Use install(DnsRebindingProtection) on your Ktor route instead", + level = DeprecationLevel.WARNING, + ) public val allowedHosts: List? = null, + @Deprecated( + message = "Use install(DnsRebindingProtection) on your Ktor route instead", + level = DeprecationLevel.WARNING, + ) public val allowedOrigins: List? = null, public val eventStore: EventStore? = null, public val retryInterval: Duration? = null, @@ -150,6 +165,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } + /** Session identifier assigned after initialization in stateful mode, or `null` in stateless mode. */ public var sessionId: String? = null private set @@ -164,6 +180,20 @@ public class StreamableHttpServerTransport(private val configuration: Configurat private val requestToStreamMapping: ConcurrentMap = ConcurrentMap() private val requestToResponseMapping: ConcurrentMap = ConcurrentMap() + /** + * JSON-response mode only: one entry per in-flight POST, completed by [send] with the collected + * responses once every request delivered by that POST has been answered. [handlePostRequest] + * awaits it so the HTTP exchange stays open while handlers run concurrently. + */ + private val pendingJsonResponses: ConcurrentMap>> = ConcurrentMap() + + /** + * JSON-response mode only: request ids retired by an inbound cancellation while their POST is + * still awaiting. Treated as settled-without-response so the POST's "a response for every id" + * completion condition is not blocked by a request that will never be answered. + */ + private val cancelledRequestIds: ConcurrentMap = ConcurrentMap() + private val sessionMutex = Mutex() private val streamMutex = Mutex() @@ -212,7 +242,6 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - @Suppress("CyclomaticComplexMethod", "ReturnCount") override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { val responseRequestId: RequestId? = when (message) { is JSONRPCResponse -> message.id @@ -231,74 +260,148 @@ public class StreamableHttpServerTransport(private val configuration: Configurat return } + val isTerminated = message is JSONRPCResponse || message is JSONRPCError + + // In JSON mode request-related notifications have no per-request SSE stream. Route them to the + // standalone GET stream before the lookup below, so a notification for an already-retired + // request is delivered there instead of becoming a spurious error. + if (configuration.enableJsonResponse && !isTerminated) { + val standaloneStream = streamsMapping[STANDALONE_SSE_STREAM_ID] + standaloneStream?.let { emitOnStream(STANDALONE_SSE_STREAM_ID, it.session, message) } + return + } + val streamId = requestToStreamMapping[routingRequestId] - ?: error("No connection established for request id $routingRequestId") + if (streamId == null) { + // Request already retired (completed, cancelled, or the JSON POST disconnected). In JSON + // mode a late terminal response is undeliverable and dropped. In SSE mode an unknown id + // is a real routing error. + if (configuration.enableJsonResponse) return + error("No connection established for request id $routingRequestId") + } val activeStream = streamsMapping[streamId] if (!configuration.enableJsonResponse) { activeStream?.let { stream -> emitOnStream(streamId, stream.session, message) } - } - - val isTerminated = message is JSONRPCResponse || message is JSONRPCError - if (!isTerminated) { - if (configuration.enableJsonResponse) { - // In JSON response mode there is no per-request SSE stream, so route notifications - // that are logically associated with a request to the standalone GET SSE stream. - val standaloneStream = streamsMapping[STANDALONE_SSE_STREAM_ID] - standaloneStream?.let { emitOnStream(STANDALONE_SSE_STREAM_ID, it.session, message) } - } - return + if (!isTerminated) return } requestToResponseMapping[responseRequestId!!] = message - val relatedIds = requestToStreamMapping.filterValues { it == streamId }.keys - - if (relatedIds.any { it !in requestToResponseMapping }) return streamMutex.withLock { - if (activeStream == null) error("No connection established for request ID: $routingRequestId") - - if (configuration.enableJsonResponse) { - activeStream.call.response.header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - sessionId?.let { activeStream.call.response.header(MCP_SESSION_ID_HEADER, it) } - val responses = relatedIds.mapNotNull { requestToResponseMapping[it] } - val payload = if (responses.size == 1) { - responses.first() - } else { - responses - } - activeStream.call.respond(payload) - } else { - activeStream.session?.close() + // 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 } - // Clean up - relatedIds.forEach { requestId -> - requestToResponseMapping.remove(requestId) - requestToStreamMapping.remove(requestId) + 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 + } + val responses = relatedIds.mapNotNull { requestToResponseMapping[it] } + cleanUpRequests(relatedIds) + pendingPost.complete(responses) + } 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) } } } - override suspend fun close() { - streamMutex.withLock { - streamsMapping.values.forEach { - try { - it.session?.close() - } catch (_: Exception) { - } - } - streamsMapping.clear() - requestToStreamMapping.clear() - requestToResponseMapping.clear() - invokeOnCloseCallback() + private fun cleanUpRequests(ids: Collection) { + ids.forEach { requestId -> + requestToResponseMapping.remove(requestId) + requestToStreamMapping.remove(requestId) + cancelledRequestIds.remove(requestId) } } /** - * Handles an incoming HTTP request, whether GET, POST or DELETE + * Scans a delivered POST body for `notifications/cancelled` and retires each referenced request + * that is awaiting on a JSON-mode POST. No-op unless [Configuration.enableJsonResponse]. + */ + private suspend fun retireCancelledJsonRequests(messages: List) { + if (!configuration.enableJsonResponse) return + val cancelledIds = messages.asSequence() + .filterIsInstance() + .filter { it.method == Method.Defined.NotificationsCancelled.value } + .mapNotNull { notification -> + val params = notification.params ?: return@mapNotNull null + runCatching { + McpJson.decodeFromJsonElement(params).requestId + }.getOrNull() + } + .toList() + cancelledIds.forEach { retireCancelledJsonRequest(it) } + } + + /** + * Marks a request cancelled by the peer as settled without a response and completes its POST once + * every request that POST delivered is settled (answered or cancelled). Without this, a cancelled + * request leaves the POST awaiting a response that, by design, is never sent. + */ + private suspend fun retireCancelledJsonRequest(requestId: RequestId) { + streamMutex.withLock { + val streamId = requestToStreamMapping[requestId] ?: return + cancelledRequestIds[requestId] = Unit + val relatedIds = requestToStreamMapping.filterValues { it == streamId }.keys + val allSettled = relatedIds.all { it in requestToResponseMapping || it in cancelledRequestIds } + if (!allSettled) return + val pendingPost = pendingJsonResponses.remove(streamId) ?: return + val responses = relatedIds.mapNotNull { requestToResponseMapping[it] } + cleanUpRequests(relatedIds) + pendingPost.complete(responses) + } + } + + override suspend fun close() { + withContext(NonCancellable) { + streamMutex.withLock { + streamsMapping.values.forEach { + try { + it.session?.close() + } catch (_: Exception) { + } + } + streamsMapping.clear() + requestToStreamMapping.clear() + requestToResponseMapping.clear() + cancelledRequestIds.clear() + // Release any POST calls suspended awaiting JSON responses. + pendingJsonResponses.values.forEach { it.cancel() } + pendingJsonResponses.clear() + invokeOnCloseCallback() + } + } + } + + /** + * Handles an incoming HTTP request, whether GET, POST or DELETE. + * + * **Note:** this method does not perform DNS rebinding protection on its own. + * When using this transport outside the [mcpStreamableHttp] / [mcpStatelessStreamableHttp] + * DSL, install the [DnsRebindingProtection] plugin on your Ktor route + * (or apply equivalent middleware) to validate `Host` / `Origin` headers. */ public suspend fun handleRequest(session: ServerSSESession?, call: ApplicationCall) { validateHeaders(call)?.let { reason -> @@ -322,9 +425,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } /** - * Handles POST requests containing JSON-RPC messages + * Handles POST requests containing JSON-RPC messages. + * + * In JSON-response mode this suspends until a response has been produced for every request in + * the POST body, then completes the HTTP exchange with the collected JSON (a single object for + * one request, an array for a batch). Bodies without requests are acknowledged with 202 + * immediately. In SSE mode the responses are emitted onto the per-request SSE stream instead. */ - @Suppress("CyclomaticComplexMethod", "LongMethod", "ReturnCount", "TooGenericExceptionCaught") public suspend fun handlePostRequest(session: ServerSSESession?, call: ApplicationCall) { try { if (!configuration.enableJsonResponse && session == null) { @@ -354,9 +461,10 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } val messages = parseBody(call) ?: return - val isInitializationRequest = messages.any { - it is JSONRPCRequest && it.method == Method.Defined.Initialize.value + val initializationRequest = messages.filterIsInstance().firstOrNull { + it.method == Method.Defined.Initialize.value } + val isInitializationRequest = initializationRequest != null if (isInitializationRequest) { if (initialized.load() && sessionId != null) { @@ -364,14 +472,17 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.BadRequest, RPCError.ErrorCode.INVALID_REQUEST, "Invalid Request: Server already initialized", + initializationRequest.id, ) return } + if (!validateProtocolVersion(call, initializationRequest.id)) return if (messages.size > 1) { call.reject( HttpStatusCode.BadRequest, RPCError.ErrorCode.INVALID_REQUEST, "Invalid Request: Only one initialization request is allowed", + initializationRequest.id, ) return } @@ -383,30 +494,84 @@ public class StreamableHttpServerTransport(private val configuration: Configurat sessionId?.let { onSessionInitialized?.invoke(it) } } } else { - if (!validateSession(call) || !validateProtocolVersion(call)) return + val requestId = messages.filterIsInstance().firstOrNull()?.id + if (!validateSession(call, requestId) || !validateProtocolVersion(call, requestId)) return } val hasRequest = messages.any { it is JSONRPCRequest } if (!hasRequest) { - call.respondNullable(status = HttpStatusCode.Accepted, message = null) + call.respond(HttpStatusCode.Accepted) messages.forEach { message -> _onMessage(message) } + // A cancellation may target a request still awaiting delivery on a JSON-mode POST; + // retire it so that POST completes instead of waiting for a response that never comes. + retireCancelledJsonRequests(messages) return } + // Extract protocol version for priming event decision. + // 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") + ?.let { McpJson.decodeFromJsonElement(it) } + ?: DEFAULT_NEGOTIATED_PROTOCOL_VERSION + } else { + call.request.header(MCP_PROTOCOL_VERSION_HEADER) ?: DEFAULT_NEGOTIATED_PROTOCOL_VERSION + } + val streamId = Uuid.random().toString() if (!configuration.enableJsonResponse) { call.appendSseHeaders() flushSse(session) // flush headers immediately - maybeSendPrimingEvent(streamId, session, call.request.header(MCP_PROTOCOL_VERSION_HEADER)) + maybeSendPrimingEvent(streamId, session, clientProtocolVersion) + } + + val pendingPost = if (configuration.enableJsonResponse) { + CompletableDeferred>() + } else { + null } streamMutex.withLock { streamsMapping[streamId] = SessionContext(session, call) messages.filterIsInstance().forEach { requestToStreamMapping[it.id] = streamId } + pendingPost?.let { pendingJsonResponses[streamId] = it } } call.coroutineContext.job.invokeOnCompletion { streamsMapping.remove(streamId) } - messages.forEach { message -> _onMessage(message) } + if (pendingPost == null) { + // SSE mode: responses are emitted onto this call's SSE stream by send(). + messages.forEach { message -> _onMessage(message) } + return + } + + // JSON-response mode. Handlers may run concurrently, so returning from delivery no longer + // implies the responses were produced. Suspend until send() has collected a response for + // every request in this POST. A client disconnect or transport close cancels this call. + try { + messages.forEach { message -> _onMessage(message) } + retireCancelledJsonRequests(messages) + val responses = pendingPost.await() + sessionId?.let { call.response.header(MCP_SESSION_ID_HEADER, it) } + if (responses.isEmpty()) { + // Every request in this POST was cancelled before producing a response; there is + // nothing to return, so acknowledge with 202 rather than an empty JSON body. + call.respond(HttpStatusCode.Accepted) + } else { + call.response.header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + val payload = if (responses.size == 1) responses.first() else responses + call.respond(payload) + } + } finally { + // On cancellation or failure, retire this POST's ids so a late response is dropped + // quietly at the send() boundary instead of leaking mappings. Idempotent no-ops on + // the success path (send() or retirement already cleaned up). + pendingJsonResponses.remove(streamId) + pendingPost.cancel() + cleanUpRequests(messages.filterIsInstance().map { it.id }) + } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { call.reject( HttpStatusCode.BadRequest, @@ -418,7 +583,6 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } /** Handles an HTTP GET request by establishing a standalone SSE stream for server-initiated notifications. */ - @Suppress("ReturnCount") public suspend fun handleGetRequest(session: ServerSSESession?, call: ApplicationCall) { // NOTE: enableJsonResponse only controls how POST responses are delivered (JSON vs. SSE). // The standalone GET SSE stream is always supported — it is the only channel available @@ -444,25 +608,40 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - if (STANDALONE_SSE_STREAM_ID in streamsMapping) { - call.reject( - HttpStatusCode.Conflict, - RPCError.ErrorCode.CONNECTION_CLOSED, - "Conflict: Only one SSE stream is allowed per session", - ) - return - } - // SSE headers (Content-Type, Cache-Control, Connection) are already set by the framework's SSE handler flushSse(sseSession) - streamsMapping[STANDALONE_SSE_STREAM_ID] = SessionContext(sseSession, call) - maybeSendPrimingEvent(STANDALONE_SSE_STREAM_ID, sseSession, call.request.header(MCP_PROTOCOL_VERSION_HEADER)) - sseSession.coroutineContext.job.invokeOnCompletion { - streamsMapping.remove(STANDALONE_SSE_STREAM_ID) + val newContext = SessionContext(sseSession, call) + streamMutex.withLock { + streamsMapping[STANDALONE_SSE_STREAM_ID]?.let { existingContext -> + // Close the previous SSE session. If alive, this cancels the old + // coroutine (which will hit its identity-guarded finally — that finally + // won't double-remove, since we replace the mapping below). + try { + existingContext.session?.close() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Ignore — the old stream may already be closed. + } + // Evict the stale mapping — the old session is closed either way. + streamsMapping.remove(STANDALONE_SSE_STREAM_ID) + } + streamsMapping[STANDALONE_SSE_STREAM_ID] = newContext } + val clientProtocolVersion = + call.request.header(MCP_PROTOCOL_VERSION_HEADER) ?: DEFAULT_NEGOTIATED_PROTOCOL_VERSION + maybeSendPrimingEvent(STANDALONE_SSE_STREAM_ID, sseSession, clientProtocolVersion) // Keep the SSE connection open until the client disconnects or the transport is closed. - // Without this, the Ktor sse{} handler returns immediately, closing the stream. - awaitCancellation() + // Cleanup uses try/finally (runs during cancellation propagation) instead of + // invokeOnCompletion (runs after job completion) to minimize the window between + // disconnect and mapping removal. Identity check ensures only this stream's entry + // is removed — not a replacement that arrived in the meantime. + try { + awaitCancellation() + } finally { + // Atomic compare-and-remove — only evict our own entry, not a replacement's. + streamsMapping.remove(STANDALONE_SSE_STREAM_ID, newContext) + } } /** Handles an HTTP DELETE request by closing the session and the transport. */ @@ -470,29 +649,29 @@ public class StreamableHttpServerTransport(private val configuration: Configurat if (!validateSession(call) || !validateProtocolVersion(call)) return sessionId?.let { onSessionClosed?.invoke(it) } close() - call.respondNullable(status = HttpStatusCode.OK, message = null) + call.respond(HttpStatusCode.OK) } /** * Closes the SSE stream associated with the given [requestId], prompting the client to reconnect. * Useful for implementing polling behavior for long-running operations. */ - @Suppress("ReturnCount", "TooGenericExceptionCaught") public suspend fun closeSseStream(requestId: RequestId) { if (configuration.enableJsonResponse) return val streamId = requestToStreamMapping[requestId] ?: return val sessionContext = streamsMapping[streamId] ?: return - try { - sessionContext.session?.close() - } catch (e: Exception) { - _onError(e) - } finally { - streamsMapping.remove(streamId) + withContext(NonCancellable) { + try { + sessionContext.session?.close() + } catch (e: Exception) { + _onError(e) + } finally { + streamsMapping.remove(streamId) + } } } - @Suppress("TooGenericExceptionCaught") private suspend fun replayEvents(store: EventStore, lastEventId: String, session: ServerSSESession) { val call: ApplicationCall = session.call @@ -539,6 +718,8 @@ public class StreamableHttpServerTransport(private val configuration: Configurat id = eventId, data = McpJson.encodeToString(message), ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { _onError(IllegalStateException("Failed to replay event: ${e.message}", e)) } @@ -550,13 +731,14 @@ public class StreamableHttpServerTransport(private val configuration: Configurat streamsMapping.remove(streamId) throwable?.let { _onError(it) } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { _onError(e) } } - @Suppress("ReturnCount") - private suspend fun validateSession(call: ApplicationCall): Boolean { + private suspend fun validateSession(call: ApplicationCall, id: RequestId? = null): Boolean { if (sessionIdGenerator == null) return true if (!initialized.load()) { @@ -564,32 +746,23 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.BadRequest, RPCError.ErrorCode.CONNECTION_CLOSED, "Bad Request: Server not initialized", + id, ) return false } - val sessionHeaderValues = call.request.headers.getAll(MCP_SESSION_ID_HEADER) + val headerId = call.request.header(MCP_SESSION_ID_HEADER) - if (sessionHeaderValues.isNullOrEmpty()) { + if (headerId == null) { call.reject( HttpStatusCode.BadRequest, RPCError.ErrorCode.CONNECTION_CLOSED, "Bad Request: Mcp-Session-Id header is required", + id, ) return false } - if (sessionHeaderValues.size > 1) { - call.reject( - HttpStatusCode.BadRequest, - RPCError.ErrorCode.CONNECTION_CLOSED, - "Bad Request: Mcp-Session-Id header must be a single value", - ) - return false - } - - val headerId = sessionHeaderValues.single() - return when (headerId) { sessionId -> true @@ -598,15 +771,15 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.NotFound, REQUEST_TIMEOUT, "Session not found", + id, ) false } } } - private suspend fun validateProtocolVersion(call: ApplicationCall): Boolean { - val protocolVersions = call.request.headers.getAll(MCP_PROTOCOL_VERSION_HEADER) - val version = protocolVersions?.lastOrNull() ?: DEFAULT_NEGOTIATED_PROTOCOL_VERSION + private suspend fun validateProtocolVersion(call: ApplicationCall, id: RequestId? = null): Boolean { + val version = call.request.headers[MCP_PROTOCOL_VERSION_HEADER] ?: return true return when (version) { !in SUPPORTED_PROTOCOL_VERSIONS -> { @@ -618,6 +791,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat ", ", ) })", + id, ) false } @@ -626,15 +800,17 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - @Suppress("ReturnCount") private fun validateHeaders(call: ApplicationCall): String? { if (!configuration.enableDnsRebindingProtection) return null configuration.allowedHosts?.let { hosts -> - val hostHeader = call.request.headers[HttpHeaders.Host]?.lowercase() - val allowedHostsLowercase = hosts.map { it.lowercase() } + val hostHeader = call.request.headers[HttpHeaders.Host] + val hostname = hostHeader?.let { extractHostname(it) }?.lowercase() + val allowedHostsLowercase = hosts.map { + extractHostname(it)?.lowercase() ?: it.lowercase() + } - if (hostHeader == null || hostHeader !in allowedHostsLowercase) { + if (hostname == null || hostname !in allowedHostsLowercase) { return "Invalid Host header: $hostHeader" } } @@ -643,7 +819,8 @@ public class StreamableHttpServerTransport(private val configuration: Configurat val originHeader = call.request.headers[HttpHeaders.Origin]?.lowercase() val allowedOriginsLowercase = origins.map { it.lowercase() } - if (originHeader == null || originHeader !in allowedOriginsLowercase) { + // Allow requests without Origin (non-browser clients cannot perform DNS rebinding) + if (originHeader != null && originHeader !in allowedOriginsLowercase) { return "Invalid Origin header: $originHeader" } } @@ -651,34 +828,24 @@ public class StreamableHttpServerTransport(private val configuration: Configurat return null } - @Suppress("TooGenericExceptionCaught") private suspend fun flushSse(session: ServerSSESession?) { try { session?.send(data = "") + } catch (e: CancellationException) { + throw e } catch (e: Exception) { _onError(e) } } - @Suppress("ReturnCount") private suspend fun parseBody(call: ApplicationCall): List? { - val maxSize = configuration.maxRequestBodySize - val contentLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull() ?: 0L - if (contentLength > maxSize) { + val body = try { + call.receiveTextWithLimit(configuration.maxRequestBodySize) + } catch (e: RequestBodyTooLargeException) { call.reject( HttpStatusCode.PayloadTooLarge, RPCError.ErrorCode.INVALID_REQUEST, - "Invalid Request: message size exceeds maximum of $maxSize bytes", - ) - return null - } - - val body = call.receiveText() - if (body.length.toLong() > maxSize) { - call.reject( - HttpStatusCode.PayloadTooLarge, - RPCError.ErrorCode.INVALID_REQUEST, - "Invalid Request: message size exceeds maximum of $maxSize bytes", + "Invalid Request: message size exceeds maximum of ${e.maxBodySize} bytes", ) return null } @@ -699,30 +866,55 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - private fun String?.accepts(mime: ContentType): Boolean = - this?.lowercase()?.contains(mime.toString().lowercase()) == true + /** + * Reports whether the `Accept` header admits [mime]. + * + * Comparison is over parsed media ranges rather than raw substrings, so wildcards are honored + * and a near miss such as `application/jsonp` no longer passes as `application/json`. An absent + * header states no preference and admits any type. + */ + private fun String?.accepts(mime: ContentType): Boolean { + if (this == null) return true + return parseHeaderValue(this).any { range -> + if (range.quality <= 0.0) return@any false + try { + mime.match(ContentType.parse(range.value)) + } catch (_: BadContentTypeFormatException) { + false + } + } + } private suspend fun emitOnStream(streamId: String, session: ServerSSESession?, message: JSONRPCMessage) { val eventId = configuration.eventStore?.storeEvent(streamId, message) try { session?.send(event = "message", id = eventId, data = McpJson.encodeToString(message)) + } catch (e: CancellationException) { + // Atomic compare-and-remove — only evict this stream's entry, not a replacement's. + val current = streamsMapping[streamId] + if (current != null && current.session === session) { + streamsMapping.remove(streamId, current) + } + throw e } catch (_: Exception) { - streamsMapping.remove(streamId) + val current = streamsMapping[streamId] + if (current != null && current.session === session) { + streamsMapping.remove(streamId, current) + } } } - @Suppress("TooGenericExceptionCaught") private suspend fun maybeSendPrimingEvent( streamId: String, session: ServerSSESession?, - clientProtocolVersion: String? = null, + clientProtocolVersion: String, ) { val store = configuration.eventStore if (store == null || session == null) return // Priming events have empty data which older clients cannot handle. // Only send priming events to clients with protocol version >= 2025-11-25 // which includes the fix for handling empty SSE data. - if (clientProtocolVersion != null && clientProtocolVersion < MIN_PRIMING_EVENT_PROTOCOL_VERSION) return + if (clientProtocolVersion < MIN_PRIMING_EVENT_PROTOCOL_VERSION) return try { val primingEventId = store.storeEvent(streamId, JSONRPCEmptyMessage) session.send( @@ -730,6 +922,8 @@ public class StreamableHttpServerTransport(private val configuration: Configurat retry = configuration.retryInterval?.inWholeMilliseconds, data = "", ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { _onError(e) } @@ -744,7 +938,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } -internal suspend fun ApplicationCall.reject(status: HttpStatusCode, code: Int, message: String) { - this.response.status(status) - this.respond(JSONRPCError(id = null, error = RPCError(code = code, message = message))) +/** Writes an already serialized body so a client accepting only `text/event-stream` still receives [status]. */ +internal suspend fun ApplicationCall.reject( + status: HttpStatusCode, + code: Int, + message: String, + id: RequestId? = null, +) { + val error = JSONRPCError(id = id, error = RPCError(code = code, message = message)) + respondText(McpJson.encodeToString(error), ContentType.Application.Json, status) } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt index c2cb2c4..a05daef 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt @@ -1,5 +1,3 @@ -@file:Suppress("TooManyFunctions") - package io.modelcontextprotocol.kotlin.sdk.server import io.github.oshai.kotlinlogging.KotlinLogging diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/utils/ToolNameValidation.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/utils/ToolNameValidation.kt new file mode 100644 index 0000000..a63c2e4 --- /dev/null +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/utils/ToolNameValidation.kt @@ -0,0 +1,60 @@ +package io.modelcontextprotocol.kotlin.sdk.server.utils + +import io.github.oshai.kotlinlogging.KotlinLogging + +private val logger = KotlinLogging.logger("ToolNameValidation") + +private const val MAX_TOOL_NAME_LENGTH = 128 + +private const val SEP_986_URL = "https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986" + +/** + * Validates a tool name against the MCP tool naming standard (SEP-986). + * Returns a list of warnings. An empty list means the name is valid. + */ +internal fun validateToolName(name: String): List { + if (name.isEmpty()) return listOf("Tool name cannot be empty") + + val invalidChars = name.toSet().filterNot { it.isAsciiAllowed() } + val remainingInvalidChars = invalidChars - setOf(' ', ',') + + return listOfNotNull( + "Tool name exceeds maximum length of $MAX_TOOL_NAME_LENGTH characters (current: ${name.length})" + .takeIf { name.length > MAX_TOOL_NAME_LENGTH }, + "Tool name contains spaces, which may cause parsing issues" + .takeIf { ' ' in invalidChars }, + "Tool name contains commas, which may cause parsing issues" + .takeIf { ',' in invalidChars }, + "Tool name contains invalid characters: ${remainingInvalidChars.joinToString(", ") { "\"$it\"" }}" + .takeIf { remainingInvalidChars.isNotEmpty() }, + "Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)" + .takeIf { invalidChars.isNotEmpty() }, + "Tool name starts or ends with a dash, which may cause parsing issues in some contexts" + .takeIf { name.startsWith('-') || name.endsWith('-') }, + "Tool name starts or ends with a dot, which may cause parsing issues in some contexts" + .takeIf { name.startsWith('.') || name.endsWith('.') }, + ) +} + +/** + * Validates the tool [name] and logs a warning if it does not conform to SEP-986. + * Does not block registration — tools with non-conforming names are still accepted. + */ +internal fun warnIfInvalidToolName(name: String) { + val warnings = validateToolName(name) + if (warnings.isEmpty()) return + + val warningLines = warnings.joinToString("\n") { " - $it" } + logger.warn { + """ + |Tool name validation warning for "$name": + |$warningLines + |Tool registration will proceed, but this may cause compatibility issues. + |Consider updating the tool name to conform to the MCP tool naming standard. + |See SEP-986: $SEP_986_URL + """.trimMargin() + } +} + +private fun Char.isAsciiAllowed(): Boolean = + this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9' || this == '_' || this == '-' || this == '.' diff --git a/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistryTest.kt b/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistryTest.kt new file mode 100644 index 0000000..c6bf72b --- /dev/null +++ b/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistryTest.kt @@ -0,0 +1,106 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlin.test.assertFailsWith + +private data class TestFeature(override val key: FeatureKey, val payload: String = "") : Feature + +private class RecordingListener : FeatureListener { + val updatedKeys = mutableListOf() + + override fun onFeatureUpdated(featureKey: String) { + updatedKeys.add(featureKey) + } +} + +class FeatureRegistryTest { + + private val registry = FeatureRegistry("Tool") + private val listener = RecordingListener() + + init { + registry.addListener(listener) + } + + @Test + fun `add should register feature and notify listener`() { + val feature = TestFeature("a") + + registry.add(feature) + + registry.values shouldBe mapOf("a" to feature) + listener.updatedKeys shouldBe listOf("a") + } + + @Test + fun `add should throw when key is already registered`() { + registry.add(TestFeature("a")) + + val exception = assertFailsWith { + registry.add(TestFeature("a", payload = "override")) + } + + exception.message shouldBe "Tool \"a\" is already registered. Remove it first to replace it." + } + + @Test + fun `add should keep original feature and not notify when duplicate is rejected`() { + val original = TestFeature("a", payload = "original") + registry.add(original) + + assertFailsWith { + registry.add(TestFeature("a", payload = "override")) + } + + registry.values shouldBe mapOf("a" to original) + listener.updatedKeys shouldBe listOf("a") + } + + @Test + fun `addAll should register all features and notify per feature`() { + val a = TestFeature("a") + val b = TestFeature("b") + + registry.addAll(listOf(a, b)) + + registry.values shouldBe mapOf("a" to a, "b" to b) + listener.updatedKeys shouldBe listOf("a", "b") + } + + @Test + fun `addAll should throw on duplicate keys within batch and register nothing`() { + val exception = assertFailsWith { + registry.addAll(listOf(TestFeature("a"), TestFeature("b"), TestFeature("a"))) + } + + exception.message shouldBe "Duplicate Tool keys in batch: [a]" + registry.values shouldBe emptyMap() + listener.updatedKeys shouldBe emptyList() + } + + @Test + fun `addAll should throw when any key is already registered and register nothing`() { + val existing = TestFeature("existing") + registry.add(existing) + + val exception = assertFailsWith { + registry.addAll(listOf(TestFeature("new"), TestFeature("existing", payload = "override"))) + } + + exception.message shouldBe "Tools already registered: [existing]. Remove them first to replace them." + registry.values shouldBe mapOf("existing" to existing) + listener.updatedKeys shouldBe listOf("existing") + } + + @Test + fun `add should succeed after the existing feature is removed`() { + registry.add(TestFeature("a", payload = "original")) + registry.remove("a") + + registry.add(TestFeature("a", payload = "replacement")) + + registry.values shouldBe mapOf("a" to TestFeature("a", payload = "replacement")) + listener.updatedKeys shouldBe listOf("a", "a", "a") + } +} diff --git a/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionAssertCapabilityTest.kt b/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionAssertCapabilityTest.kt new file mode 100644 index 0000000..a172e96 --- /dev/null +++ b/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionAssertCapabilityTest.kt @@ -0,0 +1,147 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.matchers.string.shouldContain +import io.modelcontextprotocol.kotlin.sdk.shared.Transport +import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.toJSON +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFailsWith + +/** + * Tests for the protected helpers [ServerSession.assertCapabilityForMethod] and + * [ServerSession.assertNotificationCapability]. + * + * Both helpers are `protected`, so this suite uses a small subclass [TestServerSession] + * that re-exports them via [TestServerSession.exposedAssertCapabilityForMethod] and + * [TestServerSession.exposedAssertNotificationCapability]. Client capabilities are + * seeded by replaying an [InitializeRequest] through [InitializeReplayTransport]: + * the transport drives the inbound side of the handshake on `start()`, which causes + * the session's internal [Method.Defined.Initialize] handler to populate + * [ServerSession.clientCapabilities]. + */ +class ServerSessionAssertCapabilityTest { + + @Test + fun `server TasksGet throws when client has no tasks capability`() = runTest { + val session = newTestServerSession(clientCapabilities = ClientCapabilities()) + val ex = assertFailsWith { + session.exposedAssertCapabilityForMethod(Method.Defined.TasksGet) + } + ex.message.orEmpty() shouldContain "Client does not support tasks" + } + + @Test + fun `server TasksGet succeeds when client declared tasks`() = runTest { + val session = newTestServerSession( + clientCapabilities = ClientCapabilities(tasks = ClientCapabilities.Tasks()), + ) + session.exposedAssertCapabilityForMethod(Method.Defined.TasksGet) + } + + @Test + fun `server TasksList throws when client tasks list is null`() = runTest { + val session = newTestServerSession( + clientCapabilities = ClientCapabilities(tasks = ClientCapabilities.Tasks()), + ) + val ex = assertFailsWith { + session.exposedAssertCapabilityForMethod(Method.Defined.TasksList) + } + ex.message.orEmpty() shouldContain "Client does not support listing tasks" + } + + @Test + fun `server TasksCancel throws when client tasks cancel is null`() = runTest { + val session = newTestServerSession( + clientCapabilities = ClientCapabilities(tasks = ClientCapabilities.Tasks()), + ) + val ex = assertFailsWith { + session.exposedAssertCapabilityForMethod(Method.Defined.TasksCancel) + } + ex.message.orEmpty() shouldContain "Client does not support cancelling tasks" + } + + @Test + fun `server NotificationsTasksStatus throws when server has no tasks capability`() = runTest { + val session = newTestServerSession(serverCapabilities = ServerCapabilities()) + val ex = assertFailsWith { + session.exposedAssertNotificationCapability(Method.Defined.NotificationsTasksStatus) + } + ex.message.orEmpty() shouldContain "Server does not support tasks" + } + + private suspend fun newTestServerSession( + clientCapabilities: ClientCapabilities = ClientCapabilities(), + serverCapabilities: ServerCapabilities = ServerCapabilities(), + ): TestServerSession { + val session = TestServerSession( + serverInfo = Implementation("test-server", "1.0.0"), + options = ServerOptions(capabilities = serverCapabilities), + instructions = null, + ) + val transport = InitializeReplayTransport(clientCapabilities) + session.connect(transport) + return session + } + + /** + * Test-only [ServerSession] subclass that exposes the protected + * [ServerSession.assertCapabilityForMethod] and + * [ServerSession.assertNotificationCapability] helpers to the test code. + */ + private class TestServerSession(serverInfo: Implementation, options: ServerOptions, instructions: String?) : + ServerSession(serverInfo, options, instructions) { + fun exposedAssertCapabilityForMethod(method: Method): Unit = assertCapabilityForMethod(method) + fun exposedAssertNotificationCapability(method: Method): Unit = assertNotificationCapability(method) + } + + /** + * Minimal in-memory [Transport] that drives the server-side initialize handshake. + * + * On [start], it dispatches an [InitializeRequest] carrying the configured + * [clientCapabilities] to the session via [onMessage]. The session's internal + * `initialize` handler runs synchronously and populates + * [ServerSession.clientCapabilities] before [start] returns. Other JSON-RPC + * traffic the session subsequently emits is silently consumed. + */ + private class InitializeReplayTransport(private val clientCapabilities: ClientCapabilities) : Transport { + private var onMessageBlock: (suspend (JSONRPCMessage) -> Unit)? = null + private var onCloseBlock: (() -> Unit)? = null + + override suspend fun start() { + val initializeRequest = InitializeRequest( + params = InitializeRequestParams( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = clientCapabilities, + clientInfo = Implementation("mock-client", "1.0.0"), + ), + ).toJSON().copy(id = RequestId.NumberId(1)) + onMessageBlock?.invoke(initializeRequest) + } + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) = Unit + + override suspend fun close() { + onCloseBlock?.invoke() + } + + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { + onMessageBlock = block + } + + override fun onClose(block: () -> Unit) { + onCloseBlock = block + } + + override fun onError(block: (Throwable) -> Unit) = Unit + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractKtorExtensionsTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractKtorExtensionsTest.kt index 28e21b9..7639bc6 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractKtorExtensionsTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractKtorExtensionsTest.kt @@ -14,7 +14,6 @@ import io.ktor.utils.io.readUTF8Line import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -@Suppress("AbstractClassCanBeConcreteClass") abstract class AbstractKtorExtensionsTest { protected val sseContentType = ContentType("text", "event-stream") diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionTest.kt new file mode 100644 index 0000000..ea38219 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionTest.kt @@ -0,0 +1,428 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.application.install +import io.ktor.server.response.respondText +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import io.ktor.server.routing.routing +import io.ktor.server.sse.SSE +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import kotlin.test.Test + +class DnsRebindingProtectionTest { + + companion object { + @JvmStatic + fun invalidHostCases(): List = listOf( + Arguments.of("", "Invalid Host header: (malformed or missing)"), + Arguments.of("evil.com", "Invalid Host: evil.com"), + ) + + @JvmStatic + fun extractHostnameAcceptCases(): List = listOf( + Arguments.of("localhost", "localhost"), + Arguments.of("localhost:3000", "localhost"), + Arguments.of("127.0.0.1:8080", "127.0.0.1"), + Arguments.of("[::1]", "[::1]"), + Arguments.of("[::1]:3000", "[::1]"), + Arguments.of("localhost:", "localhost"), + Arguments.of("[::1]:", "[::1]"), + ) + + @JvmStatic + fun extractHostnameRejectCases(): List = listOf( + "", // empty + "evil.com@localhost", // userinfo + "evil.com/path", // path + "evil.com?q=1", // query + "evil.com#frag", // fragment + "[::1", // malformed IPv6 + "[]", // empty brackets + "[]:3000", // empty brackets + port + " localhost", // leading whitespace + "localhost ", // trailing whitespace + "localhost\t:80", // embedded tab + "localhost:abc", // non-numeric port + "localhost:-1", // negative port + "[::1]:abc", // non-numeric IPv6 port + ":80", // leading colon + ) + + @JvmStatic + fun extractOriginHostAcceptCases(): List = listOf( + Arguments.of("http://example.com", "example.com"), + Arguments.of("http://example.com:8080", "example.com"), + Arguments.of("https://Example.COM", "Example.COM"), + Arguments.of("https://example.com", "example.com"), + ) + + @JvmStatic + fun extractOriginHostRejectCases(): List = listOf( + "example.com", // no scheme + "not-a-url", // unparseable + "", // empty + ) + } + + private fun testWithPlugin( + config: DnsRebindingProtectionConfig.() -> Unit = {}, + test: suspend ApplicationTestBuilder.() -> Unit, + ): Unit = testApplication { + application { + install(SSE) + routing { + route("/mcp") { + install(DnsRebindingProtection, config) + post { call.respondText("ok") } + } + } + } + test() + } + + @ParameterizedTest + @ValueSource(strings = ["localhost", "127.0.0.1", "[::1]", "localhost:3000", "[::1]:8080"]) + fun `plugin accepts valid Host header`(hostHeader: String) = testWithPlugin { + val response = client.post("/mcp") { + header(HttpHeaders.Host, hostHeader) + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @ParameterizedTest + @MethodSource("invalidHostCases") + fun `plugin rejects invalid Host header`(hostHeader: String, expectedBody: String) = testWithPlugin { + val response = client.post("/mcp") { + header(HttpHeaders.Host, hostHeader) + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + response.bodyAsText() shouldContain expectedBody + } + + @Test + fun `plugin does not echo raw malformed Host in rejection`() = testWithPlugin { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "evil.com@localhost") + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + response.bodyAsText() shouldNotContain "evil.com@localhost" + response.bodyAsText() shouldContain "malformed or missing" + } + + @Test + fun `plugin allows request without Origin header`() = testWithPlugin( + config = { allowedOrigins = listOf("http://localhost:3000") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `plugin rejects disallowed Origin`() = testWithPlugin( + config = { allowedOrigins = listOf("http://localhost:3000") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "http://evil.com") + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + response.bodyAsText() shouldContain "Invalid Origin host: evil.com" + } + + @Test + fun `plugin allows matching Origin`() = testWithPlugin( + config = { allowedOrigins = listOf("http://localhost:3000") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "http://localhost:3000") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `plugin allows Origin with different port but same hostname`() = testWithPlugin( + config = { allowedOrigins = listOf("http://localhost:3000") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "http://localhost:9999") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `plugin allows Origin with different scheme but same hostname`() = testWithPlugin( + config = { allowedOrigins = listOf("http://localhost:3000") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "https://localhost:3000") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `plugin rejects unparseable Origin header`() = testWithPlugin( + config = { allowedOrigins = listOf("http://localhost:3000") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "not-a-url") + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + response.bodyAsText() shouldContain "Invalid Origin header: (unparseable)" + } + + @Test + fun `plugin with custom allowedHosts accepts matching host`() = testWithPlugin( + config = { allowedHosts = listOf("myapp.com") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "myapp.com:443") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `plugin with custom allowedHosts rejects non-matching host`() = testWithPlugin( + config = { allowedHosts = listOf("myapp.com") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + } + + @Test + fun `host validation is case insensitive`() = testWithPlugin( + config = { allowedHosts = listOf("MyApp.COM") }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "myapp.com") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `origin validation is case insensitive`() = testWithPlugin( + config = { + allowedHosts = listOf("localhost") + allowedOrigins = listOf("https://MyApp.COM") + }, + ) { + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "https://myapp.com") + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.bodyAsText() shouldBe "ok" + } + + @Test + fun `plugin with empty allowedHosts rejects all requests`() = testWithPlugin( + config = { allowedHosts = emptyList() }, + ) { + val localhostResponse = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + } + localhostResponse.shouldHaveStatus(HttpStatusCode.Forbidden) + localhostResponse.bodyAsText() shouldContain "Invalid Host: localhost" + + val otherResponse = client.post("/mcp") { + header(HttpHeaders.Host, "myapp.com") + } + otherResponse.shouldHaveStatus(HttpStatusCode.Forbidden) + otherResponse.bodyAsText() shouldContain "Invalid Host: myapp.com" + } + + @Test + fun `plugin fails to install when allowedHosts contains an invalid host`() { + val ex = shouldThrow { + testWithPlugin( + config = { allowedHosts = listOf("https://example.com") }, + ) { + client.post("/mcp") { header(HttpHeaders.Host, "example.com") } + } + } + ex.message shouldContain "https://example.com" + } + + @Test + fun `plugin fails to install when allowedOrigins contains an invalid origin`() { + val ex = shouldThrow { + testWithPlugin( + config = { allowedOrigins = listOf("not-a-url") }, + ) { + client.post("/mcp") { header(HttpHeaders.Host, "localhost") } + } + } + ex.message shouldContain "not-a-url" + } + + @Test + fun `Route mcp with DNS protection enabled rejects non-localhost`() = testApplication { + application { + install(SSE) + routing { + mcp { testServer() } + } + } + + val response = client.post("/") { + header(HttpHeaders.Host, "evil.com") + contentType(ContentType.Application.Json) + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + } + + @Test + fun `Route mcp with DNS protection disabled allows any host`() = testApplication { + application { + install(SSE) + routing { + mcp(enableDnsRebindingProtection = false) { testServer() } + } + } + + val response = client.post("/") { + header(HttpHeaders.Host, "evil.com") + contentType(ContentType.Application.Json) + } + // Not 403 — the request reaches the handler (may get 400 for missing sessionId, etc.) + response.shouldHaveStatus(HttpStatusCode.BadRequest) + } + + @Test + fun `mcpStreamableHttp rejects non-localhost Host by default`() = testApplication { + application { + mcpStreamableHttp { testServer() } + } + + val response = client.post("/mcp") { + header(HttpHeaders.Host, "evil.com") + contentType(ContentType.Application.Json) + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + } + + @Test + fun `mcpStatelessStreamableHttp rejects non-localhost Host by default`() = testApplication { + application { + mcpStatelessStreamableHttp { testServer() } + } + + val response = client.post("/mcp") { + header(HttpHeaders.Host, "evil.com") + contentType(ContentType.Application.Json) + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + } + + // -- default Origin validation (secure-by-default for localhost) -- + + @Test + fun `mcpStreamableHttp rejects hostile Origin by default`() = testApplication { + application { + mcpStreamableHttp { testServer() } + } + + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "http://evil.com") + contentType(ContentType.Application.Json) + } + response.shouldHaveStatus(HttpStatusCode.Forbidden) + response.bodyAsText() shouldContain "Invalid Origin host: evil.com" + } + + @Test + fun `mcpStreamableHttp allows localhost Origin by default`() = testApplication { + application { + mcpStreamableHttp { testServer() } + } + + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Origin, "http://localhost:5173") + contentType(ContentType.Application.Json) + } + response.status shouldNotBe HttpStatusCode.Forbidden + } + + @Test + fun `mcpStreamableHttp with custom allowedHosts does not auto-validate Origin`() = testApplication { + application { + mcpStreamableHttp(allowedHosts = listOf("myapp.com")) { testServer() } + } + + val response = client.post("/mcp") { + header(HttpHeaders.Host, "myapp.com") + header(HttpHeaders.Origin, "http://evil.com") + contentType(ContentType.Application.Json) + } + response.status shouldNotBe HttpStatusCode.Forbidden + } + + // -- extractHostname unit tests -- + + @ParameterizedTest + @MethodSource("extractHostnameAcceptCases") + fun `extractHostname accepts valid host`(input: String, expected: String) { + extractHostname(input) shouldBe expected + } + + @ParameterizedTest + @MethodSource("extractHostnameRejectCases") + fun `extractHostname rejects invalid host`(input: String) { + extractHostname(input) shouldBe null + } + + // -- extractOriginHost unit tests -- + + @ParameterizedTest + @MethodSource("extractOriginHostAcceptCases") + fun `extractOriginHost extracts hostname`(input: String, expected: String) { + extractOriginHost(input) shouldBe expected + } + + @ParameterizedTest + @MethodSource("extractOriginHostRejectCases") + fun `extractOriginHost returns null for invalid origin`(input: String) { + extractOriginHost(input) shouldBe null + } + + private fun testServer(): Server = Server( + serverInfo = Implementation(name = "test-server", version = "1.0.0"), + options = ServerOptions(capabilities = ServerCapabilities()), + ) +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidationTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidationTest.kt new file mode 100644 index 0000000..b218b47 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidationTest.kt @@ -0,0 +1,438 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema +import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema +import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.EnumOption +import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema +import io.modelcontextprotocol.kotlin.sdk.types.LegacyTitledEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.PrimitiveSchemaDefinition +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.StringSchema +import io.modelcontextprotocol.kotlin.sdk.types.StringSchemaFormat +import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.junit.jupiter.api.assertDoesNotThrow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Unit tests for [validateElicitationContent]: pure-function checks of accepted form-mode + * content against the requested schema. + * + * End-to-end tests for the `createElicitation` round trip live in the `integration-test` + * module under `io.modelcontextprotocol.kotlin.sdk.client`. + */ +class ElicitationValidationTest { + + private fun schema(vararg properties: Pair, required: List? = null) = + ElicitRequestParams.RequestedSchema(properties = mapOf(*properties), required = required) + + private fun assertRejected( + schema: ElicitRequestParams.RequestedSchema, + content: JsonObject, + expectedError: String, + ) { + val exception = assertFailsWith { validateElicitationContent(schema, content) } + assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code) + val message = exception.message.orEmpty() + assertTrue( + message.startsWith("Elicitation response content does not match requested schema: "), + "Unexpected message prefix: $message", + ) + assertTrue(expectedError in message, "Expected '$expectedError' in: $message") + } + + @Test + fun `validate content matching schema passes`() { + assertDoesNotThrow { + validateElicitationContent( + schema( + "name" to StringSchema(minLength = 1, maxLength = 10), + "age" to IntegerSchema(minimum = 0, maximum = 120), + "score" to DoubleSchema(minimum = 0.0, maximum = 1.0), + "subscribed" to BooleanSchema(), + "color" to UntitledSingleSelectEnumSchema(enumValues = listOf("red", "green")), + "size" to TitledSingleSelectEnumSchema( + oneOf = listOf(EnumOption("s", "Small"), EnumOption("l", "Large")), + ), + "tags" to UntitledMultiSelectEnumSchema( + items = UntitledMultiSelectEnumSchema.Items(enumValues = listOf("a", "b", "c")), + minItems = 1, + maxItems = 2, + ), + required = listOf("name", "age"), + ), + buildJsonObject { + put("name", "octocat") + put("age", 42) + put("score", 0.5) + put("subscribed", true) + put("color", "red") + put("size", "l") + putJsonArray("tags") { add("a") } + }, + ) + } + } + + @Test + fun `validate missing required property fails`() { + assertRejected( + schema("name" to StringSchema(), required = listOf("name")), + JsonObject(emptyMap()), + "must have required property 'name'", + ) + } + + @Test + fun `validate empty content with no required properties passes`() { + assertDoesNotThrow { + validateElicitationContent(schema("name" to StringSchema()), JsonObject(emptyMap())) + } + } + + @Test + fun `validate wrong type for string property fails`() { + assertRejected( + schema("name" to StringSchema()), + buildJsonObject { put("name", 42) }, + "'name' must be string", + ) + } + + @Test + fun `validate string length constraints fail`() { + assertRejected( + schema("name" to StringSchema(minLength = 3)), + buildJsonObject { put("name", "ab") }, + "'name' must NOT have fewer than 3 characters", + ) + assertRejected( + schema("name" to StringSchema(maxLength = 3)), + buildJsonObject { put("name", "abcd") }, + "'name' must NOT have more than 3 characters", + ) + } + + @Test + fun `validate json string is not accepted for integer boolean or number`() { + // JsonPrimitive("42") is the JSON string "42", not the number 42; the validator must not + // parse string content as a number. + assertRejected( + schema("age" to IntegerSchema()), + buildJsonObject { put("age", "42") }, + "'age' must be integer", + ) + assertRejected( + schema("score" to DoubleSchema()), + buildJsonObject { put("score", "1.5") }, + "'score' must be number", + ) + assertRejected( + schema("subscribed" to BooleanSchema()), + buildJsonObject { put("subscribed", "true") }, + "'subscribed' must be boolean", + ) + } + + @Test + fun `validate integer outside minimum and maximum fails`() { + assertRejected( + schema("age" to IntegerSchema(minimum = 0)), + buildJsonObject { put("age", -1) }, + "'age' must be >= 0", + ) + assertRejected( + schema("age" to IntegerSchema(maximum = 120)), + buildJsonObject { put("age", 200) }, + "'age' must be <= 120", + ) + } + + @Test + fun `validate non-integral value for integer property fails`() { + assertRejected( + schema("age" to IntegerSchema()), + buildJsonObject { put("age", 3.5) }, + "'age' must be integer", + ) + } + + @Test + fun `validate integral number forms are accepted for integer property`() { + // JSON Schema's "integer" matches numbers with a zero fractional part. + assertDoesNotThrow { + validateElicitationContent( + schema("age" to IntegerSchema(minimum = 0, maximum = 200)), + buildJsonObject { put("age", 5.0) }, + ) + } + } + + @Test + fun `validate exponent form is accepted for integer property`() { + val content = McpJson.parseToJsonElement("""{"age": 1e2}""") as JsonObject + assertDoesNotThrow { + validateElicitationContent( + schema("age" to IntegerSchema(minimum = 0, maximum = 200)), + content, + ) + } + } + + @Test + fun `validate number outside range fails`() { + assertRejected( + schema("score" to DoubleSchema(maximum = 1.0)), + buildJsonObject { put("score", 1.5) }, + "'score' must be <= 1.0", + ) + } + + @Test + fun `validate integral value is accepted for number property`() { + assertDoesNotThrow { + validateElicitationContent( + schema("score" to DoubleSchema(minimum = 0.0)), + buildJsonObject { put("score", 5) }, + ) + } + } + + @Test + fun `validate string length counts unicode code points`() { + // JSON Schema string length counts RFC 8259 characters: one emoji is one character + // even when it is two UTF-16 units. + assertDoesNotThrow { + validateElicitationContent( + schema("emoji" to StringSchema(maxLength = 1)), + buildJsonObject { put("emoji", "😀") }, + ) + } + assertRejected( + schema("emoji" to StringSchema(minLength = 2)), + buildJsonObject { put("emoji", "😀") }, + "'emoji' must NOT have fewer than 2 characters", + ) + } + + @Test + fun `validate string format is treated as an annotation`() { + assertDoesNotThrow { + validateElicitationContent( + schema("email" to StringSchema(format = StringSchemaFormat.Email)), + buildJsonObject { put("email", "not-an-email") }, + ) + } + } + + @Test + fun `validate negative zero satisfies an inclusive zero bound`() { + // IEEE 754 comparison: -0.0 == 0.0, so an inclusive minimum of 0.0 admits -0.0. + assertDoesNotThrow { + validateElicitationContent( + schema("score" to DoubleSchema(minimum = 0.0)), + buildJsonObject { put("score", -0.0) }, + ) + } + } + + @Test + fun `validate enum value not in allowed set fails`() { + assertRejected( + schema("color" to UntitledSingleSelectEnumSchema(enumValues = listOf("red", "green"))), + buildJsonObject { put("color", "blue") }, + "'color' must be equal to one of the allowed values", + ) + assertRejected( + schema("size" to TitledSingleSelectEnumSchema(oneOf = listOf(EnumOption("s", "Small")))), + buildJsonObject { put("size", "xl") }, + "'size' must be equal to one of the allowed values", + ) + assertRejected( + schema("legacy" to LegacyTitledEnumSchema(enumValues = listOf("a", "b"))), + buildJsonObject { put("legacy", "c") }, + "'legacy' must be equal to one of the allowed values", + ) + } + + @Test + fun `validate multi-select constraints fail`() { + val tags = UntitledMultiSelectEnumSchema( + items = UntitledMultiSelectEnumSchema.Items(enumValues = listOf("a", "b", "c")), + minItems = 1, + maxItems = 2, + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { put("tags", "a") }, + "'tags' must be array", + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { + putJsonArray("tags") { + add("a") + add(42) + } + }, + "'tags' items must be string", + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { + putJsonArray("tags") { + add("a") + add("z") + } + }, + "'tags' items must be equal to one of the allowed values", + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { putJsonArray("tags") {} }, + "'tags' must NOT have fewer than 1 items", + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { + putJsonArray("tags") { + add("a") + add("b") + add("c") + } + }, + "'tags' must NOT have more than 2 items", + ) + } + + @Test + fun `validate titled multi-select membership uses option consts`() { + val sizes = TitledMultiSelectEnumSchema( + items = TitledMultiSelectEnumSchema.Items( + anyOf = listOf(EnumOption("s", "Small"), EnumOption("l", "Large")), + ), + ) + assertDoesNotThrow { + validateElicitationContent( + schema("sizes" to sizes), + buildJsonObject { putJsonArray("sizes") { add("s") } }, + ) + } + assertRejected( + schema("sizes" to sizes), + buildJsonObject { putJsonArray("sizes") { add("Small") } }, + "'sizes' items must be equal to one of the allowed values", + ) + } + + @Test + fun `validate json null is rejected with the type error for each schema kind`() { + // JsonNull is a JsonPrimitive whose content is "null"; it must fail the type check, not + // accidentally parse as a value. + assertRejected( + schema("name" to StringSchema()), + buildJsonObject { put("name", JsonNull) }, + "'name' must be string", + ) + assertRejected( + schema("age" to IntegerSchema()), + buildJsonObject { put("age", JsonNull) }, + "'age' must be integer", + ) + assertRejected( + schema("subscribed" to BooleanSchema()), + buildJsonObject { put("subscribed", JsonNull) }, + "'subscribed' must be boolean", + ) + } + + @Test + fun `validate non-primitive value is rejected for scalar schemas`() { + assertRejected( + schema("name" to StringSchema()), + buildJsonObject { putJsonArray("name") { add("x") } }, + "'name' must be string", + ) + assertRejected( + schema("age" to IntegerSchema()), + buildJsonObject { putJsonObject("age") {} }, + "'age' must be integer", + ) + } + + @Test + fun `validate number below minimum fails`() { + assertRejected( + schema("score" to DoubleSchema(minimum = 0.0)), + buildJsonObject { put("score", -0.5) }, + "'score' must be >= 0.0", + ) + } + + @Test + fun `validate non-finite value for number property fails`() { + // McpJson is lenient, so an unquoted NaN literal can arrive off the wire; it decodes to + // the same primitive as Double.NaN here. + assertRejected( + schema("score" to DoubleSchema(minimum = 0.0, maximum = 1.0)), + buildJsonObject { put("score", Double.NaN) }, + "'score' must be number", + ) + } + + @Test + fun `validate required property not declared in properties is still required`() { + // JSON Schema's `required` is independent of `properties`. + assertRejected( + schema("name" to StringSchema(), required = listOf("name", "consent")), + buildJsonObject { put("name", "octocat") }, + "must have required property 'consent'", + ) + } + + @Test + fun `validate properties not declared in schema pass`() { + // JSON Schema default: without an additionalProperties restriction, undeclared keys are permitted. + assertDoesNotThrow { + validateElicitationContent( + schema("name" to StringSchema(), required = listOf("name")), + buildJsonObject { + put("name", "octocat") + put("undeclared", 42) + }, + ) + } + } + + @Test + fun `validate reports every violation in one error`() { + val exception = assertFailsWith { + validateElicitationContent( + schema( + "name" to StringSchema(), + "age" to IntegerSchema(), + required = listOf("name"), + ), + buildJsonObject { put("age", "old") }, + ) + } + val message = exception.message.orEmpty() + assertTrue("must have required property 'name'" in message, message) + assertTrue("'age' must be integer" in message, message) + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt index 05200e5..9888867 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt @@ -24,9 +24,9 @@ class KtorApplicationExtensionsTest : AbstractKtorExtensionsTest() { * added to the same application. */ @Test - fun `Application mcp should installs SSE and coexist with other routes`() = testApplication { + fun `Application mcp should install SSE and coexist with other routes`() = testApplication { application { - mcp { testServer() } + mcp(enableDnsRebindingProtection = false) { testServer() } routing { get("/health") { call.respondText("healthy") } diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt index 5ba4448..d6b95b4 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt @@ -61,7 +61,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() { route("/api/mcp") { get("/test") { call.respondText("test-endpoint") } - mcp { testServer() } + mcp(enableDnsRebindingProtection = false) { testServer() } } } } @@ -91,7 +91,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() { route("/v1") { route("/services") { route("/mcp") { - mcp { testServer() } + mcp(enableDnsRebindingProtection = false) { testServer() } } } } @@ -112,7 +112,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() { routing { route("/api") { - mcp("/mcp-endpoint") { testServer() } + mcp("/mcp-endpoint", enableDnsRebindingProtection = false) { testServer() } } } } diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBodyTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBodyTest.kt new file mode 100644 index 0000000..9dc30cf --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBodyTest.kt @@ -0,0 +1,103 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.matchers.shouldBe +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respondText +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import kotlin.test.Test + +class RequestBodyTest { + + /** Wires a POST endpoint that echoes the body when it fits, or replies 413 when it is too large. */ + private fun ApplicationTestBuilder.installEchoEndpoint(maxBytes: Long) { + install(io.ktor.server.sse.SSE) + application { + routing { + post("/echo") { + val text = try { + call.receiveTextWithLimit(maxBytes) + } catch (e: RequestBodyTooLargeException) { + call.respondText(e.message ?: "too large", status = HttpStatusCode.PayloadTooLarge) + return@post + } + call.respondText(text) + } + } + } + } + + @Test + fun `body under the limit is returned intact`() = testApplication { + installEchoEndpoint(maxBytes = 1024) + val payload = "hello world" + + val response = client.post("/echo") { setBody(payload) } + + response.status shouldBe HttpStatusCode.OK + response.bodyAsText() shouldBe payload + } + + @Test + fun `body exactly at the limit is accepted`() = testApplication { + val payload = "x".repeat(64) + installEchoEndpoint(maxBytes = payload.length.toLong()) + + val response = client.post("/echo") { setBody(payload) } + + response.status shouldBe HttpStatusCode.OK + response.bodyAsText() shouldBe payload + } + + @Test + fun `body exceeding the limit is rejected with 413`() = testApplication { + val payload = "x".repeat(65) + installEchoEndpoint(maxBytes = 64) + + val response = client.post("/echo") { setBody(payload) } + + response.status shouldBe HttpStatusCode.PayloadTooLarge + } + + @Test + fun `large body exceeding the limit is rejected without buffering it whole`() = testApplication { + // 8 MB body against a 4 MB limit: must be rejected. + installEchoEndpoint(maxBytes = 4L * 1024 * 1024) + val payload = "x".repeat(8 * 1024 * 1024) + + val response = client.post("/echo") { setBody(payload) } + + response.status shouldBe HttpStatusCode.PayloadTooLarge + } + + @Test + fun `multibyte UTF-8 body larger than a read chunk decodes intact`() = testApplication { + // Emoji is 4 bytes in UTF-8; a payload larger than the internal read chunk forces the + // accumulated bytes to span multiple chunks. Decoding once at the end must not corrupt + // characters that straddle a chunk boundary. + val unit = "a😀b" // mixes 1- and 4-byte code units so boundaries land mid-character + val payload = unit.repeat(40_000) // well over a 64 KiB read chunk + installEchoEndpoint(maxBytes = payload.encodeToByteArray().size.toLong()) + + val response = client.post("/echo") { setBody(payload) } + + response.status shouldBe HttpStatusCode.OK + response.bodyAsText() shouldBe payload + } + + @Test + fun `body is returned intact when the limit is Long MAX_VALUE`() = testApplication { + installEchoEndpoint(maxBytes = Long.MAX_VALUE) + val payload = "hello world" + + val response = client.post("/echo") { setBody(payload) } + + response.status shouldBe HttpStatusCode.OK + response.bodyAsText() shouldBe payload + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt new file mode 100644 index 0000000..cfcbb62 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt @@ -0,0 +1,104 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.types.Role +import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.ToolResultContent +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 + +/** + * Unit tests for [validateSamplingMessages]: pure-function checks of the SEP-1577 + * last-two-messages tool_use / tool_result rules. + * + * End-to-end and capability-enforcement tests for `Server.createMessage` live in the + * `integration-test` module under the same package. + */ +class SamplingTest { + + private fun toolUse(id: String) = ToolUseContent(id = id, name = "t", input = JsonObject(emptyMap())) + private fun toolResult(id: String) = ToolResultContent(toolUseId = id, content = emptyList()) + + @Test + fun `validate empty message list is valid`() { + assertDoesNotThrow { validateSamplingMessages(emptyList()) } + } + + @Test + fun `validate text-only conversation is valid`() { + assertDoesNotThrow { + validateSamplingMessages( + listOf( + SamplingMessage(Role.User, TextContent("hi")), + SamplingMessage(Role.Assistant, TextContent("hello")), + ), + ) + } + } + + @Test + fun `validate matched tool_use and tool_result at boundary is valid`() { + assertDoesNotThrow { + validateSamplingMessages( + listOf( + SamplingMessage(Role.Assistant, listOf(TextContent("using tool"), toolUse("c1"))), + SamplingMessage(Role.User, toolResult("c1")), + ), + ) + } + } + + @Test + fun `validate orphan tool_result with no previous message fails`() { + assertFailsWith { + validateSamplingMessages( + listOf(SamplingMessage(Role.User, toolResult("missing"))), + ) + } + } + + @Test + fun `validate tool_result mixed with text in last message fails`() { + assertFailsWith { + validateSamplingMessages( + listOf( + SamplingMessage(Role.Assistant, toolUse("c1")), + SamplingMessage(Role.User, listOf(toolResult("c1"), TextContent("extra"))), + ), + ) + } + } + + @Test + fun `validate tool_result ids must match tool_use ids in previous message`() { + assertFailsWith { + validateSamplingMessages( + listOf( + SamplingMessage(Role.Assistant, toolUse("c1")), + SamplingMessage(Role.User, toolResult("wrong_id")), + ), + ) + } + } + + @Test + fun `validate tool_use requires explicit tool_result in last message`() { + val error = assertFailsWith { + validateSamplingMessages( + listOf( + SamplingMessage(Role.Assistant, toolUse("c1")), + SamplingMessage(Role.User, TextContent("missing result")), + ), + ) + } + + assertEquals( + "tool_use blocks from previous message must be followed by matching tool_result blocks", + error.message, + ) + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SseServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SseServerTransportTest.kt new file mode 100644 index 0000000..a0756f3 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SseServerTransportTest.kt @@ -0,0 +1,90 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.matchers.shouldBe +import io.ktor.client.request.post +import io.ktor.client.request.prepareGet +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.application.install +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import io.ktor.server.sse.SSE +import io.ktor.server.sse.ServerSSESession +import io.ktor.server.testing.testApplication +import io.ktor.utils.io.readLine +import io.mockk.mockk +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.Test + +class SseServerTransportTest : AbstractKtorExtensionsTest() { + + @Test + fun `handlePostMessage on a not-started transport does not deliver the message`() = testApplication { + // A registered onMessage callback opens the delivery gate, so if the not-initialized branch + // falls through it would wrongly hand the message to the application. It must return instead. + val transport = SseServerTransport("/messages", mockk(relaxed = true)) + val delivered = AtomicBoolean(false) + transport.onMessage { delivered.set(true) } + + application { + routing { + post("/messages") { transport.handlePostMessage(call) } + } + } + + val response = client.post("/messages") { + contentType(ContentType.Application.Json) + setBody("""{"jsonrpc":"2.0","id":1,"method":"ping"}""") + } + + response.status shouldBe HttpStatusCode.InternalServerError + delivered.get() shouldBe false + } + + @Test + fun `SSE POST exceeding maxRequestBodySize is rejected with 413`() = testApplication { + application { + install(SSE) + routing { + mcp(enableDnsRebindingProtection = false, maxRequestBodySize = MAX_BODY) { testServer() } + } + } + + client.prepareGet("/").execute { response -> + val sessionId = response.readSessionId() + requireNotNull(sessionId) { "sessionId not found in SSE endpoint event" } + + // A body one byte over the configured limit must be rejected before processing. + val oversized = "x".repeat((MAX_BODY + 1).toInt()) + val postResponse = client.post("/?sessionId=$sessionId") { + contentType(ContentType.Application.Json) + setBody(oversized) + } + postResponse.status shouldBe HttpStatusCode.PayloadTooLarge + } + } + + private suspend fun HttpResponse.readSessionId(): String? { + val channel = bodyAsChannel() + var eventName: String? = null + while (!channel.isClosedForRead) { + val line = channel.readLine() ?: break + when { + line.startsWith("event:") -> eventName = line.substringAfter("event:").trim() + + line.startsWith("data:") && eventName == "endpoint" -> { + return line.substringAfter("data:").trim().substringAfter("sessionId=").ifEmpty { null } + } + } + } + return null + } + + private companion object { + const val MAX_BODY = 1024L + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StatelessStreamableHttpSessionLifecycleTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StatelessStreamableHttpSessionLifecycleTest.kt new file mode 100644 index 0000000..3646faf --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StatelessStreamableHttpSessionLifecycleTest.kt @@ -0,0 +1,100 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.matchers.maps.shouldBeEmpty +import io.kotest.matchers.shouldBe +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.testing.testApplication +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest +import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.toJSON +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +/** + * Verifies that the stateless Streamable HTTP endpoint does not leak server sessions: + * every session created to serve a single request must be closed and removed from the + * session registry once that request completes (https://github.com/modelcontextprotocol/kotlin-sdk/issues/786). + */ +class StatelessStreamableHttpSessionLifecycleTest { + + private fun testServer(): Server = Server( + Implementation("test-server", "1.0"), + ServerOptions(capabilities = ServerCapabilities()), + ) + + @Test + fun `stateless endpoint removes session after each request`() = testApplication { + val server = testServer() + application { + mcpStatelessStreamableHttp { server } + } + + repeat(5) { + val response = client.post("/mcp") { + addStreamableHeaders() + setBody(initializeRequestBody()) + } + response.status shouldBe HttpStatusCode.OK + } + + eventually(5.seconds) { + server.sessions.shouldBeEmpty() + } + } + + @Test + fun `stateless endpoint removes session when the request is rejected`() = testApplication { + val server = testServer() + application { + mcpStatelessStreamableHttp { server } + } + + // Missing "text/event-stream" in Accept makes the transport reject the request. + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, ContentType.Application.Json.toString()) + contentType(ContentType.Application.Json) + setBody(initializeRequestBody()) + } + response.status shouldBe HttpStatusCode.NotAcceptable + + eventually(5.seconds) { + server.sessions.shouldBeEmpty() + } + } + + private fun HttpRequestBuilder.addStreamableHeaders() { + header(HttpHeaders.Host, "localhost") + header( + HttpHeaders.Accept, + listOf(ContentType.Application.Json, ContentType.Text.EventStream).joinToString(", "), + ) + contentType(ContentType.Application.Json) + } + + private fun initializeRequestBody(): String { + val request = InitializeRequest( + InitializeRequestParams( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = ClientCapabilities(), + clientInfo = Implementation(name = "test-client", version = "1.0.0"), + ), + ).toJSON() + + return McpJson.encodeToString(JSONRPCMessage.serializer(), request) + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportTest.kt index dcbb7a4..c6370a1 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportTest.kt @@ -5,17 +5,29 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.withClue import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.PingRequest +import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.toJSON import io.modelcontextprotocol.kotlin.test.utils.runIntegrationTest import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.io.Buffer import kotlinx.io.RawSink import kotlinx.io.RawSource @@ -33,9 +45,12 @@ import java.io.ByteArrayOutputStream import java.io.IOException import java.io.PipedInputStream import java.io.PipedOutputStream +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.test.fail +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -71,13 +86,13 @@ class StdioServerTransportTest { @Test fun `should be safe to close before start`() = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) server.close() // initialized guard makes this a no-op; must not throw } @Test fun `should start then close cleanly`() = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) server.onError { error -> throw error } @@ -96,7 +111,7 @@ class StdioServerTransportTest { @Test fun `should not read until started`() = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) server.onError { error -> throw error } @@ -125,7 +140,7 @@ class StdioServerTransportTest { @Test fun `should read multiple messages`() = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) server.onError { error -> throw error } @@ -162,7 +177,7 @@ class StdioServerTransportTest { @ParameterizedTest(name = "[{index}] input throws {0}") @MethodSource("inputErrors") fun `should invoke onError when input stream throws`(throwable: Throwable): Unit = runIntegrationTest { - val server = StdioServerTransport(FaultyRawSource(throwable).buffered(), printOutput) + val server = StdioServerTransport(input = FaultyRawSource(throwable).buffered(), output = printOutput) val capturedError = CompletableDeferred() server.onError { capturedError.complete(it) } server.onMessage {} @@ -176,7 +191,7 @@ class StdioServerTransportTest { @ParameterizedTest(name = "[{index}] output throws {0}") @MethodSource("outputErrors") fun `should invoke onError when output sink throws`(throwable: Throwable): Unit = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, FaultyRawSink(throwable).buffered()) + val server = StdioServerTransport(input = bufferedInput, output = FaultyRawSink(throwable).buffered()) val capturedError = CompletableDeferred() server.onError { capturedError.complete(it) } server.onMessage {} @@ -190,7 +205,7 @@ class StdioServerTransportTest { @Test fun `should call onClose when input EOF is reached`(): Unit = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) val didClose = CompletableDeferred() server.onError { throw it } server.onClose { didClose.complete(Unit) } @@ -206,7 +221,7 @@ class StdioServerTransportTest { @Test fun `should throw when starting twice`(): Unit = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) server.onMessage {} server.start() withClue("Server should not start twice") { @@ -217,10 +232,24 @@ class StdioServerTransportTest { server.close() } + @Test + fun `should report 'closed' error message when starting after close`(): Unit = runIntegrationTest { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) + server.onError {} + server.onMessage {} + server.start() + server.close() + + val ex = shouldThrow { + server.start() + } + ex.message shouldContain "closed" + } + @ParameterizedTest(name = "[{index}] handler throws {0}") @MethodSource("handlerErrors") fun `should continue processing messages after handler throws`(throwable: Throwable) = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) val capturedErrors = mutableListOf() val receivedMessages = mutableListOf() val secondMessageProcessed = CompletableDeferred() @@ -253,7 +282,7 @@ class StdioServerTransportTest { @Test fun `should not invoke onError for CancellationException in handler`() = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) val capturedError = CompletableDeferred() server.onError { capturedError.complete(it) } @@ -281,7 +310,7 @@ class StdioServerTransportTest { @Test fun `should continue receiving valid messages after malformed JSON is skipped`() = runIntegrationTest { - val server = StdioServerTransport(bufferedInput, printOutput) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) val received = CompletableDeferred() // ReadBuffer silently skips unparseable lines — no onError callback expected server.onError {} @@ -299,21 +328,309 @@ class StdioServerTransportTest { server.close() } - @Suppress("unused") + @Test + fun `should throw McpException when send is called before start`() = runIntegrationTest { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) + val ex = shouldThrow { + server.send(PingRequest().toJSON()) + } + ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED + } + + @Test + fun `should throw McpException when send is called after close`() = runIntegrationTest { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) + server.onError {} + server.onMessage {} + server.start() + server.close() + + val ex = shouldThrow { + server.send(PingRequest().toJSON()) + } + ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED + } + + @Test + fun `should drain in-flight outbound messages on graceful close`() = runIntegrationTest { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) + server.onError {} + server.onMessage {} + server.start() + + val numMessages = 50 + repeat(numMessages) { + server.send(PingRequest().toJSON()) + } + server.close() + + val outputLines = String(output.toByteArray()).lines().count { it.isNotBlank() } + outputLines shouldBe numMessages + } + + @Test + fun `should fail-fast when reader throws non-EOF IOException`() = runIntegrationTest { + val ioError = IOException("transient stream failure") + val server = StdioServerTransport(input = FaultyRawSource(ioError).buffered(), output = printOutput) + val errorCaptured = CompletableDeferred() + val closeCaptured = CompletableDeferred() + server.onError { errorCaptured.complete(it) } + server.onClose { closeCaptured.complete(Unit) } + server.onMessage {} + + server.start() + + errorCaptured.await() shouldBe ioError + closeCaptured.await() + } + + @Test + fun `should close input source on natural EOF`() = runIntegrationTest { + val inputClosed = CompletableDeferred() + val eofSource = object : RawSource { + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long = -1L + override fun close() { + inputClosed.complete(Unit) + } + } + val server = StdioServerTransport(input = eofSource.buffered(), output = printOutput) + val onCloseFired = CompletableDeferred() + server.onError {} + server.onMessage {} + server.onClose { onCloseFired.complete(Unit) } + + server.start() + + inputClosed.await() + onCloseFired.await() + } + + // region: concurrency + + @Test + fun `should be safe under concurrent start and close`() = runIntegrationTest(timeout = 30.seconds) { + val iterations = 2000 + repeat(iterations) { iteration -> + val server = StdioServerTransport(input = Buffer(), output = Buffer()) + server.onError {} + server.onMessage {} + + coroutineScope { + launch(Dispatchers.Default) { + try { + server.start() + } catch (_: IllegalStateException) { + } + } + launch(Dispatchers.Default) { + server.close() + } + } + + withClue("iteration $iteration: send() after race should fail with CONNECTION_CLOSED") { + val ex = shouldThrow { + server.send(PingRequest().toJSON()) + } + ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED + } + } + } + + @Test + fun `should suspend send under back-pressure`() = runIntegrationTest(timeout = 30.seconds) { + val unblock = CountDownLatch(1) + val blockingSink = object : RawSink { + override fun write(source: Buffer, byteCount: Long) { + source.clear() + unblock.await() + } + + override fun flush() { + // noop + } + + override fun close() { + unblock.countDown() + } + } + val server = StdioServerTransport(input = bufferedInput, output = blockingSink.buffered()) + server.onError {} + server.onMessage {} + server.start() + + val maxAttempts = 256 + var suspendedAt = -1 + for (i in 0 until maxAttempts) { + val ok = withTimeoutOrNull(100.milliseconds) { server.send(PingRequest().toJSON()) } + if (ok == null) { + suspendedAt = i + break + } + } + assertTrue( + suspendedAt in 0 until maxAttempts, + "send() should have suspended within $maxAttempts attempts, got suspendedAt=$suspendedAt", + ) + // Unblock writer so close() can drain. + unblock.countDown() + server.close() + } + + @Test + fun `should not hang close when handler suspends indefinitely`() = runIntegrationTest { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) + val handlerEntered = CompletableDeferred() + val neverComplete = CompletableDeferred() + server.onError {} + server.onMessage { + handlerEntered.complete(Unit) + neverComplete.await() + } + server.start() + + inputWriter.write(serializeMessage(PingRequest().toJSON())) + inputWriter.flush() + + handlerEntered.await() + server.close() + } + + @Test + fun `should not hang close when onMessage was never registered`() = runIntegrationTest { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) + server.onError {} + server.start() + + inputWriter.write(serializeMessage(PingRequest().toJSON())) + inputWriter.flush() + + delay(200.milliseconds) + + server.close() + } + + // endregion + + // region: scope and dispatcher knobs + + @Test + fun `should honor externally provided CoroutineScope`() = runIntegrationTest { + val externalScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val server = StdioServerTransport(input = bufferedInput, output = printOutput) { + scope = externalScope + } + val closeCaptured = CompletableDeferred() + server.onError {} + server.onClose { closeCaptured.complete(Unit) } + server.onMessage {} + + server.start() + externalScope.cancel() + + closeCaptured.await() + + val ex = shouldThrow { + server.send(PingRequest().toJSON()) + } + ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED + } + + @Test + fun `should use ioDispatcher for reader and writer`() = runIntegrationTest { + val threadName = "stdio-test-io-thread" + val executor = Executors.newFixedThreadPool(2) { r -> Thread(r, threadName) } + try { + val readerThread = CompletableDeferred() + val writerThread = CompletableDeferred() + val unblockReader = CountDownLatch(1) + val recordingInput = object : RawSource { + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + if (!readerThread.isCompleted) { + readerThread.complete(Thread.currentThread().name) + } + unblockReader.await() + return -1L + } + + override fun close() { + unblockReader.countDown() + } + } + val recordingOutput = object : RawSink { + override fun write(source: Buffer, byteCount: Long) { + if (!writerThread.isCompleted) { + writerThread.complete(Thread.currentThread().name) + } + source.clear() + } + + override fun flush() { + // noop + } + + override fun close() { + // noop + } + } + val server = StdioServerTransport( + input = recordingInput.buffered(), + output = recordingOutput.buffered(), + ) { + ioDispatcher = executor.asCoroutineDispatcher() + } + server.onError {} + server.onMessage {} + server.start() + + readerThread.await() shouldContain threadName + server.send(PingRequest().toJSON()) + writerThread.await() shouldContain threadName + + server.close() + } finally { + executor.shutdownNow() + } + } + + @Test + fun `should use handlerDispatcher for message handling`() = runIntegrationTest { + val threadName = "stdio-test-handler-thread" + val executor = Executors.newSingleThreadExecutor { r -> Thread(r, threadName) } + try { + val server = StdioServerTransport(input = bufferedInput, output = printOutput) { + handlerDispatcher = executor.asCoroutineDispatcher() + } + val observedThreadName = CompletableDeferred() + server.onError {} + server.onMessage { + observedThreadName.complete(Thread.currentThread().name) + } + server.start() + + inputWriter.write(serializeMessage(PingRequest().toJSON())) + inputWriter.flush() + + observedThreadName.await() shouldContain threadName + server.close() + } finally { + executor.shutdownNow() + } + } + + // endregion + private fun inputErrors() = listOf( IOException("simulated read failure"), RuntimeException("unexpected read exception"), OutOfMemoryError("unexpected read error"), ) - @Suppress("unused") private fun outputErrors() = listOf( IOException("simulated write failure"), RuntimeException("unexpected write exception"), OutOfMemoryError("unexpected write error"), ) - @Suppress("unused") private fun handlerErrors() = listOf( RuntimeException("handler failure"), IOException("handler IO failure"), diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpResponseDeliveryTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpResponseDeliveryTest.kt new file mode 100644 index 0000000..fc076eb --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpResponseDeliveryTest.kt @@ -0,0 +1,99 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.matchers.shouldBe +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.testing.testApplication +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import kotlin.test.Test + +/** + * A client opening the standalone stream sends `Accept: text/event-stream`, which matches no JSON + * converter. Responses rendered through ContentNegotiation used to reach such a client as an empty + * 406 with the intended status discarded. + */ +class StreamableHttpResponseDeliveryTest { + + private val eventStream = ContentType.Text.EventStream.toString() + + private fun testServer(): Server = Server( + Implementation("test-server", "1.0"), + ServerOptions(capabilities = ServerCapabilities()), + ) + + @Test + fun `stateless GET is rejected with 405 and advertises POST`() = testApplication { + application { mcpStatelessStreamableHttp { testServer() } } + + val response = client.get("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, eventStream) + } + + response.assertMethodNotAllowed() + } + + @Test + fun `stateless DELETE is rejected with 405 and advertises POST`() = testApplication { + application { mcpStatelessStreamableHttp { testServer() } } + + val response = client.delete("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, eventStream) + } + + response.assertMethodNotAllowed() + } + + @Test + fun `stateful DELETE for an unknown session returns 404`() = testApplication { + application { mcpStreamableHttp { testServer() } } + + val response = client.delete("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, eventStream) + header(MCP_SESSION_ID_HEADER, "unknown-session") + } + + response.status shouldBe HttpStatusCode.NotFound + response.decodeError().error.message shouldBe "Session not found" + } + + @Test + fun `POST carrying no request is acknowledged with a bodiless 202`() = testApplication { + application { mcpStatelessStreamableHttp { testServer() } } + + val response = client.post("/mcp") { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, "${ContentType.Application.Json}, $eventStream") + contentType(ContentType.Application.Json) + setBody("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + } + + response.status shouldBe HttpStatusCode.Accepted + response.bodyAsText() shouldBe "" + } + + private suspend fun HttpResponse.assertMethodNotAllowed() { + status shouldBe HttpStatusCode.MethodNotAllowed + headers[HttpHeaders.Allow] shouldBe HttpMethod.Post.value + contentType()?.withoutParameters() shouldBe ContentType.Application.Json + decodeError().error.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED + } + + private suspend fun HttpResponse.decodeError(): JSONRPCError = McpJson.decodeFromString(bodyAsText()) +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt index 8b4f6fa..43ff605 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt @@ -9,6 +9,7 @@ import io.ktor.client.call.body import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.prepareGet @@ -19,18 +20,27 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.ApplicationCall import io.ktor.server.application.install +import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.routing +import io.ktor.server.sse.ServerSSESession import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.testApplication +import io.ktor.sse.ServerSentEvent +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readLine import io.ktor.utils.io.readUTF8Line +import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotification +import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse @@ -39,11 +49,22 @@ import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult import io.modelcontextprotocol.kotlin.sdk.types.McpJson import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.RequestId import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.Tool import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.toJSON +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -51,11 +72,14 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation @@ -79,6 +103,26 @@ class StreamableHttpServerTransportTest { Arguments.of(sizeTestPayload.length.toLong(), HttpStatusCode.BadRequest), Arguments.of(sizeTestPayload.length.toLong() + 1, HttpStatusCode.BadRequest), ) + + @JvmStatic + fun postAcceptHeaderCases(): List = listOf( + Arguments.of("application/json, text/event-stream", HttpStatusCode.OK), + Arguments.of("Application/JSON;q=0.9, TEXT/Event-Stream;charset=utf-8", HttpStatusCode.OK), + Arguments.of("*/*", HttpStatusCode.OK), + Arguments.of("application/*, text/*", HttpStatusCode.OK), + Arguments.of(null, HttpStatusCode.OK), + Arguments.of("application/json", HttpStatusCode.NotAcceptable), + Arguments.of("text/event-stream", HttpStatusCode.NotAcceptable), + Arguments.of("application/jsonp, text/event-stream-bogus", HttpStatusCode.NotAcceptable), + Arguments.of("application/json, text/event-stream;q=0", HttpStatusCode.NotAcceptable), + ) + + @JvmStatic + fun getAcceptHeaderCases() = listOf( + "application/json", + "text/event-stream-bogus", + "text/event-stream;q=0", + ) } private val path = "/transport" @@ -109,6 +153,90 @@ class StreamableHttpServerTransportTest { assertFalse(onMessageCalled.get(), "Transport should not deliver messages when headers are invalid") } + @ParameterizedTest + @MethodSource("postAcceptHeaderCases") + fun `POST Accept header is matched as media ranges`(acceptHeader: String?, expectedStatus: HttpStatusCode) = + testApplication { + configTestServer() + + // Bare client: the ContentNegotiation plugin would supply an Accept header of its own, + // leaving the absent-header case untestable. + val client = createClient {} + + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + val onMessageCalled = AtomicBoolean(false) + transport.onMessage { message -> + onMessageCalled.set(true) + if (message is JSONRPCRequest) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } + } + + configureTransportEndpoint(transport) + + val response = client.post(path) { + contentType(ContentType.Application.Json) + acceptHeader?.let { header(HttpHeaders.Accept, it) } + setBody(McpJson.encodeToString(JSONRPCMessage.serializer(), buildInitializeRequestPayload())) + } + + assertEquals(expectedStatus, response.status) + assertEquals( + expectedStatus == HttpStatusCode.OK, + onMessageCalled.get(), + "Only a request that clears the Accept gate may reach the transport", + ) + } + + @Test + fun `POST with an unparsable Accept header is rejected without failing the request`() = testApplication { + configTestServer() + + val client = createClient {} + + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + val onMessageCalled = AtomicBoolean(false) + transport.onMessage { + onMessageCalled.set(true) + } + + configureTransportEndpoint(transport) + + val response = client.post(path) { + contentType(ContentType.Application.Json) + header(HttpHeaders.Accept, "not-a-media-type") + setBody(McpJson.encodeToString(JSONRPCMessage.serializer(), buildInitializeRequestPayload())) + } + + // The exact code is decided by content negotiation once the transport has rejected the request. + assertTrue(response.status.value in 400..499, "Expected a client error, got ${response.status}") + assertFalse(onMessageCalled.get(), "Transport should not deliver messages when headers are invalid") + } + + @ParameterizedTest + @MethodSource("getAcceptHeaderCases") + fun `GET whose Accept header admits no event-stream range is rejected`(acceptHeader: String) = testApplication { + configTestServer() + + val client = createClient {} + + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + + application { + routing { + get(path) { + transport.handleGetRequest(FakeServerSSESession(call, call.coroutineContext), call) + } + } + } + + val response = client.get(path) { + header(HttpHeaders.Accept, acceptHeader) + } + + assertEquals(HttpStatusCode.NotAcceptable, response.status) + } + @Test fun `initialization request establishes session and returns json response`() = testApplication { configTestServer() @@ -144,7 +272,7 @@ class StreamableHttpServerTransportTest { } @Test - fun `second initialization request returns an HTTP error`() = testApplication { + fun `second initialization request returns JSON-RPC error with request id`() = testApplication { configTestServer() val client = createTestClient() @@ -167,13 +295,74 @@ class StreamableHttpServerTransportTest { firstResponse.status shouldBe HttpStatusCode.OK + val secondRequest = buildInitializeRequestPayload().copy(id = RequestId("second-init")) val secondResponse = client.post(path) { addStreamableHeaders() header("mcp-session-id", firstResponse.headers[MCP_SESSION_ID_HEADER]) - setBody(payload) + setBody(secondRequest) } secondResponse.status shouldBe HttpStatusCode.BadRequest + val error = secondResponse.body() + error.id shouldBe secondRequest.id + error.error.message shouldBe "Invalid Request: Server already initialized" + } + + @Test + fun `init request with unsupported protocol version returns an HTTP error`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + transport.onMessage { message -> + if (message is JSONRPCRequest) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } + } + + configureTransportEndpoint(transport) + + val payload = buildInitializeRequestPayload() + val initResponse = client.post(path) { + addStreamableHeaders() + header("mcp-protocol-version", "1900-01-01") + setBody(payload) + } + + initResponse.status shouldBe HttpStatusCode.BadRequest + initResponse.headers[MCP_SESSION_ID_HEADER] shouldBe null + initResponse.body().id shouldBe payload.id + } + + @Test + fun `malformed initialize params return invalid params over streamable http`() = testApplication { + val mcpPath = "/mcp" + + application { + mcpStreamableHttp(mcpPath, enableDnsRebindingProtection = false) { + Server( + Implementation("test-server", "1.0.0"), + ServerOptions(capabilities = ServerCapabilities()), + ) + } + } + + val client = createTestClient() + + val response = client.post(mcpPath) { + addStreamableHeaders() + setBody( + """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{},"clientInfo":{"name":"repro","version":"0.1.0"}}} + """.trimIndent(), + ) + } + + response.status shouldBe HttpStatusCode.OK + val error = response.body() + error.error.code shouldBe RPCError.ErrorCode.INVALID_PARAMS + error.error.message.contains("kotlinx.serialization") shouldBe false } @Test @@ -191,18 +380,15 @@ class StreamableHttpServerTransportTest { configureTransportEndpoint(transport) - val initPayload = buildInitializeRequestPayload() val initResponse = client.post(path) { addStreamableHeaders() - setBody(initPayload) + setBody(buildInitializeRequestPayload()) } initResponse.status shouldBe HttpStatusCode.OK val sessionId = initResponse.headers[MCP_SESSION_ID_HEADER] assertNotNull(sessionId) - // TODO When https://github.com/modelcontextprotocol/kotlin-sdk/issues/547 is fixed, - // check the incompatible mcp-protocol-version in the InitializeRequest and delete the part below val response = client.post(path) { addStreamableHeaders() header("mcp-session-id", sessionId) @@ -220,6 +406,65 @@ class StreamableHttpServerTransportTest { } response.status shouldBe HttpStatusCode.BadRequest + response.body().id shouldBe RequestId("test-1") + } + + @Test + fun `batch with an initialization request echoes the initialize id when rejected`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + transport.onMessage { } + + configureTransportEndpoint(transport) + + val initPayload = buildInitializeRequestPayload() + val response = client.post(path) { + addStreamableHeaders() + setBody( + encodeMessages( + listOf( + initPayload, + JSONRPCRequest(id = RequestId("extra"), method = Method.Defined.ToolsList.value), + ), + ), + ) + } + + response.status shouldBe HttpStatusCode.BadRequest + val error = response.body() + error.error.message shouldBe "Invalid Request: Only one initialization request is allowed" + error.id shouldBe initPayload.id + } + + @Test + fun `non-init request before initialization echoes the request id`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + transport.onMessage { } + + configureTransportEndpoint(transport) + + val response = client.post(path) { + addStreamableHeaders() + setBody( + encodeMessages( + listOf( + JSONRPCRequest(id = RequestId("before-init"), method = Method.Defined.ToolsList.value), + ), + ), + ) + } + + response.status shouldBe HttpStatusCode.BadRequest + val error = response.body() + error.error.message shouldBe "Bad Request: Server not initialized" + error.id shouldBe RequestId("before-init") } @Test @@ -325,6 +570,306 @@ class StreamableHttpServerTransportTest { assertEquals("second", secondMeta?.get("label")?.jsonPrimitive?.content)*/ } + @Test + fun `json response waits for a response produced after message delivery returns`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = true), + ) + // Simulates concurrent dispatch: message delivery returns before the handler responds. + val handlerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + transport.onMessage { message -> + if (message is JSONRPCRequest) { + if (message.method == Method.Defined.Initialize.value) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } else { + handlerScope.launch { + delay(100) + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } + } + } + } + + configureTransportEndpoint(transport) + + val initResponse = client.post(path) { + addStreamableHeaders() + setBody(buildInitializeRequestPayload()) + } + initResponse.status shouldBe HttpStatusCode.OK + + val request = JSONRPCRequest(id = RequestId("async-1"), method = Method.Defined.ToolsList.value) + val response = withTimeout(10.seconds) { + client.post(path) { + addStreamableHeaders() + header(MCP_SESSION_ID_HEADER, initResponse.headers[MCP_SESSION_ID_HEADER]) + setBody(encodeMessages(listOf(request))) + } + } + + response.status shouldBe HttpStatusCode.OK + response.body() shouldBe JSONRPCResponse(request.id) + } finally { + handlerScope.cancel() + } + } + + @Test + fun `json response for a batch waits until every asynchronously produced response is ready`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = true), + ) + val firstRequest = JSONRPCRequest(id = RequestId("slow"), method = Method.Defined.ToolsList.value) + val secondRequest = JSONRPCRequest(id = RequestId("fast"), method = Method.Defined.ResourcesList.value) + + // Simulates concurrent dispatch with out-of-order completion: the second request responds + // first; the POST must stay open until the slower first response settles too. + val handlerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + transport.onMessage { message -> + if (message is JSONRPCRequest) { + if (message.method == Method.Defined.Initialize.value) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } else { + handlerScope.launch { + delay(if (message.id == firstRequest.id) 200 else 50) + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } + } + } + } + + configureTransportEndpoint(transport) + + val initResponse = client.post(path) { + addStreamableHeaders() + setBody(buildInitializeRequestPayload()) + } + initResponse.status shouldBe HttpStatusCode.OK + + val response = withTimeout(10.seconds) { + client.post(path) { + addStreamableHeaders() + header(MCP_SESSION_ID_HEADER, initResponse.headers[MCP_SESSION_ID_HEADER]) + setBody(encodeMessages(listOf(firstRequest, secondRequest))) + } + } + + response.status shouldBe HttpStatusCode.OK + val responses = response.body>() + responses.map { it.id }.toSet() shouldBe setOf(firstRequest.id, secondRequest.id) + } finally { + handlerScope.cancel() + } + } + + @Test + fun `json response POST completes with 202 when its only request is cancelled`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = true), + ) + val requestDelivered = CompletableDeferred() + // Concurrent dispatch: delivery returns before a response is produced. This request is never + // answered here, modelling a handler cancelled by notifications/cancelled. + transport.onMessage { message -> + if (message is JSONRPCRequest) { + if (message.method == Method.Defined.Initialize.value) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } else { + requestDelivered.complete(Unit) + } + } + } + + configureTransportEndpoint(transport) + + val clientScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + val initResponse = client.post(path) { + addStreamableHeaders() + setBody(buildInitializeRequestPayload()) + } + initResponse.status shouldBe HttpStatusCode.OK + val sessionId = initResponse.headers[MCP_SESSION_ID_HEADER] + + val request = JSONRPCRequest(id = RequestId("cancel-me"), method = Method.Defined.ToolsList.value) + val requestPost = clientScope.async { + client.post(path) { + addStreamableHeaders() + header(MCP_SESSION_ID_HEADER, sessionId) + setBody(encodeMessages(listOf(request))) + } + } + + // The request must be registered in-flight before it can be cancelled. + withTimeout(5.seconds) { requestDelivered.await() } + + val cancellation = CancelledNotification( + CancelledNotificationParams(requestId = request.id, reason = "client cancelled"), + ) + val cancelResponse = client.post(path) { + addStreamableHeaders() + header(MCP_SESSION_ID_HEADER, sessionId) + setBody(encodeMessages(listOf(cancellation.toJSON()))) + } + cancelResponse.status shouldBe HttpStatusCode.Accepted + + // Without retirement of the cancelled id this await never completes and the timeout fires. + val response = withTimeout(10.seconds) { requestPost.await() } + response.status shouldBe HttpStatusCode.Accepted + } finally { + clientScope.cancel() + } + } + + @Test + fun `json response for a batch returns only the non-cancelled responses`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = true), + ) + val answeredA = JSONRPCRequest(id = RequestId("a"), method = Method.Defined.ToolsList.value) + val answeredB = JSONRPCRequest(id = RequestId("b"), method = Method.Defined.ToolsList.value) + val cancelledC = JSONRPCRequest(id = RequestId("c"), method = Method.Defined.ToolsList.value) + + val cancelledDelivered = CompletableDeferred() + val handlerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + transport.onMessage { message -> + if (message is JSONRPCRequest) { + when { + message.method == Method.Defined.Initialize.value -> + transport.send(JSONRPCResponse(message.id, EmptyResult())) + + message.id == answeredA.id || message.id == answeredB.id -> + handlerScope.launch { transport.send(JSONRPCResponse(message.id, EmptyResult())) } + + message.id == cancelledC.id -> cancelledDelivered.complete(Unit) + } + } + } + + configureTransportEndpoint(transport) + + val initResponse = client.post(path) { + addStreamableHeaders() + setBody(buildInitializeRequestPayload()) + } + initResponse.status shouldBe HttpStatusCode.OK + val sessionId = initResponse.headers[MCP_SESSION_ID_HEADER] + + val batchPost = handlerScope.async { + client.post(path) { + addStreamableHeaders() + header(MCP_SESSION_ID_HEADER, sessionId) + setBody(encodeMessages(listOf(answeredA, answeredB, cancelledC))) + } + } + + withTimeout(5.seconds) { cancelledDelivered.await() } + + val cancellation = CancelledNotification(CancelledNotificationParams(requestId = cancelledC.id)) + client.post(path) { + addStreamableHeaders() + header(MCP_SESSION_ID_HEADER, sessionId) + setBody(encodeMessages(listOf(cancellation.toJSON()))) + }.status shouldBe HttpStatusCode.Accepted + + val response = withTimeout(10.seconds) { batchPost.await() } + response.status shouldBe HttpStatusCode.OK + val responses = response.body>() + responses.map { it.id }.toSet() shouldBe setOf(answeredA.id, answeredB.id) + } finally { + handlerScope.cancel() + } + } + + @Test + fun `json response send for a retired request id is dropped without error`() = runTest { + val transport = StreamableHttpServerTransport(enableJsonResponse = true) + // No stream is mapped for this id (already retired by disconnect/cancellation cleanup); + // a late terminal response must be dropped quietly, not raise "No connection established". + transport.send(JSONRPCResponse(RequestId("retired"), EmptyResult())) + } + + @Test + fun `sse response send for an unknown request id still errors`() = runTest { + val transport = StreamableHttpServerTransport(enableJsonResponse = false) + assertFailsWith { + transport.send(JSONRPCResponse(RequestId("unknown"), EmptyResult())) + } + } + + @Test + fun `sse terminal response is dropped without leaking when the per-request stream is gone`() = testApplication { + configTestServer() + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = false), + ) + transport.setSessionIdGenerator(null) // stateless: accept requests without an init handshake + val requestId = RequestId("held-request") + val delivered = CompletableDeferred() + val release = CompletableDeferred() + transport.onMessage { message -> + if (message is JSONRPCRequest) { + delivered.complete(Unit) + release.await() // keep the POST and its stream mapping alive until the test releases it + } + } + + application { + routing { + post(path) { + transport.handlePostRequest(FakeServerSSESession(call, call.coroutineContext), call) + } + } + } + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + val post = scope.async { + client.post(path) { + addStreamableHeaders() + setBody( + encodeMessages( + listOf(JSONRPCRequest(id = requestId, method = Method.Defined.ToolsList.value)), + ), + ) + } + } + withTimeout(5.seconds) { delivered.await() } + + // Drop the per-request SSE stream while the request is still mapped in-flight; the handler's + // terminal response is now undeliverable and must be dropped quietly rather than raise + // "No connection established" (which would also strand the request mappings). + transport.closeSseStream(requestId) + transport.send(JSONRPCResponse(requestId, EmptyResult())) + + post.cancel() + } finally { + release.complete(Unit) + scope.cancel() + } + } + @ParameterizedTest @MethodSource("invalidPayloads") fun `POST with a null or empty body returns an error`(payload: String?) = testApplication { @@ -399,6 +944,34 @@ class StreamableHttpServerTransportTest { response.status shouldBe HttpStatusCode.PayloadTooLarge } + @Test + fun `POST with oversized chunked body and no Content-Length returns 413`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = true, maxRequestBodySize = 1024), + ) + transport.onMessage { message -> + if (message is JSONRPCRequest) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } + } + + configureTransportEndpoint(transport) + + // Stream the body as a channel so the client uses chunked transfer-encoding with no + // Content-Length header: the size limit must hold without trusting the (absent) header. + val oversized = ByteReadChannel("x".repeat(4096).encodeToByteArray()) + val response = client.post(path) { + addStreamableHeaders() + setBody(oversized) + } + + response.status shouldBe HttpStatusCode.PayloadTooLarge + } + @ParameterizedTest @MethodSource("maxBodySizeTestCases") fun `POST with custom max request body size validates payload size`( @@ -438,6 +1011,113 @@ class StreamableHttpServerTransportTest { } } + @Test + fun `second concurrent GET SSE closes old stream and takes over`() = testApplication { + val mcpPath = "/mcp" + + application { + mcpStreamableHttp(mcpPath) { + Server( + Implementation("test-server", "1.0.0"), + ServerOptions(capabilities = ServerCapabilities()), + ) + } + } + + val client = createTestClient() + + // Step 1: Initialize session via POST + val initResponse = client.post(mcpPath) { + header(HttpHeaders.Host, "localhost") + addStreamableHeaders() + setBody(buildInitializeRequestPayload()) + } + initResponse.status shouldBe HttpStatusCode.OK + val sessionId = assertNotNull(initResponse.headers[MCP_SESSION_ID_HEADER]) + + // Step 2: Open first GET SSE stream + client.prepareGet(mcpPath) { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) + header(MCP_SESSION_ID_HEADER, sessionId) + header("mcp-protocol-version", LATEST_PROTOCOL_VERSION) + }.execute { firstResponse -> + firstResponse.status shouldBe HttpStatusCode.OK + firstResponse.bodyAsChannel().readUTF8Line() + + // Step 3: Open a second GET — the transport closes the old session + // and the new stream takes over. + client.prepareGet(mcpPath) { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) + header(MCP_SESSION_ID_HEADER, sessionId) + header("mcp-protocol-version", LATEST_PROTOCOL_VERSION) + }.execute { secondResponse -> + secondResponse.status shouldBe HttpStatusCode.OK + secondResponse.headers[MCP_SESSION_ID_HEADER] shouldBe sessionId + + // New stream is alive + val secondChannel = secondResponse.bodyAsChannel() + val firstLine = secondChannel.readLine() + firstLine.shouldNotBeNull() + secondChannel.isClosedForRead shouldBe false + } + } + } + + @Test + fun `GET SSE reconnect after previous stream disconnects should succeed`() = testApplication { + val mcpPath = "/mcp" + + application { + mcpStreamableHttp(mcpPath) { + Server( + Implementation("test-server", "1.0.0"), + ServerOptions(capabilities = ServerCapabilities()), + ) + } + } + + val client = createTestClient() + + // Step 1: Initialize session via POST + val initResponse = client.post(mcpPath) { + header(HttpHeaders.Host, "localhost") + addStreamableHeaders() + setBody(buildInitializeRequestPayload()) + } + initResponse.status shouldBe HttpStatusCode.OK + val sessionId = assertNotNull(initResponse.headers[MCP_SESSION_ID_HEADER]) + + // Step 2: Open and then close a GET SSE stream + client.prepareGet(mcpPath) { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) + header(MCP_SESSION_ID_HEADER, sessionId) + header("mcp-protocol-version", LATEST_PROTOCOL_VERSION) + }.execute { response -> + response.status shouldBe HttpStatusCode.OK + response.bodyAsChannel().readLine() + } + + // Step 3: Immediately reconnect — the transport should close the stale + // stream and allow the new one. + client.prepareGet(mcpPath) { + header(HttpHeaders.Host, "localhost") + header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) + header(MCP_SESSION_ID_HEADER, sessionId) + header("mcp-protocol-version", LATEST_PROTOCOL_VERSION) + }.execute { response -> + response.status shouldBe HttpStatusCode.OK + response.headers[MCP_SESSION_ID_HEADER] shouldBe sessionId + + val channel = response.bodyAsChannel() + val firstLine = channel.readLine() + firstLine.shouldNotBeNull() + channel.isClosedForRead shouldBe false + } + } + @Test fun `GET SSE stream includes Mcp-Session-Id header and stays open`() = testApplication { val mcpPath = "/mcp" @@ -455,6 +1135,7 @@ class StreamableHttpServerTransportTest { // Step 1: Initialize session via POST val initResponse = client.post(mcpPath) { + header(HttpHeaders.Host, "localhost") addStreamableHeaders() setBody(buildInitializeRequestPayload()) } @@ -463,6 +1144,7 @@ class StreamableHttpServerTransportTest { // Step 2: Open GET SSE stream with session ID client.prepareGet(mcpPath) { + header(HttpHeaders.Host, "localhost") header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) header(MCP_SESSION_ID_HEADER, sessionId) header("mcp-protocol-version", LATEST_PROTOCOL_VERSION) @@ -473,7 +1155,7 @@ class StreamableHttpServerTransportTest { // Verify the stream is alive by reading at least one line (flush event) val channel = response.bodyAsChannel() - val firstLine = channel.readUTF8Line() + val firstLine = channel.readLine() firstLine.shouldNotBeNull() channel.isClosedForRead shouldBe false } @@ -533,3 +1215,12 @@ class StreamableHttpServerTransportTest { } } } + +/** No-op [ServerSSESession] double that lets a POST route drive [StreamableHttpServerTransport] in SSE mode. */ +private class FakeServerSSESession( + override val call: ApplicationCall, + override val coroutineContext: CoroutineContext, +) : ServerSSESession { + override suspend fun send(event: ServerSentEvent) {} + override suspend fun close() {} +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/utils/ToolNameValidationTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/utils/ToolNameValidationTest.kt new file mode 100644 index 0000000..f6760ee --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/utils/ToolNameValidationTest.kt @@ -0,0 +1,123 @@ +package io.modelcontextprotocol.kotlin.sdk.server.utils + +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.collections.shouldNotBeEmpty +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class ToolNameValidationTest { + + @ParameterizedTest + @ValueSource( + strings = [ + "getUser", + "get_user_profile", + "user-profile-update", + "admin.tools.list", + "DATA_EXPORT_v2.1", + "a", + "Z", + "0", + "a-b.c_d", + ], + ) + fun `should return no warnings for valid tool names`(name: String) { + validateToolName(name).shouldBeEmpty() + } + + @Test + fun `should return no warnings for max length name`() { + validateToolName("a".repeat(128)).shouldBeEmpty() + } + + @Test + fun `should warn for empty name`() { + val warnings = validateToolName("") + warnings shouldHaveSize 1 + warnings shouldContain "Tool name cannot be empty" + } + + @Test + fun `should warn for name exceeding max length`() { + val warnings = validateToolName("a".repeat(129)) + warnings.shouldNotBeEmpty() + warnings.any { "exceeds maximum length" in it }.shouldBeTrue() + } + + @ParameterizedTest + @ValueSource(strings = ["get user profile", "my tool"]) + fun `should warn for names with spaces`(name: String) { + val warnings = validateToolName(name) + warnings shouldContain "Tool name contains spaces, which may cause parsing issues" + } + + @ParameterizedTest + @ValueSource(strings = ["get,user,profile", "a,b"]) + fun `should warn for names with commas`(name: String) { + val warnings = validateToolName(name) + warnings shouldContain "Tool name contains commas, which may cause parsing issues" + } + + @ParameterizedTest + @ValueSource(strings = ["user/profile/update", "a/b"]) + fun `should warn for names with forward slashes`(name: String) { + val warnings = validateToolName(name) + warnings.any { "invalid characters" in it }.shouldBeTrue() + } + + @ParameterizedTest + @ValueSource(strings = ["user@domain.com", "tool#1", "tool\$name"]) + fun `should warn for names with special characters`(name: String) { + val warnings = validateToolName(name) + warnings.any { "invalid characters" in it }.shouldBeTrue() + warnings shouldContain "Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)" + } + + @Test + fun `should warn for unicode characters`() { + val warnings = validateToolName("user-\u00f1ame") + warnings.any { "invalid characters" in it }.shouldBeTrue() + } + + @Test + fun `should warn for name starting with dash`() { + val warnings = validateToolName("-my-tool") + warnings shouldContain "Tool name starts or ends with a dash, which may cause parsing issues in some contexts" + } + + @Test + fun `should warn for name ending with dash`() { + val warnings = validateToolName("my-tool-") + warnings shouldContain "Tool name starts or ends with a dash, which may cause parsing issues in some contexts" + } + + @Test + fun `should warn for name starting with dot`() { + val warnings = validateToolName(".hidden") + warnings shouldContain "Tool name starts or ends with a dot, which may cause parsing issues in some contexts" + } + + @Test + fun `should warn for name ending with dot`() { + val warnings = validateToolName("config.") + warnings shouldContain "Tool name starts or ends with a dot, which may cause parsing issues in some contexts" + } + + @Test + fun `should produce multiple warnings for multiple issues`() { + val warnings = validateToolName("my tool,name") + warnings.any { "spaces" in it }.shouldBeTrue() + warnings.any { "commas" in it }.shouldBeTrue() + } + + @Test + fun `should not duplicate space or comma in generic invalid characters message`() { + val warnings = validateToolName("my tool") + warnings shouldContain "Tool name contains spaces, which may cause parsing issues" + warnings.none { it.startsWith("Tool name contains invalid characters") }.shouldBeTrue() + } +} diff --git a/kotlin-sdk-testing/build.gradle.kts b/kotlin-sdk-testing/build.gradle.kts index 902665c..bbb0388 100644 --- a/kotlin-sdk-testing/build.gradle.kts +++ b/kotlin-sdk-testing/build.gradle.kts @@ -4,6 +4,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { id("mcp.multiplatform") + id("mcp.detekt") id("mcp.publishing") id("mcp.dokka") alias(libs.plugins.kotlinx.binary.compatibility.validator) @@ -14,10 +15,8 @@ kotlin { iosArm64() iosX64() iosSimulatorArm64() - watchosX64() watchosArm64() watchosSimulatorArm64() - tvosX64() tvosArm64() tvosSimulatorArm64() js { diff --git a/kotlin-sdk-testing/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport.kt b/kotlin-sdk-testing/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport.kt index e6d19f7..ae5f487 100644 --- a/kotlin-sdk-testing/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport.kt +++ b/kotlin-sdk-testing/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport.kt @@ -27,9 +27,8 @@ import kotlinx.coroutines.yield * communication of JSON-RPC messages. This is useful in scenarios where communication is * message-based and channels are used as the underlying mechanism for delivery. * - * @constructor Initializes the transport with the specified [SendChannel] and [ReceiveChannel]. - * @property sendChannel The channel used to send JSON-RPC messages. - * @property receiveChannel The channel used to receive JSON-RPC messages. + * @param sendChannel The channel used to send JSON-RPC messages. + * @param receiveChannel The channel used to receive JSON-RPC messages. * @param dispatcher The coroutine dispatcher for the event loop. Defaults to [Dispatchers.Default]. */ @ExperimentalMcpApi @@ -75,11 +74,19 @@ public class ChannelTransport( /** * Creates a pair of interconnected [ChannelTransport] objects for bidirectional communication. * - * This method sets up a client and server transport using Kotlin channels, enabling messages sent - * through one transport to be received by the other. It is typically used for testing or in-memory - * communication without external networking. + * Sets up a client and server transport using Kotlin channels, so that messages sent through one + * transport are received by the other. Typically used for testing or in-memory communication + * without external networking. + * + * Example: + * ```kotlin + * val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() + * server.connect(serverTransport) + * client.connect(clientTransport) + * ``` * * @param capacity The buffer capacity of the internal channels used for communication. Defaults to 256. + * @param dispatcher The coroutine dispatcher for the event loop. Defaults to [Dispatchers.Default]. * @return A [LinkedTransports] instance containing the client and server transports. */ public fun createLinkedPair( @@ -106,7 +113,6 @@ public class ChannelTransport( override suspend fun initialize() { logger.info { "ChannelTransport starting message processing" } val started = CompletableDeferred() - @Suppress("TooGenericExceptionCaught") scope.launch(CoroutineName("ChannelTransport#${hashCode()}-event-loop")) { try { // Signal that event loop has started, then yield to ensure we're ready diff --git a/kotlin-sdk-testing/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt b/kotlin-sdk-testing/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt index 7fc41bb..4aae932 100644 --- a/kotlin-sdk-testing/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt +++ b/kotlin-sdk-testing/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt @@ -186,7 +186,6 @@ class ChannelTransportTest { val id = ((msg as JSONRPCRequest).id as RequestId.NumberId).value.toInt() received.add(id) if (id == 2) { - @Suppress("TooGenericExceptionThrown") throw RuntimeException("Error processing message 2") } if (received.size == 4) { diff --git a/kotlin-sdk/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/package.kt b/kotlin-sdk/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/package.kt index 59faee4..13bf272 100644 --- a/kotlin-sdk/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/package.kt +++ b/kotlin-sdk/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/package.kt @@ -1,25 +1,3 @@ -@file:Suppress("ktlint:standard:no-empty-class-body", "ktlint:standard:kdoc") -/** - * # MCP Kotlin SDK - * - * A Kotlin Multiplatform implementation of the Model Context Protocol (MCP). - * - * This is the main SDK module that provides a convenient single dependency - * for all MCP functionality including: - * - * - Core protocol types and utilities ([kotlin-sdk-core]) - * - Client implementations ([kotlin-sdk-client]) - * - Server implementations ([kotlin-sdk-server]) - * - * ## Usage - * - * Add this dependency to your project to get access to all MCP Kotlin SDK functionality: - * - * ```kotlin - * implementation("io.modelcontextprotocol:kotlin-sdk:$version") - * ``` - * - * This will transitively include all core, client, and server components. - */ - package io.modelcontextprotocol.kotlin.sdk + +private class Stub diff --git a/samples/README.md b/samples/README.md index 9789378..f831a74 100644 --- a/samples/README.md +++ b/samples/README.md @@ -9,6 +9,7 @@ For background on the protocol itself, see the [MCP documentation](https://model | Sample | Type | Transport | MCP Features | |--------------------------------------------------------|-------------------|-----------------|------------------------------------| | [simple-streamable-server](./simple-streamable-server) | Server | Streamable HTTP | Tools, Resources, Prompts, Logging | +| [kotlinlang-mcp-server](./kotlinlang-mcp-server) | Server | Streamable HTTP | Tools | | [kotlin-mcp-server](./kotlin-mcp-server) | Server | STDIO, SSE | Tools, Resources, Prompts | | [weather-stdio-server](./weather-stdio-server) | Server | STDIO | Tools | | [kotlin-mcp-client](./kotlin-mcp-client) | Client | STDIO | Tool discovery & invocation | @@ -30,6 +31,13 @@ A minimal Streamable HTTP server with optional Bearer token authentication. Demo (`greet`, `multi-greet`), a prompt template, a resource, and server-to-client logging notifications. [Read more →](./simple-streamable-server) +### Kotlinlang MCP Server + +A Streamable HTTP server that exposes the official Kotlin documentation (kotlinlang.org) to LLM +clients — full-text search via Algolia and page retrieval in markdown. Demonstrates wrapping a real +external API in an MCP server with in-memory caching. +[Read more →](./kotlinlang-mcp-server) + ### Kotlin MCP Server A multi-transport server supporting STDIO, SSE (plain), and SSE (Ktor plugin). Useful for exploring diff --git a/samples/kotlin-mcp-client/gradle/libs.versions.toml b/samples/kotlin-mcp-client/gradle/libs.versions.toml index f56c437..b58eef2 100644 --- a/samples/kotlin-mcp-client/gradle/libs.versions.toml +++ b/samples/kotlin-mcp-client/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] -anthropic = "2.19.0" -kotlin = "2.2.21" -ktor = "3.3.3" -mcp-kotlin = "0.11.0" -shadow = "9.4.1" -slf4j = "2.0.17" +anthropic = "2.62.0" +kotlin = "2.4.20" +ktor = "3.5.2" +mcp-kotlin = "0.15.0" +shadow = "9.6.1" +slf4j = "2.0.19" [libraries] anthropic-java = { group = "com.anthropic", name = "anthropic-java", version.ref = "anthropic" } diff --git a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.jar b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.jar index d997cfc..eddabd2 100644 Binary files a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.jar and b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties index c61a118..ad7845b 100644 --- a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties +++ b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/kotlin-mcp-client/gradlew b/samples/kotlin-mcp-client/gradlew index 739907d..249efbb 100755 --- a/samples/kotlin-mcp-client/gradlew +++ b/samples/kotlin-mcp-client/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/samples/kotlin-mcp-client/gradlew.bat b/samples/kotlin-mcp-client/gradlew.bat index e509b2d..8508ef6 100644 --- a/samples/kotlin-mcp-client/gradlew.bat +++ b/samples/kotlin-mcp-client/gradlew.bat @@ -19,12 +19,12 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,29 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/samples/kotlin-mcp-server/gradle/libs.versions.toml b/samples/kotlin-mcp-server/gradle/libs.versions.toml index 05007d0..d46c2f1 100644 --- a/samples/kotlin-mcp-server/gradle/libs.versions.toml +++ b/samples/kotlin-mcp-server/gradle/libs.versions.toml @@ -1,9 +1,9 @@ [versions] -kotlin = "2.2.21" -ktor = "3.3.3" -mcp-kotlin = "0.11.0" -slf4j = "2.0.17" -shadow = "9.4.1" +kotlin = "2.4.10" +ktor = "3.5.2" +mcp-kotlin = "0.15.0" +slf4j = "2.0.19" +shadow = "9.6.1" [libraries] ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } diff --git a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.jar b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.jar index d997cfc..eddabd2 100644 Binary files a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.jar and b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties index c61a118..ad7845b 100644 --- a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/kotlin-mcp-server/gradlew b/samples/kotlin-mcp-server/gradlew index 739907d..249efbb 100755 --- a/samples/kotlin-mcp-server/gradlew +++ b/samples/kotlin-mcp-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/samples/kotlin-mcp-server/gradlew.bat b/samples/kotlin-mcp-server/gradlew.bat index e509b2d..8508ef6 100644 --- a/samples/kotlin-mcp-server/gradlew.bat +++ b/samples/kotlin-mcp-server/gradlew.bat @@ -19,12 +19,12 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,29 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/samples/kotlinlang-mcp-server/Dockerfile b/samples/kotlinlang-mcp-server/Dockerfile new file mode 100644 index 0000000..ada7e56 --- /dev/null +++ b/samples/kotlinlang-mcp-server/Dockerfile @@ -0,0 +1,14 @@ +FROM gradle:jdk21-alpine AS build +WORKDIR /project +COPY gradle/ gradle/ +COPY gradlew settings.gradle.kts build.gradle.kts ./ +COPY src/ src/ +RUN ./gradlew shadowJar --no-daemon + +FROM eclipse-temurin:21-jre-alpine +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +WORKDIR /app +COPY --from=build /project/build/libs/*-all.jar app.jar +USER appuser +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/samples/kotlinlang-mcp-server/README.md b/samples/kotlinlang-mcp-server/README.md new file mode 100644 index 0000000..df0dd3e --- /dev/null +++ b/samples/kotlinlang-mcp-server/README.md @@ -0,0 +1,105 @@ +# Kotlinlang MCP Server + +A Streamable HTTP MCP server that exposes the official +[Kotlin documentation](https://kotlinlang.org/docs/) to LLM clients — full-text search via +[Algolia](https://www.algolia.com/) plus page retrieval in markdown through kotlinlang.org's +`_llms` endpoints. + +## Overview + +This sample demonstrates an MCP server that wraps a real external documentation source. It uses the +recommended Streamable HTTP transport, supports multiple concurrent client sessions, and caches +responses in memory to reduce upstream load. The server is intentionally read-only — it exposes two +tools, no prompts, no resources. + +## Prerequisites + +- JDK 21+ +- Algolia credentials for `kotlinlang.org` (exported as environment variables, see + [Configuration](#configuration)) + +## Build & Run + +```shell +export ALGOLIA_APP_ID=... +export ALGOLIA_API_KEY=... +export ALGOLIA_INDEX_NAME=... + +./gradlew run +``` + +The server starts on `http://localhost:8080/mcp` by default. + +Connect with the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector): + +```shell +npx @modelcontextprotocol/inspector +``` + +In the Inspector UI, select **Streamable HTTP** transport and enter `http://localhost:8080/mcp`. + +## MCP Capabilities + +### Tools + +| Name | Description | +|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| `search_kotlinlang` | Full-text search across Kotlin documentation. Returns up to 5 results filtered to `/docs/` pages. Responses cached for 10 minutes. | +| `get_kotlinlang_page` | Fetches a documentation page as markdown via kotlinlang.org's `_llms` endpoint. Accepts a path relative to `/docs/`. Cached for 1 hour. | + +Example paths accepted by `get_kotlinlang_page`: `coroutines-overview`, +`multiplatform/compose-multiplatform-and-jetpack-compose`. Leading/trailing slashes and an optional +`.html` suffix are normalized before resolution. + +## Configuration + +| Variable | Required | Default | Description | +|----------------------|----------|-----------|--------------------------| +| `ALGOLIA_APP_ID` | yes | — | Algolia application ID | +| `ALGOLIA_API_KEY` | yes | — | Algolia search API key | +| `ALGOLIA_INDEX_NAME` | yes | — | Algolia index name | +| `SERVER_PORT` | no | `8080` | HTTP server port | +| `SERVER_HOST` | no | `0.0.0.0` | HTTP server bind address | + +The server does not provide fallback values for `ALGOLIA_*`. Startup fails if any of those +variables are missing. + +## Docker + +Multi-stage build with Alpine-based images. Runs as a non-root user. + +```shell +docker build -t kotlinlang-mcp-server . + +docker run \ + -p 8080:8080 \ + -e ALGOLIA_APP_ID=... \ + -e ALGOLIA_API_KEY=... \ + -e ALGOLIA_INDEX_NAME=... \ + kotlinlang-mcp-server +``` + +## Connecting an MCP client + +Any MCP client that supports Streamable HTTP transport can connect: + +```json +{ + "mcpServers": { + "kotlinlang": { + "url": "http://localhost:8080/mcp" + } + } +} +``` + +## Limitations + +This sample is intended **for demonstration only**. Before production use, consider: + +- **In-memory cache only** — cached data is lost on server restart. +- **No authentication or authorization** — run behind a trusted proxy or restrict network access. +- **No rate limiting** on outgoing requests to Algolia and kotlinlang.org. +- **Permissive CORS** (`anyHost()`) — restrict to specific origins for any non-local deployment. +- **Streamable HTTP only** — no STDIO transport for local-only usage. +- **Tools only** — no MCP resources or prompts are exposed. diff --git a/samples/kotlinlang-mcp-server/build.gradle.kts b/samples/kotlinlang-mcp-server/build.gradle.kts new file mode 100644 index 0000000..f46f006 --- /dev/null +++ b/samples/kotlinlang-mcp-server/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.shadow) + application +} + +group = "org.example" +version = "0.1.0" + +application { + mainClass.set("org.kotlinlang.mcp.ApplicationKt") +} + +dependencies { + implementation(platform(libs.ktor.bom)) + implementation(libs.mcp.kotlin.server) + implementation(libs.ktor.server.netty) + implementation(libs.ktor.server.sse) + implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.server.cors) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.logback.classic) + + testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.ktor.client.mock) + testImplementation(libs.mcp.kotlin.client) + testImplementation(libs.mcp.kotlin.testing) +} + +tasks.test { + useJUnitPlatform() +} + +kotlin { + jvmToolchain(21) +} diff --git a/samples/kotlinlang-mcp-server/gradle.properties b/samples/kotlinlang-mcp-server/gradle.properties new file mode 100644 index 0000000..24a5976 --- /dev/null +++ b/samples/kotlinlang-mcp-server/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true + diff --git a/samples/kotlinlang-mcp-server/gradle/libs.versions.toml b/samples/kotlinlang-mcp-server/gradle/libs.versions.toml new file mode 100644 index 0000000..0c11d3b --- /dev/null +++ b/samples/kotlinlang-mcp-server/gradle/libs.versions.toml @@ -0,0 +1,31 @@ +[versions] +kotlin = "2.4.20" +ktor = "3.5.2" +mcp-kotlin = "0.15.0" +serialization = "1.11.0" +coroutines = "1.11.0" +logback = "1.6.3" +shadow = "9.6.1" + +[libraries] +ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } +ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty" } +ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse" } +ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation" } +ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors" } +ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json" } +mcp-kotlin-server = { group = "io.modelcontextprotocol", name = "kotlin-sdk-server", version.ref = "mcp-kotlin" } +mcp-kotlin-client = { group = "io.modelcontextprotocol", name = "kotlin-sdk-client", version.ref = "mcp-kotlin" } +mcp-kotlin-testing = { group = "io.modelcontextprotocol", name = "kotlin-sdk-testing", version.ref = "mcp-kotlin" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } diff --git a/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.jar b/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..eddabd2 Binary files /dev/null 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 new file mode 100644 index 0000000..ad7845b --- /dev/null +++ b/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/samples/kotlinlang-mcp-server/gradlew b/samples/kotlinlang-mcp-server/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/samples/kotlinlang-mcp-server/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/samples/kotlinlang-mcp-server/gradlew.bat b/samples/kotlinlang-mcp-server/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/samples/kotlinlang-mcp-server/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/samples/kotlinlang-mcp-server/settings.gradle.kts b/samples/kotlinlang-mcp-server/settings.gradle.kts new file mode 100644 index 0000000..bf867e0 --- /dev/null +++ b/samples/kotlinlang-mcp-server/settings.gradle.kts @@ -0,0 +1,25 @@ +rootProject.name = "kotlinlang-mcp-server" + +plugins { + // Apply the foojay-resolver plugin to allow automatic download of JDKs + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositories { + mavenLocal() + mavenCentral() + } + + versionCatalogs { + create("libs") { + val mcpKotlinVersion = providers.gradleProperty( + "mcp.kotlin.overrideVersion", + ).orNull + if (mcpKotlinVersion != null) { + logger.lifecycle("Using the override version $mcpKotlinVersion of MCP Kotlin SDK") + version("mcp-kotlin", mcpKotlinVersion) + } + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/Application.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/Application.kt new file mode 100644 index 0000000..e50b1ad --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/Application.kt @@ -0,0 +1,28 @@ +package org.kotlinlang.mcp + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.cors.routing.* +import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp +import org.kotlinlang.mcp.config.toServerConfig + +fun main(args: Array) = EngineMain.main(args) + +fun Application.module() { + val config = environment.config.toServerConfig() + val kotlinlangServer = KotlinlangServer(config) + + monitor.subscribe(ApplicationStopped) { + kotlinlangServer.close() + } + + install(CORS) { + anyHost() + allowHeader(HttpHeaders.ContentType) + } + + mcpStreamableHttp { + kotlinlangServer.server + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/KotlinlangServer.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/KotlinlangServer.kt new file mode 100644 index 0000000..0814342 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/KotlinlangServer.kt @@ -0,0 +1,118 @@ +package org.kotlinlang.mcp + +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.* +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.types.* +import kotlinx.serialization.json.* +import org.kotlinlang.mcp.algolia.AlgoliaClient +import org.kotlinlang.mcp.cache.TtlCache +import org.kotlinlang.mcp.config.ServerConfig +import org.kotlinlang.mcp.content.PageFetcher +import org.kotlinlang.mcp.tools.GetKotlinlangPage +import org.kotlinlang.mcp.tools.SearchKotlinlang +import org.slf4j.LoggerFactory +import java.io.Closeable +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +internal class KotlinlangServer(config: ServerConfig) : Closeable { + + private val logger = LoggerFactory.getLogger(KotlinlangServer::class.java) + + private val httpClient = HttpClient(CIO) { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } + install(HttpTimeout) { requestTimeoutMillis = 10_000 } + expectSuccess = true + } + + private val searchCache = TtlCache(10.minutes) + private val pageCache = TtlCache(1.hours) + + private val algoliaClient = AlgoliaClient(config, httpClient) + private val pageFetcher = PageFetcher(httpClient) + private val searchTool = SearchKotlinlang(algoliaClient, searchCache) + private val pageTool = GetKotlinlangPage(pageFetcher, pageCache) + + val server: Server = Server( + serverInfo = Implementation(name = "kotlinlang-mcp-server", version = "1.0.0"), + options = ServerOptions( + capabilities = ServerCapabilities( + tools = ServerCapabilities.Tools(listChanged = false), + ), + ), + ) { + addTool( + name = "search_kotlinlang", + description = "Search across Kotlin documentation on kotlinlang.org to find relevant pages, " + + "code examples, API references, and guides. Use this tool when you need to answer questions " + + "about Kotlin language features, standard library, coroutines, multiplatform, tooling, or any " + + "other topic covered in the official Kotlin documentation. Returns up to 5 results with page " + + "titles, paths, and text snippets. To get the full content of a specific page, use the " + + "get_kotlinlang_page tool with the page path from the search results.", + inputSchema = ToolSchema( + properties = buildJsonObject { + putJsonObject("query") { + put("type", "string") + put( + "description", + "A search query to find relevant Kotlin documentation pages " + + "(e.g. 'coroutines', 'sealed classes', 'multiplatform setup')", + ) + } + }, + required = listOf("query"), + ), + toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = true), + ) { request -> + val query = request.arguments?.get("query")?.jsonPrimitive?.content + ?: return@addTool CallToolResult( + content = listOf(TextContent(text = "Missing required argument: query")), + isError = true, + ) + logger.info("search_kotlinlang query=\"{}\"", query) + searchTool.handle(query) + } + + addTool( + name = "get_kotlinlang_page", + description = "Retrieve the full content of a specific Kotlin documentation page from " + + "kotlinlang.org in md format. Use this tool when you already know the " + + "page path (e.g., from search results) and need the complete content of that page rather " + + "than just a snippet. If the page is not found, use search_kotlinlang to discover the " + + "correct path.", + inputSchema = ToolSchema( + properties = buildJsonObject { + putJsonObject("path") { + put("type", "string") + put( + "description", + "Page path relative to /docs/, without extension. Use the page paths " + + "returned from search_kotlinlang results " + + "(e.g. 'coroutines-overview', " + + "'multiplatform/compose-multiplatform-and-jetpack-compose')", + ) + } + }, + required = listOf("path"), + ), + toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = true), + ) { request -> + val path = request.arguments?.get("path")?.jsonPrimitive?.content + ?: return@addTool CallToolResult( + content = listOf(TextContent(text = "Missing required argument: path")), + isError = true, + ) + logger.info("get_kotlinlang_page path=\"{}\"", path) + pageTool.handle(path) + } + } + + override fun close() { + httpClient.close() + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClient.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClient.kt new file mode 100644 index 0000000..0d0db42 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClient.kt @@ -0,0 +1,38 @@ +package org.kotlinlang.mcp.algolia + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* +import org.kotlinlang.mcp.config.ServerConfig + +internal class AlgoliaClient( + private val config: ServerConfig, + private val httpClient: HttpClient, +) { + suspend fun search(query: String): AlgoliaSearchResponse { + val response = httpClient.post(buildSearchUrl()) { + header("x-algolia-application-id", config.algoliaAppId) + header("x-algolia-api-key", config.algoliaApiKey) + contentType(ContentType.Application.Json) + setBody( + AlgoliaSearchRequest( + query = query, + hitsPerPage = HITS_PER_PAGE, + attributesToRetrieve = ATTRIBUTES_TO_RETRIEVE, + attributesToSnippet = ATTRIBUTES_TO_SNIPPET, + ) + ) + } + return response.body() + } + + private fun buildSearchUrl(): String = + "https://${config.algoliaAppId}-dsn.algolia.net/1/indexes/${config.algoliaIndexName}/query" + + companion object { + private const val HITS_PER_PAGE = 15 + private val ATTRIBUTES_TO_RETRIEVE = listOf("objectID", "mainTitle", "url", "headings") + private val ATTRIBUTES_TO_SNIPPET = listOf("content:40") + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModels.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModels.kt new file mode 100644 index 0000000..88c465d --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModels.kt @@ -0,0 +1,38 @@ +package org.kotlinlang.mcp.algolia + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class AlgoliaSearchRequest( + val query: String, + val hitsPerPage: Int, + val attributesToRetrieve: List, + val attributesToSnippet: List, +) + +@Serializable +internal data class AlgoliaSearchResponse( + val hits: List, +) + +@Serializable +internal data class AlgoliaHit( + val objectID: String, + val mainTitle: String? = null, + val url: String, + val headings: String? = null, + @SerialName("_snippetResult") + val snippetResult: SnippetResult? = null, +) + +@Serializable +internal data class SnippetResult( + val content: SnippetValue, +) + +@Serializable +internal data class SnippetValue( + val value: String, + val matchLevel: String, +) diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/cache/TtlCache.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/cache/TtlCache.kt new file mode 100644 index 0000000..7ba8de1 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/cache/TtlCache.kt @@ -0,0 +1,71 @@ +package org.kotlinlang.mcp.cache + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import java.util.concurrent.CancellationException +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +internal class TtlCache( + private val ttl: Duration, + private val timeSource: TimeSource = TimeSource.Monotonic, +) { + private class Entry(val value: V, val createdAt: TimeMark) + + private val map = ConcurrentHashMap>() + private val inFlight = ConcurrentHashMap>() + + fun get(key: K): V? { + val entry = map[key] ?: return null + if (entry.createdAt.elapsedNow() >= ttl) { + map.remove(key, entry) + return null + } + return entry.value + } + + fun put(key: K, value: V) { + map[key] = Entry(value, timeSource.markNow()) + evictExpired() + } + + private fun evictExpired() { + map.entries.removeIf { it.value.createdAt.elapsedNow() >= ttl } + } + + suspend fun getOrPut(key: K, loader: suspend () -> V): V { + while (true) { + get(key)?.let { return it } + + val deferred = CompletableDeferred() + val existing = inFlight.putIfAbsent(key, deferred) + if (existing != null) { + try { + return existing.await() + } catch (_: CancellationException) { + currentCoroutineContext().ensureActive() + // The loader's scope was cancelled, not ours — retry + continue + } + } + + try { + val value = loader() + put(key, value) + deferred.complete(value) + return value + } catch (e: CancellationException) { + deferred.cancel() + throw e + } catch (e: Throwable) { + deferred.completeExceptionally(e) + throw e + } finally { + inFlight.remove(key, deferred) + } + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/config/ServerConfig.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/config/ServerConfig.kt new file mode 100644 index 0000000..0c61e13 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/config/ServerConfig.kt @@ -0,0 +1,18 @@ +package org.kotlinlang.mcp.config + +import io.ktor.server.config.* + +internal data class ServerConfig( + val algoliaAppId: String, + val algoliaApiKey: String, + val algoliaIndexName: String, +) { + override fun toString(): String = + "ServerConfig(algoliaAppId=$algoliaAppId, algoliaIndexName=$algoliaIndexName)" +} + +internal fun ApplicationConfig.toServerConfig(): ServerConfig = ServerConfig( + algoliaAppId = property("algolia.appId").getString(), + algoliaApiKey = property("algolia.apiKey").getString(), + algoliaIndexName = property("algolia.indexName").getString(), +) diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/PageFetcher.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/PageFetcher.kt new file mode 100644 index 0000000..a8495b2 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/PageFetcher.kt @@ -0,0 +1,11 @@ +package org.kotlinlang.mcp.content + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* + +internal class PageFetcher(private val httpClient: HttpClient) { + suspend fun fetch(url: String): String { + return httpClient.get(url).bodyAsText() + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/UrlMapper.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/UrlMapper.kt new file mode 100644 index 0000000..ab0a8ff --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/UrlMapper.kt @@ -0,0 +1,20 @@ +package org.kotlinlang.mcp.content + +private const val BASE_URL = "https://kotlinlang.org/docs" + +internal fun normalizePath(path: String): String = path.trim().trim('/').removeSuffix(".html") + +internal fun mapPathToUrl(path: String): String { + val normalized = normalizePath(path) + require(normalized.isNotBlank()) { "Path must not be empty" } + require(".." !in normalized.split('/')) { "Path must not contain '..' segments" } + + val lastSlash = normalized.lastIndexOf('/') + return if (lastSlash == -1) { + "$BASE_URL/_llms/$normalized.txt" + } else { + val dir = normalized.substring(0, lastSlash) + val file = normalized.substring(lastSlash + 1) + "$BASE_URL/$dir/_llms/$file.txt" + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPage.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPage.kt new file mode 100644 index 0000000..d4a5097 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPage.kt @@ -0,0 +1,55 @@ +package org.kotlinlang.mcp.tools + +import io.ktor.client.plugins.* +import io.ktor.http.* +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import kotlinx.coroutines.CancellationException +import org.kotlinlang.mcp.cache.TtlCache +import org.kotlinlang.mcp.content.PageFetcher +import org.kotlinlang.mcp.content.mapPathToUrl +import org.kotlinlang.mcp.content.normalizePath +import org.slf4j.LoggerFactory + +internal class GetKotlinlangPage( + private val pageFetcher: PageFetcher, + private val cache: TtlCache, +) { + private val logger = LoggerFactory.getLogger(GetKotlinlangPage::class.java) + suspend fun handle(path: String): CallToolResult { + val normalizedPath = normalizePath(path) + return try { + val content = cache.getOrPut(normalizedPath) { + val url = mapPathToUrl(normalizedPath) + pageFetcher.fetch(url) + } + CallToolResult(content = listOf(TextContent(text = content))) + } catch (e: CancellationException) { + throw e + } catch (e: IllegalArgumentException) { + CallToolResult( + content = listOf(TextContent(text = "Invalid path: ${e.message}")), + isError = true, + ) + } catch (e: ClientRequestException) { + if (e.response.status != HttpStatusCode.NotFound) { + logger.error("Failed to fetch page path=\"{}\"", path, e) + } + val message = if (e.response.status == HttpStatusCode.NotFound) { + "Page not found: $path. Use search_kotlinlang to find the correct page path." + } else { + "Failed to fetch page: ${e.message}" + } + CallToolResult( + content = listOf(TextContent(text = message)), + isError = true, + ) + } catch (e: Exception) { + logger.error("Failed to fetch page path=\"{}\"", path, e) + CallToolResult( + content = listOf(TextContent(text = "Failed to fetch page: ${e.message ?: "unknown error"}")), + isError = true, + ) + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlang.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlang.kt new file mode 100644 index 0000000..886f3f7 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlang.kt @@ -0,0 +1,62 @@ +package org.kotlinlang.mcp.tools + +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import kotlinx.coroutines.CancellationException +import org.kotlinlang.mcp.algolia.AlgoliaClient +import org.kotlinlang.mcp.algolia.AlgoliaHit +import org.kotlinlang.mcp.cache.TtlCache +import org.slf4j.LoggerFactory + +internal class SearchKotlinlang( + private val algoliaClient: AlgoliaClient, + private val cache: TtlCache, +) { + private val logger = LoggerFactory.getLogger(SearchKotlinlang::class.java) + + companion object { + private const val MAX_RESULTS = 5 + private val HTML_TAG_REGEX = Regex("<[^>]+>") + } + + suspend fun handle(query: String): CallToolResult { + return try { + val text = cache.getOrPut(query) { + val response = algoliaClient.search(query) + val filtered = response.hits + .filter { it.url.startsWith("/docs/") } + .take(MAX_RESULTS) + if (filtered.isEmpty()) { + "No results found for: $query" + } else { + formatResults(filtered) + } + } + CallToolResult(content = listOf(TextContent(text = text))) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Search failed for query=\"{}\"", query, e) + CallToolResult( + content = listOf(TextContent(text = "Search failed: ${e.message ?: "unknown error"}")), + isError = true, + ) + } + } + + private fun formatResults(hits: List): String = + hits.mapIndexed { index, hit -> + val title = hit.mainTitle ?: "Untitled" + val path = extractPath(hit.url) + val snippetLine = hit.snippetResult?.content?.value + ?.let { "\n ${stripHtml(it)}" } + ?: "" + "${index + 1}. $title [$path]$snippetLine" + }.joinToString("\n\n") + + private fun extractPath(url: String): String = + url.removePrefix("/docs/").removeSuffix(".html") + + private fun stripHtml(text: String): String = + text.replace(HTML_TAG_REGEX, "") +} diff --git a/samples/kotlinlang-mcp-server/src/main/resources/application.conf b/samples/kotlinlang-mcp-server/src/main/resources/application.conf new file mode 100644 index 0000000..4908da5 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/resources/application.conf @@ -0,0 +1,17 @@ +ktor { + deployment { + port = 8080 + port = ${?SERVER_PORT} + host = "0.0.0.0" + host = ${?SERVER_HOST} + } + application { + modules = [ org.kotlinlang.mcp.ApplicationKt.module ] + } +} + +algolia { + appId = ${ALGOLIA_APP_ID} + apiKey = ${ALGOLIA_API_KEY} + indexName = ${ALGOLIA_INDEX_NAME} +} diff --git a/samples/kotlinlang-mcp-server/src/main/resources/logback.xml b/samples/kotlinlang-mcp-server/src/main/resources/logback.xml new file mode 100644 index 0000000..0ffe8a3 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/resources/logback.xml @@ -0,0 +1,15 @@ + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/KotlinlangServerTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/KotlinlangServerTest.kt new file mode 100644 index 0000000..c6786fb --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/KotlinlangServerTest.kt @@ -0,0 +1,62 @@ +package org.kotlinlang.mcp + +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.testing.ChannelTransport +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.kotlinlang.mcp.config.ServerConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalMcpApi::class) +class KotlinlangServerTest { + + private val testConfig = ServerConfig( + algoliaAppId = "test-app-id", + algoliaApiKey = "test-api-key", + algoliaIndexName = "test-index", + ) + + @Test + fun `server registers both tools with correct schema and annotations`() = runTest { + val kotlinlangServer = KotlinlangServer(testConfig) + kotlinlangServer.use { + val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() + + val client = Client( + clientInfo = Implementation(name = "test-client", version = "1.0"), + options = ClientOptions(), + ) + + joinAll( + launch { client.connect(clientTransport) }, + launch { kotlinlangServer.server.createSession(serverTransport) }, + ) + + val tools = client.listTools().tools + + assertEquals(2, tools.size) + + val searchTool = tools.find { it.name == "search_kotlinlang" } + val searchProps = searchTool?.inputSchema?.properties + assertTrue(searchProps?.containsKey("query") ?: false) + assertEquals(listOf("query"), searchTool.inputSchema.required) + assertEquals(true, searchTool.annotations?.readOnlyHint) + assertEquals(true, searchTool.annotations?.openWorldHint) + + val pageTool = tools.find { it.name == "get_kotlinlang_page" } + val pageProps = pageTool?.inputSchema?.properties + assertTrue(pageProps?.containsKey("path") ?: false) + assertEquals(listOf("path"), pageTool.inputSchema.required) + assertEquals(true, pageTool.annotations?.readOnlyHint) + assertEquals(true, pageTool.annotations?.openWorldHint) + + client.close() + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClientTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClientTest.kt new file mode 100644 index 0000000..4f9dff6 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClientTest.kt @@ -0,0 +1,166 @@ +package org.kotlinlang.mcp.algolia + +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.kotlinlang.mcp.config.ServerConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AlgoliaClientTest { + + private val testConfig = ServerConfig( + algoliaAppId = "test-app-id", + algoliaApiKey = "test-api-key", + algoliaIndexName = "test-index", + ) + + private val json = Json { ignoreUnknownKeys = true } + + private fun createClient(mockEngine: MockEngine): AlgoliaClient { + val httpClient = HttpClient(mockEngine) { + install(ContentNegotiation) { json(json) } + expectSuccess = true + } + return AlgoliaClient(testConfig, httpClient) + } + + @Test + fun `search sends correct URL and headers`() = runTest { + val mockEngine = MockEngine { request -> + assertEquals( + "https://test-app-id-dsn.algolia.net/1/indexes/test-index/query", + request.url.toString(), + ) + assertEquals("test-app-id", request.headers["x-algolia-application-id"]) + assertEquals("test-api-key", request.headers["x-algolia-api-key"]) + assertEquals(HttpMethod.Post, request.method) + + respond( + content = """{"hits": []}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + val client = createClient(mockEngine) + client.search("coroutines") + } + + @Test + fun `search sends correct request body`() = runTest { + val mockEngine = MockEngine { request -> + val body = json.decodeFromString(request.body.toByteArray().decodeToString()) + assertEquals("coroutines", body.query) + assertEquals(15, body.hitsPerPage) + assertEquals(listOf("objectID", "mainTitle", "url", "headings"), body.attributesToRetrieve) + assertEquals(listOf("content:40"), body.attributesToSnippet) + + respond( + content = """{"hits": []}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + val client = createClient(mockEngine) + client.search("coroutines") + } + + @Test + fun `search parses response with hits`() = runTest { + val responseJson = """ + { + "hits": [ + { + "objectID": "abc123", + "mainTitle": "Coroutines overview", + "url": "/docs/coroutines-overview.html", + "headings": "Introduction | First coroutine", + "_snippetResult": { + "content": { + "value": "Kotlin provides coroutines support", + "matchLevel": "full" + } + } + } + ] + } + """.trimIndent() + + val mockEngine = MockEngine { + respond( + content = responseJson, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + val client = createClient(mockEngine) + val result = client.search("coroutines") + + assertEquals(1, result.hits.size) + val hit = result.hits[0] + assertEquals("abc123", hit.objectID) + assertEquals("Coroutines overview", hit.mainTitle) + assertEquals("/docs/coroutines-overview.html", hit.url) + assertEquals("Introduction | First coroutine", hit.headings) + assertEquals("Kotlin provides coroutines support", hit.snippetResult?.content?.value) + assertEquals("full", hit.snippetResult?.content?.matchLevel) + } + + @Test + fun `search returns empty list when no hits`() = runTest { + val mockEngine = MockEngine { + respond( + content = """{"hits": []}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + val client = createClient(mockEngine) + val result = client.search("nonexistent") + + assertTrue(result.hits.isEmpty()) + } + + @Test + fun `search throws on 4xx error`() = runTest { + val mockEngine = MockEngine { + respond( + content = """{"message": "Invalid Application-ID or API key"}""", + status = HttpStatusCode.Forbidden, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + val client = createClient(mockEngine) + assertFailsWith { + client.search("coroutines") + } + } + + @Test + fun `search throws on 5xx error`() = runTest { + val mockEngine = MockEngine { + respond( + content = """{"message": "Internal server error"}""", + status = HttpStatusCode.InternalServerError, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + val client = createClient(mockEngine) + assertFailsWith { + client.search("coroutines") + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModelsTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModelsTest.kt new file mode 100644 index 0000000..10dbe5b --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModelsTest.kt @@ -0,0 +1,126 @@ +package org.kotlinlang.mcp.algolia + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class AlgoliaModelsTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `serializes search request to JSON`() { + val request = AlgoliaSearchRequest( + query = "coroutines", + hitsPerPage = 15, + attributesToRetrieve = listOf("mainTitle", "url", "headings"), + attributesToSnippet = listOf("content:40"), + ) + + val encoded = json.encodeToString(request) + val decoded = json.decodeFromString(encoded) + + assertEquals(request, decoded) + } + + @Test + fun `parses full response with nested snippet`() { + val responseJson = """ + { + "hits": [ + { + "objectID": "abc123", + "mainTitle": "Coroutines overview", + "url": "/docs/coroutines-overview.html", + "headings": "Introduction | First coroutine", + "_snippetResult": { + "content": { + "value": "Kotlin provides coroutines support", + "matchLevel": "full" + } + } + } + ] + } + """.trimIndent() + + val response = json.decodeFromString(responseJson) + + assertEquals(1, response.hits.size) + val hit = response.hits[0] + assertEquals("abc123", hit.objectID) + assertEquals("Coroutines overview", hit.mainTitle) + assertEquals("/docs/coroutines-overview.html", hit.url) + assertEquals("Introduction | First coroutine", hit.headings) + assertEquals("Kotlin provides coroutines support", hit.snippetResult?.content?.value) + assertEquals("full", hit.snippetResult?.content?.matchLevel) + } + + @Test + fun `parses hit with missing nullable fields`() { + val responseJson = """ + { + "hits": [ + { + "objectID": "def456", + "url": "/docs/basic-syntax.html" + } + ] + } + """.trimIndent() + + val response = json.decodeFromString(responseJson) + + val hit = response.hits[0] + assertEquals("def456", hit.objectID) + assertEquals("/docs/basic-syntax.html", hit.url) + assertEquals(null, hit.mainTitle) + assertEquals(null, hit.headings) + assertEquals(null, hit.snippetResult) + } + + @Test + fun `parses response ignoring unknown fields`() { + val responseJson = """ + { + "hits": [ + { + "objectID": "ghi789", + "url": "/docs/functions.html", + "unknownField": 42, + "_snippetResult": { + "content": { + "value": "snippet text", + "matchLevel": "none", + "fullyHighlighted": false + }, + "anotherUnknown": "ignored" + } + } + ], + "nbHits": 100, + "page": 0, + "processingTimeMS": 5 + } + """.trimIndent() + + val response = json.decodeFromString(responseJson) + + assertEquals(1, response.hits.size) + assertEquals("ghi789", response.hits[0].objectID) + assertEquals("snippet text", response.hits[0].snippetResult?.content?.value) + } + + @Test + fun `parses response with empty hits list`() { + val responseJson = """ + { + "hits": [] + } + """.trimIndent() + + val response = json.decodeFromString(responseJson) + + assertEquals(emptyList(), response.hits) + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/cache/TtlCacheTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/cache/TtlCacheTest.kt new file mode 100644 index 0000000..a191fe7 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/cache/TtlCacheTest.kt @@ -0,0 +1,144 @@ +package org.kotlinlang.mcp.cache + +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.minutes +import kotlin.time.TestTimeSource + +class TtlCacheTest { + + @Test + fun `get returns value that was put`() { + val cache = TtlCache(ttl = 10.minutes) + cache.put("key", "value") + assertEquals("value", cache.get("key")) + } + + @Test + fun `get returns null for missing key`() { + val cache = TtlCache(ttl = 10.minutes) + assertNull(cache.get("missing")) + } + + @Test + fun `get returns null after TTL expires`() { + val timeSource = TestTimeSource() + val cache = TtlCache(ttl = 10.minutes, timeSource = timeSource) + + cache.put("key", "value") + assertEquals("value", cache.get("key")) + + timeSource += 11.minutes + assertNull(cache.get("key")) + } + + @Test + fun `get returns value just before TTL expires`() { + val timeSource = TestTimeSource() + val cache = TtlCache(ttl = 10.minutes, timeSource = timeSource) + + cache.put("key", "value") + timeSource += 9.minutes + assertEquals("value", cache.get("key")) + } + + @Test + fun `put overwrites value and resets TTL`() { + val timeSource = TestTimeSource() + val cache = TtlCache(ttl = 10.minutes, timeSource = timeSource) + + cache.put("key", "old") + timeSource += 8.minutes + + cache.put("key", "new") + timeSource += 8.minutes + + assertEquals("new", cache.get("key")) + } + + @Test + fun `getOrPut returns cached value without calling loader`() = runTest { + val cache = TtlCache(ttl = 10.minutes) + cache.put("key", "cached") + + var loaderCalled = false + val result = cache.getOrPut("key") { + loaderCalled = true + "loaded" + } + + assertEquals("cached", result) + assertEquals(false, loaderCalled) + } + + @Test + fun `getOrPut calls loader on cache miss and caches result`() = runTest { + val cache = TtlCache(ttl = 10.minutes) + + val result = cache.getOrPut("key") { "loaded" } + + assertEquals("loaded", result) + assertEquals("loaded", cache.get("key")) + } + + @Test + fun `getOrPut calls loader again after TTL expires`() = runTest { + val timeSource = TestTimeSource() + val cache = TtlCache(ttl = 10.minutes, timeSource = timeSource) + + cache.getOrPut("key") { "first" } + timeSource += 11.minutes + + val result = cache.getOrPut("key") { "second" } + assertEquals("second", result) + } + + @Test + fun `put evicts expired entries`() { + val timeSource = TestTimeSource() + val cache = TtlCache(ttl = 10.minutes, timeSource = timeSource) + + cache.put("a", "1") + cache.put("b", "2") + timeSource += 11.minutes + + // Both entries are expired but still in memory. + // A new put triggers eviction of expired entries. + cache.put("c", "3") + + assertNull(cache.get("a")) + assertNull(cache.get("b")) + assertEquals("3", cache.get("c")) + } + + @Test + fun `put does not evict non-expired entries`() { + val timeSource = TestTimeSource() + val cache = TtlCache(ttl = 10.minutes, timeSource = timeSource) + + cache.put("a", "1") + timeSource += 5.minutes + cache.put("b", "2") + + assertEquals("1", cache.get("a")) + assertEquals("2", cache.get("b")) + } + + @Test + fun `concurrent put and get do not throw`() = runTest { + val cache = TtlCache(ttl = 10.minutes) + val jobs = (1..100).map { i -> + launch { + cache.put(i, "value-$i") + cache.get(i) + cache.put(i, "updated-$i") + cache.get(i) + } + } + jobs.joinAll() + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/config/ServerConfigTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/config/ServerConfigTest.kt new file mode 100644 index 0000000..5714602 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/config/ServerConfigTest.kt @@ -0,0 +1,35 @@ +package org.kotlinlang.mcp.config + +import io.ktor.server.config.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ServerConfigTest { + + @Test + fun `toServerConfig maps all Algolia properties`() { + val config = MapApplicationConfig( + "algolia.appId" to "test-app-id", + "algolia.apiKey" to "test-api-key", + "algolia.indexName" to "test-index", + ) + + val serverConfig = config.toServerConfig() + + assertEquals("test-app-id", serverConfig.algoliaAppId) + assertEquals("test-api-key", serverConfig.algoliaApiKey) + assertEquals("test-index", serverConfig.algoliaIndexName) + } + + @Test + fun `toServerConfig throws when required property is missing`() { + val config = MapApplicationConfig( + "algolia.appId" to "test-app-id", + ) + + assertFailsWith { + config.toServerConfig() + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/PageFetcherTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/PageFetcherTest.kt new file mode 100644 index 0000000..1cd3e6b --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/PageFetcherTest.kt @@ -0,0 +1,87 @@ +package org.kotlinlang.mcp.content + +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.* +import io.ktor.http.* +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class PageFetcherTest { + + private fun createFetcher(mockEngine: MockEngine): PageFetcher { + val httpClient = HttpClient(mockEngine) { + expectSuccess = true + } + return PageFetcher(httpClient) + } + + @Test + fun `fetch sends GET request to provided URL`() = runTest { + val mockEngine = MockEngine { request -> + assertEquals("https://kotlinlang.org/docs/_llms/coroutines-overview.txt", request.url.toString()) + assertEquals(HttpMethod.Get, request.method) + + respond( + content = "# Coroutines overview", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + } + + val fetcher = createFetcher(mockEngine) + fetcher.fetch("https://kotlinlang.org/docs/_llms/coroutines-overview.txt") + } + + @Test + fun `fetch returns response body as text`() = runTest { + val pageContent = "# Coroutines\n\nKotlin provides coroutines support at the language level." + + val mockEngine = MockEngine { + respond( + content = pageContent, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + } + + val fetcher = createFetcher(mockEngine) + val result = fetcher.fetch("https://kotlinlang.org/docs/_llms/coroutines-overview.txt") + + assertEquals(pageContent, result) + } + + @Test + fun `fetch throws ClientRequestException on 404`() = runTest { + val mockEngine = MockEngine { + respond( + content = "Not Found", + status = HttpStatusCode.NotFound, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + } + + val fetcher = createFetcher(mockEngine) + assertFailsWith { + fetcher.fetch("https://kotlinlang.org/docs/_llms/nonexistent.txt") + } + } + + @Test + fun `fetch throws ServerResponseException on 5xx`() = runTest { + val mockEngine = MockEngine { + respond( + content = "Internal Server Error", + status = HttpStatusCode.InternalServerError, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + } + + val fetcher = createFetcher(mockEngine) + assertFailsWith { + fetcher.fetch("https://kotlinlang.org/docs/_llms/coroutines-overview.txt") + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/UrlMapperTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/UrlMapperTest.kt new file mode 100644 index 0000000..c2076a3 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/UrlMapperTest.kt @@ -0,0 +1,91 @@ +package org.kotlinlang.mcp.content + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class UrlMapperTest { + + @Test + fun `top-level path maps to _llms URL`() { + assertEquals( + "https://kotlinlang.org/docs/_llms/coroutines-overview.txt", + mapPathToUrl("coroutines-overview"), + ) + } + + @Test + fun `nested path maps to _llms URL`() { + assertEquals( + "https://kotlinlang.org/docs/multiplatform/_llms/compose.txt", + mapPathToUrl("multiplatform/compose"), + ) + } + + @Test + fun `deep nested path maps to _llms URL`() { + assertEquals( + "https://kotlinlang.org/docs/a/b/_llms/c.txt", + mapPathToUrl("a/b/c"), + ) + } + + @Test + fun `trims leading and trailing slashes`() { + assertEquals( + "https://kotlinlang.org/docs/_llms/coroutines-overview.txt", + mapPathToUrl("/coroutines-overview/"), + ) + } + + @Test + fun `strips html extension`() { + assertEquals( + "https://kotlinlang.org/docs/_llms/coroutines-overview.txt", + mapPathToUrl("coroutines-overview.html"), + ) + } + + @Test + fun `normalizes slashes and html extension together`() { + assertEquals( + "https://kotlinlang.org/docs/multiplatform/_llms/compose.txt", + mapPathToUrl("/multiplatform/compose.html"), + ) + } + + @Test + fun `empty string throws IllegalArgumentException`() { + assertFailsWith { + mapPathToUrl("") + } + } + + @Test + fun `only slashes throws IllegalArgumentException`() { + assertFailsWith { + mapPathToUrl("///") + } + } + + @Test + fun `blank path throws IllegalArgumentException`() { + assertFailsWith { + mapPathToUrl(" ") + } + } + + @Test + fun `path traversal throws IllegalArgumentException`() { + assertFailsWith { + mapPathToUrl("../../secret") + } + } + + @Test + fun `path traversal in the middle throws IllegalArgumentException`() { + assertFailsWith { + mapPathToUrl("multiplatform/../secret") + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPageTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPageTest.kt new file mode 100644 index 0000000..18f0cfe --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPageTest.kt @@ -0,0 +1,139 @@ +package org.kotlinlang.mcp.tools + +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.http.* +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import kotlinx.coroutines.test.runTest +import org.kotlinlang.mcp.cache.TtlCache +import org.kotlinlang.mcp.content.PageFetcher +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.hours + +class GetKotlinlangPageTest { + + private fun createTool(mockEngine: MockEngine): GetKotlinlangPage { + val httpClient = HttpClient(mockEngine) { + expectSuccess = true + } + val pageFetcher = PageFetcher(httpClient) + val cache = TtlCache(ttl = 1.hours) + return GetKotlinlangPage(pageFetcher, cache) + } + + @Test + fun `handle returns page content for valid path`() = runTest { + val pageContent = "# Coroutines\n\nKotlin provides coroutines support." + val mockEngine = MockEngine { request -> + assertEquals( + "https://kotlinlang.org/docs/_llms/coroutines-overview.txt", + request.url.toString(), + ) + respond( + content = pageContent, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + } + val tool = createTool(mockEngine) + + val result = tool.handle("coroutines-overview") + val text = (result.content.first() as TextContent).text + + assertNull(result.isError) + assertEquals(pageContent, text) + } + + @Test + fun `handle returns cached content on second call`() = runTest { + var callCount = 0 + val mockEngine = MockEngine { + callCount++ + respond( + content = "# Page content", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + } + val tool = createTool(mockEngine) + + val result1 = tool.handle("coroutines-overview") + val result2 = tool.handle("coroutines-overview") + + assertEquals(1, callCount) + assertEquals( + (result1.content.first() as TextContent).text, + (result2.content.first() as TextContent).text, + ) + } + + @Test + fun `handle returns error with hint on 404`() = runTest { + val mockEngine = MockEngine { + respond( + content = "Not Found", + status = HttpStatusCode.NotFound, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + } + val tool = createTool(mockEngine) + + val result = tool.handle("nonexistent-page") + val text = (result.content.first() as TextContent).text + + assertEquals(true, result.isError) + assertTrue(text.contains("Page not found: nonexistent-page")) + assertTrue(text.contains("search_kotlinlang")) + } + + @Test + fun `handle returns error on network failure`() = runTest { + val mockEngine = MockEngine { + respond( + content = "Internal Server Error", + status = HttpStatusCode.InternalServerError, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + } + val tool = createTool(mockEngine) + + val result = tool.handle("coroutines-overview") + + assertEquals(true, result.isError) + val text = (result.content.first() as TextContent).text + assertTrue(text.startsWith("Failed to fetch page:")) + } + + @Test + fun `handle returns error on empty path`() = runTest { + val mockEngine = MockEngine { + respond( + content = "should not reach", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + } + val tool = createTool(mockEngine) + + val result = tool.handle("") + + assertEquals(true, result.isError) + val text = (result.content.first() as TextContent).text + assertTrue(text.contains("Invalid path:")) + } + + @Test + fun `handle rethrows CancellationException`() = runTest { + val mockEngine = MockEngine { + throw kotlinx.coroutines.CancellationException("cancelled") + } + val tool = createTool(mockEngine) + + kotlin.test.assertFailsWith { + tool.handle("coroutines-overview") + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlangTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlangTest.kt new file mode 100644 index 0000000..7e10420 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlangTest.kt @@ -0,0 +1,231 @@ +package org.kotlinlang.mcp.tools + +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.kotlinlang.mcp.algolia.AlgoliaClient +import org.kotlinlang.mcp.cache.TtlCache +import org.kotlinlang.mcp.config.ServerConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes +import kotlin.time.TestTimeSource + +class SearchKotlinlangTest { + + private val testConfig = ServerConfig( + algoliaAppId = "test-app-id", + algoliaApiKey = "test-api-key", + algoliaIndexName = "test-index", + ) + + private val json = Json { ignoreUnknownKeys = true } + + private fun createSearchTool( + mockEngine: MockEngine, + timeSource: TestTimeSource = TestTimeSource(), + ): SearchKotlinlang { + val httpClient = HttpClient(mockEngine) { + install(ContentNegotiation) { json(json) } + } + val algoliaClient = AlgoliaClient(testConfig, httpClient) + val cache = TtlCache(ttl = 10.minutes, timeSource = timeSource) + return SearchKotlinlang(algoliaClient, cache) + } + + private fun mockEngineWithHits(hitsJson: String): MockEngine = MockEngine { + respond( + content = """{"hits": [$hitsJson]}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + private fun hit( + objectID: String = "id1", + mainTitle: String? = "Title", + url: String = "/docs/test.html", + snippet: String? = "some text", + matchLevel: String = "full", + ): String = buildString { + append("""{"objectID":"$objectID","url":"$url"""") + if (mainTitle != null) append(""","mainTitle":"$mainTitle"""") + if (snippet != null) { + append(""","_snippetResult":{"content":{"value":"$snippet","matchLevel":"$matchLevel"}}""") + } + append("}") + } + + @Test + fun `filters out non-docs hits`() = runTest { + val engine = mockEngineWithHits( + listOf( + hit(objectID = "1", url = "/docs/coroutines.html", mainTitle = "Coroutines"), + hit(objectID = "2", url = "https://kotlinlang.org/api/core/kotlin/", mainTitle = "API"), + hit(objectID = "3", url = "/docs/flow.html", mainTitle = "Flow"), + ).joinToString(",") + ) + val tool = createSearchTool(engine) + + val result = tool.handle("coroutines") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertTrue(text.contains("Coroutines")) + assertTrue(text.contains("Flow")) + assertTrue(!text.contains("API")) + } + + @Test + fun `takes only top 5 after filtering`() = runTest { + val hits = (1..10).map { i -> + hit(objectID = "id$i", url = "/docs/page$i.html", mainTitle = "Page $i", snippet = "text $i") + } + val engine = mockEngineWithHits(hits.joinToString(",")) + val tool = createSearchTool(engine) + + val result = tool.handle("kotlin") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + for (i in 1..5) assertTrue(text.contains("Page $i"), "Should contain Page $i") + for (i in 6..10) assertTrue(!text.contains("Page $i"), "Should not contain Page $i") + } + + @Test + fun `formats results with title path and snippet`() = runTest { + val engine = mockEngineWithHits( + hit( + objectID = "1", + url = "/docs/coroutines-overview.html", + mainTitle = "Coroutines overview", + snippet = "Kotlin provides coroutines support", + ) + ) + val tool = createSearchTool(engine) + + val result = tool.handle("coroutines") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertEquals( + """ + 1. Coroutines overview [coroutines-overview] + Kotlin provides coroutines support + """.trimIndent(), + text, + ) + } + + @Test + fun `strips HTML tags from snippet`() = runTest { + val engine = mockEngineWithHits( + hit(snippet = "coroutines are great") + ) + val tool = createSearchTool(engine) + + val result = tool.handle("coroutines") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertTrue(text.contains("coroutines are great")) + assertTrue(!text.contains("")) + assertTrue(!text.contains("")) + } + + @Test + fun `uses Untitled when mainTitle is null`() = runTest { + val engine = mockEngineWithHits( + hit(mainTitle = null, url = "/docs/basics.html", snippet = "some text") + ) + val tool = createSearchTool(engine) + + val result = tool.handle("basics") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertTrue(text.contains("Untitled [basics]")) + } + + @Test + fun `omits snippet line when snippetResult is null`() = runTest { + val engine = mockEngineWithHits( + hit(url = "/docs/basics.html", mainTitle = "Basics", snippet = null) + ) + val tool = createSearchTool(engine) + + val result = tool.handle("basics") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertEquals("1. Basics [basics]", text) + } + + @Test + fun `returns no results message when all hits are filtered out`() = runTest { + val engine = mockEngineWithHits( + hit(url = "https://kotlinlang.org/api/core/kotlin/", mainTitle = "API") + ) + val tool = createSearchTool(engine) + + val result = tool.handle("something") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertNull(result.isError) + assertEquals("No results found for: something", text) + } + + @Test + fun `returns isError true when Algolia fails`() = runTest { + val engine = MockEngine { + respond( + content = """{"message": "Internal server error"}""", + status = HttpStatusCode.InternalServerError, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val tool = createSearchTool(engine) + + val result = tool.handle("coroutines") + + assertEquals(true, result.isError) + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + assertTrue(text.startsWith("Search failed:")) + } + + @Test + fun `caches result and does not call Algolia on second request`() = runTest { + var callCount = 0 + val engine = MockEngine { + callCount++ + respond( + content = """{"hits": [${hit(url = "/docs/test.html")}]}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val tool = createSearchTool(engine) + + tool.handle("coroutines") + tool.handle("coroutines") + + assertEquals(1, callCount) + } + + @Test + fun `extracts path from nested URL`() = runTest { + val engine = mockEngineWithHits( + hit( + url = "/docs/multiplatform/compose.html", + mainTitle = "Compose", + snippet = "text", + ) + ) + val tool = createSearchTool(engine) + + val result = tool.handle("compose") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertTrue(text.contains("[multiplatform/compose]")) + } +} diff --git a/samples/simple-streamable-server/gradle/libs.versions.toml b/samples/simple-streamable-server/gradle/libs.versions.toml index e74e9df..5f35f74 100644 --- a/samples/simple-streamable-server/gradle/libs.versions.toml +++ b/samples/simple-streamable-server/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] -kotlin = "2.2.21" -ktor = "3.3.3" -junit = "6.0.3" -mcp-kotlin = "0.11.0" -slf4j = "2.0.17" -shadow = "9.4.1" +kotlin = "2.4.20" +ktor = "3.5.2" +junit = "6.1.3" +mcp-kotlin = "0.15.0" +slf4j = "2.0.19" +shadow = "9.6.1" [libraries] ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } diff --git a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar index d997cfc..eddabd2 100644 Binary files a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar and b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties index c61a118..ad7845b 100644 --- a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/simple-streamable-server/gradlew b/samples/simple-streamable-server/gradlew index 739907d..249efbb 100755 --- a/samples/simple-streamable-server/gradlew +++ b/samples/simple-streamable-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/samples/simple-streamable-server/gradlew.bat b/samples/simple-streamable-server/gradlew.bat index e509b2d..8508ef6 100644 --- a/samples/simple-streamable-server/gradlew.bat +++ b/samples/simple-streamable-server/gradlew.bat @@ -19,12 +19,12 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,29 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/samples/weather-stdio-server/gradle/libs.versions.toml b/samples/weather-stdio-server/gradle/libs.versions.toml index cc2b53a..9d609a9 100644 --- a/samples/weather-stdio-server/gradle/libs.versions.toml +++ b/samples/weather-stdio-server/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] -coroutines = "1.10.2" -kotlin = "2.2.21" -ktor = "3.3.3" -logging = "8.0.01" -mcp-kotlin = "0.11.0" -shadow = "9.4.1" -slf4j = "2.0.17" +coroutines = "1.11.0" +kotlin = "2.4.20" +ktor = "3.5.2" +logging = "8.0.4" +mcp-kotlin = "0.15.0" +shadow = "9.6.1" +slf4j = "2.0.19" [libraries] kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging-jvm", version.ref = "logging" } diff --git a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar index d997cfc..eddabd2 100644 Binary files a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar and b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties index c61a118..ad7845b 100644 --- a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/weather-stdio-server/gradlew b/samples/weather-stdio-server/gradlew index 739907d..249efbb 100755 --- a/samples/weather-stdio-server/gradlew +++ b/samples/weather-stdio-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/samples/weather-stdio-server/gradlew.bat b/samples/weather-stdio-server/gradlew.bat index e509b2d..8508ef6 100644 --- a/samples/weather-stdio-server/gradlew.bat +++ b/samples/weather-stdio-server/gradlew.bat @@ -19,12 +19,12 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,29 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/test-utils/build.gradle.kts b/test-utils/build.gradle.kts index 91574e0..c6baf2d 100644 --- a/test-utils/build.gradle.kts +++ b/test-utils/build.gradle.kts @@ -4,6 +4,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { id("mcp.multiplatform") + id("mcp.detekt") } kotlin { @@ -11,10 +12,8 @@ kotlin { iosArm64() iosX64() iosSimulatorArm64() - watchosX64() watchosArm64() watchosSimulatorArm64() - tvosX64() tvosArm64() tvosSimulatorArm64() js { diff --git a/test-utils/detekt-baseline-main.xml b/test-utils/detekt-baseline-main.xml new file mode 100644 index 0000000..431f3d9 --- /dev/null +++ b/test-utils/detekt-baseline-main.xml @@ -0,0 +1,20 @@ + + + + + ForbiddenMethodCall:Retry.kt:RetryExtension$println( "[Retry] Failure on attempt $attempt/$maxAttempts for ${ describeTest( extensionContext, ) }: ${lastError.message}", ) + ForbiddenMethodCall:Retry.kt:RetryExtension$println( "[Retry] Giving up after $attempt attempts for ${ describeTest( extensionContext, ) }: ${lastError.message}", ) + ForbiddenMethodCall:TypeScriptRunner.kt:TypeScriptRunner$println("Adding exports to ${file.absolutePath}") + ForbiddenMethodCall:TypeScriptRunner.kt:TypeScriptRunner$println("Command failed: ${e.message}") + ForbiddenMethodCall:TypeScriptRunner.kt:TypeScriptRunner$println("Detected pnpm-specific protocols or workspace. Attempting pnpm install...") + ForbiddenMethodCall:TypeScriptRunner.kt:TypeScriptRunner$println("Executing: ${fullCommand.joinToString(" ")}") + ForbiddenMethodCall:TypeScriptRunner.kt:TypeScriptRunner$println("Installing TypeScript dependencies in ${typescriptDir.absolutePath}") + ForbiddenMethodCall:TypeScriptRunner.kt:TypeScriptRunner$println("[TS-INSTALL] $it") + ForbiddenMethodCall:TypeScriptRunner.kt:TypeScriptRunner$println("npm install failed. Patching package.json and trying again...") + ForbiddenMethodCall:processUtils.kt:println("Warning: Error reading process error stream: ${e.message}") + ForbiddenMethodCall:processUtils.kt:println("Warning: Error reading process output: ${e.message}") + ForbiddenMethodCall:processUtils.kt:println("[$prefix] $it") + ForbiddenMethodCall:processUtils.kt:println("[$prefix][err] $it") + ForbiddenVoid:Retry.kt:RetryExtension$Void + + diff --git a/test-utils/detekt-baseline.xml b/test-utils/detekt-baseline.xml deleted file mode 100644 index 5abd0b4..0000000 --- a/test-utils/detekt-baseline.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - CyclomaticComplexMethod:Retry.kt:RetryExtension$private fun executeWithRetry - ThrowsCount:Retry.kt:RetryExtension$private fun executeWithRetry - - diff --git a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/Retry.kt b/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/Retry.kt index a37e6e3..1dcf585 100644 --- a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/Retry.kt +++ b/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/Retry.kt @@ -16,7 +16,6 @@ import java.util.Optional @ExtendWith(RetryExtension::class) public annotation class Retry(val times: Int = 3, val delayMs: Long = 1000) -@Suppress("ForbiddenVoid") // needed for Java API interop public class RetryExtension : InvocationInterceptor { override fun interceptTestMethod( invocation: Invocation, diff --git a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt b/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt index e7f5798..ff9292e 100644 --- a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt +++ b/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt @@ -18,7 +18,6 @@ public object TypeScriptRunner { * @param logPrefix Prefix for log lines * @return The started Process */ - @Suppress("LongParameterList") public fun run( typescriptDir: File, scriptPath: String, @@ -54,7 +53,6 @@ public object TypeScriptRunner { * @param typescriptDir The directory containing package.json * @throws RuntimeException if npm install fails */ - @Suppress("ReturnCount") public fun installDependencies(typescriptDir: File) { require(typescriptDir.isDirectory()) { "Type script directory does not exist: ${typescriptDir.absolutePath}" } println("Installing TypeScript dependencies in ${typescriptDir.absolutePath}") diff --git a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/processUtils.kt b/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/processUtils.kt index 71fcfa6..a80c825 100644 --- a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/processUtils.kt +++ b/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/processUtils.kt @@ -14,7 +14,6 @@ import kotlin.time.Duration.Companion.seconds */ public fun killProcessOnPort(port: Int) { val killCommand = if (isWindows) { - @Suppress("MaxLineLength") listOf( "cmd.exe", "/c",