diff --git a/.claude/skills/kdoc/SKILL.md b/.claude/skills/kdoc/SKILL.md index cfacfc3..4760293 100644 --- a/.claude/skills/kdoc/SKILL.md +++ b/.claude/skills/kdoc/SKILL.md @@ -5,62 +5,40 @@ description: "Add KDoc documentation to Kotlin public API. Use whenever the user # KDoc Generator -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. +Add KDoc comments to public Kotlin API declarations. -## Tag placement +## What to document -| 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` | +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 -## Style +Include `@Deprecated` elements. -- 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. +## Context -## Examples +Read the implementation, not just the signature, to write accurate descriptions. Understanding what the code actually does prevents superficial or misleading documentation. -**Class with mixed primary-constructor parameters:** +## KDoc format + +**Class/interface example:** ````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 - * @param scope coroutine scope for session lifecycle tasks - * @param secret used internally for session token signing + * @property timeout idle timeout before a session is evicted */ -public class SessionManager( - public val maxSessions: Int, // public val → @property - scope: CoroutineScope, // no val/var → @param - private val secret: ByteArray, // private val → @param -) ```` -**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:** +**Function example:** ````kotlin /** @@ -68,7 +46,10 @@ public class Connection(public val id: String) { * * 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}"))) * } * ``` @@ -79,3 +60,15 @@ public class Connection(public val id: String) { * @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 67770a7..76f4434 100644 --- a/.github/workflows/apidocs.yaml +++ b/.github/workflows/apidocs.yaml @@ -24,24 +24,24 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} cache: gradle - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 - name: Generate Dokka Site run: |- ./gradlew knit docs:classes docs:knitCheck dokkaGenerate - name: Upload artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@v4 with: path: build/dokka/html diff --git a/.github/workflows/automations.yml b/.github/workflows/automations.yml index 60a9d3b..4f88d37 100644 --- a/.github/workflows/automations.yml +++ b/.github/workflows/automations.yml @@ -27,7 +27,7 @@ jobs: name: Mark/unmark stale PRs and issues runs-on: ubuntu-latest steps: - - uses: actions/stale@v11 + - uses: actions/stale@v10 with: debug-only: ${{ env.DRY_RUN }} exempt-draft-pr: true @@ -66,7 +66,7 @@ jobs: name: Close stale PRs and issues runs-on: ubuntu-latest steps: - - uses: actions/stale@v11 + - uses: actions/stale@v10 with: debug-only: ${{ env.DRY_RUN }} only-labels: 'stale' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ff99581..08242f7 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: "build" + gradle-tasks: "ktlintCheck build" max-workers: 3 - os: macos-latest @@ -43,17 +43,17 @@ jobs: max-workers: 2 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Set up Node.js if: matrix.os == 'ubuntu-latest' - uses: actions/setup-node@v7 + uses: actions/setup-node@v6 with: node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed @@ -63,7 +63,7 @@ jobs: run: npm ci - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: validate-wrappers: true add-job-summary: 'always' @@ -104,6 +104,22 @@ 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' }} @@ -126,16 +142,16 @@ jobs: contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: validate-wrappers: true add-job-summary: 'always' @@ -168,16 +184,16 @@ jobs: name: "Build Sample: ${{ matrix.sample }}" timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: add-job-summary: 'always' cache-read-only: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2e7c58f..5697b61 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,22 +25,22 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@v6 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: distribution: ${{ env.JAVA_DISTRIBUTION }} java-version: ${{ env.JAVA_VERSION }} - name: Initialize CodeQL - uses: github/codeql-action/init@v4.38.0 + uses: github/codeql-action/init@v4 with: languages: java-kotlin build-mode: manual - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: validate-wrappers: true cache-read-only: true @@ -52,6 +52,6 @@ jobs: --no-daemon --rerun-tasks --stacktrace - name: Analyze - uses: github/codeql-action/analyze@v4.38.0 + uses: github/codeql-action/analyze@v4 with: category: '/language:java-kotlin' diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index b3e3935..4058d02 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -25,16 +25,16 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -76,16 +76,16 @@ jobs: - sse-retry steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -107,16 +107,16 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} @@ -146,16 +146,16 @@ jobs: - auth/cross-app-access-complete-flow steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index e7e94c3..6073684 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@v3 + - uses: dependabot/fetch-metadata@v2 id: metadata - if: >- diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml index f01f9c8..6b60e87 100644 --- a/.github/workflows/gradle-publish.yml +++ b/.github/workflows/gradle-publish.yml @@ -17,23 +17,18 @@ jobs: packages: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: '21' distribution: 'temurin' - - name: Set up Node.js - uses: actions/setup-node@v7 - with: - node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 - - name: Check - run: ./gradlew check --no-configuration-cache + - name: Build + run: ./gradlew build --rerun-tasks --max-workers 2 --continue - name: Publish to Maven Central Portal id: publish diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 7463a9e..2c910fd 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -36,16 +36,16 @@ jobs: name: Build Sample (${{ matrix.sample }}) timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + uses: actions/setup-java@v5 with: java-version: ${{ env.JAVA_VERSION }} distribution: ${{ env.JAVA_DISTRIBUTION }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v6 with: validate-wrappers: true add-job-summary: 'always' diff --git a/.gitignore b/.gitignore index 805579c..d9f0fde 100644 --- a/.gitignore +++ b/.gitignore @@ -56,7 +56,7 @@ node_modules dist ### SWE agents ### -.claude/ +.claude/settings.local.json .junie/ ### Conformance test results ### diff --git a/.idea/detekt.xml b/.idea/detekt.xml new file mode 100644 index 0000000..d89f908 --- /dev/null +++ b/.idea/detekt.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/.idea/externalDependencies.xml b/.idea/externalDependencies.xml new file mode 100644 index 0000000..0a081df --- /dev/null +++ b/.idea/externalDependencies.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/.idea/ktlint-plugin.xml b/.idea/ktlint-plugin.xml new file mode 100644 index 0000000..e8bd90c --- /dev/null +++ b/.idea/ktlint-plugin.xml @@ -0,0 +1,7 @@ + + + + DISTRACT_FREE + DEFAULT + + \ No newline at end of file diff --git a/README.md b/README.md index aefadcf..58a2cad 100644 --- a/README.md +++ b/README.md @@ -729,7 +729,7 @@ val client = Client( ) client.setRequestHandler(Method.Defined.SamplingCreateMessage) { request, _ -> - val content = request.messages.lastOrNull()?.content?.firstOrNull() + val content = request.messages.lastOrNull()?.content val prompt = if (content is TextContent) content.text else "your topic" CreateMessageResult( model = "gpt-4o-mini", diff --git a/build.gradle.kts b/build.gradle.kts index 2c2dbe3..72c5553 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,8 @@ +import dev.detekt.gradle.extensions.FailOnSeverity + plugins { id("mcp.dokka") + alias(libs.plugins.detekt) alias(libs.plugins.ktlint) alias(libs.plugins.kover) } @@ -20,6 +23,16 @@ 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 4a057ce..84676a4 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -8,7 +8,6 @@ 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 deleted file mode 100644 index 3c1419e..0000000 --- a/buildSrc/src/main/kotlin/mcp.detekt.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -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 98a0a5e..a331139 100644 --- a/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts +++ b/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts @@ -45,6 +45,11 @@ kotlin { events("failed") } systemProperty("kotest.output.ansi", "true") + reports { + junitXml.required.set(true) + junitXml.includeSystemOutLog.set(true) + junitXml.includeSystemErrLog.set(true) + } } } macosArm64() @@ -57,3 +62,12 @@ kotlin { explicitApi = ExplicitApiMode.Strict jvmToolchain(21) } + +tasks.named("detekt").configure { + dependsOn( + "detektMainJvm", + "detektCommonMainSourceSet", + "detektTestJvm", + "detektCommonTestSourceSet", + ) +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..9135aae --- /dev/null +++ b/codecov.yml @@ -0,0 +1,51 @@ +# 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 a82b404..4d44734 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -3,839 +3,41 @@ config: warningsAsErrors: false checkExhaustiveness: false -processors: - active: false - -console-reports: - active: true - exclude: - - 'ProjectStatisticsReport' - - 'ComplexityReport' - - 'NotificationReport' - - 'IssuesReport' - - 'FileBasedIssuesReport' - # - 'LiteIssuesReport' - -comments: - 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 + excludes: &testFolders + - '**/test/**' + - '**/commonTest/**' + - '**/jvmTest/**' + - '**/jsTest/**' + - '**/iosTest/**' 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: [] + excludes: *testFolders 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 + excludes: *testFolders 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 + excludes: *testFolders -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.*' +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 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 8a18957..03d5ee3 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,6 +12,8 @@ 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 c81c98c..54d5c8c 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,6 +45,7 @@ internal const val WAV_BASE64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABA private val logger = KotlinLogging.logger {} +@Suppress("LongMethod") fun Server.registerConformanceTools() { // 1. Simple text addTool( @@ -175,17 +176,11 @@ fun Server.registerConformanceTools() { CreateMessageRequest( CreateMessageRequestParams( maxTokens = 10000, - messages = listOf(SamplingMessage(Role.User, listOf(TextContent(prompt)))), + messages = listOf(SamplingMessage(Role.User, TextContent(prompt))), ), ), ) - val sampledText = result.content.joinToString("\n") { content -> - when (content) { - is TextContent -> content.text - else -> "Non-text sampling content: ${content::class.simpleName}" - } - } - CallToolResult(listOf(TextContent(sampledText))) + CallToolResult(listOf(TextContent(result.content.toString()))) } // 9. Elicitation diff --git a/context7.json b/context7.json index 5fde250..a28c0fc 100644 --- a/context7.json +++ b/context7.json @@ -12,6 +12,7 @@ "CONTRIBUTING.md", "LICENSE", "SECURITY.md", + "codecov.yml", "gradlew", "gradlew.bat", ".editorconfig", diff --git a/gradle.properties b/gradle.properties index a5655c3..09a658b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,4 +8,4 @@ kotlin.daemon.jvmargs=-Xmx4G kotlin.native.ignoreDisabledTargets=true group=io.modelcontextprotocol -version=0.15.0 +version=0.11.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a8d034f..78f55fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,33 +1,34 @@ [versions] # plugins version -atomicfu = "0.33.0" -binaryCompatibilityValidatorPlugin = "0.18.2" -detekt = "2.0.0-alpha.6" +atomicfu = "0.32.1" +binaryCompatibilityValidatorPlugin = "0.18.1" +detekt = "2.0.0-alpha.2" dokka = "2.2.0" -kotlin = "2.4.10" -kover = "0.9.9" +kotlin = "2.3.10" +kover = "0.9.8" ktlint = "14.2.0" knit = "0.5.1" -mavenPublish = "0.37.0" -netty = "4.2.18.Final" +mavenPublish = "0.36.0" +netty = "4.2.12.Final" +openapi-generator = "7.21.0" # libraries version awaitility = "4.3.0" -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" +collections-immutable = "0.4.0" +coroutines = "1.10.2" +kotest = "6.1.9" +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" [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" } @@ -64,6 +65,7 @@ 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" } @@ -74,7 +76,9 @@ 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 eddabd2..d997cfc 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ad7845b..c61a118 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,9 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.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 249efbb..739907d 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# gradlew start up script for POSIX generated by Gradle. +# Gradle start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh gradlew +# ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # 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 +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/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 8508ef6..c4bdd3a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,82 +1,93 @@ -@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% +@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 diff --git a/integration-test/build.gradle.kts b/integration-test/build.gradle.kts index 7a3a4a3..30afe9e 100644 --- a/integration-test/build.gradle.kts +++ b/integration-test/build.gradle.kts @@ -1,6 +1,5 @@ plugins { id("mcp.multiplatform") - id("mcp.detekt") } kotlin { diff --git a/integration-test/detekt-baseline-test.xml b/integration-test/detekt-baseline-test.xml new file mode 100644 index 0000000..2df611a --- /dev/null +++ b/integration-test/detekt-baseline-test.xml @@ -0,0 +1,17 @@ + + + + + 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 099329d..d0acda5 100644 --- a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt @@ -14,17 +14,13 @@ import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult -import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification -import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotificationParams import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION @@ -50,10 +46,8 @@ import io.modelcontextprotocol.kotlin.sdk.types.Tool import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.UrlElicitationRequiredException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.joinAll @@ -73,7 +67,6 @@ import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail -import kotlin.time.Duration.Companion.seconds class ClientTest { @Test @@ -113,7 +106,7 @@ class ClientTest { ), options = ClientOptions( capabilities = ClientCapabilities( - sampling = ClientCapabilities.sampling, + sampling = EmptyJsonObject, ), ), ) @@ -159,7 +152,7 @@ class ClientTest { ), options = ClientOptions( capabilities = ClientCapabilities( - sampling = ClientCapabilities.sampling, + sampling = EmptyJsonObject, ), ), ) @@ -346,7 +339,7 @@ class ClientTest { val client = Client( clientInfo = Implementation(name = "test client", version = "1.0"), options = ClientOptions( - capabilities = ClientCapabilities(sampling = ClientCapabilities.sampling), + capabilities = ClientCapabilities(sampling = EmptyJsonObject), ), ) @@ -546,15 +539,6 @@ class ClientTest { val serverSession = serverSessionResult.await() - // Register the tap after createSession so it chains onto the session's subscription instead - // of pre-empting the handshake. - val cancelledOnWire = CompletableDeferred() - serverTransport.onMessage { message -> - if (message is JSONRPCNotification && message.method == Method.Defined.NotificationsCancelled.value) { - cancelledOnWire.complete(Unit) - } - } - serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> // Simulate delay def.complete(Unit) @@ -580,7 +564,6 @@ class ClientTest { def.await() runCatching { job.cancel("Cancelled by test") } defCancel.await() - cancelledOnWire.await() defTimeOut.await() } @@ -619,9 +602,17 @@ class ClientTest { val serverSession = serverSessionResult.await() serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> - // Never respond: the handler runs on a real dispatcher while the 1 ms timeout runs on the - // virtual test clock, so a real-time delay could race it. Parking keeps the test deterministic. - awaitCancellation() + // Simulate a delayed response + // Wait ~100ms unless canceled + try { + withTimeout(100L) { + // Just delay here, if timeout is 0 on the client side, this won't return in time + delay(100) + } + } catch (_: Exception) { + // If aborted, just rethrow or return early + } + ListResourcesResult(resources = emptyList()) } // Request with 1 msec timeout should fail immediately @@ -642,16 +633,18 @@ class ClientTest { ), options = ClientOptions( capabilities = ClientCapabilities( - sampling = ClientCapabilities.sampling, + sampling = EmptyJsonObject, ), ), ) client.setRequestHandler(Method.Defined.SamplingCreateMessage) { _, _ -> CreateMessageResult( - role = Role.Assistant, - content = TextContent(text = "Test response"), model = "test-model", + role = Role.Assistant, + content = TextContent( + text = "Test response", + ), ) } @@ -677,7 +670,7 @@ class ClientTest { val client = Client( clientInfo = Implementation(name = "test client", version = "1.0"), options = ClientOptions( - capabilities = ClientCapabilities(sampling = ClientCapabilities.sampling), + capabilities = ClientCapabilities(sampling = EmptyJsonObject), ), ) @@ -900,8 +893,8 @@ class ClientTest { ), ) - // The handler runs on Dispatchers.Default after concurrent dispatch is enabled; await the deferred. - val rootListChangedReceived = CompletableDeferred() + // Track notifications + var rootListChangedNotificationReceived = false val serverSessionResult = CompletableDeferred() @@ -920,13 +913,16 @@ class ClientTest { serverSession.setNotificationHandler( Method.Defined.NotificationsRootsListChanged, ) { - rootListChangedReceived.complete(Unit) + rootListChangedNotificationReceived = true CompletableDeferred(Unit) } client.sendRootsListChanged() - withTimeout(5.seconds) { rootListChangedReceived.await() } + assertTrue( + rootListChangedNotificationReceived, + "Notification should be sent when sendRootsListChanged is called", + ) } @Test @@ -1064,7 +1060,7 @@ class ClientTest { Implementation(name = "test client", version = "1.0"), ClientOptions( capabilities = ClientCapabilities( - elicitation = ClientCapabilities.Elicitation(), + elicitation = EmptyJsonObject, ), ), ) @@ -1124,103 +1120,6 @@ class ClientTest { assertEquals(elicitationResultContent, result.content) } - @Test - fun `should reject accepted elicitation content that does not match requested schema`() = runTest { - val requestedSchema = ElicitRequestParams.RequestedSchema( - properties = mapOf("name" to StringSchema()), - required = listOf("name"), - ) - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Accept, content = buildJsonObject { put("name", 42) }) - } - - val exception = assertFailsWith { - serverSession.createElicitation(message = "mismatch", requestedSchema = requestedSchema) - } - assertTrue( - exception.message.orEmpty() - .startsWith("Elicitation response content does not match requested schema: "), - "Unexpected message: ${exception.message}", - ) - assertTrue("'name' must be string" in exception.message.orEmpty(), "Unexpected message: ${exception.message}") - - client.close() - } - - @Test - fun `should reject accepted elicitation without content when schema has required properties`() = runTest { - val requestedSchema = ElicitRequestParams.RequestedSchema( - properties = mapOf("name" to StringSchema()), - required = listOf("name"), - ) - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Accept, content = null) - } - - val exception = assertFailsWith { - serverSession.createElicitation(message = "no content", requestedSchema = requestedSchema) - } - assertTrue( - "must have required property 'name'" in exception.message.orEmpty(), - "Unexpected message: ${exception.message}", - ) - - client.close() - } - - @Test - fun `should accept elicitation without content when schema has no required properties`() = runTest { - val requestedSchema = ElicitRequestParams.RequestedSchema( - properties = mapOf("name" to StringSchema()), - ) - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Accept, content = null) - } - - val result = serverSession.createElicitation(message = "no required", requestedSchema = requestedSchema) - - assertEquals(ElicitResult.Action.Accept, result.action) - assertNull(result.content) - - client.close() - } - - @Test - fun `should pass through declined elicitation without validation`() = runTest { - val requestedSchema = ElicitRequestParams.RequestedSchema( - properties = mapOf("name" to StringSchema()), - required = listOf("name"), - ) - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Decline) - } - - val result = serverSession.createElicitation(message = "decline", requestedSchema = requestedSchema) - - assertEquals(ElicitResult.Action.Decline, result.action) - assertNull(result.content) - - client.close() - } - - @Test - fun `should pass through cancelled elicitation without validation`() = runTest { - val requestedSchema = ElicitRequestParams.RequestedSchema( - properties = mapOf("name" to StringSchema()), - required = listOf("name"), - ) - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Cancel) - } - - val result = serverSession.createElicitation(message = "cancel", requestedSchema = requestedSchema) - - assertEquals(ElicitResult.Action.Cancel, result.action) - assertNull(result.content) - - client.close() - } - @Test fun `should apply elicitation defaults for missing fields in empty content`() = runTest { val schema = defaultsTestSchema() @@ -1354,189 +1253,6 @@ class ClientTest { client.close() } - // ── URL-mode elicitation (SEP-1036) ───────────────────────────────── - - @Test - fun `should handle URL mode elicitation end-to-end`() = runTest { - val client = Client( - Implementation(name = "test client", version = "1.0"), - ClientOptions( - capabilities = ClientCapabilities( - elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), - ), - ), - ) - - val elicitationId = "550e8400-e29b-41d4-a716-446655440000" - val url = "https://oauth.example.com/authorize" - - client.setElicitationHandler { request -> - val params = assertIs(request.params) - assertEquals(elicitationId, params.elicitationId) - assertEquals(url, params.url) - ElicitResult(action = ElicitResult.Action.Accept) - } - - val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() - val server = Server( - serverInfo = Implementation(name = "test server", version = "1.0"), - options = ServerOptions(capabilities = ServerCapabilities()), - ) - - val serverSessionResult = CompletableDeferred() - listOf( - launch { client.connect(clientTransport) }, - launch { serverSessionResult.complete(server.createSession(serverTransport)) }, - ).joinAll() - val serverSession = serverSessionResult.await() - - val result = serverSession.createElicitation( - message = "Authorize access to continue", - elicitationId = elicitationId, - url = url, - ) - - assertEquals(ElicitResult.Action.Accept, result.action) - assertNull(result.content) - - client.close() - } - - @Test - fun `should reject URL mode elicitation when client supports only form mode`() = runTest { - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Accept) - } - - val exception = assertFailsWith { - serverSession.createElicitation( - message = "Authorize", - elicitationId = "id-1", - url = "https://example.com/auth", - ) - } - assertTrue(exception.message!!.contains("elicitation.url")) - - client.close() - } - - @Test - fun `should deliver elicitation complete notification to client`() = runTest { - val received = CompletableDeferred() - val client = Client( - Implementation(name = "test client", version = "1.0"), - ClientOptions( - capabilities = ClientCapabilities( - elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), - ), - ), - ) - client.setElicitationCompleteHandler { received.complete(it) } - - val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() - val server = Server( - serverInfo = Implementation(name = "test server", version = "1.0"), - options = ServerOptions(capabilities = ServerCapabilities()), - ) - - val serverSessionResult = CompletableDeferred() - listOf( - launch { client.connect(clientTransport) }, - launch { serverSessionResult.complete(server.createSession(serverTransport)) }, - ).joinAll() - val serverSession = serverSessionResult.await() - - val elicitationId = "complete-id-1" - serverSession.sendElicitationComplete( - ElicitationCompleteNotification(ElicitationCompleteNotificationParams(elicitationId = elicitationId)), - ) - - val notification = received.await() - assertEquals(elicitationId, notification.params.elicitationId) - - client.close() - } - - @Test - fun `should reject elicitation complete when client supports only form mode`() = runTest { - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Accept) - } - - val exception = assertFailsWith { - serverSession.sendElicitationComplete( - ElicitationCompleteNotification(ElicitationCompleteNotificationParams(elicitationId = "id-1")), - ) - } - assertTrue(exception.message!!.contains("elicitation.url")) - - client.close() - } - - @Test - fun `setElicitationCompleteHandler should require url capability`() = runTest { - val client = Client( - Implementation(name = "test client", version = "1.0"), - ClientOptions( - capabilities = ClientCapabilities( - elicitation = ClientCapabilities.Elicitation(), - ), - ), - ) - - assertFailsWith { - client.setElicitationCompleteHandler { } - } - } - - @Test - fun `should surface URL elicitation required error to client as typed exception`() = runTest { - val client = Client( - Implementation(name = "test client", version = "1.0"), - ClientOptions( - capabilities = ClientCapabilities( - elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), - ), - ), - ) - - val elicitationId = "auth-required-1" - val url = "https://oauth.example.com/authorize" - - val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() - val server = Server( - serverInfo = Implementation(name = "test server", version = "1.0"), - options = ServerOptions(capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(true))), - ) - server.addTool("needs-auth", "Requires URL elicitation") { - throw UrlElicitationRequiredException( - listOf( - ElicitRequestURLParams( - message = "Authorize to continue", - elicitationId = elicitationId, - url = url, - ), - ), - ) - } - - val serverSessionResult = CompletableDeferred() - listOf( - launch { client.connect(clientTransport) }, - launch { serverSessionResult.complete(server.createSession(serverTransport)) }, - ).joinAll() - serverSessionResult.await() - - val exception = assertFailsWith { - client.callTool(name = "needs-auth", arguments = emptyMap()) - } - val elicitation = exception.elicitations.single() - assertEquals(elicitationId, elicitation.elicitationId) - assertEquals(url, elicitation.url) - - client.close() - } - private fun defaultsTestSchema(): ElicitRequestParams.RequestedSchema = ElicitRequestParams.RequestedSchema( properties = mapOf( "name" to StringSchema(description = "User name", default = "John Doe"), @@ -1556,7 +1272,7 @@ class ClientTest { ): Pair = kotlinx.coroutines.coroutineScope { val client = Client( Implementation(name = "test client", version = "1.0"), - ClientOptions(capabilities = ClientCapabilities(elicitation = ClientCapabilities.Elicitation())), + ClientOptions(capabilities = ClientCapabilities(elicitation = EmptyJsonObject)), ) 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 fc3c1d3..585af3f 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt @@ -7,13 +7,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.test.utils.createSleepyProcessBuilder import io.modelcontextprotocol.kotlin.test.utils.createTeeProcessBuilder import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.io.Buffer -import kotlinx.io.RawSource import kotlinx.io.asSink import kotlinx.io.asSource import kotlinx.io.buffered @@ -22,12 +18,10 @@ import org.junit.jupiter.api.Timeout import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.condition.DisabledOnOs import org.junit.jupiter.api.condition.OS -import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.test.assertFalse -import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail import kotlin.time.Duration.Companion.seconds @@ -115,43 +109,6 @@ class StdioClientTransportTest : BaseTransportTest() { } } - @OptIn(ExperimentalAtomicApi::class) - @Test - fun `should close cleanly while stdin read is blocked`(): Unit = runBlocking(Dispatchers.IO) { - val input = BlockingRawSource() - - val transport = StdioClientTransport( - input = input.buffered(), - output = Buffer(), - ) - - val didClose = AtomicBoolean(false) - transport.onClose { didClose.store(true) } - transport.onError { error -> - fail("Unexpected error while closing transport: $error") - } - - transport.start() - - eventually(2.seconds) { - assertTrue(input.readStarted, "Transport should start reading stdin before close") - } - - val closeJob = async { - transport.close() - } - - val closed = withTimeoutOrNull(1.seconds) { - closeJob.await() - } - - input.close() - closeJob.await() - - assertNotNull(closed, "Transport.close() should not wait for stdin to produce data or EOF") - assertTrue(didClose.load(), "Transport should be closed after close() call") - } - @Test fun `should read messages`() = runTest { val processBuilder = createTeeProcessBuilder() @@ -190,22 +147,4 @@ class StdioClientTransportTest : BaseTransportTest() { process.waitFor() process.destroyForcibly() } - - private class BlockingRawSource : RawSource { - private val closed = CountDownLatch(1) - - @Volatile - var readStarted: Boolean = false - private set - - override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { - readStarted = true - closed.await() - return -1L - } - - override fun close() { - closed.countDown() - } - } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/ProtocolConcurrencyIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/ProtocolConcurrencyIntegrationTest.kt deleted file mode 100644 index d8d64ec..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/ProtocolConcurrencyIntegrationTest.kt +++ /dev/null @@ -1,306 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.integration - -import io.kotest.matchers.shouldBe -import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi -import io.modelcontextprotocol.kotlin.sdk.client.Client -import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions -import io.modelcontextprotocol.kotlin.sdk.server.Server -import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions -import io.modelcontextprotocol.kotlin.sdk.server.ServerSession -import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport -import io.modelcontextprotocol.kotlin.sdk.shared.currentRequestHandlerExtra -import io.modelcontextprotocol.kotlin.sdk.testing.ChannelTransport -import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest -import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult -import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest -import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest -import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse -import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION -import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest -import io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult -import io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest -import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult -import io.modelcontextprotocol.kotlin.sdk.types.Method -import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import io.modelcontextprotocol.kotlin.sdk.types.Role -import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.toJSON -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.atomic.AtomicReference -import kotlin.coroutines.cancellation.CancellationException -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.time.Duration.Companion.seconds - -/** - * End-to-end tests for concurrent inbound dispatch enabled after the MCP handshake. - * - * Handlers run on a real [kotlinx.coroutines.Dispatchers.Default], so these use [runBlocking] with - * explicit [withTimeout] guards. A virtual test clock would false-fire timeouts against cross-thread - * real dispatchers. - */ -@OptIn(ExperimentalMcpApi::class) -class ProtocolConcurrencyIntegrationTest { - - private fun rootsClient(): Client = Client( - clientInfo = Implementation(name = "test client", version = "1.0"), - options = ClientOptions( - capabilities = ClientCapabilities(roots = ClientCapabilities.Roots(listChanged = null)), - ), - ) - - private fun toolsServer(): Server = Server( - serverInfo = Implementation(name = "test server", version = "1.0"), - options = ServerOptions( - capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = null)), - ), - ) - - // ChannelTransport delivers inbound messages as soon as its read loop starts, which is before - // start() marks it operational. Creating the session first keeps the client's initialize from - // landing on a transport that would reject the session's response. - private suspend fun connect( - client: Client, - clientTransport: ChannelTransport, - server: Server, - serverTransport: ChannelTransport, - ): ServerSession { - val serverSession = server.createSession(serverTransport) - client.connect(clientTransport) - return serverSession - } - - // The motivating deadlock over real serial read loops: a server tool handler makes a - // server-to-client request (roots/list) while still handling the client's tools/call. - @Test - fun `server tool handler can call roots list mid-handling without deadlock`() = runBlocking { - val client = rootsClient() - val server = toolsServer() - val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() - - val serverSession = connect(client, clientTransport, server, serverTransport) - - // Register the tools/call handler directly on the session so it can call back to the client - // mid-handling. Before concurrent dispatch this would deadlock: the server read loop is - // blocked by the tool handler and can never deliver the roots/list response. - serverSession.setRequestHandler(Method.Defined.ToolsCall) { _, _ -> - val roots = serverSession.request(ListRootsRequest()) - CallToolResult(content = listOf(TextContent(text = "roots=${roots.roots.size}"))) - } - - val result = withTimeout(10.seconds) { - client.callTool(CallToolRequest(CallToolRequestParams("needs-roots"))) - } - val text = (result as CallToolResult).content.filterIsInstance().single().text - text shouldBe "roots=0" - - client.close() - server.close() - } - - // Client-side mirror: a client sampling handler makes a client-to-server request (tools/list) - // while still handling the server's sampling/createMessage. - @Test - fun `client sampling handler can call the server mid-handling without deadlock`() = runBlocking { - val client = Client( - clientInfo = Implementation(name = "test client", version = "1.0"), - options = ClientOptions( - capabilities = ClientCapabilities(sampling = ClientCapabilities.Sampling()), - ), - ) - val server = toolsServer() - val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() - - client.setRequestHandler(Method.Defined.SamplingCreateMessage) { _, _ -> - val tools = client.request(ListToolsRequest()) - CreateMessageResult( - role = Role.Assistant, - content = TextContent(text = "tools=${tools.tools.size}"), - model = "test-model", - ) - } - - val serverSession = connect(client, clientTransport, server, serverTransport) - - val result = withTimeout(10.seconds) { - serverSession.request( - CreateMessageRequest( - CreateMessageRequestParams( - maxTokens = 100, - messages = listOf(SamplingMessage(role = Role.User, content = TextContent(text = "hi"))), - ), - ), - ) - } - val text = result.content.filterIsInstance().single().text - text shouldBe "tools=0" - - client.close() - server.close() - } - - // Nested-cancel cascade: cancelling the client's tools/call must reach the server tool handler - // over the wire and cascade into its nested roots/list request, cancelling the parked - // client-side roots handler. No tools/call response is ever sent. - @Test - fun `cancelling a tool call cascades into its nested roots list request`() = runBlocking { - val client = rootsClient() - val server = toolsServer() - val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() - - val capturedToolCallId = AtomicReference(null) - val toolHandlerCancelled = CompletableDeferred() - val rootsHandlerEntered = CompletableDeferred() - val rootsHandlerCancelled = CompletableDeferred() - val parkForever = CompletableDeferred() - val unexpectedToolCallResponse = CompletableDeferred() - - // Tap the server->client direction (client transport inbound) to prove no tools/call - // response is ever delivered once the call is cancelled. - clientTransport.onMessage { message -> - if (message is JSONRPCResponse && message.id == capturedToolCallId.get()) { - unexpectedToolCallResponse.complete(Unit) - } - } - - val serverSession = connect(client, clientTransport, server, serverTransport) - - // Client roots handler parks until cancelled and records the cancellation. - client.setRequestHandler(Method.Defined.RootsList) { _, _ -> - rootsHandlerEntered.complete(Unit) - try { - parkForever.await() - } catch (e: CancellationException) { - rootsHandlerCancelled.complete(e) - throw e - } - } - - // Tool handler makes a nested server->client request and records its own cancellation. - serverSession.setRequestHandler(Method.Defined.ToolsCall) { _, _ -> - val extra = currentRequestHandlerExtra()!! - capturedToolCallId.set(extra.requestId) - try { - extra.sendRequest(ListRootsRequest()) - } catch (e: CancellationException) { - toolHandlerCancelled.complete(e) - throw e - } - CallToolResult(content = listOf(TextContent(text = "unreachable"))) - } - - val call = launch { client.callTool(CallToolRequest(CallToolRequestParams("nested"))) } - - withTimeout(10.seconds) { rootsHandlerEntered.await() } - call.cancel(CancellationException("user aborted")) - - withTimeout(10.seconds) { toolHandlerCancelled.await() } - withTimeout(10.seconds) { rootsHandlerCancelled.await() } - - // The server suppresses the response for a cancelled request, so none is ever sent. - assertFalse( - unexpectedToolCallResponse.isCompleted, - "no tools/call response should be delivered after the call was cancelled", - ) - - client.close() - server.close() - } - - // Init gate plus context element in a high-level handler: initialize and tools/list are - // pipelined before notifications/initialized and answered serially in order. After the - // handshake a high-level addTool handler can observe currentRequestHandlerExtra(). - @Test - fun `pipelined initialize and tools list are processed serially then context element is available`(): Unit = - runBlocking { - val server = toolsServer() - server.addTool(name = "ctx-tool", description = "returns its request id") { - val extra = currentRequestHandlerExtra() - checkNotNull(extra) { "currentRequestHandlerExtra() must be non-null inside a tool handler" } - CallToolResult(content = listOf(TextContent(text = extra.requestId.asText()))) - } - - val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() - - val arrivalOrder = CopyOnWriteArrayList() - val pending = ConcurrentHashMap>() - clientTransport.onMessage { message -> - val id = when (message) { - is JSONRPCResponse -> message.id - is JSONRPCError -> message.id - else -> null - } - if (id != null) { - arrivalOrder.add(id) - pending[id]?.complete(message) - } - } - - // Drive the client side manually over the raw transport. - server.createSession(serverTransport) - - val initRequest = InitializeRequest( - InitializeRequestParams( - protocolVersion = LATEST_PROTOCOL_VERSION, - capabilities = ClientCapabilities(), - clientInfo = Implementation(name = "raw-client", version = "1.0"), - ), - ).toJSON() - val toolsListRequest = ListToolsRequest().toJSON() - - val initAnswered = pending.expect(initRequest.id) - val toolsListAnswered = pending.expect(toolsListRequest.id) - - // Pipelined before notifications/initialized: both handled in the serial dispatch phase. - clientTransport.send(initRequest) - clientTransport.send(toolsListRequest) - - withTimeout(10.seconds) { initAnswered.await() } - withTimeout(10.seconds) { toolsListAnswered.await() } - - // Deterministic serial ordering: initialize answered before tools/list. - assertEquals( - listOf(initRequest.id, toolsListRequest.id), - arrivalOrder.toList(), - "pipelined requests must be answered in arrival order", - ) - - // Complete the handshake, then call the high-level tool. - clientTransport.send(InitializedNotification().toJSON()) - - val toolCallRequest = CallToolRequest(CallToolRequestParams("ctx-tool")).toJSON() - val toolCallAnswered = pending.expect(toolCallRequest.id) - clientTransport.send(toolCallRequest) - - val toolCallResponse = withTimeout(10.seconds) { toolCallAnswered.await() } as JSONRPCResponse - val text = (toolCallResponse.result as CallToolResult).content.filterIsInstance().single().text - // The handler echoed its request id, proving currentRequestHandlerExtra() was available. - text shouldBe toolCallRequest.id.asText() - } - - private fun ConcurrentHashMap>.expect( - id: RequestId, - ): CompletableDeferred = CompletableDeferred().also { put(id, it) } - - private fun RequestId.asText(): String = when (this) { - is RequestId.StringId -> value - is RequestId.NumberId -> value.toString() - } -} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/AbstractPromptIntegrationTest.kt index fb97782..d14ae88 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,15 +6,9 @@ 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 @@ -24,9 +18,7 @@ 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 @@ -698,76 +690,11 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() { val expectedMessage = "Prompt not found: non-existent-prompt" - withClue("Exception code should be INVALID_PARAMS: ${RPCError.ErrorCode.INVALID_PARAMS}") { - exception.code shouldBe RPCError.ErrorCode.INVALID_PARAMS + withClue("Exception code should be INTERNAL_ERROR: -32603") { + exception.code shouldBe -32603 } withClue("Unexpected error message for non-existent prompt") { exception.message shouldBe expectedMessage } } - - @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 ab2a854..4fc53a5 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,16 +1,11 @@ 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 @@ -23,10 +18,8 @@ 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 @@ -77,6 +70,23 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() { ) } + server.addResource( + uri = testResourceUri, + name = testResourceName, + description = testResourceDescription, + mimeType = "text/plain", + ) { request -> + ReadResourceResult( + contents = listOf( + TextResourceContents( + text = testResourceContent, + uri = request.params.uri, + mimeType = "text/plain", + ), + ), + ) + } + server.addResource( uri = binaryResourceUri, name = binaryResourceName, @@ -299,76 +309,4 @@ 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 b802122..7da82cc 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,15 +6,8 @@ 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 @@ -31,9 +24,7 @@ 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 @@ -800,75 +791,4 @@ 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 535393e..9bc0c17 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt @@ -3,7 +3,6 @@ package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer -import io.ktor.server.sse.Heartbeat import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp @@ -18,10 +17,7 @@ import io.ktor.server.cio.CIO as ServerCIO open class AbstractStreamableHttpIntegrationTest { - suspend fun initTestServer( - name: String? = null, - sseHeartbeatConfig: (Heartbeat.() -> Unit)? = null, - ): StreamableHttpTestServer { + suspend fun initTestServer(name: String? = null): StreamableHttpTestServer { val mcpServer = Server( Implementation(name = name ?: DEFAULT_SERVER_NAME, version = VERSION), ServerOptions( @@ -56,7 +52,7 @@ open class AbstractStreamableHttpIntegrationTest { host = URL, port = PORT, ) { - mcpStreamableHttp(sseHeartbeatConfig = sseHeartbeatConfig) { mcpServer } + mcpStreamableHttp { mcpServer } } return StreamableHttpTestServer( diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpClientTransportIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpClientTransportIntegrationTest.kt deleted file mode 100644 index 598825f..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpClientTransportIntegrationTest.kt +++ /dev/null @@ -1,270 +0,0 @@ -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 deleted file mode 100644 index 8019557..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpHeartbeatIntegrationTest.kt +++ /dev/null @@ -1,174 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp - -import io.kotest.matchers.shouldBe -import io.ktor.client.HttpClient -import io.ktor.client.plugins.sse.SSE -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.prepareGet -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsChannel -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import io.ktor.sse.ServerSentEvent -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.readUTF8Line -import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest -import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest -import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION -import io.modelcontextprotocol.kotlin.sdk.types.toJSON -import io.modelcontextprotocol.kotlin.test.utils.actualPort -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.serialization.json.Json -import kotlin.test.Test -import kotlin.test.assertNotNull -import kotlin.time.Duration.Companion.milliseconds -import io.ktor.client.engine.cio.CIO as ClientCIO - -private const val SESSION_ID_HEADER = "mcp-session-id" -private const val PROTOCOL_VERSION_HEADER = "mcp-protocol-version" -private const val DEFAULT_HEARTBEAT_LINE = ": heartbeat" -private const val CUSTOM_HEARTBEAT_LINE = ": mcp-heartbeat" - -class StreamableHttpHeartbeatIntegrationTest : AbstractStreamableHttpIntegrationTest() { - - @Test - fun `GET SSE stream emits configured heartbeat`(): Unit = runBlocking(Dispatchers.IO) { - var server: StreamableHttpTestServer? = null - var httpClient: HttpClient? = null - - try { - server = initTestServer("heartbeat-test") { - period = 50.milliseconds - event = ServerSentEvent(comments = "mcp-heartbeat") - } - val mcpUrl = "http://$URL:${server.ktorServer.actualPort()}/mcp" - httpClient = HttpClient(ClientCIO) { install(SSE) } - val sessionId = initializeSession(httpClient, mcpUrl) - - httpClient.prepareGet(mcpUrl) { - addSseHeaders(sessionId) - }.execute { response -> - response.status shouldBe HttpStatusCode.OK - response.headers[SESSION_ID_HEADER] shouldBe sessionId - - response.bodyAsChannel().readLineMatching(CUSTOM_HEARTBEAT_LINE) shouldBe CUSTOM_HEARTBEAT_LINE - } - } finally { - httpClient?.close() - server?.ktorServer?.stopSuspend(1000, 2000) - } - } - - @Test - fun `GET SSE stream does not emit heartbeat by default`(): Unit = runBlocking(Dispatchers.IO) { - var server: StreamableHttpTestServer? = null - var httpClient: HttpClient? = null - - try { - server = initTestServer("no-heartbeat-test") - val mcpUrl = "http://$URL:${server.ktorServer.actualPort()}/mcp" - httpClient = HttpClient(ClientCIO) { install(SSE) } - val sessionId = initializeSession(httpClient, mcpUrl) - - httpClient.prepareGet(mcpUrl) { - addSseHeaders(sessionId) - }.execute { response -> - response.status shouldBe HttpStatusCode.OK - response.headers[SESSION_ID_HEADER] shouldBe sessionId - - response.bodyAsChannel().readLineMatching(timeoutMillis = 150) { line -> - line.isHeartbeatSseLine() - } shouldBe null - } - } finally { - httpClient?.close() - server?.ktorServer?.stopSuspend(1000, 2000) - } - } - - @Test - fun `GET SSE stream emits configured heartbeat repeatedly`(): Unit = runBlocking(Dispatchers.IO) { - var server: StreamableHttpTestServer? = null - var httpClient: HttpClient? = null - - try { - server = initTestServer("repeating-heartbeat-test") { - period = 50.milliseconds - event = ServerSentEvent(comments = "mcp-heartbeat") - } - val mcpUrl = "http://$URL:${server.ktorServer.actualPort()}/mcp" - httpClient = HttpClient(ClientCIO) { install(SSE) } - val sessionId = initializeSession(httpClient, mcpUrl) - - httpClient.prepareGet(mcpUrl) { - addSseHeaders(sessionId) - }.execute { response -> - response.status shouldBe HttpStatusCode.OK - response.headers[SESSION_ID_HEADER] shouldBe sessionId - - val channel = response.bodyAsChannel() - channel.readLineMatching(CUSTOM_HEARTBEAT_LINE) shouldBe CUSTOM_HEARTBEAT_LINE - channel.readLineMatching(CUSTOM_HEARTBEAT_LINE) shouldBe CUSTOM_HEARTBEAT_LINE - } - } finally { - httpClient?.close() - server?.ktorServer?.stopSuspend(1000, 2000) - } - } - - private suspend fun initializeSession(client: HttpClient, mcpUrl: String): String { - val response = client.post(mcpUrl) { - contentType(ContentType.Application.Json) - header( - HttpHeaders.Accept, - "${ContentType.Application.Json}, ${ContentType.Text.EventStream}", - ) - setBody(Json.encodeToString(buildInitPayload())) - } - - response.status shouldBe HttpStatusCode.OK - return assertNotNull(response.headers[SESSION_ID_HEADER]) - } - - private fun buildInitPayload(): JSONRPCRequest = InitializeRequest( - InitializeRequestParams( - protocolVersion = LATEST_PROTOCOL_VERSION, - capabilities = ClientCapabilities(), - clientInfo = Implementation(name = "heartbeat-test-client", version = "1.0.0"), - ), - ).toJSON() - - private fun io.ktor.client.request.HttpRequestBuilder.addSseHeaders(sessionId: String) { - header(HttpHeaders.Accept, ContentType.Text.EventStream.toString()) - header(SESSION_ID_HEADER, sessionId) - header(PROTOCOL_VERSION_HEADER, LATEST_PROTOCOL_VERSION) - } - - private suspend fun ByteReadChannel.readLineMatching(expectedLine: String, timeoutMillis: Long = 2_000): String? = - readLineMatching(timeoutMillis) { line -> line == expectedLine } - - private suspend fun ByteReadChannel.readLineMatching( - timeoutMillis: Long = 2_000, - matches: (String) -> Boolean, - ): String? = withTimeoutOrNull(timeoutMillis.milliseconds) { - var line = readUTF8Line() - while (line != null) { - if (matches(line)) return@withTimeoutOrNull line - line = readUTF8Line() - } - null - } - - private fun String.isHeartbeatSseLine(): Boolean = this == DEFAULT_HEARTBEAT_LINE || - this == CUSTOM_HEARTBEAT_LINE || - (startsWith(":") && contains("heartbeat", ignoreCase = true)) || - (startsWith("event:") && contains("heartbeat", ignoreCase = true)) -} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpSseReconnectTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpSseReconnectTest.kt deleted file mode 100644 index 751f8dd..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpSseReconnectTest.kt +++ /dev/null @@ -1,171 +0,0 @@ -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 a1cf797..81a3b41 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionLoggingTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionLoggingTest.kt @@ -1,7 +1,5 @@ package io.modelcontextprotocol.kotlin.sdk.server -import io.kotest.assertions.nondeterministic.continually -import io.kotest.assertions.nondeterministic.eventually import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe @@ -19,9 +17,6 @@ import kotlinx.serialization.json.JsonPrimitive import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource -import java.util.concurrent.CopyOnWriteArrayList -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { @@ -61,7 +56,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { @ParameterizedTest @EnumSource(LoggingLevel::class) fun `notification should filter logging messages below level`(minLevel: LoggingLevel): Unit = runBlocking { - val receivedMessages = CopyOnWriteArrayList() + val receivedMessages = mutableListOf() client.setNotificationHandler(Method.Defined.NotificationsMessage) { receivedMessages.add(it) CompletableDeferred(Unit) @@ -84,10 +79,8 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { client.callTool(CallToolRequest(CallToolRequestParams("test-notification-filtered"))) - // Handlers dispatch on Dispatchers.Default after the handshake, so await the expected count - // before asserting order. val expectedLevels = LoggingLevel.entries.filter { it >= minLevel } - eventually(5.seconds) { receivedMessages shouldHaveSize expectedLevels.size } + receivedMessages shouldHaveSize expectedLevels.size receivedMessages.map { it.params.level } shouldBe expectedLevels } @@ -122,7 +115,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { @ParameterizedTest @EnumSource(LoggingLevel::class) fun `sendLoggingMessage should filter messages below level`(minLevel: LoggingLevel): Unit = runBlocking { - val receivedMessages = CopyOnWriteArrayList() + val receivedMessages = mutableListOf() client.setNotificationHandler(Method.Defined.NotificationsMessage) { receivedMessages.add(it) CompletableDeferred(Unit) @@ -145,16 +138,14 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { client.callTool(CallToolRequest(CallToolRequestParams("test-logging-level"))) - // Handlers dispatch on Dispatchers.Default after the handshake, so await the expected count - // before asserting order. val expectedLevels = LoggingLevel.entries.filter { it >= minLevel } - eventually(5.seconds) { receivedMessages shouldHaveSize expectedLevels.size } + receivedMessages shouldHaveSize expectedLevels.size receivedMessages.map { it.params.level } shouldBe expectedLevels } @Test fun `sendLoggingMessage should send no messages when level is set to highest`(): Unit = runBlocking { - val receivedMessages = CopyOnWriteArrayList() + val receivedMessages = mutableListOf() client.setNotificationHandler(Method.Defined.NotificationsMessage) { receivedMessages.add(it) CompletableDeferred(Unit) @@ -176,7 +167,6 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() { } client.callTool(CallToolRequest(CallToolRequestParams("test-logging-highest"))) - // Handlers dispatch asynchronously, so hold for a window to confirm nothing ever arrives. - continually(500.milliseconds) { receivedMessages.shouldBeEmpty() } + receivedMessages.shouldBeEmpty() } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt index 581e5ae..ee8953a 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt @@ -9,7 +9,6 @@ import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotificationParams -import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest @@ -62,7 +61,6 @@ class ClientConnectionTest : AbstractServerFeaturesTest() { override fun getClientCapabilities(): ClientCapabilities = ClientCapabilities( roots = ClientCapabilities.Roots(listChanged = true), - elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), ) private val sampleRoots = listOf(Root("file:///project", "Project Root")) diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt deleted file mode 100644 index f161d8e..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt +++ /dev/null @@ -1,251 +0,0 @@ -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 52f6908..1eb828f 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerBulkFeaturesTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerBulkFeaturesTest.kt @@ -1,6 +1,5 @@ package io.modelcontextprotocol.kotlin.sdk.server -import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.collections.shouldHaveSize @@ -58,39 +57,6 @@ class ServerBulkFeaturesTest : AbstractServerFeaturesTest() { tools.map { it.name } shouldContainExactlyInAnyOrder listOf("existing", "new-a", "new-b") } - @Test - fun `addTools should throw and register nothing when a tool is already registered`() = runTest { - server.addTool("existing", "Existing") { CallToolResult(emptyList()) } - - shouldThrow { - server.addTools( - listOf( - RegisteredTool(Tool("new-a", ToolSchema())) { CallToolResult(emptyList()) }, - RegisteredTool(Tool("existing", ToolSchema())) { CallToolResult(emptyList()) }, - ), - ) - } - - val tools = client.listTools().tools - - tools shouldHaveSize 1 - tools.map { it.name } shouldContainExactlyInAnyOrder listOf("existing") - } - - @Test - fun `addTools should throw when batch contains duplicate names`() = runTest { - shouldThrow { - server.addTools( - listOf( - RegisteredTool(Tool("dup", ToolSchema())) { CallToolResult(emptyList()) }, - RegisteredTool(Tool("dup", ToolSchema())) { CallToolResult(emptyList()) }, - ), - ) - } - - client.listTools().tools.shouldBeEmpty() - } - // ── addPrompts ───────────────────────────────────────────────────────────── @Test @@ -123,25 +89,6 @@ class ServerBulkFeaturesTest : AbstractServerFeaturesTest() { prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("existing-prompt", "new-p") } - @Test - fun `addPrompts should throw and register nothing when a prompt is already registered`() = runTest { - server.addPrompt("existing-prompt", "Existing") { GetPromptResult(messages = emptyList()) } - - shouldThrow { - server.addPrompts( - listOf( - RegisteredPrompt(Prompt("new-p")) { GetPromptResult(messages = emptyList()) }, - RegisteredPrompt(Prompt("existing-prompt")) { GetPromptResult(messages = emptyList()) }, - ), - ) - } - - val prompts = client.listPrompts().prompts - - prompts shouldHaveSize 1 - prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("existing-prompt") - } - // ── addResources ─────────────────────────────────────────────────────────── @Test @@ -176,27 +123,6 @@ class ServerBulkFeaturesTest : AbstractServerFeaturesTest() { resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://existing-res", "test://new-res") } - @Test - fun `addResources should throw and register nothing when a resource is already registered`() = runTest { - server.addResource("test://existing-res", "Existing", "Existing resource") { - ReadResourceResult(emptyList()) - } - - shouldThrow { - server.addResources( - listOf( - RegisteredResource(Resource("test://new-res", "New")) { ReadResourceResult(emptyList()) }, - RegisteredResource(Resource("test://existing-res", "Dup")) { ReadResourceResult(emptyList()) }, - ), - ) - } - - val resources = client.listResources().resources - - resources shouldHaveSize 1 - resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://existing-res") - } - // ── Partial-batch remove ─────────────────────────────────────────────────── @Test diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsTest.kt index 44b1e42..d7ae46d 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsTest.kt @@ -1,8 +1,5 @@ package io.modelcontextprotocol.kotlin.sdk.server -import io.kotest.matchers.string.shouldContain -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.Method @@ -113,26 +110,6 @@ class ServerPromptsTest : AbstractServerFeaturesTest() { assertFalse(promptListChangedNotificationReceived, "No notification should be sent when prompt doesn't exist") } - @Test - fun `addPrompt should throw when prompt with same name is already registered`() = runTest { - server.addPrompt(Prompt("test-prompt", "Original Prompt", null)) { - GetPromptResult(description = "original", messages = emptyList()) - } - - val exception = assertThrows { - server.addPrompt(Prompt("test-prompt", "Duplicate Prompt", null)) { - GetPromptResult(description = "duplicate", messages = emptyList()) - } - } - exception.message.orEmpty() shouldContain "Prompt \"test-prompt\" is already registered" - - // The original registration is intact - val prompts = client.listPrompts().prompts - assertEquals(1, prompts.size, "Only the original prompt should be registered") - val result = client.getPrompt(GetPromptRequest(GetPromptRequestParams("test-prompt"))) - assertEquals("original", result.description) - } - @Test fun `removePrompt should throw when prompts capability is not supported`() = runTest { var promptListChangedNotificationReceived = false diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt index 8405469..c8ace19 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt @@ -5,7 +5,6 @@ import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.maps.shouldContainKey import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe -import io.kotest.matchers.string.shouldContain import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesRequest import io.modelcontextprotocol.kotlin.sdk.types.McpException @@ -163,24 +162,6 @@ class ServerResourceTemplateTest : AbstractServerFeaturesTest() { removed shouldBe false } - @Test - fun `addResourceTemplate should throw when template with same uriTemplate is already registered`() { - server.addResourceTemplate("test://items/{id}", "Item") { _, _ -> - ReadResourceResult(emptyList()) - } - - val exception = assertThrows { - server.addResourceTemplate("test://items/{id}", "Duplicate Item") { _, _ -> - ReadResourceResult(emptyList()) - } - } - exception.message.orEmpty() shouldContain "ResourceTemplate \"test://items/{id}\" is already registered" - - // The original registration is intact - server.resourceTemplates shouldHaveSize 1 - server.resourceTemplates[0].name shouldBe "Item" - } - @Test fun `addResourceTemplate should throw when resources capability is not supported`() { val noResourcesServer = Server( diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesTest.kt index a830788..fafc3c8 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesTest.kt @@ -1,10 +1,7 @@ package io.modelcontextprotocol.kotlin.sdk.server -import io.kotest.matchers.string.shouldContain import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.Method -import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest -import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities @@ -118,24 +115,6 @@ class ServerResourcesTest : AbstractServerFeaturesTest() { ) } - @Test - fun `addResource should throw when resource with same uri is already registered`() = runTest { - server.addResource("test://resource", "Original", "Original resource") { - ReadResourceResult(listOf(TextResourceContents(text = "original", uri = "test://resource"))) - } - - val exception = assertThrows { - server.addResource("test://resource", "Duplicate", "Duplicate resource") { - ReadResourceResult(listOf(TextResourceContents(text = "duplicate", uri = "test://resource"))) - } - } - exception.message.orEmpty() shouldContain "Resource \"test://resource\" is already registered" - - // The original registration is intact - val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://resource"))) - assertEquals("original", (result.contents.single() as TextResourceContents).text) - } - @Test fun `removeResource should throw when resources capability is not supported`() = runTest { // Create server without resources capability diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt index 38b169a..5031a9c 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt @@ -7,11 +7,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION import io.modelcontextprotocol.kotlin.sdk.types.RPCError -import io.modelcontextprotocol.kotlin.sdk.types.RequestId import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.toJSON import kotlinx.coroutines.CompletableDeferred @@ -20,13 +18,9 @@ import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonObject import org.junit.jupiter.api.Test import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -47,18 +41,6 @@ class ServerSessionInitializeTest { ), ) - private fun createMalformedInitializeRequest(): JSONRPCRequest = JSONRPCRequest( - id = RequestId(1), - method = "initialize", - params = buildJsonObject { - putJsonObject("capabilities") {} - putJsonObject("clientInfo") { - put("name", "repro") - put("version", "0.1.0") - } - }, - ) - @Test fun `should handle first initialize request successfully`() = runTest { val session = createSession() @@ -83,30 +65,6 @@ class ServerSessionInitializeTest { assertEquals("test-client", session.clientVersion?.name) } - // Both initialize requests arrive before notifications/initialized, in the serial dispatch - // phase, so processing and response order stay deterministic. - @Test - fun `should classify malformed initialize params as invalid params`() = runTest { - val session = createSession() - val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() - - val responseDone = CompletableDeferred() - clientTransport.onMessage { message -> - if (message is JSONRPCError) { - responseDone.complete(message) - } - } - - session.connect(serverTransport) - clientTransport.send(createMalformedInitializeRequest()) - - val response = responseDone.await() - assertEquals(RPCError.ErrorCode.INVALID_PARAMS, response.error.code) - assertFalse(response.error.message.contains("kotlinx.serialization")) - assertNull(session.clientCapabilities) - assertNull(session.clientVersion) - } - @Test fun `should reject duplicate initialize request`() = runTest { val session = createSession() diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsNotificationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsNotificationTest.kt index 9ac744b..3572082 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsNotificationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsNotificationTest.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.test.runTest import org.awaitility.kotlin.await import org.awaitility.kotlin.untilAsserted import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -82,27 +81,6 @@ class ServerToolsNotificationTest : AbstractServerFeaturesTest() { } } - @Test - fun `addTool should not send notification when duplicate is rejected`() = runTest { - // Track notifications - val notifications = mutableListOf() - client.setNotificationHandler( - Method.Defined.NotificationsToolsListChanged, - ) { notification -> - notifications.add(notification) - CompletableDeferred(Unit) - } - - server.addTool("test-tool", "Test Tool") { CallToolResult(emptyList()) } - assertThrows { - server.addTool("test-tool", "Duplicate Tool") { CallToolResult(emptyList()) } - } - // Close the server to stop processing further events and flush notifications - server.close() - - assertEquals(1, notifications.size, "Only the first registration should send a notification") - } - @Test fun `notification should not be send when removed tool does not exists`() = runTest { // Track notifications diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt index 1b3df91..c2e8201 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt @@ -1,8 +1,5 @@ package io.modelcontextprotocol.kotlin.sdk.server -import io.kotest.matchers.string.shouldContain -import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest -import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.Method @@ -101,52 +98,6 @@ 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 4d798ce..ea246db 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt @@ -6,6 +6,7 @@ import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.ServerSession import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult @@ -15,6 +16,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult import io.modelcontextprotocol.kotlin.sdk.types.Method import io.modelcontextprotocol.kotlin.sdk.types.Resource import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlin.test.Test @@ -39,9 +43,18 @@ class ChannelTransportTest { clientInfo = Implementation(name = "test client", version = "1.0"), ) - val serverSession = server.createSession(serverTransport) - client.connect(clientTransport) + val serverSessionResult = CompletableDeferred() + listOf( + launch { + client.connect(clientTransport) + }, + launch { + serverSessionResult.complete(server.createSession(serverTransport)) + }, + ).joinAll() + + val serverSession = serverSessionResult.await() serverSession.setRequestHandler(Method.Defined.Initialize) { _, _ -> InitializeResult( protocolVersion = LATEST_PROTOCOL_VERSION, diff --git a/integration-test/src/jvmTest/typescript/package-lock.json b/integration-test/src/jvmTest/typescript/package-lock.json index ffb53fc..ca30f65 100644 --- a/integration-test/src/jvmTest/typescript/package-lock.json +++ b/integration-test/src/jvmTest/typescript/package-lock.json @@ -8,23 +8,23 @@ "name": "mcp-typescript-test-sdk", "version": "1.0.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.28.0", "express": "^5.2.1", - "zod": "^4.6.2" + "zod": "^4.3.6" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^26.5.1", + "@types/node": "^25.5.0", "cors": "^2.8.6", - "tsx": "^4.23.13", - "typescript": "^7.0.2" + "tsx": "^4.21.0", + "typescript": "^6.0.2" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], @@ -39,9 +39,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], @@ -56,9 +56,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], @@ -73,9 +73,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], @@ -90,9 +90,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -107,9 +107,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], @@ -124,9 +124,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], @@ -141,9 +141,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], @@ -158,9 +158,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], @@ -175,9 +175,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], @@ -192,9 +192,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], @@ -209,9 +209,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], @@ -226,9 +226,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], @@ -243,9 +243,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], @@ -260,9 +260,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], @@ -277,9 +277,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], @@ -294,9 +294,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], @@ -311,9 +311,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], @@ -328,9 +328,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], @@ -345,9 +345,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], @@ -362,9 +362,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "version": "0.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==", "cpu": [ "x64" ], @@ -379,9 +379,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], @@ -396,9 +396,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], @@ -413,9 +413,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], @@ -430,9 +430,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], @@ -447,9 +447,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], @@ -464,24 +464,24 @@ } }, "node_modules/@hono/node-server": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", - "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=18.14.1" }, "peerDependencies": { "hono": "^4" } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", + "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -579,13 +579,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.5.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", - "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.9.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/qs": { @@ -623,346 +623,6 @@ "@types/node": "*" } }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1010,20 +670,20 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^1.0.5", "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" @@ -1033,19 +693,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1229,9 +876,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1241,9 +888,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1254,32 +901,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.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" } }, "node_modules/escape-html": { @@ -1362,13 +1009,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", - "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -1387,9 +1033,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -1501,6 +1147,19 @@ "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", @@ -1526,9 +1185,9 @@ } }, "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1538,9 +1197,9 @@ } }, "node_modules/hono": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", - "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1589,9 +1248,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -1802,13 +1461,12 @@ } }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -1850,6 +1508,16 @@ "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", @@ -1945,14 +1613,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1964,13 +1632,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" + "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" @@ -2035,13 +1703,14 @@ } }, "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.28.0" + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" }, "bin": { "tsx": "dist/cli.mjs" @@ -2054,75 +1723,37 @@ } }, "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { - "content-type": "^2.0.0", + "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.6" } }, "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" + "node": ">=14.17" } }, "node_modules/undici-types": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", - "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -2166,9 +1797,9 @@ "license": "ISC" }, "node_modules/zod": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz", - "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/integration-test/src/jvmTest/typescript/package.json b/integration-test/src/jvmTest/typescript/package.json index b0147e4..0a43cd2 100644 --- a/integration-test/src/jvmTest/typescript/package.json +++ b/integration-test/src/jvmTest/typescript/package.json @@ -9,16 +9,16 @@ "server:http": "tsx server/http-server.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.28.0", "express": "^5.2.1", - "zod": "^4.6.2" + "zod": "^4.3.6" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^26.5.1", + "@types/node": "^25.5.0", "cors": "^2.8.6", - "tsx": "^4.23.13", - "typescript": "^7.0.2" + "tsx": "^4.21.0", + "typescript": "^6.0.2" } } diff --git a/kotlin-sdk-client/api/kotlin-sdk-client.api b/kotlin-sdk-client/api/kotlin-sdk-client.api index d856f5b..da0e752 100644 --- a/kotlin-sdk-client/api/kotlin-sdk-client.api +++ b/kotlin-sdk-client/api/kotlin-sdk-client.api @@ -34,7 +34,6 @@ public class io/modelcontextprotocol/kotlin/sdk/client/Client : io/modelcontextp public final fun removeRoot (Ljava/lang/String;)Z public final fun removeRoots (Ljava/util/List;)I public final fun sendRootsListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun setElicitationCompleteHandler (Lkotlin/jvm/functions/Function1;)V public final fun setElicitationHandler (Lkotlin/jvm/functions/Function1;)V public final fun setLoggingLevel (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun setLoggingLevel$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; @@ -42,7 +41,6 @@ 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 { @@ -52,8 +50,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/client/ClientKt { public final class io/modelcontextprotocol/kotlin/sdk/client/ClientOptions : io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions { public fun ()V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZLkotlin/coroutines/CoroutineContext;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZLkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;Z)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; } @@ -102,8 +100,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/client/StdioClientTranspor } public final class io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport { - public fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;ILkotlin/jvm/functions/Function1;)V - public synthetic fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;ILkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getProtocolVersion ()Ljava/lang/String; diff --git a/kotlin-sdk-client/build.gradle.kts b/kotlin-sdk-client/build.gradle.kts index fbb1300..f50266c 100644 --- a/kotlin-sdk-client/build.gradle.kts +++ b/kotlin-sdk-client/build.gradle.kts @@ -4,7 +4,6 @@ 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) @@ -15,8 +14,10 @@ kotlin { iosArm64() iosX64() iosSimulatorArm64() + watchosX64() watchosArm64() watchosSimulatorArm64() + tvosX64() tvosArm64() tvosSimulatorArm64() js { @@ -55,6 +56,7 @@ 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 new file mode 100644 index 0000000..5cddbe1 --- /dev/null +++ b/kotlin-sdk-client/detekt-baseline-commonMainSourceSet.xml @@ -0,0 +1,14 @@ + + + + + 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 new file mode 100644 index 0000000..cab4322 --- /dev/null +++ b/kotlin-sdk-client/detekt-baseline-commonTestSourceSet.xml @@ -0,0 +1,8 @@ + + + + + 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 c34e75c..8411ab0 100644 --- a/kotlin-sdk-client/detekt-baseline-main.xml +++ b/kotlin-sdk-client/detekt-baseline-main.xml @@ -2,6 +2,18 @@ - LoopWithTooManyJumpStatements:StreamableHttpClientTransport.kt:StreamableHttpClientTransport$while + 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.") diff --git a/kotlin-sdk-client/detekt-baseline-test.xml b/kotlin-sdk-client/detekt-baseline-test.xml new file mode 100644 index 0000000..dd6adfa --- /dev/null +++ b/kotlin-sdk-client/detekt-baseline-test.xml @@ -0,0 +1,14 @@ + + + + + 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 3352104..34b9a5b 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,7 +4,6 @@ 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 @@ -14,12 +13,10 @@ 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 @@ -48,9 +45,7 @@ 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 @@ -64,7 +59,6 @@ import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.supportsUrl import io.modelcontextprotocol.kotlin.sdk.types.toJson import kotlinx.atomicfu.atomic import kotlinx.atomicfu.getAndUpdate @@ -72,14 +66,11 @@ import kotlinx.atomicfu.update import kotlinx.collections.immutable.minus import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toPersistentSet -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray -import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException private val logger = KotlinLogging.logger {} @@ -88,17 +79,12 @@ private val logger = KotlinLogging.logger {} * Options for configuring the MCP client. * * @property capabilities The capabilities this client supports. - * @param enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with the server. - * @param handlerCoroutineContext Coroutine context for inbound handlers. See [ProtocolOptions.handlerCoroutineContext]. + * @property enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with the server. */ public class ClientOptions( public val capabilities: ClientCapabilities = ClientCapabilities(), enforceStrictCapabilities: Boolean = true, - handlerCoroutineContext: CoroutineContext = Dispatchers.Default, -) : ProtocolOptions( - enforceStrictCapabilities = enforceStrictCapabilities, - handlerCoroutineContext = handlerCoroutineContext, -) +) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) /** * Initializes and connects an MCP client using the provided clientInfo [Implementation], client options, @@ -135,6 +121,7 @@ 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) { @@ -184,7 +171,6 @@ 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 } @@ -223,7 +209,6 @@ public open class Client(private val clientInfo: Implementation, options: Client serverInstructions = result.instructions notification(InitializedNotification()) - enableConcurrentDispatch() } catch (error: Throwable) { logger.error(error) { "Failed to initialize client: ${error.message}" } close() @@ -250,15 +235,10 @@ public open class Client(private val clientInfo: Implementation, options: Client Method.Defined.PromptsGet, Method.Defined.PromptsList, + Method.Defined.CompletionComplete, -> { checkNotNull(serverCapabilities?.prompts) { - "Server does not support prompts (required for $method)" - } - } - - Method.Defined.CompletionComplete -> { - checkNotNull(serverCapabilities?.completions) { - "Server does not support completions (required for $method)" + throw IllegalStateException("Server does not support prompts (required for $method)") } } @@ -271,10 +251,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) { - check(resCaps.subscribe == true) { - "Server does not support resource subscriptions (required for $method)" - } + if (method == Method.Defined.ResourcesSubscribe && resCaps.subscribe != true) { + throw IllegalStateException( + "Server does not support resource subscriptions (required for $method)", + ) } } @@ -284,12 +264,6 @@ 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 } @@ -300,24 +274,6 @@ 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 -> { @@ -326,12 +282,6 @@ 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, @@ -373,26 +323,6 @@ 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. * @@ -407,7 +337,7 @@ public open class Client(private val clientInfo: Implementation, options: Client * @param params The completion request parameters. * @param options Optional request options. * @return The completion result returned by the server, or `null` if none. - * @throws IllegalStateException If the server does not support completions. + * @throws IllegalStateException If the server does not support prompts or completion. */ public suspend fun complete(params: CompleteRequest, options: RequestOptions? = null): CompleteResult = request(params, options) @@ -602,8 +532,9 @@ 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 { - checkNotNull(capabilities.roots) { - "Client does not support roots capability." + 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.") } logger.info { "Removing root: $uri" } val oldMap = roots.getAndUpdate { current -> current.remove(uri) } @@ -659,18 +590,9 @@ public open class Client(private val clientInfo: Implementation, options: Client /** * Sets the elicitation handler. * - * The handler receives both form-mode ([ElicitRequestFormParams]) and URL-mode - * ([io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams]) requests; - * branch on `request.params` to tell them apart. For URL mode, - * the host application must obtain explicit user consent and display the target domain before - * navigating — the SDK never opens or validates the URL — and should return - * [ElicitResult.Action.Decline] or [ElicitResult.Action.Cancel] when it cannot or will not proceed. - * A URL-mode [ElicitResult.Action.Accept] only signals consent; the outcome arrives out-of-band via - * [setElicitationCompleteHandler]. - * - * When a form-mode handler returns [ElicitResult.Action.Accept], any properties missing from + * When the handler returns [ElicitResult.Action.Accept], any properties missing from * [ElicitResult.content] are automatically populated with default values defined in the - * elicitation schema. URL-mode responses carry no content. + * elicitation schema. * * @param handler The elicitation handler. * @throws IllegalStateException if the client does not support elicitation. @@ -688,37 +610,9 @@ 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 deleted file mode 100644 index 1290cb0..0000000 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SamplingValidation.kt +++ /dev/null @@ -1,24 +0,0 @@ -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 0295eb9..3c9d88b 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,12 +24,11 @@ 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 @@ -136,7 +135,6 @@ public class SseClientTransport( throw e } finally { closeResources() - invokeOnCloseCallback() } } @@ -173,15 +171,13 @@ public class SseClientTransport( } override suspend fun closeResources() { - withContext(NonCancellable) { - job?.cancel() - try { - if (::session.isInitialized) session.cancel() - if (::scope.isInitialized) scope.cancel() - endpoint.cancel() - } catch (e: Throwable) { - _onError(e) - } + job?.cancelAndJoin() + 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 5e990ee..12529cc 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,7 +21,6 @@ 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 @@ -32,7 +31,6 @@ 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 @@ -227,6 +225,7 @@ public class StdioClientTransport @JvmOverloads public constructor( } override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) { + @Suppress("SwallowedException") try { sendChannel.send(message) } catch (e: CancellationException) { @@ -242,20 +241,8 @@ public class StdioClientTransport @JvmOverloads public constructor( } override suspend fun closeResources() { - withContext(NonCancellable) { - scope.stopProcessing("Closed") - closeReadSources() - scope.coroutineContext[Job]?.join() // Wait for all coroutines to complete - } - } - - private fun closeReadSources() { - runCatching { input.close() } - .onFailure { logger.debug(it) { "Error closing stdin source" } } - error?.let { source -> - runCatching { source.close() } - .onFailure { logger.debug(it) { "Error closing stderr source" } } - } + scope.stopProcessing("Closed") + scope.coroutineContext[Job]?.join() // Wait for all coroutines to complete } private fun sendOutboundMessage(message: JSONRPCMessage, sink: Sink, mainScope: CoroutineScope) { @@ -277,8 +264,6 @@ 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) } @@ -299,7 +284,7 @@ public class StdioClientTransport @JvmOverloads public constructor( ) { val buffer = Buffer() try { - source.use { + source.use { source -> 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 6df352a..999e25a 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt @@ -21,10 +21,8 @@ import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.http.isSuccess -import io.ktor.utils.io.charsets.TooLongLineException import io.ktor.utils.io.readUTF8Line import io.modelcontextprotocol.kotlin.sdk.shared.AbstractClientTransport -import io.modelcontextprotocol.kotlin.sdk.shared.TooLongFrameException import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification @@ -43,10 +41,6 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.pow import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -55,24 +49,11 @@ 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") @@ -84,32 +65,21 @@ private sealed interface ConnectResult { } /** - * Client transport implementing the MCP Streamable HTTP transport specification. + * 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. * - * 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 + * @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 */ +@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( @@ -124,20 +94,12 @@ public class StreamableHttpClientTransport( url: String, reconnectionTime: Duration?, requestBuilder: HttpRequestBuilder.() -> Unit = {}, - ) : this( - client, - url, - ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), - requestBuilder = requestBuilder, - ) + ) : this(client, url, ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), requestBuilder) override val logger: KLogger = KotlinLogging.logger {} - /** 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 @@ -159,6 +121,7 @@ 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)}" } @@ -175,7 +138,6 @@ public class StreamableHttpClientTransport( val jsonBody = McpJson.encodeToString(message) val response = client.post(url) { applyCommonHeaders(this) - applyStandardPostHeaders(this, message) headers.append(HttpHeaders.Accept, "${ContentType.Application.Json}, ${ContentType.Text.EventStream}") contentType(ContentType.Application.Json) setBody(jsonBody) @@ -298,6 +260,7 @@ 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) { @@ -333,6 +296,7 @@ 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 { @@ -399,36 +363,7 @@ public class StreamableHttpClientTransport( } } - private fun applyStandardPostHeaders(builder: HttpRequestBuilder, message: JSONRPCMessage) { - val (method, params) = when (message) { - is JSONRPCRequest -> message.method to message.params - is JSONRPCNotification -> message.method to message.params - else -> return - } - - builder.headers { - append(MCP_METHOD_HEADER, method) - - val paramsObject = params as? JsonObject ?: return@headers - val mcpName = paramsObject.stringValue("name") ?: paramsObject.stringValue("uri") - mcpName?.let { append(MCP_NAME_HEADER, it.encodeMcpHeaderValue()) } - } - } - - private fun JsonObject.stringValue(key: String): String? = - (get(key) as? JsonPrimitive)?.takeIf { it.isString }?.content - - @OptIn(ExperimentalEncodingApi::class) - private fun String.encodeMcpHeaderValue(): String { - val containsUnsafeCharacters = any { it != '\t' && it.code !in 0x20..0x7e } - val hasEdgeWhitespace = firstOrNull()?.isWhitespace() == true || lastOrNull()?.isWhitespace() == true - val matchesBase64Sentinel = startsWith(MCP_BASE64_PREFIX) && endsWith(MCP_BASE64_SUFFIX) - - if (!containsUnsafeCharacters && !hasEdgeWhitespace && !matchesBase64Sentinel) return this - - return "$MCP_BASE64_PREFIX${Base64.Default.encode(encodeToByteArray())}$MCP_BASE64_SUFFIX" - } - + @Suppress("TooGenericExceptionCaught") private suspend fun collectSse( session: ClientSSESession, replayMessageId: RequestId?, @@ -473,6 +408,7 @@ public class StreamableHttpClientTransport( return SseStreamResult(hasPrimingEvent, receivedResponse, localLastEventId, localServerRetryDelay) } + @Suppress("CyclomaticComplexMethod") private suspend fun handleInlineSse( response: HttpResponse, replayMessageId: RequestId?, @@ -519,15 +455,9 @@ public class StreamableHttpClientTransport( } } + @Suppress("LoopWithTooManyJumpStatements") while (!channel.isClosedForRead) { - // Bound each line so a server that streams a line without ever terminating it cannot - // exhaust client memory; readUTF8Line returns null at the end of the stream. - val line = try { - channel.readUTF8Line(maxInlineSseEventSize) - } catch (_: TooLongLineException) { - throw TooLongFrameException(maxInlineSseEventSize.toLong() + 1, maxInlineSseEventSize) - } - if (line == null) break + val line = channel.readUTF8Line() ?: break if (line.isEmpty()) { dispatch(id = id, eventName = eventName, data = sb.toString()) // reset @@ -541,13 +471,7 @@ public class StreamableHttpClientTransport( line.startsWith("event:") -> eventName = line.substringAfter("event:").trim() - line.startsWith("data:") -> { - sb.append(line.substringAfter("data:").trim()) - // Cap an event assembled from many data: lines that never sees a terminating blank line. - if (sb.length > maxInlineSseEventSize) { - throw TooLongFrameException(sb.length.toLong(), maxInlineSseEventSize) - } - } + line.startsWith("data:") -> sb.append(line.substringAfter("data:").trim()) line.startsWith("retry:") -> line.substringAfter("retry:").trim().toLongOrNull()?.let { localServerRetryDelay = it.milliseconds diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientAssertCapabilityTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientAssertCapabilityTest.kt deleted file mode 100644 index e9379a7..0000000 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientAssertCapabilityTest.kt +++ /dev/null @@ -1,171 +0,0 @@ -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 14af546..3627b6c 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,14 +53,6 @@ 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 319ba2a..6cc7f6e 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,6 +1,5 @@ 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 @@ -10,7 +9,6 @@ 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 { @@ -92,31 +90,6 @@ 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 d9aaf7a..67b2f07 100644 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/streamable/http/StreamableHttpClientTransportTest.kt +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/streamable/http/StreamableHttpClientTransportTest.kt @@ -19,7 +19,6 @@ import io.ktor.http.headersOf import io.ktor.utils.io.ByteReadChannel import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport -import io.modelcontextprotocol.kotlin.sdk.shared.TooLongFrameException import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification @@ -44,15 +43,11 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class StreamableHttpClientTransportTest { - private fun createTransport( - maxInlineSseEventSize: Int = 16 * 1024 * 1024, - handler: MockRequestHandler, - ): StreamableHttpClientTransport { + private fun createTransport(handler: MockRequestHandler): StreamableHttpClientTransport { val mockEngine = MockEngine(handler) val httpClient = HttpClient(mockEngine) { install(SSE) { @@ -60,11 +55,7 @@ class StreamableHttpClientTransportTest { } } - return StreamableHttpClientTransport( - httpClient, - url = "http://localhost:8080/mcp", - maxInlineSseEventSize = maxInlineSseEventSize, - ) + return StreamableHttpClientTransport(httpClient, url = "http://localhost:8080/mcp") } private fun buildSseMessage(id: String, method: String, params: String): String = buildString { @@ -246,100 +237,6 @@ class StreamableHttpClientTransportTest { transport.close() } - @Test - fun `should send MCP method and name headers from name`() = runTest { - val transport = createTransport { request -> - assertEquals("tools/call", request.headers["Mcp-Method"]) - assertEquals("read_file", request.headers["Mcp-Name"]) - respond(content = "", status = HttpStatusCode.Accepted) - } - - transport.start() - transport.send( - JSONRPCRequest( - id = "test-id", - method = "tools/call", - params = buildJsonObject { - put("name", JsonPrimitive("read_file")) - put("arguments", buildJsonObject { put("path", JsonPrimitive("README.md")) }) - }, - ), - ) - transport.close() - } - - @Test - fun `should send MCP name header from URI`() = runTest { - val transport = createTransport { request -> - assertEquals("resources/read", request.headers["Mcp-Method"]) - assertEquals("file:///README.md", request.headers["Mcp-Name"]) - respond(content = "", status = HttpStatusCode.Accepted) - } - - transport.start() - transport.send( - JSONRPCRequest( - id = "test-id", - method = "resources/read", - params = buildJsonObject { put("uri", JsonPrimitive("file:///README.md")) }, - ), - ) - transport.close() - } - - @Test - fun `should encode MCP name header values when required`() = runTest { - val cases = listOf( - "Hello, 世界" to "=?base64?SGVsbG8sIOS4lueVjA==?=", - " padded " to "=?base64?IHBhZGRlZCA=?=", - "line1\nline2" to "=?base64?bGluZTEKbGluZTI=?=", - "=?base64?literal?=" to "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=", - ) - - cases.forEachIndexed { index, (name, expectedHeader) -> - val transport = createTransport { request -> - assertEquals(expectedHeader, request.headers["Mcp-Name"]) - respond(content = "", status = HttpStatusCode.Accepted) - } - - transport.start() - transport.send( - JSONRPCRequest( - id = "test-id-$index", - method = "tools/call", - params = buildJsonObject { put("name", JsonPrimitive(name)) }, - ), - ) - transport.close() - } - } - - @Test - fun `should send MCP method header for notifications`() = runTest { - val transport = createTransport { request -> - assertEquals("notifications/tools/list_changed", request.headers["Mcp-Method"]) - assertNull(request.headers["Mcp-Name"]) - respond(content = "", status = HttpStatusCode.Accepted) - } - - transport.start() - transport.send(JSONRPCNotification(method = "notifications/tools/list_changed")) - transport.close() - } - - @Test - fun `should omit MCP standard headers for responses`() = runTest { - val transport = createTransport { request -> - assertNull(request.headers["Mcp-Method"]) - assertNull(request.headers["Mcp-Name"]) - respond(content = "", status = HttpStatusCode.Accepted) - } - - transport.start() - transport.send(JSONRPCResponse(id = RequestId.StringId("test-id"))) - transport.close() - } - // Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support @Ignore @Test @@ -581,170 +478,6 @@ 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 -> @@ -910,7 +643,7 @@ class StreamableHttpClientTransportTest { eventually { while (receivedMessages.isEmpty()) { - delay(10.milliseconds) + delay(10) } } @@ -1101,6 +834,7 @@ 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 new file mode 100644 index 0000000..4a04e81 --- /dev/null +++ b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractStreamableHttpClientTest.kt @@ -0,0 +1,42 @@ +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 deleted file mode 100644 index 7775a08..0000000 --- a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientSamplingValidationTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -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 new file mode 100644 index 0000000..fb71904 --- /dev/null +++ b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockMcp.kt @@ -0,0 +1,241 @@ +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 new file mode 100644 index 0000000..a1fe89e --- /dev/null +++ b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt @@ -0,0 +1,293 @@ +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 new file mode 100644 index 0000000..dbdf930 --- /dev/null +++ b/kotlin-sdk-core/.openapi-generator-ignore @@ -0,0 +1,29 @@ +# 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 b3e88d8..d4f88ba 100644 --- a/kotlin-sdk-core/api/kotlin-sdk-core.api +++ b/kotlin-sdk-core/api/kotlin-sdk-core.api @@ -59,7 +59,6 @@ public abstract class io/modelcontextprotocol/kotlin/sdk/shared/Protocol { public abstract fun assertRequestHandlerCapability (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun connect (Lio/modelcontextprotocol/kotlin/sdk/shared/Transport;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - protected final fun enableConcurrentDispatch ()V public final fun getFallbackNotificationHandler ()Lkotlin/jvm/functions/Function2; public final fun getFallbackRequestHandler ()Lkotlin/jvm/functions/Function3; public final fun getNotificationHandlers ()Ljava/util/Map; @@ -72,7 +71,6 @@ public abstract class io/modelcontextprotocol/kotlin/sdk/shared/Protocol { public static synthetic fun notification$default (Lio/modelcontextprotocol/kotlin/sdk/shared/Protocol;Lio/modelcontextprotocol/kotlin/sdk/types/Notification;Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun onClose ()V public fun onError (Ljava/lang/Throwable;)V - protected fun onInitializedNotification ()V public final fun removeNotificationHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public final fun removeRequestHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public final fun request (Lio/modelcontextprotocol/kotlin/sdk/types/Request;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -81,23 +79,19 @@ 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 (ZJLkotlin/coroutines/CoroutineContext;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (ZJLkotlin/coroutines/CoroutineContext;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZJLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getEnforceStrictCapabilities ()Z - public final fun getHandlerCoroutineContext ()Lkotlin/coroutines/CoroutineContext; public final fun getTimeout-UwyO8pc ()J public final fun setEnforceStrictCapabilities (Z)V - public final fun setHandlerCoroutineContext (Lkotlin/coroutines/CoroutineContext;)V public final fun setTimeout-LRDsOJo (J)V } @@ -112,16 +106,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferKt { public static final fun serializeMessage (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;)Ljava/lang/String; } -public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra : kotlin/coroutines/AbstractCoroutineContextElement { - public static final field Key Lio/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra$Key; - public final fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; - public final fun getRequestId ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestId; - public final fun sendNotification (Lio/modelcontextprotocol/kotlin/sdk/types/Notification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun sendRequest (Lio/modelcontextprotocol/kotlin/sdk/types/Request;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun sendRequest$default (Lio/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra;Lio/modelcontextprotocol/kotlin/sdk/types/Request;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; -} - -public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra$Key : kotlin/coroutines/CoroutineContext$Key { +public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra { + public fun ()V } public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestOptions : io/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions { @@ -138,12 +124,6 @@ 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 @@ -390,19 +370,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/BooleanSchema$Compan public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/CacheScope : java/lang/Enum { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope$Companion; - public static final field Private Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; - public static final field Public Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; - public static fun values ()[Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/CacheScope$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequest$Companion; public fun (Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams;)V @@ -416,7 +383,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolRequest : io public final fun getName ()Ljava/lang/String; public fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestParams; - public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -441,26 +407,24 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolRequestBuild public final fun arguments (Lkotlin/jvm/functions/Function1;)V public final fun arguments (Lkotlinx/serialization/json/JsonObject;)V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getName ()Ljava/lang/String; public final fun setName (Ljava/lang/String;)V } public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams$Companion; - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Lkotlinx/serialization/json/JsonObject; - public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; - public final fun component4-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun copy-6R815Ik (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; - public static synthetic fun copy-6R815Ik$default (Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; + public final fun component3-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; + public final fun copy-EDEVuBg (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; + public static synthetic fun copy-EDEVuBg$default (Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolRequestParams; public fun equals (Ljava/lang/Object;)Z public final fun getArguments ()Lkotlinx/serialization/json/JsonObject; public fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; public final fun getName ()Ljava/lang/String; - public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -639,32 +603,22 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CancelledNotificatio public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/CapabilitiesKt { - public static final fun getSupportsUrl (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;)Z -} - public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Companion; public fun ()V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots;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 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 final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots; - 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 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 fun equals (Ljava/lang/Object;)Z - public final fun getElicitation ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation; + public final fun getElicitation ()Lkotlinx/serialization/json/JsonObject; public final fun getExperimental ()Lkotlinx/serialization/json/JsonObject; - public final fun getExtensions ()Ljava/util/Map; public final fun getRoots ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots; - public final fun getSampling ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling; - public final fun getTasks ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Tasks; + public final fun getSampling ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -681,39 +635,8 @@ public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ClientCapa } public final class io/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Companion { - 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 getElicitation ()Lkotlinx/serialization/json/JsonObject; + public final fun getSampling ()Lkotlinx/serialization/json/JsonObject; public final fun serializer ()Lkotlinx/serialization/KSerializer; } @@ -746,169 +669,17 @@ 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 (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Elicitation;)V + public final fun elicitation (Lkotlin/jvm/functions/Function1;)V + public final fun elicitation (Lkotlinx/serialization/json/JsonObject;)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 (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Sampling;)V + public final fun sampling (Lkotlin/jvm/functions/Function1;)V + public final fun sampling (Lkotlinx/serialization/json/JsonObject;)V } public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/ClientNotification : io/modelcontextprotocol/kotlin/sdk/types/Notification { @@ -980,7 +751,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CompleteRequestBuild public fun ()V public final fun argument (Ljava/lang/String;Ljava/lang/String;)V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun context (Ljava/util/Map;)V public final fun context (Lkotlin/jvm/functions/Function1;)V public final fun ref (Lio/modelcontextprotocol/kotlin/sdk/types/Reference;)V @@ -1160,8 +931,6 @@ 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; @@ -1190,7 +959,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestParams; public final fun getStopSequences ()Ljava/util/List; public final fun getSystemPrompt ()Ljava/lang/String; - public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; public final fun getTemperature ()Ljava/lang/Double; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -1214,7 +982,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getContext ()Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext; public final fun getMaxTokens ()Ljava/lang/Integer; public final fun getStopSequences ()Ljava/util/List; @@ -1235,12 +1003,9 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams$Companion; - public synthetic fun (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;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 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; @@ -1248,9 +1013,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 ()Ljava/util/List; - public final fun copy-7OuPiQU (ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams; - public static synthetic fun copy-7OuPiQU$default (Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams;ILjava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext;Ljava/lang/Double;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams; + public final fun 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 fun equals (Ljava/lang/Object;)Z public final fun getIncludeContext ()Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext; public final fun getMaxTokens ()I @@ -1260,10 +1025,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest public final fun getModelPreferences ()Lio/modelcontextprotocol/kotlin/sdk/types/ModelPreferences; public final fun getStopSequences ()Ljava/util/List; public final fun getSystemPrompt ()Ljava/lang/String; - public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; public final fun getTemperature ()Ljava/lang/Double; - public final fun getToolChoice ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolChoice; - public final fun getTools ()Ljava/util/List; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -1285,19 +1047,17 @@ 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/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 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 final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/Role; - public final fun component2 ()Ljava/util/List; + public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent; 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;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 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 fun equals (Ljava/lang/Object;)Z - public final fun getContent ()Ljava/util/List; + public final fun getContent ()Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent; public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getModel ()Ljava/lang/String; public final fun getRole ()Lio/modelcontextprotocol/kotlin/sdk/types/Role; @@ -1408,104 +1168,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CustomRequest$Compan public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest$Companion; - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;)V - public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest;Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest; - public fun equals (Ljava/lang/Object;)Z - public final fun getMeta--Khlwt8 ()Lkotlinx/serialization/json/JsonObject; - public fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; - public fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; - public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestParams; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequest$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams$Companion; - public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1--Khlwt8 ()Lkotlinx/serialization/json/JsonObject; - public final fun copy-hVVZdjo (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; - public static synthetic fun copy-hVVZdjo$default (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; - public fun equals (Ljava/lang/Object;)Z - public fun getMeta--Khlwt8 ()Lkotlinx/serialization/json/JsonObject; - public synthetic fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverRequestParams$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverResult : io/modelcontextprotocol/kotlin/sdk/types/ServerResult { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult$Companion; - public fun (Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Ljava/lang/String;Ljava/lang/String;JLio/modelcontextprotocol/kotlin/sdk/types/CacheScope;Lkotlinx/serialization/json/JsonObject;)V - public synthetic fun (Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Ljava/lang/String;Ljava/lang/String;JLio/modelcontextprotocol/kotlin/sdk/types/CacheScope;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/util/List; - public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; - public final fun component3 ()Ljava/lang/String; - public final fun component4 ()Ljava/lang/String; - public final fun component5 ()J - public final fun component6 ()Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; - public final fun component7 ()Lkotlinx/serialization/json/JsonObject; - public final fun copy (Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Ljava/lang/String;Ljava/lang/String;JLio/modelcontextprotocol/kotlin/sdk/types/CacheScope;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Ljava/lang/String;Ljava/lang/String;JLio/modelcontextprotocol/kotlin/sdk/types/CacheScope;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult; - public fun equals (Ljava/lang/Object;)Z - public final fun getCacheScope ()Lio/modelcontextprotocol/kotlin/sdk/types/CacheScope; - public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; - public final fun getInstructions ()Ljava/lang/String; - public fun getMeta ()Lkotlinx/serialization/json/JsonObject; - public final fun getResultType ()Ljava/lang/String; - public final fun getSupportedVersions ()Ljava/util/List; - public final fun getTtlMs ()J - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/DiscoverResult$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/DiscoverResult;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/DiscoverResult$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/DoubleSchema : io/modelcontextprotocol/kotlin/sdk/types/NumberSchemaDefinition { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/DoubleSchema$Companion; public fun ()V @@ -1579,7 +1241,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequest$Compan public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getMessage ()Ljava/lang/String; public final fun requestedSchema (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;)V public final fun requestedSchema (Lkotlin/jvm/functions/Function1;)V @@ -1957,7 +1619,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/GetPromptRequest$Com public final class io/modelcontextprotocol/kotlin/sdk/types/GetPromptRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/GetPromptRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getArguments ()Ljava/util/Map; public final fun getName ()Ljava/lang/String; public final fun setArguments (Ljava/util/Map;)V @@ -2402,7 +2064,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/InitializeRequest$Co public final class io/modelcontextprotocol/kotlin/sdk/types/InitializeRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/InitializeRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun capabilities (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;)V public final fun capabilities (Lkotlin/jvm/functions/Function1;)V public final fun getProtocolVersion ()Ljava/lang/String; @@ -2792,7 +2454,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequest$C public final class io/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListPromptsResult : io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { @@ -2863,7 +2525,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplate public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplatesRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplatesRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplatesResult : io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { @@ -2934,7 +2596,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequest public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourcesResult : io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { @@ -3004,7 +2666,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest$Com public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsResult : io/modelcontextprotocol/kotlin/sdk/types/ClientResult { @@ -3138,7 +2800,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListToolsRequest$Com public final class io/modelcontextprotocol/kotlin/sdk/types/ListToolsRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ListToolsRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/ListToolsResult : io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { @@ -3262,7 +2924,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Logging_dslKt { public abstract interface annotation class io/modelcontextprotocol/kotlin/sdk/types/McpDsl : java/lang/annotation/Annotation { } -public class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception { +public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception { public fun (I)V public fun (ILjava/lang/String;)V public fun (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;)V @@ -3272,7 +2934,7 @@ public class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/E public final fun getData ()Lkotlinx/serialization/json/JsonElement; } -public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/MediaContent : io/modelcontextprotocol/kotlin/sdk/types/ContentBlock, io/modelcontextprotocol/kotlin/sdk/types/SamplingMessageContent { +public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/MediaContent : io/modelcontextprotocol/kotlin/sdk/types/ContentBlock { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent$Companion; } @@ -3356,7 +3018,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Method$Defined : jav public static final field ResourcesUnsubscribe Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field RootsList Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field SamplingCreateMessage Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; - public static final field ServerDiscover Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field TasksCancel Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field TasksGet Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field TasksList Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; @@ -3565,7 +3226,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/PingRequest$Companio public final class io/modelcontextprotocol/kotlin/sdk/types/PingRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/PingRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; } public final class io/modelcontextprotocol/kotlin/sdk/types/PingRequest_dslKt { @@ -3879,8 +3540,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/RPCError$ErrorCode { public static final field PARSE_ERROR I public static final field REQUEST_TIMEOUT I public static final field RESOURCE_NOT_FOUND I - public static final field UNSUPPORTED_PROTOCOL_VERSION I - public static final field URL_ELICITATION_REQUIRED I } public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { @@ -3917,7 +3576,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest$ public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getUri ()Ljava/lang/String; public final fun setUri (Ljava/lang/String;)V } @@ -4125,12 +3784,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/RequestMeta { public static fun equals-impl (Lkotlinx/serialization/json/JsonObject;Ljava/lang/Object;)Z public static final fun equals-impl0 (Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Z public static final fun get-impl (Lkotlinx/serialization/json/JsonObject;Ljava/lang/String;)Lkotlinx/serialization/json/JsonElement; - public static final fun getClientCapabilities-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities; - public static final fun getClientInfo-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/Implementation; public final fun getJson ()Lkotlinx/serialization/json/JsonObject; - public static final fun getLogLevel-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; public static final fun getProgressToken-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/RequestId; - public static final fun getProtocolVersion-impl (Lkotlinx/serialization/json/JsonObject;)Ljava/lang/String; public static final fun getRelatedTask-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/RelatedTaskMetadata; public fun hashCode ()I public static fun hashCode-impl (Lkotlinx/serialization/json/JsonObject;)I @@ -4541,18 +4196,13 @@ 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/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 fun (Lio/modelcontextprotocol/kotlin/sdk/types/Role;Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/Role; - 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 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 fun equals (Ljava/lang/Object;)Z - public final fun getContent ()Ljava/util/List; - public final fun getMeta ()Lkotlinx/serialization/json/JsonObject; + public final fun getContent ()Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent; public final fun getRole ()Lio/modelcontextprotocol/kotlin/sdk/types/Role; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -4580,15 +4230,6 @@ 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 @@ -4604,26 +4245,22 @@ 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;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 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 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 ()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 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 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; @@ -4706,97 +4343,6 @@ 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 @@ -4884,7 +4430,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/SetLevelRequest$Comp public final class io/modelcontextprotocol/kotlin/sdk/types/SetLevelRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/SetLevelRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getLoggingLevel ()Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; public final fun setLoggingLevel (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;)V } @@ -4957,7 +4503,6 @@ 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; } @@ -5050,7 +4595,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/SubscribeRequest$Com public final class io/modelcontextprotocol/kotlin/sdk/types/SubscribeRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/SubscribeRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getUri ()Ljava/lang/String; public final fun setUri (Ljava/lang/String;)V } @@ -5527,49 +5072,6 @@ 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 @@ -5630,59 +5132,20 @@ 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 (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 (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 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; @@ -5703,41 +5166,6 @@ 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; @@ -5818,7 +5246,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequest$C public final class io/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequestBuilder : io/modelcontextprotocol/kotlin/sdk/types/RequestBuilder { public fun ()V public fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequest; - public synthetic fun build$io_modelcontextprotocol_kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; + public synthetic fun build$kotlin_sdk_core ()Lio/modelcontextprotocol/kotlin/sdk/types/Request; public final fun getUri ()Ljava/lang/String; public final fun setUri (Ljava/lang/String;)V } @@ -5853,35 +5281,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/UnsubscribeRequestPa public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData$Companion; - public fun (Ljava/util/List;Ljava/lang/String;)V - public final fun component1 ()Ljava/util/List; - public final fun component2 ()Ljava/lang/String; - public final fun copy (Ljava/util/List;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData; - public fun equals (Ljava/lang/Object;)Z - public final fun getRequested ()Ljava/lang/String; - public final fun getSupported ()Ljava/util/List; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/UnsupportedProtocolVersionData$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema : io/modelcontextprotocol/kotlin/sdk/types/MultiSelectEnumSchema { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items;Ljava/util/List;)V @@ -5984,39 +5383,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/UntitledSingleSelect public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$Companion; - public fun (Ljava/util/List;)V - public final fun component1 ()Ljava/util/List; - public final fun copy (Ljava/util/List;)Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData; - public fun equals (Ljava/lang/Object;)Z - public final fun getElicitations ()Ljava/util/List; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredData$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredException : io/modelcontextprotocol/kotlin/sdk/types/McpException { - public fun (Ljava/util/List;Ljava/lang/String;)V - public synthetic fun (Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getElicitations ()Ljava/util/List; -} - public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/WithMeta { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/WithMeta$Companion; public abstract fun getMeta ()Lkotlinx/serialization/json/JsonObject; diff --git a/kotlin-sdk-core/build.gradle.kts b/kotlin-sdk-core/build.gradle.kts index 1d19f5d..0e702c4 100644 --- a/kotlin-sdk-core/build.gradle.kts +++ b/kotlin-sdk-core/build.gradle.kts @@ -4,10 +4,65 @@ 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 @@ -36,8 +91,10 @@ 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 new file mode 100644 index 0000000..d774a5b --- /dev/null +++ b/kotlin-sdk-core/detekt-baseline-commonMainSourceSet.xml @@ -0,0 +1,27 @@ + + + + + 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 d483d7e..a22fbcd 100644 --- a/kotlin-sdk-core/detekt-baseline-main.xml +++ b/kotlin-sdk-core/detekt-baseline-main.xml @@ -2,6 +2,31 @@ + 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 27013d3..5f67999 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,10 +99,8 @@ 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. - * @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. + * @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. */ 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 a738289..7b88100 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,13 +6,11 @@ import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi /** - * 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. + * Implements [onClose], [onError] and [onMessage] functions of [Transport] providing + * corresponding [_onClose], [_onError] and [_onMessage] properties to use for an implementation. */ @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 908e524..43c0ae5 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt @@ -27,34 +27,15 @@ import kotlinx.atomicfu.AtomicRef import kotlinx.atomicfu.atomic import kotlinx.atomicfu.getAndUpdate import kotlinx.atomicfu.update -import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentMap -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.job -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.serialization.SerializationException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.encodeToJsonElement -import kotlin.coroutines.AbstractCoroutineContextElement -import kotlin.coroutines.ContinuationInterceptor -import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -72,26 +53,23 @@ 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, ) /** @@ -99,27 +77,21 @@ 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. * - * @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 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. * @property onProgress callback for progress notifications. - * If set, requests progress notifications from the remote end (if supported); - * when progress notifications are received, this callback is invoked. + * If set, requests progress notifications from the remote end (if supported). + * When progress notifications are received, this callback will be invoked. * @property timeout a timeout for this request. - * If exceeded, a [McpException] with code `RequestTimeout` is raised from [Protocol.request]. - * If not specified, [DEFAULT_REQUEST_TIMEOUT] is used. + * If exceeded, a McpException with code `RequestTimeout` will be raised from request(). + * If not specified, `DEFAULT_REQUEST_TIMEOUT` will be used as the timeout. */ public class RequestOptions( relatedRequestId: RequestId? = null, @@ -155,7 +127,7 @@ public class RequestOptions( override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + onProgress.hashCode() + result = 31 * result + (onProgress?.hashCode() ?: 0) result = 31 * result + timeout.hashCode() return result } @@ -166,88 +138,28 @@ public class RequestOptions( /** * Extra data given to request handlers. - * - * One instance exists per in-flight inbound request. It is passed as the second parameter to - * low-level handlers registered via [Protocol.setRequestHandler] **and** installed into the - * handler coroutine's [CoroutineContext], so code nested anywhere under the handler can reach it - * via [currentRequestHandlerExtra] without parameter plumbing. - * - * @property requestId the JSON-RPC id of the request being handled - * @property method the request's method */ -public class RequestHandlerExtra internal constructor( - public val requestId: RequestId, - public val method: Method, - private val protocol: Protocol, - internal val capturedTransport: Transport, -) : AbstractCoroutineContextElement(Key) { - - /** Context key for retrieving the extra from a handler coroutine's context. */ - public companion object Key : CoroutineContext.Key - - /** - * Sends a notification tagged with `relatedRequestId = `[requestId], letting transports - * associate it with the request being handled (e.g. route it onto the request's SSE stream). - */ - public suspend fun sendNotification(notification: Notification) { - protocol.notification(notification, relatedRequestId = requestId) - } - - /** - * Sends a request to the peer tagged with `relatedRequestId = `[requestId]. - * - * Mirrors [Protocol.request]; cancellation of the handler coroutine propagates into this call. - */ - public suspend fun sendRequest(request: Request, options: RequestOptions? = null): T { - val base = options ?: RequestOptions() - return protocol.request( - request, - RequestOptions( - relatedRequestId = requestId, - resumptionToken = base.resumptionToken, - onResumptionToken = base.onResumptionToken, - onProgress = base.onProgress, - timeout = base.timeout, - ), - ) - } -} - -/** The [RequestHandlerExtra] of the MCP request being handled by the current coroutine, or `null` outside a handler. */ -public suspend fun currentRequestHandlerExtra(): RequestHandlerExtra? = currentCoroutineContext()[RequestHandlerExtra] +public class RequestHandlerExtra internal val COMPLETED = CompletableDeferred(Unit).also { it.complete(Unit) } -/** Cap on how many recently-cancelled outbound request ids are remembered to quiet late responses/progress. */ -private const val CANCELLED_REQUEST_IDS_REMEMBERED = 256 - -// Bypass set: exempt from both bound tiers so the connection cannot self-block. -// Responses are not listed — they are handled inline before dispatch. -private val CONTROL_METHODS: Set = setOf( - Method.Defined.Ping.value, - Method.Defined.NotificationsCancelled.value, - Method.Defined.NotificationsProgress.value, - Method.Defined.NotificationsInitialized.value, -) - /** * Implements MCP protocol framing on top of a pluggable transport, including * features like request/response linking, notifications, and progress. * - * @property options protocol-level configuration; `null` falls back to defaults + * @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 */ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptions?) { - private val connectionRef: AtomicRef = atomic(null) - - /** The active transport, or `null` if not connected. */ - public val transport: Transport? - get() = connectionRef.value?.transport + public var transport: Transport? = null + private set private val _requestHandlers: AtomicRef RequestResult?>> = atomic(persistentMapOf()) - - /** Registered request handlers keyed by method name. */ public val requestHandlers: Map< String, suspend ( @@ -259,41 +171,20 @@ 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. * @@ -323,11 +214,6 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio public var fallbackNotificationHandler: (suspend (notification: JSONRPCNotification) -> Unit)? = null init { - setNotificationHandler(Method.Defined.NotificationsCancelled) { notification -> - handleCancelledNotification(notification) - COMPLETED - } - setNotificationHandler(Method.Defined.NotificationsProgress) { notification -> onProgress(notification) COMPLETED @@ -338,64 +224,15 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } } - /** - * Enables concurrent dispatch of inbound messages on the current connection. - * - * Called by subclasses when the MCP initialization phase completes (the client after the - * initialize exchange; the server session on receiving `notifications/initialized`). Until - * then, inbound messages are handled inline in arrival order. Reset by [connect]. - */ - protected fun enableConcurrentDispatch() { - val connection = connectionRef.value ?: return - connection.concurrentDispatchEnabled.value = true - } - - /** - * Router hook invoked whenever `notifications/initialized` is received, before handler lookup. - * - * Runs regardless of which notification handler is registered for the method, so replacing the - * handler via [setNotificationHandler] cannot disable subclass initialization logic. - */ - protected open fun onInitializedNotification() {} - /** * Attaches to the given transport, starts it, and starts listening for messages. * - * The Protocol object assumes ownership of the Transport, replacing any callbacks that have - * already been set, and expects that it is the only user of the Transport instance going forward. - * - * @throws IllegalStateException if this Protocol is already connected; call [close] first. + * The Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward. */ public open suspend fun connect(transport: Transport) { - val maxConcurrent = options?.maxConcurrentHandlers ?: DEFAULT_MAX_CONCURRENT_HANDLERS - val maxInFlight = options?.maxInFlightHandlers ?: DEFAULT_MAX_IN_FLIGHT_HANDLERS - require(maxConcurrent > 0) { "maxConcurrentHandlers must be positive, but was $maxConcurrent" } - require(maxInFlight >= maxConcurrent) { - "maxInFlightHandlers ($maxInFlight) must be >= maxConcurrentHandlers ($maxConcurrent)" - } - val handlerContext = (options?.handlerCoroutineContext ?: Dispatchers.Default).minusKey(Job) - val interceptor = handlerContext[ContinuationInterceptor] - if (interceptor === Dispatchers.Unconfined || - interceptor?.let { it::class.simpleName?.contains("Unconfined") } == true - ) { - logger.warn { - "handlerCoroutineContext uses an unconfined dispatcher ($interceptor); " + - "handler resumptions will run on the transport read loop and may block unrelated messages" - } - } - val connection = Connection( - transport = transport, - handlerScope = CoroutineScope(SupervisorJob() + handlerContext + CoroutineName("McpProtocol")), - executionSemaphore = Semaphore(maxConcurrent), - maxInFlightHandlers = maxInFlight, - ) - if (!connectionRef.compareAndSet(null, connection)) { - connection.handlerScope.cancel() - error("Protocol is already connected; close() the current connection before connecting a new transport") - } - + this.transport = transport transport.onClose { - doClose(connection) + doClose() } transport.onError { @@ -403,43 +240,24 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } transport.onMessage { message -> - if (connectionRef.value !== connection) { - logger.trace { "Dropping message received after close: ${message::class.simpleName}" } - return@onMessage - } when (message) { is JSONRPCResponse -> onResponse(message, null) - is JSONRPCRequest -> dispatchRequest(connection, message) - is JSONRPCNotification -> dispatchNotification(connection, message) + is JSONRPCRequest -> onRequest(message) + is JSONRPCNotification -> onNotification(message) is JSONRPCError -> onResponse(null, message) is JSONRPCEmptyMessage -> Unit } } logger.info { "Starting transport" } - try { - transport.start() - } catch (cause: Throwable) { - // Roll back so the Protocol can be reconnected after a failed start; idempotent with - // a transport-initiated doClose (both CAS on the same connection). - if (connectionRef.compareAndSet(connection, null)) { - connection.handlerScope.cancel() - } - throw cause - } + transport.start() } - private fun doClose(connection: Connection) { - // A stale onClose from a previous transport must not tear down the successor connection. - if (!connectionRef.compareAndSet(connection, null)) { - logger.trace { "Ignoring close signal from a stale transport" } - return - } - val handlersToNotify = _responseHandlers.getAndSet(persistentMapOf()).values.toList() + private fun doClose() { + val handlersToNotify = _responseHandlers.value.values.toList() + _responseHandlers.getAndSet(persistentMapOf()) _progressHandlers.getAndSet(persistentMapOf()) - recentlyCancelledRequestIds.getAndSet(persistentListOf()) - connection.inFlightRequestJobs.getAndSet(persistentMapOf()) - connection.handlerScope.cancel() + transport = null onClose() val error = McpException(RPCError.ErrorCode.CONNECTION_CLOSED, "Connection closed") @@ -448,110 +266,6 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio } } - private suspend fun dispatchRequest(connection: Connection, request: JSONRPCRequest) { - if (!connection.concurrentDispatchEnabled.value) { - // Serial phase (before the peer completes MCP initialization): inline, in arrival - // order — the delivering coroutine awaits the handler. - onRequest(request, connection, trackCancellation = false) - return - } - val bypass = request.method in CONTROL_METHODS - if (!bypass && !tryAdmit(connection)) { - rejectOverloadedRequest(connection, request) - return - } - // UNDISPATCHED starts the handler synchronously in the delivering coroutine, preserving - // arrival order, then moves to handlerCoroutineContext at its first suspension. - connection.handlerScope.launch(start = CoroutineStart.UNDISPATCHED) { - // Register before any suspension point so a following notifications/cancelled can find - // and cancel even a handler still parked on admission. - registerInFlight(connection, request.id, coroutineContext.job) - try { - if (bypass) { - onRequest(request, connection, trackCancellation = true) - } else { - connection.executionSemaphore.withPermit { - onRequest(request, connection, trackCancellation = true) - } - } - } finally { - if (!bypass) connection.inFlightCount.decrementAndGet() - } - } - } - - private suspend fun dispatchNotification(connection: Connection, notification: JSONRPCNotification) { - if (notification.method == Method.Defined.NotificationsInitialized.value) { - onInitializedNotification() - } - if (!connection.concurrentDispatchEnabled.value) { - onNotification(notification) - return - } - val bypass = notification.method in CONTROL_METHODS - if (!bypass && !tryAdmit(connection)) { - val dropped = IllegalStateException( - "Dropped notification ${notification.method}: too many in-flight messages", - ) - logger.warn { dropped.message } - onError(dropped) - return - } - connection.handlerScope.launch(start = CoroutineStart.UNDISPATCHED) { - try { - if (bypass) { - onNotification(notification) - } else { - connection.executionSemaphore.withPermit { onNotification(notification) } - } - } finally { - if (!bypass) connection.inFlightCount.decrementAndGet() - } - } - } - - private fun tryAdmit(connection: Connection): Boolean { - while (true) { - val current = connection.inFlightCount.value - if (current >= connection.maxInFlightHandlers) return false - if (connection.inFlightCount.compareAndSet(current, current + 1)) return true - } - } - - private suspend fun rejectOverloadedRequest(connection: Connection, request: JSONRPCRequest) { - logger.warn { - "Rejecting request ${request.method} (id: ${request.id}): " + - "in-flight handler limit (${connection.maxInFlightHandlers}) reached" - } - try { - connection.transport.send( - JSONRPCError( - id = request.id, - error = RPCError( - code = RPCError.ErrorCode.INTERNAL_ERROR, - message = "Server is busy: too many in-flight messages", - ), - ), - ) - } catch (e: CancellationException) { - throw e - } catch (cause: Throwable) { - logger.error(cause) { "Failed to send busy rejection for request id: ${request.id}" } - onError(cause) - } - } - - private fun handleCancelledNotification(notification: CancelledNotification) { - val connection = connectionRef.value ?: return - val requestId = notification.params.requestId - val job = connection.inFlightRequestJobs.value[requestId] - if (job == null) { - logger.trace { "Ignoring cancellation for unknown or already-completed request: $requestId" } - return - } - job.cancel("Cancelled by peer: ${notification.params.reason ?: "unknown"}") - } - private suspend fun onNotification(notification: JSONRPCNotification) { logger.trace { "Received notification: ${notification.method}" } @@ -563,24 +277,21 @@ 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, connection: Connection, trackCancellation: Boolean) { + private suspend fun onRequest(request: JSONRPCRequest) { logger.trace { "Received request: ${request.method} (id: ${request.id})" } val handler = requestHandlers[request.method] ?: fallbackRequestHandler - val capturedTransport = connection.transport if (handler === null) { logger.trace { "No handler found for request: ${request.method}" } try { - capturedTransport.send( + transport?.send( JSONRPCError( id = request.id, error = RPCError( @@ -589,8 +300,6 @@ 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) @@ -598,77 +307,37 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio return } - val extra = RequestHandlerExtra( - requestId = request.id, - method = Method.from(request.method), - protocol = this, - capturedTransport = capturedTransport, - ) - - // Registration in the in-flight registry already happened in dispatchRequest's launch - // block (synchronously, pre-suspension). Here we only need the job for suppression checks. - val handlerJob = if (trackCancellation) currentCoroutineContext().job else null - - withContext(extra) { - try { - val result = handler(request, extra) - logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" } - if (handlerJob?.isCancelled == true) { - logger.trace { "Suppressing response for request cancelled by peer (id: ${request.id})" } - return@withContext - } - capturedTransport.send( - JSONRPCResponse( - id = request.id, - result = result ?: EmptyResult(), - ), - ) - } catch (e: CancellationException) { - // Intentional exception to the always-rethrow-CE convention, only at this boundary. - if (handlerJob == null) throw e // serial phase: inline dispatch is not response-suppressed - if (handlerJob.isCancelled) throw e // genuine peer or close cancel: suppress the response - // CE escaped a live handler job (e.g. a leaked inner withTimeout). Answer - // INTERNAL_ERROR so the peer does not hang until its own timeout. - respondWithError(capturedTransport, request, e) - } catch (cause: Throwable) { - if (handlerJob?.isCancelled == true) { - logger.trace { "Suppressing error response for request cancelled by peer (id: ${request.id})" } - return@withContext - } - respondWithError(capturedTransport, request, cause) - } - } - } - - private fun registerInFlight(connection: Connection, id: RequestId, job: Job) { - val previous = connection.inFlightRequestJobs.getAndUpdate { current -> current.putting(id, job) }[id] - if (previous != null) { - logger.warn { "Duplicate in-flight request id $id; replacing the previous handler job" } - } - job.invokeOnCompletion { - // Identity guard: never evict a successor job registered under a reused id. - connection.inFlightRequestJobs.update { current -> - if (current[id] === job) current.removing(id) else current - } - } - } - - private suspend fun respondWithError(transport: Transport, request: JSONRPCRequest, cause: Throwable) { - logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" } + @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException") try { - val rpcError = if (cause is McpException) { - RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data) - } else { - RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error") - } - transport.send(JSONRPCError(id = request.id, error = rpcError)) + val result = handler(request, RequestHandlerExtra()) + logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" } + + transport?.send( + JSONRPCResponse( + id = request.id, + result = result ?: EmptyResult(), + ), + ) } catch (e: CancellationException) { throw e - } catch (sendError: Throwable) { - logger.error(sendError) { - "Failed to send error response for request: ${request.method} (id: ${request.id})" + } catch (cause: Throwable) { + logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" } + + try { + val rpcError = if (cause is McpException) { + RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data) + } else { + RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error") + } + transport?.send(JSONRPCError(id = request.id, error = rpcError)) + } catch (e: CancellationException) { + throw e + } catch (sendError: Throwable) { + logger.error(sendError) { + "Failed to send error response for request: ${request.method} (id: ${request.id})" + } + // Optionally implement fallback behavior here } - onError(sendError) } } @@ -683,10 +352,6 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val handler = _progressHandlers.value[progressToken] if (handler == null) { - if (isRecentlyCancelled(progressToken)) { - logger.trace { "Ignoring progress for a locally cancelled request: $progressToken" } - return - } val error = Error( "Received a progress notification for an unknown token: ${McpJson.encodeToString(notification)}", ) @@ -703,7 +368,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val oldResponseHandlers = _responseHandlers.getAndUpdate { current -> if (messageId != null && messageId in current) { - current.removing(messageId) + current.remove(messageId) } else { current } @@ -712,12 +377,8 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val handler = oldResponseHandlers[messageId] if (handler != null) { - messageId?.let { msg -> _progressHandlers.update { it.removing(msg) } } + messageId?.let { msg -> _progressHandlers.update { it.remove(msg) } } } else { - if (isRecentlyCancelled(messageId)) { - logger.trace { "Ignoring response for a locally cancelled request: $messageId" } - return - } onError( IllegalStateException( "Received a response for an unknown message ID: ${McpJson.encodeToString(error ?: response)}", @@ -729,8 +390,8 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio if (response != null) { handler(response, null) } else { - checkNotNull(error) - val mcpException = McpException.fromError( + check(error != null) + val mcpException = McpException( code = error.error.code, message = error.error.message, data = error.error.data, @@ -775,8 +436,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio public suspend fun request(request: Request, options: RequestOptions? = null): T { logger.trace { "Sending request: ${request.method}" } val result = CompletableDeferred() - val connection = connectionRef.value ?: error("Not connected") - val transport = connection.transport + val transport = transport ?: error("Not connected") if (this@Protocol.options?.enforceStrictCapabilities == true) { assertCapabilityForMethod(request.method) @@ -786,7 +446,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio options?.onProgress?.let { progressHandler -> logger.trace { "Registering progress handler for request id: $id" } _progressHandlers.update { current -> - current.putting(id, progressHandler) + current.put(id, progressHandler) } val paramsObject = (this.params as? JsonObject) ?: JsonObject(emptyMap()) @@ -805,99 +465,57 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio val jsonRpcRequestId = jsonRpcRequest.id _responseHandlers.update { current -> - current.putting(jsonRpcRequestId) { response, error -> + current.put(jsonRpcRequestId) { response, error -> if (error != null) { result.completeExceptionally(error) - return@putting + return@put } try { @Suppress("UNCHECKED_CAST") result.complete(response!!.result as T) - } catch (e: Throwable) { - result.completeExceptionally(e) + } catch (error: Throwable) { + result.completeExceptionally(error) } } } - if (connectionRef.value !== connection) { - // Connection closed or replaced during registration; nothing would complete this handler. - _responseHandlers.update { it.removing(jsonRpcRequestId) } - _progressHandlers.update { it.removing(jsonRpcRequestId) } - throw McpException(RPCError.ErrorCode.CONNECTION_CLOSED, "Connection closed") - } + val cancel: suspend (Throwable) -> Unit = { reason: Throwable -> + _responseHandlers.update { current -> current.remove(jsonRpcRequestId) } + _progressHandlers.update { current -> current.remove(jsonRpcRequestId) } - val cancelPending: suspend (reason: Throwable, notifyPeer: Boolean) -> Unit = { reason, notifyPeer -> - // Remember before removing: a racing onResponse/onProgress that sees the handler gone - // must also see the id, or it reports a late message as unknown. Order is load-bearing. - rememberCancelledRequestId(jsonRpcRequestId) - _responseHandlers.update { current -> current.removing(jsonRpcRequestId) } - _progressHandlers.update { current -> current.removing(jsonRpcRequestId) } + val notification = CancelledNotification( + params = CancelledNotificationParams( + requestId = jsonRpcRequestId, + reason = reason.message ?: "Unknown", + ), + ) - if (notifyPeer) { - val notification = CancelledNotification( - params = CancelledNotificationParams( - requestId = jsonRpcRequestId, - reason = reason.message ?: "Unknown", - ), - ) - transport.send(notification.toJSON(), options) - } + val jsonRpcNotification = notification.toJSON() + + transport.send(jsonRpcNotification, options) result.completeExceptionally(reason) } - // The MCP spec forbids cancelling `initialize`; local cleanup still runs, - // but no notifications/cancelled goes on the wire (timeout and cancel paths alike). - val notifyPeerOnCancel = request.method != Method.Defined.Initialize - val timeout = options?.timeout ?: DEFAULT_REQUEST_TIMEOUT try { - val response = withTimeoutOrNull(timeout) { + withTimeout(timeout) { logger.trace { "Sending request message with id: $jsonRpcRequestId" } - transport.send(jsonRpcRequest, options) - result.await() + this@Protocol.transport?.send(jsonRpcRequest, options) } - if (response == null) { - // Our own request timeout expired. An outer withTimeout's cancellation propagates - // through withTimeoutOrNull instead of returning null and is handled by the catch below. - logger.error { "Request timed out after ${timeout.inWholeMilliseconds}ms: ${request.method}" } - val timeoutError = McpException( + return result.await() + } catch (cause: TimeoutCancellationException) { + logger.error { "Request timed out after ${timeout.inWholeMilliseconds}ms: ${request.method}" } + cancel( + McpException( code = RPCError.ErrorCode.REQUEST_TIMEOUT, message = "Request timed out", data = JsonObject(mutableMapOf("timeout" to JsonPrimitive(timeout.inWholeMilliseconds))), - ) - withContext(NonCancellable) { - try { - cancelPending(timeoutError, notifyPeerOnCancel) - } catch (e: Throwable) { - logger.warn(e) { "Failed to notify peer about timed-out request" } - onError(e) - } - } - result.cancel() - throw timeoutError - } - return response - } catch (cause: CancellationException) { - // Plain caller cancellation, or an outer withTimeout that propagated through - // withTimeoutOrNull (only our own deadline returns null there). NonCancellable so the - // cancellation notification still goes out even though the caller's job is cancelled. - // A secondary send failure must never replace the original CancellationException. - withContext(NonCancellable) { - try { - cancelPending(cause, notifyPeerOnCancel) - } catch (e: Throwable) { - logger.warn(e) { "Failed to notify peer about cancelled request" } - onError(e) - } - } + ), + ) + result.cancel(cause) throw cause - } finally { - // Backstop for the failed-send path. The success, timeout and cancellation paths already - // removed these handlers, so this only bites when the outbound send failed. - _responseHandlers.update { it.removing(jsonRpcRequestId) } - _progressHandlers.update { it.removing(jsonRpcRequestId) } } } @@ -918,11 +536,6 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio * Registers a handler to invoke when this protocol object receives a request with the given method. * * Note that this will replace any previous request handler for the same method. - * - * Handlers may run concurrently after initialization and are cancelled cooperatively, so always - * re-throw [CancellationException]. Replacing a built-in control-method handler (`ping`, - * `notifications/cancelled`, `notifications/progress`, `notifications/initialized`) with slow - * code degrades connection liveness. */ public inline fun setRequestHandler( method: Method, @@ -938,60 +551,31 @@ 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.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) + current.put(method.value) { jSONRPCRequest, extraHandler -> + val request = jSONRPCRequest.fromJSON() + val response = block(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.removing(method.value) } + _requestHandlers.update { current -> current.remove(method.value) } } /** * Registers a handler to invoke when this protocol object receives a notification with the given method. * * Note that this will replace any previous notification handler for the same method. - * - * Handlers may run concurrently after initialization and are cancelled cooperatively, so always - * re-throw [CancellationException]. Replacing a built-in control-method handler (`ping`, - * `notifications/cancelled`, `notifications/progress`, `notifications/initialized`) with slow - * code degrades connection liveness. Replacing `notifications/cancelled` in particular disables - * inbound request cancellation entirely, since that handler is what cancels the running job. */ public fun setNotificationHandler(method: Method, handler: (notification: T) -> Deferred) { _notificationHandlers.update { current -> - current.putting(method.value) { + current.put(method.value) { @Suppress("UNCHECKED_CAST") handler(it.fromJSON() as T) } @@ -1002,22 +586,6 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio * Removes the notification handler for the given method. */ public fun removeNotificationHandler(method: Method) { - _notificationHandlers.update { current -> current.removing(method.value) } - } - - private class Connection( - val transport: Transport, - val handlerScope: CoroutineScope, - val executionSemaphore: Semaphore, - val maxInFlightHandlers: Int, - ) { - /** Init gate: inbound dispatch is inline/serial until this flips. */ - val concurrentDispatchEnabled = atomic(false) - - /** Admission tier: launched-but-not-completed handler jobs (running + parked). */ - val inFlightCount = atomic(0) - - /** In-flight request handler jobs, for `notifications/cancelled`. */ - val inFlightRequestJobs: AtomicRef> = atomic(persistentMapOf()) + _notificationHandlers.update { current -> current.remove(method.value) } } } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBuffer.kt index 8102cf6..d4c8b43 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,24 +4,13 @@ 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 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 - } +public class ReadBuffer { private val logger = KotlinLogging.logger { } @@ -50,6 +39,7 @@ public class ReadBuffer internal constructor(private val maxFrameSize: Int) { val line = readNextLine() ?: return null if (line.isBlank()) continue + @Suppress("TooGenericExceptionCaught") val message = try { deserializeMessage(line) } catch (e: Exception) { @@ -64,11 +54,7 @@ public class ReadBuffer internal constructor(private val maxFrameSize: Int) { private fun readNextLine(): String? { val lfIndex = if (buffer.exhausted()) -1L else buffer.indexOf('\n'.code.toByte()) - if (lfIndex == -1L) { - failIfFrameTooLong(buffer.size) - return null - } - failIfFrameTooLong(lfIndex) + if (lfIndex == -1L) return null return if (lfIndex == 0L) { buffer.skip(1) @@ -86,20 +72,13 @@ public class ReadBuffer internal constructor(private val maxFrameSize: Int) { } } - 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 a89c102..cb57547 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). * - * @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 + * @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. */ 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 baf3723..5f8bf9d 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,7 +16,6 @@ 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" @@ -75,8 +74,6 @@ 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 6a9015b..6b12492 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,32 +6,15 @@ import kotlin.jvm.JvmOverloads /** * Represents an error specific to the MCP protocol. * - * @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 + * @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. */ -public open class McpException @JvmOverloads public constructor( +public class McpException @JvmOverloads public constructor( public val code: Int, message: String = "MCP error $code", public val data: JsonElement? = null, cause: Throwable? = null, -) : Exception(message, cause) { - internal companion object { - /** - * Reconstructs the most specific [McpException] subtype for a JSON-RPC error. - * - * Recognizes [RPCError.ErrorCode.URL_ELICITATION_REQUIRED] errors and returns a - * [UrlElicitationRequiredException] when [data] carries valid URL-mode elicitations; - * otherwise returns a plain [McpException]. Never throws — a malformed payload simply - * degrades to a plain [McpException]. - */ - fun fromError(code: Int, message: String, data: JsonElement?): McpException { - if (code == RPCError.ErrorCode.URL_ELICITATION_REQUIRED && data != null) { - UrlElicitationRequiredException.fromDataOrNull(message, data)?.let { return it } - } - return McpException(code = code, message = message, data = data) - } - } -} +) : Exception(message, cause) diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PingRequest.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PingRequest.kt index 8280404..c45ed30 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 params optional request parameters carrying metadata + * @property meta optional request metadata */ @Serializable public data class PingRequest(override val params: BaseRequestParams? = null) : @@ -17,7 +17,6 @@ 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 deleted file mode 100644 index f3da768..0000000 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/UrlElicitationRequiredException.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.types - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.encodeToJsonElement - -/** - * Payload carried by the [data][RPCError.data] of a [RPCError.ErrorCode.URL_ELICITATION_REQUIRED] error. - * - * @property elicitations The URL-mode elicitations that must be completed before the original request - * can succeed. Each entry is equivalent to an `elicitation/create` request. - */ -@Serializable -public data class UrlElicitationRequiredData(val elicitations: List) - -/** - * Signals that one or more URL-mode elicitations must be completed before a request can be processed. - * - * Serialized over the wire as a JSON-RPC error with code [RPCError.ErrorCode.URL_ELICITATION_REQUIRED] - * and [data][RPCError.data] holding the [elicitations]. A server throws this from a request handler to - * tell the client that a URL-mode elicitation is required; the client receives it from any request as a - * typed exception, equivalent to receiving an `elicitation/create` request. - * - * The client is responsible for obtaining explicit user consent and surfacing each [ElicitRequestURLParams] - * before navigation — the SDK never opens or validates URLs. - * - * Example client-side handling: - * ```kotlin - * try { - * client.callTool(name = "create_repo", arguments = args) - * } catch (e: UrlElicitationRequiredException) { - * for (elicitation in e.elicitations) { - * // Show elicitation.message and the target domain, then ask the user for consent. - * if (userConsents(elicitation.url)) openInBrowser(elicitation.url) - * // Await completion via Client.setElicitationCompleteHandler, then optionally retry the call. - * } - * } - * ``` - * - * @property elicitations The required URL-mode elicitations (always non-empty). - */ -public class UrlElicitationRequiredException( - public val elicitations: List, - message: String = defaultMessage(elicitations), -) : McpException( - code = RPCError.ErrorCode.URL_ELICITATION_REQUIRED, - message = message, - data = McpJson.encodeToJsonElement(UrlElicitationRequiredData(elicitations)), -) { - internal companion object { - private fun defaultMessage(elicitations: List): String = - if (elicitations.size > 1) "URL elicitations are required" else "URL elicitation is required" - - /** - * Reconstructs a [UrlElicitationRequiredException] from JSON-RPC error [data], or `null` when the - * payload is absent, malformed, or carries no elicitations. Never throws. - */ - fun fromDataOrNull(message: String, data: JsonElement): UrlElicitationRequiredException? = - runCatching { McpJson.decodeFromJsonElement(data).elicitations } - .getOrNull() - ?.takeIf { it.isNotEmpty() } - ?.let { UrlElicitationRequiredException(it, message) } - } -} diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.dsl.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/capabilities.dsl.kt index 0d8b2b7..146eabf 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,32 +36,45 @@ import kotlinx.serialization.json.buildJsonObject */ @McpDsl public class ClientCapabilitiesBuilder @PublishedApi internal constructor() { - private var sampling: ClientCapabilities.Sampling? = null + private var sampling: JsonObject? = null private var roots: ClientCapabilities.Roots? = null - private var elicitation: ClientCapabilities.Elicitation? = null - private var extensions: Map? = null + private var elicitation: JsonObject? = null private var experimental: JsonObject? = null /** - * Sampling capability configuration. See [ClientCapabilities.Sampling]. + * Indicates that the client supports sampling from an LLM. * - * 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. + * Use [ClientCapabilities.sampling] for default empty configuration. * * Example: * ```kotlin * capabilities { - * sampling(ClientCapabilities.Sampling(tools = EmptyJsonObject)) + * sampling(ClientCapabilities.sampling) * } * ``` * * @param value The sampling capability configuration */ - public fun sampling(value: ClientCapabilities.Sampling) { + public fun sampling(value: JsonObject) { 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. * @@ -99,33 +112,25 @@ public class ClientCapabilitiesBuilder @PublishedApi internal constructor() { * * @param value The elicitation capability configuration */ - public fun elicitation(value: ClientCapabilities.Elicitation) { + public fun elicitation(value: JsonObject) { this.elicitation = value } /** - * 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. + * Indicates that the client supports elicitation from the server with custom configuration. * * Example: * ```kotlin * capabilities { - * extensions(mapOf( - * "io.modelcontextprotocol/ui" to buildJsonObject { - * put("mimeTypes", JsonArray(listOf(JsonPrimitive("text/html")))) - * } - * )) + * elicitation { + * put("mode", JsonPrimitive("interactive")) + * } * } * ``` * - * @param value The map of extension identifiers to their settings + * @param block Lambda for building the elicitation configuration */ - public fun extensions(value: Map) { - this.extensions = value - } + public fun elicitation(block: JsonObjectBuilder.() -> Unit): Unit = elicitation(buildJsonObject(block)) /** * Defines experimental, non-standard capabilities that the client supports. @@ -172,7 +177,6 @@ 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 ecb8c48..fae24c3 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,84 +41,26 @@ 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: Sampling? = null, + public val sampling: JsonObject? = null, public val roots: Roots? = null, - public val elicitation: Elicitation? = null, - public val tasks: Tasks? = null, + public val elicitation: JsonObject? = null, public val experimental: JsonObject? = null, - public val extensions: Map? = null, ) { /** - * 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 + * @property sampling convenience value to enable the sampling capability + * @property elicitation convenience value to enable the elicitation capability */ public companion object { - public val sampling: Sampling = Sampling() - public val elicitation: Elicitation = Elicitation() + public val sampling: JsonObject = EmptyJsonObject + public val elicitation: JsonObject = EmptyJsonObject } /** @@ -133,71 +75,8 @@ 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. * @@ -213,11 +92,7 @@ public val ClientCapabilities.Elicitation?.supportsUrl: Boolean * @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( @@ -226,9 +101,7 @@ public data class ServerCapabilities( val prompts: Prompts? = null, val logging: JsonObject? = null, val completions: JsonObject? = null, - val tasks: Tasks? = null, val experimental: JsonObject? = null, - val extensions: Map? = null, ) { /** @@ -277,35 +150,4 @@ 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 f65b1ed..ac867af 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-03-26" +public const val DEFAULT_NEGOTIATED_PROTOCOL_VERSION: String = "2025-06-18" /** All MCP protocol versions supported by this SDK. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( @@ -28,10 +28,11 @@ 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? } @@ -90,6 +91,9 @@ 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 { @@ -124,10 +128,11 @@ 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 1eb5329..c782e3e 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,6 +12,7 @@ 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 { @@ -37,7 +38,6 @@ 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,8 +48,6 @@ 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 815e5f6..a1c9e4f 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,45 +29,23 @@ 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, - 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 -} +public sealed interface MediaContent : ContentBlock /** * Text provided to or from an LLM. @@ -196,50 +174,3 @@ 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 deleted file mode 100644 index d486b1f..0000000 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/discovery.kt +++ /dev/null @@ -1,107 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.types - -import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Required -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.JsonObject - -/** - * Parameters for a `server/discover` request. - * - * Discovery belongs to the request-scoped lifecycle and therefore requires a protocol version and - * client capabilities in [meta]. Client information is recommended by the protocol but optional. - * Unknown metadata entries are preserved by [RequestMeta]. - * - * @property meta request-scoped protocol and client metadata - * @throws IllegalArgumentException if required request-scoped metadata is absent - * @throws SerializationException if required metadata is malformed - */ -@Serializable -@ExperimentalMcpApi -public data class DiscoverRequestParams( - @SerialName("_meta") - override val meta: RequestMeta, -) : RequestParams { - init { - requireNotNull(meta.protocolVersion) { "${RequestMetaKeys.PROTOCOL_VERSION} is required" } - requireNotNull(meta.clientCapabilities) { "${RequestMetaKeys.CLIENT_CAPABILITIES} is required" } - } -} - -/** - * Requests the protocol versions, capabilities, and metadata advertised by a server. - * - * @property params request metadata required by the request-scoped lifecycle - */ -@Serializable -@ExperimentalMcpApi -public data class DiscoverRequest(override val params: DiscoverRequestParams) : ClientRequest { - @OptIn(ExperimentalSerializationApi::class) - @EncodeDefault - override val method: Method = Method.Defined.ServerDiscover - - /** Metadata for this request. */ - public val meta: RequestMeta - get() = params.meta -} - -/** Defines where a cached discovery result may be reused. */ -@Serializable -@ExperimentalMcpApi -public enum class CacheScope { - /** The result may only be reused within the same authorization context. */ - @SerialName("private") - Private, - - /** The result may be shared across authorization contexts. */ - @SerialName("public") - Public, -} - -/** - * Result returned by `server/discover`. - * - * @property supportedVersions protocol versions supported by the server - * @property capabilities capabilities advertised by the server - * @property instructions optional guidance for clients using the server - * @property resultType discriminator for the result representation - * @property ttlMs number of milliseconds clients may treat this result as fresh - * @property cacheScope authorization boundary within which the result may be reused - * @property meta optional response metadata, including optional server information - * @throws IllegalArgumentException if [ttlMs] is negative - */ -@Serializable -@ExperimentalMcpApi -public data class DiscoverResult( - val supportedVersions: List, - val capabilities: ServerCapabilities, - val instructions: String? = null, - @EncodeDefault - val resultType: String = COMPLETE_RESULT_TYPE, - @Required - val ttlMs: Long = 0, - @Required - val cacheScope: CacheScope = CacheScope.Private, - @SerialName("_meta") - override val meta: JsonObject? = null, -) : ServerResult { - init { - require(ttlMs >= 0) { "ttlMs must be non-negative, but was $ttlMs" } - } -} - -/** - * Data returned when a request selects an unsupported protocol version. - * - * @property supported protocol versions supported by the receiver - * @property requested protocol version selected by the request - */ -@Serializable -@ExperimentalMcpApi -public data class UnsupportedProtocolVersionData(val supported: List, val requested: String) - -internal const val COMPLETE_RESULT_TYPE: String = "complete" diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.kt index 1bb73e6..bb303f0 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,10 +46,11 @@ 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 /** @@ -63,6 +64,7 @@ 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( @@ -71,7 +73,6 @@ public sealed interface ElicitRequestParams : RequestParams { val properties: Map, val required: List? = null, ) { - /** Always "object" for elicitation schemas. */ @EncodeDefault val type: String = "object" } @@ -90,7 +91,7 @@ public sealed interface ElicitRequestParams : RequestParams { ReplaceWith("ElicitRequestFormParams(message, requestedSchema = requestedSchema, meta = meta)"), DeprecationLevel.WARNING, ) -@Suppress("FunctionName") +@Suppress("FunctionNaming", "FunctionName") public fun ElicitRequestParams( message: String, requestedSchema: ElicitRequestParams.RequestedSchema, @@ -112,6 +113,7 @@ 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 @@ -122,7 +124,6 @@ public data class ElicitRequestFormParams( @SerialName("_meta") override val meta: RequestMeta? = null, ) : ElicitRequestParams { - /** The elicitation mode discriminator, always `"form"`. */ @EncodeDefault public val mode: String = "form" } @@ -133,11 +134,6 @@ public data class ElicitRequestFormParams( * Directs the user to an external URL for out-of-band interactions (e.g., OAuth flows, * payment processing, or entering sensitive credentials) that must not pass through the MCP client. * - * Handling the [url] is the responsibility of the host application: it should require HTTPS, clearly - * display the target domain, obtain explicit user consent, and open the URL in a secure browser context. - * The SDK neither opens, fetches, nor validates the URL. URLs must only appear in URL-mode requests — - * never render a URL from a form-mode request as a clickable link. - * * @property message The message explaining why the interaction is needed. * @property elicitationId A unique identifier for this elicitation. The client MUST treat * this ID as an opaque value. @@ -145,6 +141,7 @@ 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 @@ -156,7 +153,6 @@ public data class ElicitRequestURLParams( @SerialName("_meta") override val meta: RequestMeta? = null, ) : ElicitRequestParams { - /** The elicitation mode discriminator, always `"url"`. */ @EncodeDefault public val mode: String = "url" } @@ -186,6 +182,10 @@ 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 f3e1575..2cb58b7 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 7e10904..1a9ea2e 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,10 +104,11 @@ 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 } @@ -130,6 +131,7 @@ 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"). @@ -142,7 +144,6 @@ 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 @@ -185,13 +186,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 } @@ -206,12 +207,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 } @@ -226,12 +227,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 } @@ -269,16 +270,6 @@ public data class RPCError(val code: Int, val message: String, val data: JsonEle /** Resource not found */ public const val RESOURCE_NOT_FOUND: Int = -32002 - /** The request selected a protocol version that the receiver does not support. */ - public const val UNSUPPORTED_PROTOCOL_VERSION: Int = -32022 - - /** - * A URL-mode elicitation must be completed before the request can be processed. - * The error [data][RPCError.data] carries the required URL-mode elicitations - * (see [UrlElicitationRequiredException]). - */ - public const val URL_ELICITATION_REQUIRED: Int = -32042 - // Standard JSON-RPC 2.0 error codes /** Invalid JSON was received */ diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/methods.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/methods.kt index 1824ad0..dae2fc7 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,10 +4,11 @@ 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 /** @@ -16,7 +17,6 @@ public sealed interface Method { @Serializable public enum class Defined(override val value: String) : Method { Initialize("initialize"), - ServerDiscover("server/discover"), Ping("ping"), ResourcesList("resources/list"), ResourcesTemplatesList("resources/templates/list"), @@ -54,13 +54,4 @@ public sealed interface Method { */ @Serializable public data class Custom(override val value: String) : Method - - public companion object { - private val definedByValue: Map by lazy { - Defined.entries.associateBy { it.value } - } - - /** Resolves a wire method string to a [Defined] entry when known, [Custom] otherwise. */ - internal fun from(value: String): Method = definedByValue[value] ?: Custom(value) - } } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/notification.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/notification.kt index 3d33c21..24be040 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,7 +34,9 @@ public sealed interface ClientNotification : Notification public sealed interface ServerNotification : Notification /** - * Interface for notification parameter types. Inherits the optional `_meta` field from [WithMeta]. + * Interface for notification parameter types. + * + * @property meta Optional metadata for the notification. */ @Serializable public sealed interface NotificationParams : WithMeta @@ -74,13 +76,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 7d885fb..9f52ec9 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/request.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/request.kt @@ -1,10 +1,8 @@ package io.modelcontextprotocol.kotlin.sdk.types -import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -13,22 +11,15 @@ 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 { @@ -49,88 +40,6 @@ public value class RequestMeta(public val json: JsonObject) { McpJson.decodeFromJsonElement(element) } - /** - * The MCP protocol version selected for this request, or `null` when absent. - * - * This field is required by the request-scoped lifecycle introduced in protocol version - * `2026-07-28`, but remains optional here so older requests retain their wire shape. - * - * @throws SerializationException if the field is present but is not a string - */ - @ExperimentalMcpApi - public val protocolVersion: String? - get() = json[RequestMetaKeys.PROTOCOL_VERSION]?.let { element -> - if (element !is JsonPrimitive || !element.isString) { - throw SerializationException("${RequestMetaKeys.PROTOCOL_VERSION} must be a JSON string") - } - element.content - } - - /** - * Information about the client making this request, or `null` when absent. - * - * Request-scoped clients should include this field unless specifically configured not to do - * so. The self-reported value is intended for display, logging, and debugging; servers should - * not use it to change behavior or rely on it for security decisions. - * - * @throws SerializationException if the field is present but is not a valid [Implementation] - */ - @ExperimentalMcpApi - public val clientInfo: Implementation? - get() = json[RequestMetaKeys.CLIENT_INFO]?.let { element -> - try { - McpJson.decodeFromJsonElement(element) - } catch (cause: SerializationException) { - throw SerializationException( - "${RequestMetaKeys.CLIENT_INFO} must be a valid client implementation", - cause, - ) - } - } - - /** - * Capabilities declared by the client for this request, or `null` when absent. - * - * Servers using the request-scoped lifecycle must not infer capabilities from an earlier - * request. An empty object means the client supports no optional capabilities. - * - * @throws SerializationException if the field is present but is not valid [ClientCapabilities] - */ - @ExperimentalMcpApi - public val clientCapabilities: ClientCapabilities? - get() = json[RequestMetaKeys.CLIENT_CAPABILITIES]?.let { element -> - try { - McpJson.decodeFromJsonElement(element) - } catch (cause: SerializationException) { - throw SerializationException( - "${RequestMetaKeys.CLIENT_CAPABILITIES} must be a valid client capabilities object", - cause, - ) - } - } - - /** - * Minimum severity of request-associated log notifications, or `null` when absent. - * - * In the request-scoped lifecycle, absence means that the server must not emit log - * notifications for this request. - * - * @throws SerializationException if the field is present but is not a valid [LoggingLevel] - */ - @Deprecated("Per-request log levels are deprecated as of MCP protocol version 2026-07-28 (SEP-2577).") - @ExperimentalMcpApi - public val logLevel: LoggingLevel? - get() = json[RequestMetaKeys.LOG_LEVEL]?.let { element -> - try { - McpJson.decodeFromJsonElement(element) - } catch (cause: SerializationException) { - throw SerializationException( - "${RequestMetaKeys.LOG_LEVEL} must be a valid logging level", - cause, - ) - } - } - /** * Retrieves the value associated with the specified key from the JSON object. * @@ -163,14 +72,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? } @@ -233,7 +142,7 @@ public sealed interface ServerResult : RequestResult /** * An empty result for a request containing optional metadata. * - * @property meta Additional metadata for the response. Defaults to an empty JSON object. + * @param meta Additional metadata for the response. Defaults to an empty JSON object. */ @Serializable public data class EmptyResult(@SerialName("_meta") override val meta: JsonObject? = null) : @@ -242,10 +151,11 @@ 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 1afa0e6..86ce3ce 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,14 +117,15 @@ public data class ResourceTemplateReference(val uri: String) : Reference { } /** - * The contents of a specific resource or sub-resource. Carries the optional `_meta` field via [WithMeta]. + * 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. */ @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? } @@ -135,7 +136,6 @@ 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,7 +152,6 @@ 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( @@ -168,7 +167,6 @@ 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 7ee4f9e..194ea4f 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,6 +53,7 @@ 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 { @@ -60,7 +61,6 @@ 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 4752ff5..1bc00e2 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, listOf(TextContent("Hello"))), - * SamplingMessage(Role.Assistant, listOf(TextContent("Hi!"))) + * SamplingMessage(Role.User, TextContent("Hello")), + * SamplingMessage(Role.Assistant, TextContent("Hi!")) * )) * ``` */ @@ -246,7 +246,7 @@ public class SamplingMessageBuilder @PublishedApi internal constructor() { * ``` */ public fun user(content: MediaContent) { - messages.add(SamplingMessage(Role.User, listOf(content))) + messages.add(SamplingMessage(Role.User, content)) } /** @@ -258,7 +258,7 @@ public class SamplingMessageBuilder @PublishedApi internal constructor() { * ``` */ public fun assistant(content: MediaContent) { - messages.add(SamplingMessage(Role.Assistant, listOf(content))) + messages.add(SamplingMessage(Role.Assistant, 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 7cc2685..97fc0c0 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,37 +75,11 @@ 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 blocks of the message; MUST contain at least one block. - * @property meta Optional metadata reserved by MCP for clients and servers. + * @property content The content of the message. Can be text, image, or audio content. */ @Serializable -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) -} +public data class SamplingMessage(val role: Role, val content: MediaContent) // ============================================================================ // sampling/createMessage @@ -182,12 +156,6 @@ public data class CreateMessageRequest(override val params: CreateMessageRequest public val metadata: JsonObject? get() = params.metadata - /** - * Task metadata, if the caller is requesting task-augmented execution. - */ - public val task: TaskMetadata? - get() = params.task - /** * Metadata for this request. May include a progressToken for out-of-band progress notifications. */ @@ -216,14 +184,6 @@ 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. */ @@ -237,9 +197,6 @@ 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 @@ -269,41 +226,22 @@ 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 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 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 stopReason The reason why sampling stopped, if known. - * Common values: [StopReason.EndTurn], [StopReason.StopSequence], [StopReason.MaxTokens], - * [StopReason.ToolUse]. + * Common values: "end_turn", "max_tokens", "stop_sequence", "content_filter" * @property meta Optional metadata for this response. */ @Serializable public data class CreateMessageResult( val role: Role, - @Serializable(with = SamplingContentSerializer::class) - val content: List, + val content: MediaContent, val model: String, val stopReason: StopReason? = null, @SerialName("_meta") override val meta: JsonObject? = null, -) : ClientResult { - init { - require(content.isNotEmpty()) { "content must contain at least one block" } - } - - /** - * Convenience constructor for a single-block response. Wraps [content] in a - * singleton list so call sites can write - * `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) -} +) : ClientResult /** * The reason why the LLM stopped generating tokens. @@ -317,38 +255,10 @@ 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 0982500..479cc66 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,6 +24,7 @@ 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 @@ -35,7 +36,6 @@ 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,6 +73,7 @@ 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 @@ -83,7 +84,6 @@ 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,6 +95,7 @@ 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 @@ -105,7 +106,6 @@ 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,6 +115,7 @@ 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 @@ -123,7 +124,6 @@ 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,6 +161,7 @@ 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 @@ -171,7 +172,6 @@ public data class UntitledSingleSelectEnumSchema( val enumValues: List, val default: String? = null, ) : SingleSelectEnumSchema { - /** JSON Schema type discriminator, always `"string"`. */ @EncodeDefault val type: String = "string" } @@ -182,6 +182,7 @@ 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 @@ -191,7 +192,6 @@ public data class TitledSingleSelectEnumSchema( val oneOf: List, val default: String? = null, ) : SingleSelectEnumSchema { - /** JSON Schema type discriminator, always `"string"`. */ @EncodeDefault val type: String = "string" } @@ -205,6 +205,7 @@ 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") @@ -217,7 +218,6 @@ 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,6 +238,7 @@ 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 @@ -249,13 +250,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 @@ -263,7 +264,6 @@ public data class UntitledMultiSelectEnumSchema( @SerialName("enum") val enumValues: List, ) { - /** JSON Schema type discriminator, always `"string"`. */ @EncodeDefault val type: String = "string" } @@ -277,6 +277,7 @@ 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 @@ -288,7 +289,6 @@ 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 36b7e37..3e25957 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,22 +1,16 @@ 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 @@ -60,11 +54,18 @@ internal object MethodSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("io.modelcontextprotocol.kotlin.sdk.types.Method", PrimitiveKind.STRING) + private val definedMethods: Map by lazy { + Method.Defined.entries.associateBy { it.value } + } + override fun serialize(encoder: Encoder, value: Method) { encoder.encodeString(value.value) } - override fun deserialize(decoder: Decoder): Method = Method.from(decoder.decodeString()) + override fun deserialize(decoder: Decoder): Method { + val decodedString = decoder.decodeString() + return definedMethods[decodedString] ?: Method.Custom(decodedString) + } } // ============================================================================ @@ -120,101 +121,6 @@ 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 // ============================================================================ @@ -239,11 +145,9 @@ internal object ResourceContentsPolymorphicSerializer : // Request Serializers // ============================================================================ -@OptIn(ExperimentalMcpApi::class) private val clientRequestDeserializers: Map> by lazy { mapOf( Method.Defined.CompletionComplete.value to CompleteRequest.serializer(), - Method.Defined.ServerDiscover.value to DiscoverRequest.serializer(), Method.Defined.Initialize.value to InitializeRequest.serializer(), Method.Defined.Ping.value to PingRequest.serializer(), Method.Defined.LoggingSetLevel.value to SetLevelRequest.serializer(), @@ -420,16 +324,10 @@ private fun selectClientResultDeserializer(element: JsonElement): Deserializatio * Selects the appropriate deserializer for server results based on JSON content. * Returns null if the structure doesn't match any known server result type. */ -@OptIn(ExperimentalMcpApi::class) +@Suppress("CyclomaticComplexMethod") 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() @@ -532,6 +430,7 @@ 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 @@ -560,6 +459,7 @@ 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"] @@ -589,6 +489,7 @@ 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 7363561..0f154e5 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,27 +17,22 @@ 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? } @@ -242,13 +237,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 1f0f54e..4f642f4 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,14 +39,12 @@ 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. @@ -79,25 +77,19 @@ 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 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 type Always "object" for tool schemas. * @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" @@ -203,12 +195,6 @@ public data class CallToolRequest(override val params: CallToolRequestParams) : public val arguments: JsonObject? get() = params.arguments - /** - * Task metadata, if the caller is requesting task-augmented execution. - */ - public val task: TaskMetadata? - get() = params.task - /** * Metadata for this request. May include a progressToken for out-of-band progress notifications. */ @@ -222,8 +208,6 @@ public data class CallToolRequest(override val params: CallToolRequestParams) : * @property name The name of the tool to invoke. * @property arguments Arguments to pass to the tool. Keys are argument names, values are the argument values. * The structure must match the tool's input schema. - * @property task If set, requests task-augmented execution. A receiver that supports it returns - * a [CreateTaskResult] (result via `tasks/result`); otherwise it returns a [CallToolResult] directly. * @property meta Optional metadata for this request. May include a progressToken for * out-of-band progress notifications. */ @@ -231,7 +215,6 @@ public data class CallToolRequest(override val params: CallToolRequestParams) : public data class CallToolRequestParams( val name: String, val arguments: JsonObject? = null, - val task: TaskMetadata? = null, @SerialName("_meta") override val meta: RequestMeta? = null, ) : RequestParams diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher.kt index aa2abb9..c5619d5 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,9 +66,11 @@ 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. * - * @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) + * @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. */ public class PathSegmentTemplateMatcher @JvmOverloads constructor( override val resourceTemplate: ResourceTemplate, @@ -116,6 +118,7 @@ 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 d99bfda..867cc9f 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,10 +32,11 @@ 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 deleted file mode 100644 index b3c95ac..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyTest.kt +++ /dev/null @@ -1,524 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.shared - -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import io.kotest.matchers.string.shouldContain -import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult -import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse -import io.modelcontextprotocol.kotlin.sdk.types.McpException -import io.modelcontextprotocol.kotlin.sdk.types.Method -import io.modelcontextprotocol.kotlin.sdk.types.PingRequest -import io.modelcontextprotocol.kotlin.sdk.types.RPCError -import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put -import kotlin.coroutines.cancellation.CancellationException -import kotlin.test.Test -import kotlin.time.Duration.Companion.seconds - -class ProtocolConcurrencyTest { - - private fun TestScope.newProtocol(): TestProtocol = - TestProtocol(ProtocolOptions(handlerCoroutineContext = StandardTestDispatcher(testScheduler))) - - @Test - fun `a suspended slow handler does not block a later fast request`() = runTest { - val protocol = newProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - protocol.fallbackRequestHandler = { request, _ -> - if (request.method == "test/slow") delay(10.seconds) - EmptyResult() - } - - transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) - transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) - runCurrent() - - responsesOn(transport).map { it.id } shouldBe listOf(RequestId(2L)) - - advanceTimeBy(11.seconds) - runCurrent() - responsesOn(transport).map { it.id } shouldBe listOf(RequestId(2L), RequestId(1L)) - } - - @Test - fun `dispatch is serial until concurrent dispatch is enabled`() = runTest { - val protocol = newProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - // gate NOT flipped - - protocol.fallbackRequestHandler = { request, _ -> - if (request.method == "test/slow") delay(10.seconds) - EmptyResult() - } - - val delivery = launch { - transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) - transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) - } - runCurrent() - // inline handling: the delivering coroutine is parked inside the slow handler - delivery.isCompleted shouldBe false - responsesOn(transport) shouldBe emptyList() - - advanceTimeBy(11.seconds) - runCurrent() - delivery.isCompleted shouldBe true - responsesOn(transport).map { it.id } shouldBe listOf(RequestId(1L), RequestId(2L)) - } - - // the hook fires before handler lookup and cannot be disabled by a user handler - @Test - fun `initialized notification invokes the router hook even when a user handler is registered`() = runTest { - val protocol = newProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - - var userHandlerCalled = false - protocol.setNotificationHandler(Method.Defined.NotificationsInitialized) { - userHandlerCalled = true - COMPLETED - } - transport.deliver(JSONRPCNotification(method = Method.Defined.NotificationsInitialized.value)) - runCurrent() - - protocol.initializedNotificationCount shouldBe 1 - userHandlerCalled shouldBe true - } - - @Test - fun `cancelled notification cancels the in-flight handler and suppresses the response`() = runTest { - val protocol = newProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - val entered = CompletableDeferred() - val sawCancellation = CompletableDeferred() - protocol.fallbackRequestHandler = { _, _ -> - entered.complete(Unit) - try { - delay(10.seconds) - } catch (e: CancellationException) { - sawCancellation.complete(Unit) - throw e - } - EmptyResult() - } - - transport.deliver(JSONRPCRequest(id = RequestId(5L), method = "test/slow")) - runCurrent() - entered.await() - - transport.deliver(cancelledNotification(requestId = RequestId(5L), reason = "user gave up")) - runCurrent() - sawCancellation.await() - advanceUntilIdle() - - responsesOn(transport) shouldBe emptyList() - errorsOn(transport) shouldBe emptyList() // JSONRPCError messages on the wire - protocol.errors shouldBe emptyList() - } - - @Test - fun `cancelled notification for an unknown id is a silent no-op`() = runTest { - val protocol = newProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - transport.deliver(cancelledNotification(requestId = RequestId(404L), reason = "nope")) - advanceUntilIdle() - - protocol.errors shouldBe emptyList() - } - - @Test - fun `handler leaking CancellationException without being cancelled produces INTERNAL_ERROR`() = runTest { - val protocol = newProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - protocol.fallbackRequestHandler = { _, _ -> throw CancellationException("leaked") } - - transport.deliver(JSONRPCRequest(id = RequestId(6L), method = "test/leak")) - advanceUntilIdle() - - val error = errorsOn(transport).single() - error.id shouldBe RequestId(6L) - error.error.code shouldBe RPCError.ErrorCode.INTERNAL_ERROR - } - - // suppression race variants - @Test - fun `handler that swallows cancellation and returns normally still gets no response`() = runTest { - val protocol = newProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - val entered = CompletableDeferred() - protocol.fallbackRequestHandler = { _, _ -> - entered.complete(Unit) - try { - delay(10.seconds) - } catch (_: CancellationException) { - // swallow (anti-pattern) and return normally - } - EmptyResult() - } - - transport.deliver(JSONRPCRequest(id = RequestId(7L), method = "test/swallow")) - runCurrent() - entered.await() - transport.deliver(cancelledNotification(requestId = RequestId(7L), reason = "stop")) - advanceUntilIdle() - - responsesOn(transport) shouldBe emptyList() - } - - @Test - fun `handler throwing non-CE while cancelled gets no error response`() = runTest { - val protocol = newProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - val entered = CompletableDeferred() - protocol.fallbackRequestHandler = { _, _ -> - entered.complete(Unit) - try { - delay(10.seconds) - } catch (_: CancellationException) { - throw IllegalStateException("boom") - } - EmptyResult() - } - - transport.deliver(JSONRPCRequest(id = RequestId(8L), method = "test/throw")) - runCurrent() - entered.await() - transport.deliver(cancelledNotification(requestId = RequestId(8L), reason = "stop")) - advanceUntilIdle() - - errorsOn(transport) shouldBe emptyList() - protocol.errors shouldBe emptyList() - } - - @Test - fun `suspension-free notification handlers observe strict arrival order`() = runTest { - val protocol = newProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - val seen = mutableListOf() - protocol.fallbackNotificationHandler = { notification -> - seen.add((notification.params as JsonObject).getValue("i").jsonPrimitive.int) - } - - repeat(20) { i -> - transport.deliver( - JSONRPCNotification(method = "test/event", params = buildJsonObject { put("i", i) }), - ) - } - advanceUntilIdle() - - seen shouldBe (0 until 20).toList() - } - - @Test - fun `reconnect resets the dispatch gate to serial`() = runTest { - val protocol = newProtocol() - val transport1 = RecordingTransport() - protocol.connect(transport1) - protocol.enableConcurrency() - protocol.close() - - val transport2 = RecordingTransport() - protocol.connect(transport2) - protocol.fallbackRequestHandler = { request, _ -> - if (request.method == "test/slow") delay(10.seconds) - EmptyResult() - } - - val delivery = launch { - transport2.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) - transport2.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) - } - runCurrent() - delivery.isCompleted shouldBe false // serial again: gate reset - advanceTimeBy(11.seconds) - runCurrent() - responsesOn(transport2).map { it.id } shouldBe listOf(RequestId(1L), RequestId(2L)) - } - - @Test - fun `close cancels in-flight handlers suppresses responses and fails pending outbound requests`() = runTest { - val protocol = newProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - val entered = CompletableDeferred() - val sawCancellation = CompletableDeferred() - protocol.fallbackRequestHandler = { _, _ -> - entered.complete(Unit) - try { - delay(10.seconds) - } catch (e: CancellationException) { - sawCancellation.complete(Unit) - throw e - } - EmptyResult() - } - transport.deliver(JSONRPCRequest(id = RequestId(9L), method = "test/slow")) - runCurrent() - entered.await() - - // shouldThrow inside the async so its failure does not cancel the (non-supervisor) test scope. - val pending = async { shouldThrow { protocol.request(PingRequest()) } } - transport.awaitRequest() // outbound ping on the wire - - protocol.close() - runCurrent() - - sawCancellation.await() - responsesOn(transport) shouldBe emptyList() - val thrown = pending.await() - thrown.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - } - - @Test - fun `message delivered after close is dropped without crash or response`() = runTest { - val protocol = newProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - protocol.close() - - transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/anything")) - advanceUntilIdle() - - responsesOn(transport) shouldBe emptyList() - errorsOn(transport) shouldBe emptyList() - } - - @Test - fun `at most maxConcurrentHandlers handlers run concurrently while control messages bypass`() = runTest { - val protocol = TestProtocol( - ProtocolOptions( - handlerCoroutineContext = StandardTestDispatcher(testScheduler), - maxConcurrentHandlers = 2, - ), - ) - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - var entered = 0 - val release = CompletableDeferred() - protocol.fallbackRequestHandler = { _, _ -> - entered += 1 - release.await() - EmptyResult() - } - - repeat(4) { i -> transport.deliver(JSONRPCRequest(id = RequestId(i.toLong()), method = "test/slow")) } - runCurrent() - entered shouldBe 2 // exactly two running, two parked on the semaphore - - // ping (bypass) is answered while saturated - transport.deliver(JSONRPCRequest(id = RequestId(100L), method = Method.Defined.Ping.value)) - runCurrent() - responsesOn(transport).map { it.id } shouldBe listOf(RequestId(100L)) - - // responses (bypass, inline) are processed while saturated - val pending = async { protocol.request(PingRequest()) } - runCurrent() - val outbound = transport.awaitRequest() - transport.deliver(JSONRPCResponse(id = outbound.id, result = EmptyResult())) - runCurrent() - pending.isCompleted shouldBe true - - release.complete(Unit) - advanceUntilIdle() - entered shouldBe 4 - responsesOn(transport) shouldHaveSize 5 - } - - @Test - fun `flood beyond maxInFlightHandlers is rejected fail-fast and the read loop never suspends`() = runTest { - val protocol = TestProtocol( - ProtocolOptions( - handlerCoroutineContext = StandardTestDispatcher(testScheduler), - maxConcurrentHandlers = 1, - maxInFlightHandlers = 4, - ), - ) - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - val release = CompletableDeferred() - protocol.fallbackRequestHandler = { _, _ -> - release.await() - EmptyResult() - } - - // 10 requests delivered back-to-back; delivery must complete without advancing - // virtual time — i.e. the read loop was never suspended on admission. - val delivery = launch { - repeat(10) { i -> transport.deliver(JSONRPCRequest(id = RequestId(i.toLong()), method = "test/slow")) } - } - runCurrent() - delivery.isCompleted shouldBe true - - // 4 admitted (1 running + 3 parked), 6 rejected immediately - val busyErrors = errorsOn(transport) - busyErrors shouldHaveSize 6 - busyErrors.forEach { - it.error.code shouldBe RPCError.ErrorCode.INTERNAL_ERROR - it.error.message shouldBe "Server is busy: too many in-flight messages" - } - - // ping still answered at full saturation - transport.deliver(JSONRPCRequest(id = RequestId(100L), method = Method.Defined.Ping.value)) - runCurrent() - responsesOn(transport).map { it.id } shouldBe listOf(RequestId(100L)) - - release.complete(Unit) - advanceUntilIdle() - responsesOn(transport) shouldHaveSize 5 // ping + the 4 admitted - } - - @Test - fun `overflowing notifications are dropped and reported via onError`() = runTest { - val protocol = TestProtocol( - ProtocolOptions( - handlerCoroutineContext = StandardTestDispatcher(testScheduler), - maxConcurrentHandlers = 1, - maxInFlightHandlers = 2, - ), - ) - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - // parks one handler with a request: holds the sole execution permit and one in-flight slot - val release = CompletableDeferred() - protocol.fallbackRequestHandler = { _, _ -> - release.await() - EmptyResult() - } - transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/hog")) - runCurrent() - - // notification 1 is admitted (parked on the semaphore, behind the hogged permit); - // notification 2 overflows the admission cap and is dropped instead of parked. - val notificationRelease = CompletableDeferred() - protocol.fallbackNotificationHandler = { notificationRelease.await() } - transport.deliver(JSONRPCNotification(method = "test/event")) - transport.deliver(JSONRPCNotification(method = "test/event")) - runCurrent() - - protocol.errors shouldHaveSize 1 - protocol.errors.single().message.orEmpty() shouldContain "too many in-flight messages" - errorsOn(transport) shouldBe emptyList() // no wire error for dropped notifications - - release.complete(Unit) - notificationRelease.complete(Unit) - advanceUntilIdle() - } - - @Test - fun `cancelling saturating handlers releases permits and lets parked handlers run`() = runTest { - val protocol = TestProtocol( - ProtocolOptions( - handlerCoroutineContext = StandardTestDispatcher(testScheduler), - maxConcurrentHandlers = 1, - ), - ) - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - val secondCompleted = CompletableDeferred() - protocol.fallbackRequestHandler = { request, _ -> - if (request.method == "test/hog") { - delay(1000.seconds) // cancellable park - error("unreachable") - } - secondCompleted.complete(Unit) - EmptyResult() - } - - transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/hog")) // holds the permit - transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/next")) // parked on the semaphore - runCurrent() - secondCompleted.isCompleted shouldBe false - - // cancel the hog THROUGH the bypass while saturated - transport.deliver(cancelledNotification(requestId = RequestId(1L), reason = "make room")) - advanceUntilIdle() - - secondCompleted.isCompleted shouldBe true - responsesOn(transport).map { it.id } shouldBe listOf(RequestId(2L)) // hog suppressed, next answered - } - - @Test - fun `a parked handler can itself be cancelled before it ever runs`() = runTest { - val protocol = TestProtocol( - ProtocolOptions( - handlerCoroutineContext = StandardTestDispatcher(testScheduler), - maxConcurrentHandlers = 1, - ), - ) - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - protocol.fallbackRequestHandler = { request, _ -> - if (request.method == "test/hog") { - delay(1000.seconds) // cancellable park - error("unreachable") - } - EmptyResult() - } - - transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/hog")) // holds the permit - transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/next")) // parked on the semaphore - runCurrent() - - // cancel the parked handler before it ever acquires the permit, then cancel the hog too - transport.deliver(cancelledNotification(requestId = RequestId(2L), reason = "give up while parked")) - transport.deliver(cancelledNotification(requestId = RequestId(1L), reason = "make room")) - advanceUntilIdle() - - // no response for id=2 at all: its withPermit acquire was cancelled before onRequest ever ran - responsesOn(transport) shouldBe emptyList() - errorsOn(transport) shouldBe emptyList() - } -} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptionsTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptionsTest.kt deleted file mode 100644 index 81bf24a..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptionsTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.shared - -import io.kotest.matchers.shouldBe -import kotlinx.coroutines.Dispatchers -import kotlin.test.Test - -class ProtocolOptionsTest { - - @Test - fun `defaults expose concurrency knobs`() { - val options = ProtocolOptions() - options.handlerCoroutineContext shouldBe Dispatchers.Default - options.maxConcurrentHandlers shouldBe 64 - options.maxInFlightHandlers shouldBe 256 - } -} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOutboundCancellationTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOutboundCancellationTest.kt deleted file mode 100644 index 38ad556..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOutboundCancellationTest.kt +++ /dev/null @@ -1,167 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.shared - -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.kotest.matchers.string.shouldContain -import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams -import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest -import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse -import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION -import io.modelcontextprotocol.kotlin.sdk.types.McpException -import io.modelcontextprotocol.kotlin.sdk.types.McpJson -import io.modelcontextprotocol.kotlin.sdk.types.Method -import io.modelcontextprotocol.kotlin.sdk.types.PingRequest -import io.modelcontextprotocol.kotlin.sdk.types.RPCError -import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withTimeout -import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long -import kotlin.coroutines.cancellation.CancellationException -import kotlin.test.Test -import kotlin.time.Duration.Companion.seconds - -class ProtocolOutboundCancellationTest { - - @Test - fun `request times out awaiting the response and sends CancelledNotification with timeout reason`() = runTest { - val protocol = TestProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - - val inFlight = async { - shouldThrow { - protocol.request(PingRequest(), RequestOptions(timeout = 5.seconds)) - } - } - val sent = transport.awaitRequest() // request is on the wire; peer never responds - advanceTimeBy(6.seconds) - runCurrent() - - val thrown = inFlight.await() - thrown.code shouldBe RPCError.ErrorCode.REQUEST_TIMEOUT - thrown.data?.jsonObject?.get("timeout")?.jsonPrimitive?.long shouldBe 5.seconds.inWholeMilliseconds - - val cancelledJson = transport.sentWithOptions - .map { it.first } - .filterIsInstance() - .single { it.method == Method.Defined.NotificationsCancelled.value } - val cancelledParams = McpJson.decodeFromJsonElement(cancelledJson.params!!) - cancelledParams.requestId shouldBe sent.id - cancelledParams.reason.shouldNotBeNull() shouldContain "timed out" - } - - @Test - fun `outer withTimeout around request propagates the original timeout exception and notifies the peer`() = runTest { - val protocol = TestProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - - val inFlight = async { - shouldThrow { - withTimeout(1.seconds) { - protocol.request(PingRequest()) - } - } - } - transport.awaitRequest() // request is on the wire; peer never responds - advanceTimeBy(2.seconds) - runCurrent() - inFlight.await() - - transport.sentWithOptions.map { it.first }.filterIsInstance() - .filter { it.method == Method.Defined.NotificationsCancelled.value } shouldHaveSize 1 - } - - @Test - fun `caller cancellation sends CancelledNotification and rethrows the original exception`() = runTest { - val protocol = TestProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - - val cancelled = CompletableDeferred() - val job = launch { - try { - protocol.request(PingRequest()) - } catch (e: CancellationException) { - cancelled.complete(e) - throw e - } - } - transport.awaitRequest() - job.cancelAndJoin() - - cancelled.await() - val wire = transport.sentWithOptions.map { it.first }.filterIsInstance() - .filter { it.method == Method.Defined.NotificationsCancelled.value } - wire shouldHaveSize 1 - } - - @Test - fun `cancelling the initialize request performs local cleanup but sends no CancelledNotification`() = runTest { - val protocol = TestProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - - val job = launch { - protocol.request( - InitializeRequest( - InitializeRequestParams( - protocolVersion = LATEST_PROTOCOL_VERSION, - capabilities = ClientCapabilities(), - clientInfo = Implementation(name = "t", version = "1"), - ), - ), - ) - } - val sent = transport.awaitRequest() - sent.method shouldBe Method.Defined.Initialize.value - job.cancelAndJoin() - - transport.sentWithOptions.map { it.first }.filterIsInstance() - .filter { it.method == Method.Defined.NotificationsCancelled.value } shouldHaveSize 0 - protocol.responseHandlers shouldBe emptyMap() - } - - @Test - fun `late response for a cancelled request is ignored silently`() = runTest { - val protocol = TestProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - - val job = launch { protocol.request(PingRequest()) } - val sent = transport.awaitRequest() - job.cancelAndJoin() - - transport.deliver(JSONRPCResponse(id = sent.id, result = EmptyResult())) - - protocol.errors shouldHaveSize 0 // no "unknown message ID" onError - } - - @Test - fun `response for a genuinely unknown id still reports onError`() = runTest { - val protocol = TestProtocol() - val transport = RecordingTransport() - protocol.connect(transport) - - transport.deliver(JSONRPCResponse(id = RequestId(999L), result = EmptyResult())) - - protocol.errors shouldHaveSize 1 - } -} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTest.kt index 2846ccc..b228071 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,31 +1,20 @@ 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.launch +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObjectBuilder @@ -35,10 +24,8 @@ 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 @@ -139,36 +126,6 @@ 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) @@ -194,174 +151,58 @@ class ProtocolTest { transport.deliver(JSONRPCResponse(sent.id, EmptyResult())) inFlight.await() } +} - @Test - fun `request handler receives enriched extra and ambient context element`() = runTest { - protocol.connect(transport) +private class TestProtocol : Protocol(null) { + override fun assertCapabilityForMethod(method: Method) { + // noop + } + override fun assertNotificationCapability(method: Method) { + // noop + } + override fun assertRequestHandlerCapability(method: Method) { + // noop + } +} - var seenByParameter: RequestHandlerExtra? = null - var seenAmbient: RequestHandlerExtra? = null - protocol.fallbackRequestHandler = { _, extra -> - seenByParameter = extra - seenAmbient = currentRequestHandlerExtra() - EmptyResult() - } +private class RecordingTransport : Transport { + private val sentMessages = Channel(Channel.UNLIMITED) + private var onMessageCallback: (suspend (JSONRPCMessage) -> Unit)? = null + private var onCloseCallback: (() -> Unit)? = null - 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 start() { + // noop } - @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 send(message: JSONRPCMessage, options: TransportSendOptions?) { + sentMessages.send(message) } - @Test - fun `extra sendNotification stamps relatedRequestId`() = runTest { - protocol.connect(transport) - - protocol.fallbackRequestHandler = { _, extra -> - extra.sendNotification( - ProgressNotification( - ProgressNotificationParams(progressToken = ProgressToken(1L), progress = 0.5), - ), - ) - EmptyResult() - } - - transport.deliver(JSONRPCRequest(id = RequestId(42L), method = "custom/progressing")) - - val sent = transport.sentWithOptions.first { it.first is JSONRPCNotification } - sent.second?.relatedRequestId shouldBe RequestId(42L) + override suspend fun close() { + onCloseCallback?.invoke() } - @Test - fun `extra sendRequest stamps relatedRequestId and preserves caller options`() = runTest { - protocol.connect(transport) - - protocol.fallbackRequestHandler = { _, extra -> - extra.sendRequest( - PingRequest(), - RequestOptions(resumptionToken = "tok", onProgress = { }, timeout = 30.seconds), - ) - EmptyResult() - } - - // The serial phase runs the handler inline inside deliver(), and the handler suspends - // awaiting the nested request's response — drive delivery from a child coroutine. - val delivery = launch { - transport.deliver(JSONRPCRequest(id = RequestId(42L), method = "custom/nested")) - } - - val outbound = transport.awaitRequest() - val recorded = transport.sentWithOptions - .first { (it.first as? JSONRPCRequest)?.id == outbound.id } - .second - val options = recorded.shouldBeInstanceOf() - options.relatedRequestId shouldBe RequestId(42L) - options.resumptionToken shouldBe "tok" - options.timeout shouldBe 30.seconds - options.onProgress shouldNotBe null - - transport.deliver(JSONRPCResponse(id = outbound.id, result = EmptyResult())) - delivery.join() + override fun onClose(block: () -> Unit) { + onCloseCallback = block } - @Test - fun `currentRequestHandlerExtra returns null outside handlers`() = runTest { - currentRequestHandlerExtra() shouldBe null + override fun onError(block: (Throwable) -> Unit) { + // noop } - @Test - fun `connect while already connected throws IllegalStateException`() = runTest { - protocol.connect(transport) - shouldThrow { - protocol.connect(RecordingTransport()) - } + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { + onMessageCallback = block } - @Test - fun `stale onClose from a previous transport does not tear down the successor connection`() = runTest { - protocol.connect(transport) - val staleCloseCallback = transport.closeCallback ?: error("onClose not registered") - - protocol.close() // disconnect first transport (fires its own doClose) - val second = RecordingTransport() - protocol.connect(second) // reconnect - - staleCloseCallback() // late duplicate close signal from transport #1 - - // successor connection must still be alive - protocol.transport shouldBe second + suspend fun awaitRequest(): JSONRPCRequest { + val message = sentMessages.receive() + return message as? JSONRPCRequest + ?: error("Expected JSONRPCRequest but received ${message::class.simpleName}") } - @Test - fun `failed transport start rolls back the connection and allows reconnect`() = runTest { - val failing = object : Transport { - override suspend fun start(): Unit = error("boom") - override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {} - override suspend fun close() {} - override fun onClose(block: () -> Unit) {} - override fun onError(block: (Throwable) -> Unit) {} - override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {} - } - shouldThrow { protocol.connect(failing) } - protocol.transport shouldBe null // rolled back - protocol.connect(transport) // reconnect succeeds, no "already connected" - protocol.transport shouldBe transport - } - - @Test - fun `request cleans up its handlers when the transport send fails`() = runTest { - // Send fails without closing the connection, so the leak is not masked by doClose(). - val failingSend = object : Transport { - override suspend fun start() {} - override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?): Unit = - throw IllegalStateException("send failed") - override suspend fun close() {} - override fun onClose(block: () -> Unit) {} - override fun onError(block: (Throwable) -> Unit) {} - override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {} - } - protocol.connect(failingSend) - - shouldThrow { - protocol.request(PingRequest(), RequestOptions(onProgress = {})) - } - - protocol.responseHandlers.size shouldBe 0 - protocol.progressHandlers.size shouldBe 0 - } - - @Test - fun `connect rejects non-positive maxConcurrentHandlers`() = runTest { - val error = shouldThrow { - TestProtocol(ProtocolOptions(maxConcurrentHandlers = 0)).connect(RecordingTransport()) - } - error.message.shouldNotBeNull() shouldContain "maxConcurrentHandlers" - } - - @Test - fun `connect rejects maxInFlightHandlers below maxConcurrentHandlers`() = runTest { - val error = shouldThrow { - TestProtocol(ProtocolOptions(maxConcurrentHandlers = 4, maxInFlightHandlers = 0)) - .connect(RecordingTransport()) - } - error.message.shouldNotBeNull() shouldContain "maxInFlightHandlers" + suspend fun deliver(message: JSONRPCMessage) { + val callback = onMessageCallback ?: error("onMessage callback not registered") + callback(message) } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTestFakes.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTestFakes.kt deleted file mode 100644 index 9e8dd60..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolTestFakes.kt +++ /dev/null @@ -1,112 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.shared - -import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse -import io.modelcontextprotocol.kotlin.sdk.types.McpJson -import io.modelcontextprotocol.kotlin.sdk.types.Method -import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import kotlinx.coroutines.channels.Channel -import kotlinx.serialization.json.encodeToJsonElement - -/** - * Shared [Protocol] test double: records [onError] calls, counts `notifications/initialized` - * router-hook invocations, and no-ops all capability assertions. - * - * The concurrency gate is flipped only via [enableConcurrency]; nothing in this double flips it - * implicitly, so tests keep today's serial semantics unless they opt in. - */ -internal class TestProtocol(options: ProtocolOptions? = null) : Protocol(options) { - val errors = mutableListOf() - var initializedNotificationCount = 0 - - fun enableConcurrency() { - enableConcurrentDispatch() - } - - override fun onInitializedNotification() { - initializedNotificationCount++ - // deliberately does NOT flip the gate — tests control the flip explicitly - } - - override fun onError(error: Throwable) { - errors.add(error) - } - - override fun assertCapabilityForMethod(method: Method) { - // noop - } - override fun assertNotificationCapability(method: Method) { - // noop - } - public override fun assertRequestHandlerCapability(method: Method) { - // noop - } -} - -/** Shared [Transport] test double: records every sent message (with its options) and replays inbound ones. */ -internal class RecordingTransport : Transport { - val sentMessages = Channel(Channel.UNLIMITED) - private var onMessageCallback: (suspend (JSONRPCMessage) -> Unit)? = null - private var onCloseCallback: (() -> Unit)? = null - - val sentWithOptions = mutableListOf>() - - var closeCallback: (() -> Unit)? = null - private set - - override suspend fun start() { - // noop - } - - override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { - sentWithOptions.add(message to options) - sentMessages.send(message) - } - - override suspend fun close() { - onCloseCallback?.invoke() - } - - override fun onClose(block: () -> Unit) { - closeCallback = block - onCloseCallback = block - } - - override fun onError(block: (Throwable) -> Unit) { - // noop - } - - override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { - onMessageCallback = block - } - - suspend fun awaitRequest(): JSONRPCRequest { - while (true) { - val message = sentMessages.receive() - if (message is JSONRPCRequest) return message - } - } - - /** Cross-thread-safe reader used by real-dispatcher tests; [sentWithOptions] is single-thread only. */ - suspend fun awaitSent(): JSONRPCMessage = sentMessages.receive() - - suspend fun deliver(message: JSONRPCMessage) { - val callback = onMessageCallback ?: error("onMessage callback not registered") - callback(message) - } -} - -internal fun responsesOn(transport: RecordingTransport): List = - transport.sentWithOptions.map { it.first }.filterIsInstance() - -internal fun errorsOn(transport: RecordingTransport): List = - transport.sentWithOptions.map { it.first }.filterIsInstance() - -internal fun cancelledNotification(requestId: RequestId, reason: String): JSONRPCNotification = JSONRPCNotification( - method = Method.Defined.NotificationsCancelled.value, - params = McpJson.encodeToJsonElement(CancelledNotificationParams(requestId = requestId, reason = reason)), -) diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferTest.kt index e5e50f8..7b4f053 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,9 +6,7 @@ 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 { @@ -77,35 +75,4 @@ 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 3468aae..33d5653 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,7 +1,6 @@ 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 @@ -9,7 +8,6 @@ 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 { @@ -121,7 +119,7 @@ class CapabilitiesTest { @Test fun `should serialize ClientCapabilities with sampling`() { val capabilities = ClientCapabilities( - sampling = ClientCapabilities.Sampling(), + sampling = ClientCapabilities.sampling, ) verifySerialization( capabilities, @@ -184,53 +182,6 @@ 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 { @@ -264,15 +215,11 @@ 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, @@ -288,9 +235,6 @@ class CapabilitiesTest { "feature1": { "enabled": true } - }, - "extensions": { - "io.modelcontextprotocol/ui": {} } } """.trimIndent(), @@ -311,9 +255,9 @@ class CapabilitiesTest { val capabilities = verifyDeserialization(McpJson, json) - assertEquals(ClientCapabilities.Sampling(), capabilities.sampling) + assertEquals(EmptyJsonObject, capabilities.sampling) assertEquals(true, capabilities.roots?.listChanged) - assertEquals(ClientCapabilities.Elicitation(), capabilities.elicitation) + assertEquals(EmptyJsonObject, capabilities.elicitation) } @Test @@ -326,20 +270,14 @@ 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) @@ -528,32 +466,6 @@ 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 { @@ -587,9 +499,6 @@ 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( @@ -600,7 +509,6 @@ class CapabilitiesTest { logging = ServerCapabilities.Logging, completions = ServerCapabilities.Completions, experimental = experimental, - extensions = extensions, ) verifySerialization( capabilities, @@ -623,9 +531,6 @@ class CapabilitiesTest { "feature1": { "enabled": true } - }, - "extensions": { - "io.modelcontextprotocol/ui": {} } } """.trimIndent(), @@ -673,7 +578,6 @@ class CapabilitiesTest { assertNull(capabilities.logging) assertNull(capabilities.completions) assertNull(capabilities.experimental) - assertNull(capabilities.extensions) } @Test @@ -686,9 +590,6 @@ class CapabilitiesTest { ), prompts = ServerCapabilities.Prompts(listChanged = true), logging = ServerCapabilities.Logging, - extensions = mapOf( - "io.modelcontextprotocol/ui" to EmptyJsonObject, - ), ) verifySerializationRoundTrip(original, McpJson) @@ -736,11 +637,10 @@ class CapabilitiesTest { } """.trimIndent() - // Sampling is now a typed struct; unknown fields are ignored on deserialization - val capabilities = McpJson.decodeFromString(json) + val capabilities = verifyDeserialization(McpJson, json) - // Should not fail - additionalProperties are allowed (unknown fields are ignored by Sampling) - assertNotNull(capabilities.sampling) + // Should not fail - additionalProperties are allowed + assertEquals("customValue", capabilities.sampling?.get("customProperty")?.toString()?.trim('"')) } @Test @@ -758,189 +658,4 @@ 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 c6dee5d..f110326 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,26 +1,17 @@ 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!") @@ -317,133 +308,4 @@ 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 deleted file mode 100644 index 691af45..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/DiscoveryTest.kt +++ /dev/null @@ -1,324 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.types - -import io.kotest.assertions.json.shouldEqualJson -import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFails -import kotlin.test.assertFailsWith -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -@OptIn(ExperimentalMcpApi::class) -class DiscoveryTest { - - @Test - fun `should decode server discover request from its wire shape`() { - val request = McpJson.decodeFromString( - """ - { - "method": "server/discover", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { - "name": "test-client", - "version": "1.0.0" - }, - "io.modelcontextprotocol/clientCapabilities": {} - } - } - } - """.trimIndent(), - ) - - val discover = assertIs(request) - assertEquals(Method.Defined.ServerDiscover, discover.method) - assertEquals("2026-07-28", discover.meta.protocolVersion) - assertEquals("test-client", discover.meta.clientInfo?.name) - assertEquals(ClientCapabilities(), discover.meta.clientCapabilities) - } - - @Test - fun `should decode discovery request without optional client info`() { - val request = McpJson.decodeFromString( - """ - { - "method": "server/discover", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientCapabilities": {}, - "com.example/traceId": "trace-123" - } - } - } - """.trimIndent(), - ) - - val discover = assertIs(request) - assertEquals("2026-07-28", discover.meta.protocolVersion) - assertEquals(ClientCapabilities(), discover.meta.clientCapabilities) - assertNull(discover.meta.clientInfo) - assertNotNull(discover.meta["com.example/traceId"]) - McpJson.encodeToString(discover) shouldEqualJson """ - { - "method": "server/discover", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientCapabilities": {}, - "com.example/traceId": "trace-123" - } - } - } - """.trimIndent() - } - - @Test - fun `should reject discovery requests without required request metadata`() { - val invalidParams = listOf( - "{}" to "_meta", - """{"_meta": {}}""" to RequestMetaKeys.PROTOCOL_VERSION, - """ - { - "_meta": { - "${RequestMetaKeys.PROTOCOL_VERSION}": "2026-07-28", - "${RequestMetaKeys.CLIENT_INFO}": { - "name": "test-client", - "version": "1.0.0" - } - } - } - """.trimIndent() to RequestMetaKeys.CLIENT_CAPABILITIES, - ) - - invalidParams.forEach { (params, missingField) -> - val failure = assertFails { - McpJson.decodeFromString( - """{"method": "server/discover", "params": $params}""", - ) - } - - assertTrue( - failure.message.orEmpty().contains(missingField), - "Expected failure to identify missing field $missingField, but was: $failure", - ) - } - } - - @Test - fun `should serialize every required discovery result field`() { - val result = DiscoverResult( - supportedVersions = listOf("2026-07-28"), - capabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = true), - ), - instructions = "Use tools intentionally.", - ) - - McpJson.encodeToString(result) shouldEqualJson """ - { - "supportedVersions": ["2026-07-28"], - "capabilities": { - "tools": {"listChanged": true} - }, - "instructions": "Use tools intentionally.", - "resultType": "complete", - "ttlMs": 0, - "cacheScope": "private" - } - """.trimIndent() - } - - @Test - fun `should encode optional server info in result metadata`() { - val result = DiscoverResult( - supportedVersions = listOf("2026-07-28"), - capabilities = ServerCapabilities(), - meta = Json.parseToJsonElement( - """ - { - "io.modelcontextprotocol/serverInfo": { - "name": "test-server", - "version": "2.0.0" - }, - "com.example/source": "edge" - } - """.trimIndent(), - ).jsonObject, - ) - - McpJson.encodeToString(result) shouldEqualJson """ - { - "supportedVersions": ["2026-07-28"], - "capabilities": {}, - "resultType": "complete", - "ttlMs": 0, - "cacheScope": "private", - "_meta": { - "io.modelcontextprotocol/serverInfo": { - "name": "test-server", - "version": "2.0.0" - }, - "com.example/source": "edge" - } - } - """.trimIndent() - assertEquals( - Json.parseToJsonElement("""{"name":"test-server","version":"2.0.0"}"""), - result.meta?.get("io.modelcontextprotocol/serverInfo"), - ) - } - - @Test - fun `should encode required defaults independently of McpJson`() { - val result = DiscoverResult( - supportedVersions = listOf("2026-07-28"), - capabilities = ServerCapabilities(), - ) - - Json.encodeToString(result) shouldEqualJson """ - { - "supportedVersions": ["2026-07-28"], - "capabilities": {}, - "resultType": "complete", - "ttlMs": 0, - "cacheScope": "private" - } - """.trimIndent() - } - - @Test - fun `should reject discovery results without required cache fields`() { - val invalidResults = listOf( - """ - { - "supportedVersions": ["2026-07-28"], - "capabilities": {}, - "cacheScope": "private" - } - """.trimIndent(), - """ - { - "supportedVersions": ["2026-07-28"], - "capabilities": {}, - "ttlMs": 0 - } - """.trimIndent(), - ) - - invalidResults.forEach { wire -> - assertFails { McpJson.decodeFromString(wire) } - assertFails { McpJson.decodeFromString(wire) } - } - } - - @Test - fun `should use complete when a discovery result omits resultType`() { - val result = McpJson.decodeFromString( - """ - { - "supportedVersions": ["2026-07-28"], - "capabilities": {}, - "ttlMs": 0, - "cacheScope": "private" - } - """.trimIndent(), - ) - - assertEquals(COMPLETE_RESULT_TYPE, assertIs(result).resultType) - } - - @Test - fun `should deserialize discovery results polymorphically`() { - val wire = - """ - { - "supportedVersions": ["2026-07-28"], - "capabilities": {}, - "resultType": "complete", - "ttlMs": 250, - "cacheScope": "public", - "_meta": { - "io.modelcontextprotocol/serverInfo": { - "name": "server", - "version": "1" - }, - "com.example/source": "edge" - } - } - """.trimIndent() - val result = McpJson.decodeFromString(wire) - - val discover = assertIs(result) - assertEquals(250, discover.ttlMs) - assertEquals(CacheScope.Public, discover.cacheScope) - assertEquals( - Json.parseToJsonElement("""{"name":"server","version":"1"}"""), - discover.meta?.get("io.modelcontextprotocol/serverInfo"), - ) - assertNotNull(discover.meta?.get("com.example/source")) - McpJson.encodeToString(discover) shouldEqualJson wire - } - - @Test - fun `should not treat supportedVersions alone as a discovery discriminator`() { - val result = McpJson.decodeFromString( - """ - { - "tools": [], - "supportedVersions": ["extension-value"] - } - """.trimIndent(), - ) - - assertIs(result) - } - - @Test - fun `should not treat an initialize extension as a discovery result`() { - val result = McpJson.decodeFromString( - """ - { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "serverInfo": { - "name": "test-server", - "version": "1.0.0" - }, - "supportedVersions": ["extension-value"] - } - """.trimIndent(), - ) - - assertIs(result) - } - - @Test - fun `should reject a negative discovery ttl`() { - assertFailsWith { - DiscoverResult( - supportedVersions = listOf("2026-07-28"), - capabilities = ServerCapabilities(), - ttlMs = -1, - ) - } - } - - @Test - fun `unsupported version data should round trip without losing versions`() { - val data = UnsupportedProtocolVersionData( - supported = listOf("2026-07-28", "2025-11-25"), - requested = "2099-01-01", - ) - - val encoded = McpJson.encodeToString(data) - assertEquals(data, McpJson.decodeFromString(encoded)) - assertEquals(-32022, RPCError.ErrorCode.UNSUPPORTED_PROTOCOL_VERSION) - } -} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ElicitationTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ElicitationTest.kt index bd5ef06..5392890 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ElicitationTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ElicitationTest.kt @@ -10,7 +10,6 @@ import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization import io.modelcontextprotocol.kotlin.test.utils.verifySerialization import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put @@ -177,6 +176,7 @@ class ElicitationTest { // ── Deprecated compat ─────────────────────────────────────────────── @Test + @Suppress("DEPRECATION") fun `deprecated factory function should create ElicitRequestFormParams`() { val schema = ElicitRequestParams.RequestedSchema( properties = mapOf("name" to StringSchema()), @@ -188,6 +188,7 @@ class ElicitationTest { } @Test + @Suppress("DEPRECATION") fun `deprecated requestedSchema should return schema for form mode`() { val schema = ElicitRequestParams.RequestedSchema( properties = mapOf("x" to BooleanSchema()), @@ -197,6 +198,7 @@ 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"), @@ -274,86 +276,4 @@ class ElicitationTest { ) } } - - // ── URLElicitationRequired error (-32042) ─────────────────────────── - - @Test - fun `UrlElicitationRequiredException carries code and serialized elicitations`() { - val elicitation = ElicitRequestURLParams( - message = "Authorize access to continue", - elicitationId = "550e8400-e29b-41d4-a716-446655440000", - url = "https://oauth.example.com/authorize", - ) - val exception = UrlElicitationRequiredException(listOf(elicitation)) - - exception.code shouldBe RPCError.ErrorCode.URL_ELICITATION_REQUIRED - exception.elicitations shouldBe listOf(elicitation) - - val data = exception.data.shouldNotBeNull() - McpJson.encodeToString(data) shouldEqualJson """ - { - "elicitations": [ - { - "message": "Authorize access to continue", - "elicitationId": "550e8400-e29b-41d4-a716-446655440000", - "url": "https://oauth.example.com/authorize", - "mode": "url" - } - ] - } - """.trimIndent() - } - - @Test - fun `fromError reconstructs typed exception for -32042 with valid data`() { - val data = McpJson.encodeToJsonElement( - UrlElicitationRequiredData( - listOf(ElicitRequestURLParams(message = "m", elicitationId = "id-1", url = "https://example.com/a")), - ), - ) - - val exception = McpException.fromError( - code = RPCError.ErrorCode.URL_ELICITATION_REQUIRED, - message = "needs auth", - data = data, - ) - - val typed = exception.shouldBeInstanceOf() - typed.message shouldBe "needs auth" - typed.elicitations.single().elicitationId shouldBe "id-1" - } - - @Test - fun `fromError returns plain McpException for unrelated code`() { - val exception = McpException.fromError( - code = RPCError.ErrorCode.INVALID_PARAMS, - message = "bad params", - data = null, - ) - (exception is UrlElicitationRequiredException) shouldBe false - exception.code shouldBe RPCError.ErrorCode.INVALID_PARAMS - } - - @Test - fun `fromError degrades to plain McpException for -32042 with malformed data`() { - val malformed = buildJsonObject { put("unexpected", "shape") } - - val exception = McpException.fromError( - code = RPCError.ErrorCode.URL_ELICITATION_REQUIRED, - message = "x", - data = malformed, - ) - - (exception is UrlElicitationRequiredException) shouldBe false - exception.code shouldBe RPCError.ErrorCode.URL_ELICITATION_REQUIRED - } - - @Test - fun `fromError degrades to plain McpException when elicitations empty`() { - val data = McpJson.encodeToJsonElement(UrlElicitationRequiredData(emptyList())) - - val exception = McpException.fromError(RPCError.ErrorCode.URL_ELICITATION_REQUIRED, "x", data) - - (exception is UrlElicitationRequiredException) shouldBe false - } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/InitializeTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/InitializeTest.kt index e5797b9..3da2a8b 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,12 +15,13 @@ 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 904454f..954fb1e 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,6 +397,7 @@ class PrimitiveSchemaTest { // ── LegacyTitledEnumSchema ──────────────────────────────────────────── + @Suppress("DEPRECATION") @Test fun `should serialize LegacyTitledEnumSchema`() { verifySerialization( @@ -419,6 +420,7 @@ 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 6087b20..1d6da19 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RequestTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RequestTest.kt @@ -2,120 +2,20 @@ package io.modelcontextprotocol.kotlin.sdk.types import io.kotest.assertions.json.shouldEqualJson import io.kotest.matchers.shouldBe -import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization import io.modelcontextprotocol.kotlin.test.utils.verifySerialization -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.double import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull -import kotlin.test.assertTrue class RequestTest { - @OptIn(ExperimentalMcpApi::class) - @Suppress("DEPRECATION") - @Test - fun `should decode typed request metadata without discarding extensions`() { - val request = McpJson.decodeFromString( - """ - { - "method": "tools/list", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": { - "name": "wire-client", - "version": "1.2.3" - }, - "io.modelcontextprotocol/clientCapabilities": { - "sampling": {}, - "com.example/custom-capability": {"enabled": true} - }, - "io.modelcontextprotocol/logLevel": "warning", - "com.example/traceId": "trace-123" - } - } - } - """.trimIndent(), - ) - - val listTools = assertIs(request) - val meta = assertNotNull(listTools.params?.meta) - meta.protocolVersion shouldBe "2026-07-28" - meta.clientInfo shouldBe Implementation(name = "wire-client", version = "1.2.3") - assertNotNull(meta.clientCapabilities?.sampling) - meta.logLevel shouldBe LoggingLevel.Warning - meta["com.example/traceId"]?.jsonPrimitive?.content shouldBe "trace-123" - assertNotNull(meta.json[RequestMetaKeys.CLIENT_CAPABILITIES]?.let { it as? JsonObject }) - } - - @OptIn(ExperimentalMcpApi::class) - @Test - fun `empty client capabilities should mean no optional capabilities`() { - val json = Json.parseToJsonElement( - """{"${RequestMetaKeys.CLIENT_CAPABILITIES}": {}}""", - ) as JsonObject - - RequestMeta(json).clientCapabilities shouldBe ClientCapabilities() - } - - @OptIn(ExperimentalMcpApi::class) - @Suppress("DEPRECATION") - @Test - fun `typed request metadata should reject malformed fields`() { - val malformedValues = listOf( - RequestMetaKeys.PROTOCOL_VERSION to "{}", - RequestMetaKeys.PROTOCOL_VERSION to "null", - RequestMetaKeys.CLIENT_INFO to "\"not-an-implementation\"", - RequestMetaKeys.CLIENT_INFO to "{\"name\":\"missing-version\"}", - RequestMetaKeys.CLIENT_INFO to "null", - RequestMetaKeys.CLIENT_CAPABILITIES to "\"not-capabilities\"", - RequestMetaKeys.CLIENT_CAPABILITIES to "[]", - RequestMetaKeys.CLIENT_CAPABILITIES to "null", - RequestMetaKeys.LOG_LEVEL to "\"verbose\"", - RequestMetaKeys.LOG_LEVEL to "7", - RequestMetaKeys.LOG_LEVEL to "null", - ) - - malformedValues.forEach { (key, value) -> - val json = Json.parseToJsonElement("""{"$key":$value}""") as JsonObject - val meta = RequestMeta(json) - - val failure = assertFailsWith { - when (key) { - RequestMetaKeys.PROTOCOL_VERSION -> meta.protocolVersion - RequestMetaKeys.CLIENT_INFO -> meta.clientInfo - RequestMetaKeys.CLIENT_CAPABILITIES -> meta.clientCapabilities - else -> meta.logLevel - } - } - assertTrue(failure.message.orEmpty().contains(key)) - } - } - - @OptIn(ExperimentalMcpApi::class) - @Test - fun `legacy requests should not gain request scoped metadata`() { - val request = ListToolsRequest() - - McpJson.encodeToString(request) shouldEqualJson """ - { - "method": "tools/list" - } - """.trimIndent() - assertNull(request.params?.meta?.protocolVersion) - } - @Test fun `should expose progress token from RequestMeta`() { val stringTokenMeta = RequestMeta(buildJsonObject { put("progressToken", "sync-1") }) diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/SamplingTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/SamplingTest.kt index b7bc5cf..696f322 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,13 +1,7 @@ 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 @@ -20,21 +14,6 @@ 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") @@ -87,7 +66,7 @@ class SamplingTest { fun `should serialize SamplingMessage`() { val message = SamplingMessage( role = Role.User, - content = listOf(TextContent(text = "Summarize the latest release.")), + content = TextContent(text = "Summarize the latest release."), ) verifySerialization( @@ -106,6 +85,7 @@ class SamplingTest { } @Test + @Suppress("LongMethod") fun `should serialize CreateMessageRequest with all fields`() { val request = CreateMessageRequest( CreateMessageRequestParams( @@ -113,11 +93,11 @@ class SamplingTest { messages = listOf( SamplingMessage( role = Role.User, - content = listOf(TextContent(text = "You are a helpful assistant.")), + content = TextContent(text = "You are a helpful assistant."), ), SamplingMessage( role = Role.User, - content = listOf(TextContent(text = "Provide a short summary.")), + content = TextContent(text = "Provide a short summary."), ), ), modelPreferences = ModelPreferences( @@ -222,7 +202,7 @@ class SamplingTest { val message = params.messages.first() assertEquals(Role.User, message.role) - val content = assertIs(message.content.single()) + val content = assertIs(message.content) assertEquals("Draft a project update.", content.text) val preferences = assertNotNull(params.modelPreferences) @@ -279,7 +259,7 @@ class SamplingTest { val result = verifyDeserialization(McpJson, json) assertEquals(Role.Assistant, result.role) - val text = assertIs(result.content.single()) + val text = assertIs(result.content) assertEquals("Summary complete.", text.text) assertEquals("gpt-4o", result.model) assertEquals(StopReason.StopSequence, result.stopReason) @@ -287,249 +267,4 @@ 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 358866a..543e12d 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,6 +37,7 @@ class ToolsTest { } @Test + @Suppress("LongMethod") fun `should serialize Tool with annotations and schemas`() { val inputSchema = ToolSchema( properties = buildJsonObject { @@ -157,61 +158,6 @@ 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() @@ -319,57 +265,6 @@ class ToolsTest { assertEquals(ProgressToken(77), params.meta?.progressToken) } - @Test - fun `should serialize CallToolRequest with task augmentation`() { - val request = CallToolRequest( - CallToolRequestParams( - name = "long-running", - arguments = buildJsonObject { put("query", "MCP protocol") }, - task = TaskMetadata(ttl = 60_000L), - ), - ) - - verifySerialization( - request, - McpJson, - """ - { - "method": "tools/call", - "params": { - "name": "long-running", - "arguments": { - "query": "MCP protocol" - }, - "task": { - "ttl": 60000 - } - } - } - """.trimIndent(), - ) - - assertEquals(TaskMetadata(ttl = 60_000L), request.task) - } - - @Test - fun `should omit task from CallToolRequest when null`() { - val request = CallToolRequest(CallToolRequestParams(name = "plain")) - - assertEquals(null, request.task) - verifySerialization( - request, - McpJson, - """ - { - "method": "tools/call", - "params": { - "name": "plain" - } - } - """.trimIndent(), - ) - } - @Test fun `should serialize CallToolResult with structured content`() { val result = CallToolResult( diff --git a/kotlin-sdk-core/src/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 25d9ee6..e4a949f 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,10 +4,8 @@ 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.buildJsonObject +import kotlinx.serialization.json.double import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlin.test.Test @@ -33,7 +31,6 @@ class CapabilitiesDslTest { roots.shouldBeNull() elicitation.shouldBeNull() experimental.shouldBeNull() - extensions.shouldBeNull() } } @@ -42,36 +39,40 @@ class CapabilitiesDslTest { val request = buildInitializeRequest { protocolVersion = "2024-11-05" capabilities { - sampling(ClientCapabilities.Sampling(tools = EmptyJsonObject)) + sampling { + put("temperature", 0.7) + put("maxTokens", 1000) + put("topP", 0.95) + } roots(listChanged = true) - elicitation(ClientCapabilities.Elicitation(form = EmptyJsonObject, url = EmptyJsonObject)) + elicitation { + put("mode", "interactive") + put("timeout", 30) + put("retries", 3) + } 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 { - tools shouldBe EmptyJsonObject + get("temperature")?.jsonPrimitive?.double shouldBe 0.7 + get("maxTokens")?.jsonPrimitive?.content shouldBe "1000" + get("topP")?.jsonPrimitive?.double shouldBe 0.95 } roots shouldNotBeNull { listChanged shouldBe true } elicitation shouldNotBeNull { - form shouldBe EmptyJsonObject - url shouldBe EmptyJsonObject + get("mode")?.jsonPrimitive?.content shouldBe "interactive" + get("timeout")?.jsonPrimitive?.content shouldBe "30" + get("retries")?.jsonPrimitive?.content shouldBe "3" } experimental shouldNotBeNull { get("customFeature")?.jsonPrimitive?.content shouldBe "true" @@ -79,11 +80,6 @@ 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" - } - } } } @@ -114,57 +110,13 @@ 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(ClientCapabilities.Sampling()) - sampling(ClientCapabilities.Sampling(tools = EmptyJsonObject)) // Should overwrite + sampling { put("temperature", 0.5) } + sampling { put("temperature", 0.9) } // Should overwrite experimental { put("feature", "v1") } experimental { put("feature", "v2") } // Should overwrite } @@ -173,7 +125,7 @@ class CapabilitiesDslTest { request.params.capabilities.shouldNotBeNull { sampling shouldNotBeNull { - tools shouldBe EmptyJsonObject + get("temperature")?.jsonPrimitive?.double shouldBe 0.9 } 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 4801d02..180a308 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.single() as TextContent).shouldNotBeNull { + (request.params.messages[0].content as TextContent).shouldNotBeNull { text shouldBe "Hello, world!" annotations.shouldBeNull() meta.shouldBeNull() @@ -71,7 +71,7 @@ class ContentDslTest { } } - (request.params.messages[0].content.single() as TextContent).shouldNotBeNull { + (request.params.messages[0].content 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.single() as TextContent).text shouldBe "Hello 🌍! Ça va? Москва 北京 مرحبا" + (request.params.messages[0].content as TextContent).text shouldBe "Hello 🌍! Ça va? Москва 北京 مرحبا" } @Test @@ -112,7 +112,7 @@ class ContentDslTest { } } - (request.params.messages[0].content.single() as TextContent).text shouldBe "" + (request.params.messages[0].content as TextContent).text shouldBe "" } @Test @@ -144,7 +144,7 @@ class ContentDslTest { } } - (request.params.messages[0].content.single() as ImageContent).shouldNotBeNull { + (request.params.messages[0].content as ImageContent).shouldNotBeNull { data shouldBe "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" mimeType shouldBe "image/png" @@ -177,7 +177,7 @@ class ContentDslTest { } } - (request.params.messages[0].content.single() as ImageContent).shouldNotBeNull { + (request.params.messages[0].content 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.single() as ImageContent).mimeType shouldBe mime + (request.params.messages[0].content as ImageContent).mimeType shouldBe mime } } @@ -256,7 +256,7 @@ class ContentDslTest { } } - (request.params.messages[0].content.single() as AudioContent).shouldNotBeNull { + (request.params.messages[0].content as AudioContent).shouldNotBeNull { data shouldBe "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==" mimeType shouldBe "audio/wav" annotations.shouldBeNull() @@ -288,7 +288,7 @@ class ContentDslTest { } } - (request.params.messages[0].content.single() as AudioContent).shouldNotBeNull { + (request.params.messages[0].content 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.single() as TextContent).annotations?.priority shouldBe 0.0 + (requestMin.params.messages[0].content 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.single() as TextContent).annotations?.priority shouldBe 1.0 + (requestMax.params.messages[0].content as TextContent).annotations?.priority shouldBe 1.0 } @Test @@ -376,7 +376,7 @@ class ContentDslTest { } } - (request.params.messages[0].content.single() as TextContent).annotations shouldNotBeNull { + (request.params.messages[0].content 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 da2a4cb..cb35115 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,17 +20,16 @@ class InitializeDslTest { val request = buildInitializeRequest { protocolVersion = "2024-11-05" capabilities { - sampling(ClientCapabilities.Sampling()) + sampling { + put("maxTokens", 100) + } roots(listChanged = true) - elicitation(ClientCapabilities.Elicitation(form = EmptyJsonObject)) + elicitation { + put("mode", "interactive") + } experimental { put("custom", true) } - extensions( - mapOf( - "io.modelcontextprotocol/ui" to EmptyJsonObject, - ), - ) } info( name = "TestClient", @@ -42,13 +41,10 @@ class InitializeDslTest { request.params.protocolVersion shouldBe "2024-11-05" request.params.capabilities.shouldNotBeNull { - sampling shouldNotBeNull { } + sampling?.get("maxTokens")?.jsonPrimitive?.int shouldBe 100 roots?.listChanged shouldBe true - elicitation?.form shouldBe EmptyJsonObject + elicitation?.get("mode")?.jsonPrimitive?.content shouldBe "interactive" experimental?.get("custom")?.jsonPrimitive?.content shouldBe "true" - extensions shouldNotBeNull { - get("io.modelcontextprotocol/ui") shouldBe EmptyJsonObject - } } request.params.clientInfo.shouldNotBeNull { name shouldBe "TestClient" @@ -74,30 +70,25 @@ class InitializeDslTest { } @Test - fun `capabilities DSL should support direct typed values`() { - val samplingValue = ClientCapabilities.Sampling(tools = EmptyJsonObject) - val elicitationValue = ClientCapabilities.Elicitation(form = EmptyJsonObject, url = EmptyJsonObject) + fun `capabilities DSL should support direct JsonObject values`() { + val samplingObj = buildJsonObject { put("key", "value") } + val elicitationObj = buildJsonObject { put("key", "value") } 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(samplingValue) - elicitation(elicitationValue) + sampling(samplingObj) + elicitation(elicitationObj) experimental(experimentalObj) - extensions(extensionsMap) } info("Test", "1.0") } request.params.capabilities.shouldNotBeNull { - sampling shouldBe samplingValue - elicitation shouldBe elicitationValue + sampling shouldBe samplingObj + elicitation shouldBe elicitationObj 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 98817c1..c53a400 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,6 +28,7 @@ import kotlin.test.Test @OptIn(ExperimentalMcpApi::class) class SamplingDslTest { @Test + @Suppress("LongMethod") fun `buildCreateMessageRequest should build with all fields`() { val request = buildCreateMessageRequest { maxTokens = 1000 @@ -70,7 +71,7 @@ class SamplingDslTest { request.params.stopSequences shouldBe listOf("STOP") request.params.messages shouldHaveSize 5 - (request.params.messages[2].content.single() as TextContent).shouldNotBeNull { + (request.params.messages[2].content as TextContent).shouldNotBeNull { text shouldBe "Text with annotations" annotations shouldNotBeNull { audience shouldBe listOf(Role.User) @@ -80,13 +81,13 @@ class SamplingDslTest { meta?.get("key")?.jsonPrimitive?.content shouldBe "value" } - (request.params.messages[3].content.single() as ImageContent).shouldNotBeNull { + (request.params.messages[3].content as ImageContent).shouldNotBeNull { data shouldBe "base64image" mimeType shouldBe "image/png" annotations?.priority shouldBe 0.5 } - (request.params.messages[4].content.single() as AudioContent).shouldNotBeNull { + (request.params.messages[4].content as AudioContent).shouldNotBeNull { data shouldBe "base64audio" mimeType shouldBe "audio/wav" } @@ -101,7 +102,7 @@ class SamplingDslTest { @Test fun `buildCreateMessageRequest should support direct assignments`() { - val messages = listOf(SamplingMessage(Role.User, listOf(TextContent("Hello")))) + val messages = listOf(SamplingMessage(Role.User, TextContent("Hello"))) val preferences = ModelPreferences(costPriority = 0.1) val request = buildCreateMessageRequest { maxTokens = 100 @@ -125,8 +126,8 @@ class SamplingDslTest { assistant(content) } } - request.params.messages[0].content.single() shouldBe content - request.params.messages[1].content.single() shouldBe content + request.params.messages[0].content shouldBe content + request.params.messages[1].content 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 deleted file mode 100644 index 60658f8..0000000 --- a/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrencyJvmTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.shared - -import io.kotest.matchers.shouldBe -import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse -import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put -import kotlin.test.Test -import kotlin.time.Duration.Companion.seconds - -/** - * Real-dispatcher counterparts to [ProtocolConcurrencyTest]. - * - * These run under [runBlocking] (real time, real threads on [Dispatchers.Default]) rather than - * `runTest`: a virtual-time scheduler auto-advances past the `withTimeout` guards and cannot - * observe genuine cross-thread parallelism, so the concurrency assertions could pass vacuously. - */ -class ProtocolConcurrencyJvmTest { - - @Test - fun `fast request completes while a slow handler is parked on Dispatchers Default`() = runBlocking { - val protocol = TestProtocol(ProtocolOptions(handlerCoroutineContext = Dispatchers.Default)) - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - val slowEntered = CompletableDeferred() - val releaseSlow = CompletableDeferred() - protocol.fallbackRequestHandler = { request, _ -> - if (request.method == "test/slow") { - slowEntered.complete(Unit) - releaseSlow.await() - } - EmptyResult() - } - - transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) - withTimeout(5.seconds) { slowEntered.await() } - transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) - - val fast = withTimeout(5.seconds) { transport.awaitSent() } - (fast as JSONRPCResponse).id shouldBe RequestId(2L) - - releaseSlow.complete(Unit) - val slow = withTimeout(5.seconds) { transport.awaitSent() } - (slow as JSONRPCResponse).id shouldBe RequestId(1L) - Unit - } - - @Test - fun `dispatch stays serial before the gate flips on Dispatchers Default`() = runBlocking { - val protocol = TestProtocol(ProtocolOptions(handlerCoroutineContext = Dispatchers.Default)) - val transport = RecordingTransport() - protocol.connect(transport) - // gate NOT flipped - - val slowEntered = CompletableDeferred() - val releaseSlow = CompletableDeferred() - protocol.fallbackRequestHandler = { request, _ -> - if (request.method == "test/slow") { - slowEntered.complete(Unit) - releaseSlow.await() - } - EmptyResult() - } - - // A single delivering coroutine models the transport read loop: the second deliver cannot - // start until the first returns, so a parked slow handler blocks the fast request. - val delivery = launch { - transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow")) - transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast")) - } - withTimeout(5.seconds) { slowEntered.await() } - - // The delivering coroutine is parked inside the slow handler; fast was never delivered. - transport.sentMessages.tryReceive().isFailure shouldBe true - - releaseSlow.complete(Unit) - val first = withTimeout(5.seconds) { transport.awaitSent() } - (first as JSONRPCResponse).id shouldBe RequestId(1L) - val second = withTimeout(5.seconds) { transport.awaitSent() } - (second as JSONRPCResponse).id shouldBe RequestId(2L) - delivery.join() - } - - @Test - fun `suspension-free notification handlers keep arrival order on Dispatchers Default`() = runBlocking { - val protocol = TestProtocol(ProtocolOptions(handlerCoroutineContext = Dispatchers.Default)) - val transport = RecordingTransport() - protocol.connect(transport) - protocol.enableConcurrency() - - // safe: appended only from the delivering coroutine (UNDISPATCHED, suspension-free) - val seen = mutableListOf() - protocol.fallbackNotificationHandler = { notification -> - seen.add((notification.params as JsonObject).getValue("i").jsonPrimitive.int) - } - repeat(100) { i -> - transport.deliver(JSONRPCNotification(method = "test/event", params = buildJsonObject { put("i", i) })) - } - seen shouldBe (0 until 100).toList() - } -} diff --git a/kotlin-sdk-server/api/kotlin-sdk-server.api b/kotlin-sdk-server/api/kotlin-sdk-server.api index d8e4c7a..80da5b5 100644 --- a/kotlin-sdk-server/api/kotlin-sdk-server.api +++ b/kotlin-sdk-server/api/kotlin-sdk-server.api @@ -32,37 +32,20 @@ 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;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;)V - public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;)V - public static final fun mcp (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;)V - public static synthetic fun mcp$default (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V - public static synthetic fun mcp$default (Lio/ktor/server/routing/Route;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V - public static synthetic fun mcp$default (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static final fun mcp (Lio/ktor/server/application/Application;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 mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V - public static final fun mcpStatelessStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;)V public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V - public static synthetic fun mcpStatelessStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V - public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static final fun mcpStreamableHttp (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun mcpStreamableHttp$default (Lio/ktor/server/application/Application;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V } public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt : io/modelcontextprotocol/kotlin/sdk/server/Feature { @@ -171,8 +154,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/ServerOptions : io/ public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;)V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Z)V public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;Lkotlin/coroutines/CoroutineContext;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;Lkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; public final fun getResourceTemplateMatcherFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory; } @@ -200,7 +183,6 @@ public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelc public fun onClose ()V public final fun onClose (Lkotlin/jvm/functions/Function0;)V public final fun onInitialized (Lkotlin/jvm/functions/Function0;)V - protected fun onInitializedNotification ()V public final fun ping (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun sendElicitationComplete (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun sendLoggingMessage (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingMessageNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -211,8 +193,7 @@ public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelc } public final class io/modelcontextprotocol/kotlin/sdk/server/SseServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport { - public fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;J)V - public synthetic fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;)V public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getSessionId ()Ljava/lang/String; public final fun handleMessage (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -223,22 +204,11 @@ 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 193eb90..126c516 100644 --- a/kotlin-sdk-server/build.gradle.kts +++ b/kotlin-sdk-server/build.gradle.kts @@ -1,6 +1,5 @@ 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 new file mode 100644 index 0000000..d383ec9 --- /dev/null +++ b/kotlin-sdk-server/detekt-baseline-commonMainSourceSet.xml @@ -0,0 +1,13 @@ + + + + + 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 aad11fa..a27f38a 100644 --- a/kotlin-sdk-server/detekt-baseline-main.xml +++ b/kotlin-sdk-server/detekt-baseline-main.xml @@ -2,6 +2,29 @@ + 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 21e0925..23dbe89 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt @@ -10,14 +10,11 @@ import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification -import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult -import io.modelcontextprotocol.kotlin.sdk.types.IncludeContext import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest import io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification -import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.PingRequest import io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.Request @@ -27,7 +24,6 @@ import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification import io.modelcontextprotocol.kotlin.sdk.types.ServerNotification import io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification -import io.modelcontextprotocol.kotlin.sdk.types.supportsUrl private val logger = KotlinLogging.logger {} @@ -36,6 +32,7 @@ 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 { /** @@ -97,17 +94,12 @@ public interface ClientConnection { * Sends a message to the client requesting elicitation. * This typically results in a form being displayed to the end user. * - * Accepted content is validated against [requestedSchema]. If content is absent, an empty - * object is used for validation only, so declared `required` properties still apply. The - * returned [ElicitResult] is not modified. - * * @param message The message for the elicitation to display. - * @param requestedSchema The schema the server requests for the elicitation result. + * @param requestedSchema The schema requested by the client for the elicitation result. * Influences the form displayed to the user. * @param options Optional request options. * @return The result of the elicitation request. * @throws IllegalStateException If the server or client does not support elicitation. - * @throws McpException If an accepted response does not match [requestedSchema]. */ public suspend fun createElicitation( message: String, @@ -137,15 +129,10 @@ public interface ClientConnection { * Sends a message to the client requesting elicitation. * This typically results in a form being displayed to the user. * - * For form-mode requests, accepted content is validated against the requested schema. If - * content is absent, an empty object is used for validation only, so declared `required` - * properties still apply. The returned [ElicitResult] is not modified. - * * @param request The elicitation request parameters. * @param options Optional request options. * @return The result of the elicitation request. * @throws IllegalStateException If the server or client does not support elicitation. - * @throws McpException If an accepted form-mode response does not match its requested schema. */ public suspend fun createElicitation(request: ElicitRequest, options: RequestOptions? = null): ElicitResult @@ -188,6 +175,7 @@ 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 @@ -212,34 +200,13 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client } override suspend fun createMessage(request: CreateMessageRequest, options: RequestOptions?): CreateMessageResult { - 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." + with(request.params) { + logger.debug { + "Creating message with ${messages.size} messages, maxTokens=$maxTokens, " + + "temperature=$temperature, " + + "systemPrompt=${if (systemPrompt != null) "present" else "absent"}" } } - - 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) } @@ -251,11 +218,6 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client override suspend fun createElicitation(request: ElicitRequest, options: RequestOptions?): ElicitResult { val params = request.params - if (params is ElicitRequestURLParams) { - require(session.clientCapabilities?.elicitation.supportsUrl) { - "Client did not advertise elicitation.url capability; cannot send a URL-mode elicitation." - } - } logger.debug { when (params) { is ElicitRequestFormParams -> @@ -268,13 +230,7 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client } } logger.trace { "ElicitRequest: $request" } - val result: ElicitResult = request(request, options) - if (params is ElicitRequestFormParams && result.action == ElicitResult.Action.Accept) { - // Peer-response backstop: accepted form content is client-supplied. Absent content is - // validated as an empty object so declared `required` properties still apply. - validateElicitationContent(params.requestedSchema, result.content ?: EmptyJsonObject) - } - return result + return request(request, options) } override suspend fun createElicitation( @@ -323,9 +279,6 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client } override suspend fun sendElicitationComplete(notification: ElicitationCompleteNotification) { - require(session.clientCapabilities?.elicitation.supportsUrl) { - "Client did not advertise elicitation.url capability; cannot send an elicitation completion notification." - } logger.debug { "Sending elicitation complete notification for: ${notification.params.elicitationId}" } notification(notification) } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt deleted file mode 100644 index d976d34..0000000 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt +++ /dev/null @@ -1,174 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema -import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema -import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema -import io.modelcontextprotocol.kotlin.sdk.types.LegacyTitledEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.McpException -import io.modelcontextprotocol.kotlin.sdk.types.PrimitiveSchemaDefinition -import io.modelcontextprotocol.kotlin.sdk.types.RPCError -import io.modelcontextprotocol.kotlin.sdk.types.StringSchema -import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.longOrNull -import kotlin.math.floor - -/** - * Validates accepted form-mode elicitation [content] against the supported constraints in - * [requestedSchema]: types, `required`, numeric bounds, string lengths, enum membership, and item - * counts. Undeclared properties are permitted. - * [StringSchema.format] values are treated as annotations and are not asserted. - * - * @throws McpException with [RPCError.ErrorCode.INVALID_PARAMS] listing the detected violations - * (at most one per property). - */ -internal fun validateElicitationContent(requestedSchema: ElicitRequestParams.RequestedSchema, content: JsonObject) { - val errors = buildList { - for (name in requestedSchema.required.orEmpty()) { - if (name !in content) add("must have required property '$name'") - } - for ((name, schema) in requestedSchema.properties) { - val value = content[name] ?: continue - checkProperty(name, schema, value)?.let(::add) - } - } - if (errors.isNotEmpty()) { - throw McpException( - code = RPCError.ErrorCode.INVALID_PARAMS, - message = "Elicitation response content does not match requested schema: ${errors.joinToString(", ")}", - ) - } -} - -private fun checkProperty(name: String, schema: PrimitiveSchemaDefinition, value: JsonElement): String? = - when (schema) { - is StringSchema -> { - val string = stringValue(value) ?: return "'$name' must be string" - val minLength = schema.minLength - val maxLength = schema.maxLength - val length = codePointCount(string) - when { - minLength != null && length < minLength -> - "'$name' must NOT have fewer than $minLength characters" - - maxLength != null && length > maxLength -> - "'$name' must NOT have more than $maxLength characters" - - else -> null - } - } - - is IntegerSchema -> { - val integer = integerValue(value) ?: return "'$name' must be integer" - checkRange(name, integer, schema.minimum?.toLong(), schema.maximum?.toLong()) - } - - is DoubleSchema -> { - val number = doubleValue(value) ?: return "'$name' must be number" - checkRange(name, number, schema.minimum, schema.maximum) - } - - is BooleanSchema -> if (booleanValue(value) == null) "'$name' must be boolean" else null - - is UntitledSingleSelectEnumSchema -> checkEnum(name, value, schema.enumValues) - - is TitledSingleSelectEnumSchema -> checkEnum(name, value, schema.oneOf.map { it.const }) - - is LegacyTitledEnumSchema -> checkEnum(name, value, schema.enumValues) - - is UntitledMultiSelectEnumSchema -> - checkMultiSelect(name, value, schema.items.enumValues, schema.minItems, schema.maxItems) - - is TitledMultiSelectEnumSchema -> - checkMultiSelect(name, value, schema.items.anyOf.map { it.const }, schema.minItems, schema.maxItems) - } - -// Keep Double statically typed so comparisons use IEEE 754 semantics; generic Comparable ordering -// treats -0.0 as less than 0.0. -private fun checkRange( - name: String, - value: Long, - minimum: Long?, - maximum: Long?, -): String? = when { - minimum != null && value < minimum -> "'$name' must be >= $minimum" - maximum != null && value > maximum -> "'$name' must be <= $maximum" - else -> null -} - -private fun checkRange( - name: String, - value: Double, - minimum: Double?, - maximum: Double?, -): String? = when { - minimum != null && value < minimum -> "'$name' must be >= $minimum" - maximum != null && value > maximum -> "'$name' must be <= $maximum" - else -> null -} - -private fun checkEnum(name: String, value: JsonElement, allowed: List): String? { - val string = stringValue(value) ?: return "'$name' must be string" - return if (string in allowed) null else "'$name' must be equal to one of the allowed values" -} - -private fun checkMultiSelect( - name: String, - value: JsonElement, - allowed: List, - minItems: Int?, - maxItems: Int?, -): String? { - val array = value as? JsonArray ?: return "'$name' must be array" - if (minItems != null && array.size < minItems) return "'$name' must NOT have fewer than $minItems items" - if (maxItems != null && array.size > maxItems) return "'$name' must NOT have more than $maxItems items" - for (element in array) { - val item = stringValue(element) ?: return "'$name' items must be string" - if (item !in allowed) return "'$name' items must be equal to one of the allowed values" - } - return null -} - -// JSON distinguishes the string "42" from the number 42; kotlinx's JsonPrimitive accessors parse -// string content too, so every helper checks isString to preserve that distinction. - -private fun stringValue(value: JsonElement): String? = (value as? JsonPrimitive)?.takeIf { it.isString }?.content - -private fun integerValue(value: JsonElement): Long? { - val primitive = (value as? JsonPrimitive)?.takeIf { !it.isString } ?: return null - primitive.longOrNull?.let { return it } - // JSON Schema's "integer" also matches numbers with a zero fractional part (e.g. 5.0, 1e2). - // Values that do not parse as Long are evaluated using their finite Double representation. - val number = primitive.doubleOrNull ?: return null - return if (number.isFinite() && number == floor(number)) number.toLong() else null -} - -private fun doubleValue(value: JsonElement): Double? = - (value as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() } - -private fun booleanValue(value: JsonElement): Boolean? = - (value as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull - -// JSON Schema string lengths count Unicode code points (RFC 8259 characters), not UTF-16 units, -// so a surrogate pair is one character. -private fun codePointCount(string: String): Int { - var count = 0 - var index = 0 - while (index < string.length) { - if (string[index].isHighSurrogate() && index + 1 < string.length && string[index + 1].isLowSurrogate()) { - index++ - } - index++ - count++ - } - return count -} diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Feature.kt index a892c6c..ce55f3e 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,7 +64,6 @@ 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 42c2b8f..d996b2d 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,7 +38,6 @@ 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) @@ -341,7 +340,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 142d59c..0ed6558 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistry.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistry.kt @@ -20,12 +20,9 @@ internal interface FeatureListener { * A generic registry for managing features of a specified type. This class provides thread-safe * operations for adding, removing, and retrieving features from the registry. * - * Feature keys are unique: [add] and [addAll] reject keys that are already registered. - * To replace a feature, [remove] it first and add the new one. - * * @param T The type of the feature, constrained to implement the [Feature] interface. * @param featureType A string description of the type of feature being managed. - * Used for logging and error messages. + * Used primarily for logging purposes. */ internal class FeatureRegistry(private val featureType: String) { @@ -47,11 +44,11 @@ internal class FeatureRegistry(private val featureType: String) { private val listeners = atomic(persistentListOf()) internal fun addListener(listener: FeatureListener) { - listeners.update { it.adding(listener) } + listeners.update { it.add(listener) } } internal fun removeListener(listener: FeatureListener) { - listeners.update { it.removing(listener) } + listeners.update { it.remove(listener) } } /** @@ -61,42 +58,27 @@ internal class FeatureRegistry(private val featureType: String) { */ internal fun add(feature: T) { logger.info { "Adding $featureType: \"${feature.key}\"" } - registry.update { current -> - require(!current.containsKey(feature.key)) { - "$featureType \"${feature.key}\" is already registered. Remove it first to replace it." - } - current.putting(feature.key, feature) - } + val oldMap = registry.getAndUpdate { current -> current.put(feature.key, feature) } + val oldFeature = oldMap[feature.key] logger.info { "Added $featureType: \"${feature.key}\"" } - notifyFeatureUpdated(null, feature) + notifyFeatureUpdated(oldFeature, feature) } /** * Adds the given list of features to the registry. Each feature is mapped by its key - * and added to the current registry. The operation is all-or-nothing: if any key is - * rejected, no features are added. + * and added to the current registry. * * @param features The list of features to add to the registry. */ internal fun addAll(features: List) { logger.info { "Adding ${featureType}s: ${features.size}" } - val newEntries = features.associateBy { it.key } - require(newEntries.size == features.size) { - val duplicated = features.groupingBy { it.key }.eachCount().filterValues { it > 1 }.keys - "Duplicate $featureType keys in batch: $duplicated" - } - registry.update { current -> - val conflicting = newEntries.keys.filter { current.containsKey(it) } - require(conflicting.isEmpty()) { - "${featureType}s already registered: $conflicting. Remove them first to replace them." - } - current.puttingAll(newEntries) - } + val oldMap = registry.getAndUpdate { current -> current.putAll(features.associateBy { it.key }) } logger.info { "Added ${featureType}s: ${features.size}" } for (feature in features) { - notifyFeatureUpdated(null, feature) + val oldFeature = oldMap[feature.key] + notifyFeatureUpdated(oldFeature, feature) } } @@ -108,7 +90,7 @@ internal class FeatureRegistry(private val featureType: String) { */ internal fun remove(key: FeatureKey): Boolean { logger.info { "Removing $featureType: \"$key\"" } - val oldMap = registry.getAndUpdate { current -> current.removing(key) } + val oldMap = registry.getAndUpdate { current -> current.remove(key) } val removedFeature = oldMap[key] val removed = removedFeature != null diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/HostValidation.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/HostValidation.kt deleted file mode 100644 index ef010fe..0000000 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/HostValidation.kt +++ /dev/null @@ -1,195 +0,0 @@ -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 3127499..35dcc7e 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,18 +1,15 @@ +@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 import io.ktor.server.application.ApplicationCall -import io.ktor.server.application.ApplicationCallPipeline import io.ktor.server.application.MissingApplicationPluginException import io.ktor.server.application.install import io.ktor.server.request.ApplicationRequest import io.ktor.server.request.header -import io.ktor.server.request.httpMethod -import io.ktor.server.response.header import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.RoutingContext @@ -21,10 +18,8 @@ import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.route import io.ktor.server.routing.routing -import io.ktor.server.sse.Heartbeat import io.ktor.server.sse.SSE import io.ktor.server.sse.ServerSSESession -import io.ktor.server.sse.heartbeat import io.ktor.server.sse.sse import io.ktor.utils.io.KtorDsl import io.modelcontextprotocol.kotlin.sdk.types.RPCError @@ -40,31 +35,14 @@ 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 -@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, -) { +public fun Route.mcp(path: String, block: ServerSSESession.() -> Server) { route(path) { - mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block) + mcp(block) } } @@ -75,28 +53,12 @@ public fun Route.mcp( * **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 -@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, -) { +public fun Route.mcp(block: ServerSSESession.() -> Server) { try { plugin(SSE) } catch (e: MissingApplicationPluginException) { @@ -108,12 +70,10 @@ public fun Route.mcp( ) } - installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins) - val transportManager = TransportManager() sse { - mcpSseEndpoint("", transportManager, maxRequestBodySize, block) + mcpSseEndpoint("", transportManager, block) } post { @@ -129,43 +89,22 @@ public fun Route.mcp( * 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 -@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, -) { +public fun Application.mcp(block: ServerSSESession.() -> Server) { installMcpContentNegotiation() install(SSE) routing { - mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block) + mcp(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() @@ -175,21 +114,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) { - val sessionId = context.request.header(MCP_SESSION_ID_HEADER) - if (sessionId != null && transportManager.getTransport(sessionId) != null) { - context.response.header(MCP_SESSION_ID_HEADER, sessionId) - } - } - } - sse { val transport = existingStreamableTransport(call, transportManager) ?: return@sse - sseHeartbeatConfig?.let { config -> heartbeat(config) } transport.handleRequest(this, call) } @@ -222,59 +148,48 @@ 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 `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 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 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 = true, + enableDnsRebindingProtection: Boolean = false, 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, @@ -282,10 +197,18 @@ private fun Application.mcpStatelessStreamableHttp( ) } get { - call.rejectUnsupportedMethod() + call.reject( + HttpStatusCode.MethodNotAllowed, + RPCError.ErrorCode.CONNECTION_CLOSED, + "Method not allowed.", + ) } delete { - call.rejectUnsupportedMethod() + call.reject( + HttpStatusCode.MethodNotAllowed, + RPCError.ErrorCode.CONNECTION_CLOSED, + "Method not allowed.", + ) } } } @@ -296,79 +219,48 @@ private fun Application.mcpStatelessStreamableHttp( * over _stateless_ [Streamable HTTP Transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) * * Sets up an HTTP POST endpoint at [path]. GET and DELETE requests return 405 Method Not Allowed. - * Every request/response pair is returned as JSON, so this endpoint opens no SSE stream and - * offers no resumability. Use [mcpStreamableHttp] when either is required. + * Simple request/response pairs are returned as JSON (not SSE streams). * * Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation] - * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson]. + * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE]. * * @param path The URL path where the server listens for incoming JSON-RPC requests. Defaults to "/mcp". - * @param enableDnsRebindingProtection Determines whether DNS rebinding protection is enabled. Defaults to `true`. - * @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 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 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 = true, + enableDnsRebindingProtection: Boolean = false, 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, maxRequestBodySize) + val transport = mcpSseTransport(postEndpoint, transportManager) val server = block() @@ -387,9 +279,8 @@ private suspend fun ServerSSESession.mcpSseEndpoint( private fun ServerSSESession.mcpSseTransport( postEndpoint: String, transportManager: TransportManager, - maxRequestBodySize: Long, ): SseServerTransport { - val transport = SseServerTransport(postEndpoint, this, maxRequestBodySize) + val transport = SseServerTransport(postEndpoint, this) transportManager.addTransport(transport.sessionId, transport) logger.info { "New SSE connection established and stored with sessionId: ${transport.sessionId}" } @@ -407,17 +298,11 @@ private suspend fun RoutingContext.mcpStatelessStreamableHttpEndpoint( logger.info { "New stateless StreamableHttp connection established without sessionId" } val server = block() - val session = server.createSession(transport) + server.onClose { logger.info { "Server connection closed without sessionId" } } + server.createSession(transport) - try { - transport.handleRequest(null, this.call) - logger.debug { "Server connected to transport without sessionId" } - } finally { - // A stateless session serves exactly one request: close it so the server's - // session registry and notification subscriptions do not grow unboundedly. - session.close() - logger.debug { "Stateless session closed after request completion" } - } + transport.handleRequest(null, this.call) + logger.debug { "Server connected to transport without sessionId" } } private suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportManager) { @@ -439,16 +324,6 @@ private suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportMa logger.trace { "Message handled for sessionId: $sessionId" } } -/** A stateless endpoint serves POST only, offering neither an SSE stream to open nor a session to delete. */ -private suspend fun ApplicationCall.rejectUnsupportedMethod() { - response.header(HttpHeaders.Allow, HttpMethod.Post.value) - reject( - HttpStatusCode.MethodNotAllowed, - RPCError.ErrorCode.CONNECTION_CLOSED, - "Method not allowed.", - ) -} - private fun ApplicationRequest.sessionId(): String? = header(MCP_SESSION_ID_HEADER) private suspend fun existingStreamableTransport( @@ -510,14 +385,3 @@ 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 deleted file mode 100644 index 0cc4143..0000000 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBody.kt +++ /dev/null @@ -1,34 +0,0 @@ -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 f525ec2..f8c6581 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt @@ -5,6 +5,7 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.encodeURLPath import io.ktor.server.application.ApplicationCall import io.ktor.server.request.contentType +import io.ktor.server.request.receiveText import io.ktor.server.response.respondText import io.ktor.server.sse.ServerSSESession import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport @@ -26,24 +27,13 @@ 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`. * - * @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. + * @property sessionId unique identifier for this transport session, generated randomly on creation */ @OptIn(ExperimentalAtomicApi::class) -public class SseServerTransport( - private val endpoint: String, - private val session: ServerSSESession, - private val maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, -) : AbstractTransport() { - init { - require(maxRequestBodySize > 0) { "maxRequestBodySize must be greater than 0" } - } - +public class SseServerTransport(private val endpoint: String, private val session: ServerSSESession) : + AbstractTransport() { private val initialized: AtomicBoolean = AtomicBoolean(false) - /** Unique identifier for this transport session, generated randomly on creation. */ @OptIn(ExperimentalUuidApi::class) public val sessionId: String = Uuid.random().toString() @@ -85,7 +75,6 @@ public class SseServerTransport( val message = "SSE connection not established" call.respondText(message, status = HttpStatusCode.InternalServerError) _onError.invoke(IllegalStateException(message)) - return } val body = try { @@ -94,13 +83,7 @@ public class SseServerTransport( error("Unsupported content-type: $ct") } - 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 + call.receiveText() } catch (e: Exception) { call.respondText("Invalid message: ${e.message}", status = HttpStatusCode.BadRequest) _onError.invoke(e) @@ -109,8 +92,6 @@ public class SseServerTransport( try { handleMessage(body) - } catch (e: CancellationException) { - throw e } catch (e: Exception) { call.respondText("Error handling message $body: ${e.message}", status = HttpStatusCode.BadRequest) return @@ -127,8 +108,6 @@ public class SseServerTransport( 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 deleted file mode 100644 index 8243171..0000000 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingValidation.kt +++ /dev/null @@ -1,54 +0,0 @@ -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 6f7711f..de219d4 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,7 +1,6 @@ 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 @@ -46,18 +45,15 @@ import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations import io.modelcontextprotocol.kotlin.sdk.types.ToolExecution import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest -import io.modelcontextprotocol.kotlin.sdk.types.UrlElicitationRequiredException import io.modelcontextprotocol.kotlin.sdk.utils.MatchResult import io.modelcontextprotocol.kotlin.sdk.utils.PathSegmentTemplateMatcher import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcher import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcherFactory import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put -import kotlin.coroutines.CoroutineContext import kotlin.jvm.JvmOverloads import kotlin.time.ExperimentalTime @@ -67,21 +63,15 @@ private val logger = KotlinLogging.logger {} * Configuration options for the MCP server. * * @property capabilities The capabilities this server supports. - * @param enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with clients. - * Forwarded to [ProtocolOptions.enforceStrictCapabilities]. + * @property enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with clients. * @property resourceTemplateMatcherFactory The factory used to create [ResourceTemplateMatcher] instances * for matching resource URIs against registered templates. Defaults to [PathSegmentTemplateMatcher.factory]. - * @param handlerCoroutineContext Coroutine context for inbound handlers. See [ProtocolOptions.handlerCoroutineContext]. */ public class ServerOptions( public val capabilities: ServerCapabilities, enforceStrictCapabilities: Boolean = true, public val resourceTemplateMatcherFactory: ResourceTemplateMatcherFactory = PathSegmentTemplateMatcher.factory, - handlerCoroutineContext: CoroutineContext = Dispatchers.Default, -) : ProtocolOptions( - enforceStrictCapabilities = enforceStrictCapabilities, - handlerCoroutineContext = handlerCoroutineContext, -) { +) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) { @JvmOverloads public constructor( capabilities: ServerCapabilities, @@ -101,12 +91,13 @@ 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. * - * @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 + * @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 * 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, @@ -312,7 +303,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)) } @@ -320,9 +311,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. @@ -330,6 +321,7 @@ 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, @@ -365,7 +357,6 @@ 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) } @@ -652,6 +643,7 @@ 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 { @@ -659,9 +651,6 @@ public open class Server( } } catch (e: CancellationException) { throw e - } catch (e: UrlElicitationRequiredException) { - // Surface a required URL-mode elicitation as a JSON-RPC error (-32042) - throw e } catch (e: Exception) { logger.error(e) { "Error executing tool ${requestParams.name}" } CallToolResult( @@ -682,10 +671,7 @@ public open class Server( val prompt = promptRegistry.get(requestParams.name) ?: run { logger.error { "Prompt not found: ${requestParams.name}" } - throw McpException( - code = RPCError.ErrorCode.INVALID_PARAMS, - message = "Prompt not found: ${requestParams.name}", - ) + throw IllegalArgumentException("Prompt not found: ${requestParams.name}") } return prompt.run { session.clientConnection.messageProvider(request) diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt index f20d3e1..2f55c76 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,28 +39,17 @@ import kotlin.uuid.Uuid private val logger = KotlinLogging.logger {} /** - * Represents an active server-side session in the Model Context Protocol. + * Represents a server session. * - * 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 + * @property sessionId unique identifier for this session, generated randomly on creation */ +@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() @@ -71,10 +60,14 @@ public open class ServerSession( private val _clientCapabilities: AtomicRef = atomic(null) private val _clientVersion: AtomicRef = atomic(null) - /** Capabilities reported by the client during initialization, or `null` before the handshake completes. */ + /** + * The client's reported capabilities after initialization. + */ public val clientCapabilities: ClientCapabilities? get() = _clientCapabilities.value - /** Client implementation information reported during initialization, or `null` before the handshake completes. */ + /** + * The client's version information after initialization. + */ public val clientVersion: Implementation? get() = _clientVersion.value /** @@ -108,18 +101,10 @@ public open class ServerSession( } } - override fun onInitializedNotification() { - // Enable concurrency before the user's onInitialized callback, which runs synchronously. - enableConcurrentDispatch() - } - internal val clientConnection: ClientConnection = ClientConnectionImpl(this) /** * Registers a callback to be invoked when the server has completed initialization. - * - * The callback must be synchronous and fast: it runs on the message-dispatch path for - * `notifications/initialized`, after concurrent dispatch has been enabled for the session. */ public fun onInitialized(block: () -> Unit) { val old = _onInitialized @@ -180,12 +165,6 @@ public open class ServerSession( } } - Defined.TasksGet, - Defined.TasksResult, - Defined.TasksList, - Defined.TasksCancel, - -> assertTasksCapabilityForMethod(method) - Defined.Ping -> { // No specific capability required } @@ -196,28 +175,6 @@ 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. * @@ -261,12 +218,6 @@ public open class ServerSession( } } - Defined.NotificationsTasksStatus -> { - if (serverCapabilities.tasks == null) { - error("Server does not support tasks (required for ${method.value})") - } - } - Defined.NotificationsCancelled, Defined.NotificationsProgress, -> { @@ -421,17 +372,12 @@ public open class ServerSession( * Sends a message to the client requesting an elicitation. * This typically results in a form being displayed to the end user. * - * Accepted content is validated against [requestedSchema]. If content is absent, an empty - * object is used for validation only, so declared `required` properties still apply. The - * returned [ElicitResult] is not modified. - * * @param message The message for the elicitation to display. - * @param requestedSchema The schema the server requests for the elicitation result. + * @param requestedSchema The schema requested by the client for the elicitation result. * Influences the form displayed to the user. * @param options Optional request options. * @return The result of the elicitation request. * @throws IllegalStateException If the server or client does not support elicitation. - * @throws McpException If an accepted response does not match [requestedSchema]. */ public suspend fun createElicitation( message: String, diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt index 9261be2..8fd46dd 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,27 +4,16 @@ 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 @@ -34,347 +23,195 @@ import kotlinx.io.Source import kotlinx.io.buffered import kotlinx.io.readByteArray import kotlinx.io.writeString -import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.coroutines.CoroutineContext private const val READ_BUFFER_SIZE = 8192L /** - * Server-side MCP transport that exchanges JSON-RPC messages over arbitrary byte streams. + * A server transport that communicates with a client via standard I/O. * - * Reads framed messages from the supplied [Source] and writes framed messages to the supplied - * [Sink]. Three internal coroutines drive the pipeline: + * Reads from input [Source] and writes to output [Sink]. * - * - **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() - * ``` + * @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. */ @OptIn(ExperimentalAtomicApi::class) -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), - ) +public class StdioServerTransport(private val inputStream: Source, outputStream: Sink) : AbstractTransport() { private val logger = KotlinLogging.logger {} private val readBuffer = ReadBuffer() - private val readChannel = Channel(Channel.BUFFERED) - private val writeChannel = Channel(Channel.BUFFERED) - private val outputSink = output.buffered() + private val initialized: AtomicBoolean = AtomicBoolean(false) + private var readingJob: Job? = null + private var sendingJob: Job? = null + private var processingJob: Job? = null - private enum class State { New, Operational, Stopped } + 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 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 (!state.compareAndSet(State.New, State.Operational)) { - when (state.load()) { - State.Stopped -> error("StdioServerTransport is already closed!") - else -> error("StdioServerTransport already started!") - } + if (!initialized.compareAndSet(expectedValue = false, newValue = true)) { + error("StdioServerTransport already started!") } - try { - val resolvedScope = scope ?: CoroutineScope( - currentCoroutineContext() + IODispatcher + SupervisorJob(), - ) - effectiveScope = resolvedScope + // Launch a coroutine to read from stdin + readingJob = launchReadingJob() - 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) - } + // Launch a coroutine to process messages from readChannel + processingJob = launchProcessingJob() + + // Launch a coroutine to handle message sending + sendingJob = launchSendingJob() } - 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) + 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) } } + } 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 suspend fun writerPump() { - try { - for (message in writeChannel) { - val json = serializeMessage(message) - outputSink.writeString(json) - outputSink.flush() + 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" } } - } 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() { - var previous: State - do { - previous = state.load() - } while (previous != State.Stopped && !state.compareAndSet(previous, State.Stopped)) - - if (previous == State.New) { - setupComplete.complete(Unit) - return - } + if (!initialized.compareAndSet(expectedValue = true, newValue = false)) return withContext(NonCancellable) { - setupComplete.await() - - if (previous == State.Stopped) { - writerJob?.join() - return@withContext - } - - runCatching { input.close() } - .onFailure { logger.warn(it) { "Failed to close input source" } } - readerJob?.cancel() - readChannel.close() - processorJob?.cancelAndJoin() writeChannel.close() - writerJob?.join() - runCatching { outputSink.close() } - .onFailure { logger.warn(it) { "Failed to close output sink" } } + sendingJob?.cancelAndJoin() + + runCatching { + inputStream.close() + }.onFailure { logger.warn(it) { "Failed to close stdin" } } + + readingJob?.cancel() + readChannel.close() + + processingJob?.cancelAndJoin() + readBuffer.clear() - if (ownsScope) { - effectiveScope?.coroutineContext?.get(Job)?.cancel() - } + + runCatching { + outputSink.flush() + outputSink.close() + }.onFailure { logger.warn(it) { "Failed to close stdout" } } + 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?) { - 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) + writeChannel.send(message) } } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt index dcba848..70948d9 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt @@ -1,28 +1,25 @@ package io.modelcontextprotocol.kotlin.sdk.server -import io.ktor.http.BadContentTypeFormatException import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode -import io.ktor.http.parseHeaderValue import io.ktor.server.application.ApplicationCall import io.ktor.server.request.contentType import io.ktor.server.request.header import io.ktor.server.request.httpMethod +import io.ktor.server.request.receiveText import io.ktor.server.response.header import io.ktor.server.response.respond -import io.ktor.server.response.respondText +import io.ktor.server.response.respondNullable import io.ktor.server.sse.ServerSSESession import io.ktor.util.collections.ConcurrentMap import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions -import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams import io.modelcontextprotocol.kotlin.sdk.types.DEFAULT_NEGOTIATED_PROTOCOL_VERSION import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCEmptyMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse import io.modelcontextprotocol.kotlin.sdk.types.McpJson @@ -31,19 +28,15 @@ 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 @@ -52,6 +45,7 @@ import kotlin.uuid.Uuid internal const val MCP_SESSION_ID_HEADER = "mcp-session-id" private const val MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version" private const val MCP_RESUMPTION_TOKEN_HEADER = "Last-Event-ID" +private const val DEFAULT_MAX_REQUEST_BODY_SIZE: Long = 4L * 1024 * 1024 // 4 MB private const val MIN_PRIMING_EVENT_PROTOCOL_VERSION = "2025-11-25" /** @@ -77,8 +71,10 @@ 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()") @@ -101,6 +97,7 @@ 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( @@ -139,20 +136,8 @@ public class StreamableHttpServerTransport(private val configuration: Configurat */ public class Configuration( public val enableJsonResponse: Boolean = false, - @Deprecated( - message = "Use install(DnsRebindingProtection) on your Ktor route instead", - level = DeprecationLevel.WARNING, - ) public val enableDnsRebindingProtection: Boolean = false, - @Deprecated( - message = "Use install(DnsRebindingProtection) on your Ktor route instead", - level = DeprecationLevel.WARNING, - ) public val allowedHosts: List? = null, - @Deprecated( - message = "Use install(DnsRebindingProtection) on your Ktor route instead", - level = DeprecationLevel.WARNING, - ) public val allowedOrigins: List? = null, public val eventStore: EventStore? = null, public val retryInterval: Duration? = null, @@ -165,7 +150,6 @@ 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 @@ -180,20 +164,6 @@ public class StreamableHttpServerTransport(private val configuration: Configurat private val requestToStreamMapping: ConcurrentMap = ConcurrentMap() private val requestToResponseMapping: ConcurrentMap = ConcurrentMap() - /** - * JSON-response mode only: one entry per in-flight POST, completed by [send] with the collected - * responses once every request delivered by that POST has been answered. [handlePostRequest] - * awaits it so the HTTP exchange stays open while handlers run concurrently. - */ - private val pendingJsonResponses: ConcurrentMap>> = ConcurrentMap() - - /** - * JSON-response mode only: request ids retired by an inbound cancellation while their POST is - * still awaiting. Treated as settled-without-response so the POST's "a response for every id" - * completion condition is not blocked by a request that will never be answered. - */ - private val cancelledRequestIds: ConcurrentMap = ConcurrentMap() - private val sessionMutex = Mutex() private val streamMutex = Mutex() @@ -242,6 +212,7 @@ 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 @@ -260,148 +231,74 @@ public class StreamableHttpServerTransport(private val configuration: Configurat return } - val isTerminated = message is JSONRPCResponse || message is JSONRPCError - - // In JSON mode request-related notifications have no per-request SSE stream. Route them to the - // standalone GET stream before the lookup below, so a notification for an already-retired - // request is delivered there instead of becoming a spurious error. - if (configuration.enableJsonResponse && !isTerminated) { - val standaloneStream = streamsMapping[STANDALONE_SSE_STREAM_ID] - standaloneStream?.let { emitOnStream(STANDALONE_SSE_STREAM_ID, it.session, message) } - return - } - val streamId = requestToStreamMapping[routingRequestId] - if (streamId == null) { - // Request already retired (completed, cancelled, or the JSON POST disconnected). In JSON - // mode a late terminal response is undeliverable and dropped. In SSE mode an unknown id - // is a real routing error. - if (configuration.enableJsonResponse) return - error("No connection established for request id $routingRequestId") - } + ?: error("No connection established for request id $routingRequestId") val activeStream = streamsMapping[streamId] if (!configuration.enableJsonResponse) { activeStream?.let { stream -> emitOnStream(streamId, stream.session, message) } - if (!isTerminated) return + } + + val isTerminated = message is JSONRPCResponse || message is JSONRPCError + if (!isTerminated) { + if (configuration.enableJsonResponse) { + // In JSON response mode there is no per-request SSE stream, so route notifications + // that are logically associated with a request to the standalone GET SSE stream. + val standaloneStream = streamsMapping[STANDALONE_SSE_STREAM_ID] + standaloneStream?.let { emitOnStream(STANDALONE_SSE_STREAM_ID, it.session, message) } + } + return } requestToResponseMapping[responseRequestId!!] = message + val relatedIds = requestToStreamMapping.filterValues { it == streamId }.keys + + if (relatedIds.any { it !in requestToResponseMapping }) return streamMutex.withLock { - // The request may have been retired (disconnect or cancellation cleanup) between recording - // the response above and acquiring the lock. Reclaim the stray mapping and drop it. - if (routingRequestId !in requestToStreamMapping) { - requestToResponseMapping.remove(routingRequestId) - return - } - // Recomputed under the lock. Handlers may respond concurrently, and the sender that - // observes the last settled request also retires the ids, so a losing sender sees an - // empty set. A cancelled request counts as settled even though it produces no response. - val relatedIds = requestToStreamMapping.filterValues { it == streamId }.keys - if (relatedIds.isEmpty() || - relatedIds.any { it !in requestToResponseMapping && it !in cancelledRequestIds } - ) { - return - } + if (activeStream == null) error("No connection established for request ID: $routingRequestId") if (configuration.enableJsonResponse) { - // Hand the collected responses to the POST suspended in [handlePostRequest], which - // completes the HTTP exchange. A null pendingPost means the POST was already retired - // (disconnect or cancellation), so the responses are undeliverable and dropped quietly. - val pendingPost = pendingJsonResponses.remove(streamId) - if (pendingPost == null) { - cleanUpRequests(relatedIds) - return - } + activeStream.call.response.header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + sessionId?.let { activeStream.call.response.header(MCP_SESSION_ID_HEADER, it) } val responses = relatedIds.mapNotNull { requestToResponseMapping[it] } - cleanUpRequests(relatedIds) - pendingPost.complete(responses) + val payload = if (responses.size == 1) { + responses.first() + } else { + responses + } + activeStream.call.respond(payload) } else { - // A null stream means the per-request SSE connection closed before the response - // settled, so the ids are retired rather than leaked. - activeStream?.session?.close() - cleanUpRequests(relatedIds) + activeStream.session?.close() } - } - } - private fun cleanUpRequests(ids: Collection) { - ids.forEach { requestId -> - requestToResponseMapping.remove(requestId) - requestToStreamMapping.remove(requestId) - cancelledRequestIds.remove(requestId) - } - } - - /** - * Scans a delivered POST body for `notifications/cancelled` and retires each referenced request - * that is awaiting on a JSON-mode POST. No-op unless [Configuration.enableJsonResponse]. - */ - private suspend fun retireCancelledJsonRequests(messages: List) { - if (!configuration.enableJsonResponse) return - val cancelledIds = messages.asSequence() - .filterIsInstance() - .filter { it.method == Method.Defined.NotificationsCancelled.value } - .mapNotNull { notification -> - val params = notification.params ?: return@mapNotNull null - runCatching { - McpJson.decodeFromJsonElement(params).requestId - }.getOrNull() + // Clean up + relatedIds.forEach { requestId -> + requestToResponseMapping.remove(requestId) + requestToStreamMapping.remove(requestId) } - .toList() - cancelledIds.forEach { retireCancelledJsonRequest(it) } - } - - /** - * Marks a request cancelled by the peer as settled without a response and completes its POST once - * every request that POST delivered is settled (answered or cancelled). Without this, a cancelled - * request leaves the POST awaiting a response that, by design, is never sent. - */ - private suspend fun retireCancelledJsonRequest(requestId: RequestId) { - streamMutex.withLock { - val streamId = requestToStreamMapping[requestId] ?: return - cancelledRequestIds[requestId] = Unit - val relatedIds = requestToStreamMapping.filterValues { it == streamId }.keys - val allSettled = relatedIds.all { it in requestToResponseMapping || it in cancelledRequestIds } - if (!allSettled) return - val pendingPost = pendingJsonResponses.remove(streamId) ?: return - val responses = relatedIds.mapNotNull { requestToResponseMapping[it] } - cleanUpRequests(relatedIds) - pendingPost.complete(responses) } } override suspend fun close() { - withContext(NonCancellable) { - streamMutex.withLock { - streamsMapping.values.forEach { - try { - it.session?.close() - } catch (_: Exception) { - } + 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() } + streamsMapping.clear() + requestToStreamMapping.clear() + requestToResponseMapping.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. + * Handles an incoming HTTP request, whether GET, POST or DELETE */ public suspend fun handleRequest(session: ServerSSESession?, call: ApplicationCall) { validateHeaders(call)?.let { reason -> @@ -425,13 +322,9 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } /** - * Handles POST requests containing JSON-RPC messages. - * - * In JSON-response mode this suspends until a response has been produced for every request in - * the POST body, then completes the HTTP exchange with the collected JSON (a single object for - * one request, an array for a batch). Bodies without requests are acknowledged with 202 - * immediately. In SSE mode the responses are emitted onto the per-request SSE stream instead. + * Handles POST requests containing JSON-RPC messages */ + @Suppress("CyclomaticComplexMethod", "LongMethod", "ReturnCount", "TooGenericExceptionCaught") public suspend fun handlePostRequest(session: ServerSSESession?, call: ApplicationCall) { try { if (!configuration.enableJsonResponse && session == null) { @@ -461,10 +354,9 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } val messages = parseBody(call) ?: return - val initializationRequest = messages.filterIsInstance().firstOrNull { - it.method == Method.Defined.Initialize.value + val isInitializationRequest = messages.any { + it is JSONRPCRequest && it.method == Method.Defined.Initialize.value } - val isInitializationRequest = initializationRequest != null if (isInitializationRequest) { if (initialized.load() && sessionId != null) { @@ -472,17 +364,14 @@ 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 } @@ -494,84 +383,30 @@ public class StreamableHttpServerTransport(private val configuration: Configurat sessionId?.let { onSessionInitialized?.invoke(it) } } } else { - val requestId = messages.filterIsInstance().firstOrNull()?.id - if (!validateSession(call, requestId) || !validateProtocolVersion(call, requestId)) return + if (!validateSession(call) || !validateProtocolVersion(call)) return } val hasRequest = messages.any { it is JSONRPCRequest } if (!hasRequest) { - call.respond(HttpStatusCode.Accepted) + call.respondNullable(status = HttpStatusCode.Accepted, message = null) messages.forEach { message -> _onMessage(message) } - // A cancellation may target a request still awaiting delivery on a JSON-mode POST; - // retire it so that POST completes instead of waiting for a response that never comes. - retireCancelledJsonRequests(messages) return } - // 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, clientProtocolVersion) - } - - val pendingPost = if (configuration.enableJsonResponse) { - CompletableDeferred>() - } else { - null + maybeSendPrimingEvent(streamId, session, call.request.header(MCP_PROTOCOL_VERSION_HEADER)) } streamMutex.withLock { streamsMapping[streamId] = SessionContext(session, call) messages.filterIsInstance().forEach { requestToStreamMapping[it.id] = streamId } - pendingPost?.let { pendingJsonResponses[streamId] = it } } call.coroutineContext.job.invokeOnCompletion { streamsMapping.remove(streamId) } - if (pendingPost == null) { - // SSE mode: responses are emitted onto this call's SSE stream by send(). - messages.forEach { message -> _onMessage(message) } - return - } - - // JSON-response mode. Handlers may run concurrently, so returning from delivery no longer - // implies the responses were produced. Suspend until send() has collected a response for - // every request in this POST. A client disconnect or transport close cancels this call. - try { - messages.forEach { message -> _onMessage(message) } - retireCancelledJsonRequests(messages) - val responses = pendingPost.await() - sessionId?.let { call.response.header(MCP_SESSION_ID_HEADER, it) } - if (responses.isEmpty()) { - // Every request in this POST was cancelled before producing a response; there is - // nothing to return, so acknowledge with 202 rather than an empty JSON body. - call.respond(HttpStatusCode.Accepted) - } else { - call.response.header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - val payload = if (responses.size == 1) responses.first() else responses - call.respond(payload) - } - } finally { - // On cancellation or failure, retire this POST's ids so a late response is dropped - // quietly at the send() boundary instead of leaking mappings. Idempotent no-ops on - // the success path (send() or retirement already cleaned up). - pendingJsonResponses.remove(streamId) - pendingPost.cancel() - cleanUpRequests(messages.filterIsInstance().map { it.id }) - } - } catch (e: CancellationException) { - throw e + messages.forEach { message -> _onMessage(message) } } catch (e: Exception) { call.reject( HttpStatusCode.BadRequest, @@ -583,6 +418,7 @@ 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 @@ -608,40 +444,25 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - // SSE headers (Content-Type, Cache-Control, Connection) are already set by the framework's SSE handler - flushSse(sseSession) - 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 + 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 + } + + call.appendSseHeaders() + flushSse(sseSession) // flush headers immediately + 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 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. - // 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) - } + // Without this, the Ktor sse{} handler returns immediately, closing the stream. + awaitCancellation() } /** Handles an HTTP DELETE request by closing the session and the transport. */ @@ -649,29 +470,29 @@ public class StreamableHttpServerTransport(private val configuration: Configurat if (!validateSession(call) || !validateProtocolVersion(call)) return sessionId?.let { onSessionClosed?.invoke(it) } close() - call.respond(HttpStatusCode.OK) + call.respondNullable(status = HttpStatusCode.OK, message = null) } /** * 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 - withContext(NonCancellable) { - try { - sessionContext.session?.close() - } catch (e: Exception) { - _onError(e) - } finally { - streamsMapping.remove(streamId) - } + 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 @@ -708,8 +529,8 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - // SSE headers are already set by the framework's SSE handler. - flushSse(session) + call.appendSseHeaders() + flushSse(session) // flush headers immediately val streamId = store.replayEventsAfter(lastEventId) { eventId, message -> try { @@ -718,8 +539,6 @@ 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)) } @@ -731,14 +550,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat streamsMapping.remove(streamId) throwable?.let { _onError(it) } } - } catch (e: CancellationException) { - throw e } catch (e: Exception) { _onError(e) } } - private suspend fun validateSession(call: ApplicationCall, id: RequestId? = null): Boolean { + @Suppress("ReturnCount") + private suspend fun validateSession(call: ApplicationCall): Boolean { if (sessionIdGenerator == null) return true if (!initialized.load()) { @@ -746,23 +564,32 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.BadRequest, RPCError.ErrorCode.CONNECTION_CLOSED, "Bad Request: Server not initialized", - id, ) return false } - val headerId = call.request.header(MCP_SESSION_ID_HEADER) + val sessionHeaderValues = call.request.headers.getAll(MCP_SESSION_ID_HEADER) - if (headerId == null) { + if (sessionHeaderValues.isNullOrEmpty()) { 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 @@ -771,15 +598,15 @@ public class StreamableHttpServerTransport(private val configuration: Configurat HttpStatusCode.NotFound, REQUEST_TIMEOUT, "Session not found", - id, ) false } } } - private suspend fun validateProtocolVersion(call: ApplicationCall, id: RequestId? = null): Boolean { - val version = call.request.headers[MCP_PROTOCOL_VERSION_HEADER] ?: return true + 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 return when (version) { !in SUPPORTED_PROTOCOL_VERSIONS -> { @@ -791,7 +618,6 @@ public class StreamableHttpServerTransport(private val configuration: Configurat ", ", ) })", - id, ) false } @@ -800,17 +626,15 @@ 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] - val hostname = hostHeader?.let { extractHostname(it) }?.lowercase() - val allowedHostsLowercase = hosts.map { - extractHostname(it)?.lowercase() ?: it.lowercase() - } + val hostHeader = call.request.headers[HttpHeaders.Host]?.lowercase() + val allowedHostsLowercase = hosts.map { it.lowercase() } - if (hostname == null || hostname !in allowedHostsLowercase) { + if (hostHeader == null || hostHeader !in allowedHostsLowercase) { return "Invalid Host header: $hostHeader" } } @@ -819,8 +643,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat val originHeader = call.request.headers[HttpHeaders.Origin]?.lowercase() val allowedOriginsLowercase = origins.map { it.lowercase() } - // Allow requests without Origin (non-browser clients cannot perform DNS rebinding) - if (originHeader != null && originHeader !in allowedOriginsLowercase) { + if (originHeader == null || originHeader !in allowedOriginsLowercase) { return "Invalid Origin header: $originHeader" } } @@ -828,24 +651,34 @@ 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 body = try { - call.receiveTextWithLimit(configuration.maxRequestBodySize) - } catch (e: RequestBodyTooLargeException) { + val maxSize = configuration.maxRequestBodySize + val contentLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull() ?: 0L + if (contentLength > maxSize) { call.reject( HttpStatusCode.PayloadTooLarge, RPCError.ErrorCode.INVALID_REQUEST, - "Invalid Request: message size exceeds maximum of ${e.maxBodySize} bytes", + "Invalid Request: message size exceeds maximum of $maxSize bytes", + ) + return null + } + + val body = call.receiveText() + if (body.length.toLong() > maxSize) { + call.reject( + HttpStatusCode.PayloadTooLarge, + RPCError.ErrorCode.INVALID_REQUEST, + "Invalid Request: message size exceeds maximum of $maxSize bytes", ) return null } @@ -866,55 +699,30 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - /** - * Reports whether the `Accept` header admits [mime]. - * - * Comparison is over parsed media ranges rather than raw substrings, so wildcards are honored - * and a near miss such as `application/jsonp` no longer passes as `application/json`. An absent - * header states no preference and admits any type. - */ - private fun String?.accepts(mime: ContentType): Boolean { - if (this == null) return true - return parseHeaderValue(this).any { range -> - if (range.quality <= 0.0) return@any false - try { - mime.match(ContentType.parse(range.value)) - } catch (_: BadContentTypeFormatException) { - false - } - } - } + private fun String?.accepts(mime: ContentType): Boolean = + this?.lowercase()?.contains(mime.toString().lowercase()) == true private suspend fun emitOnStream(streamId: String, session: ServerSSESession?, message: JSONRPCMessage) { val eventId = configuration.eventStore?.storeEvent(streamId, message) 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) { - val current = streamsMapping[streamId] - if (current != null && current.session === session) { - streamsMapping.remove(streamId, current) - } + streamsMapping.remove(streamId) } } + @Suppress("TooGenericExceptionCaught") private suspend fun maybeSendPrimingEvent( streamId: String, session: ServerSSESession?, - clientProtocolVersion: String, + clientProtocolVersion: String? = null, ) { 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 < MIN_PRIMING_EVENT_PROTOCOL_VERSION) return + if (clientProtocolVersion != null && clientProtocolVersion < MIN_PRIMING_EVENT_PROTOCOL_VERSION) return try { val primingEventId = store.storeEvent(streamId, JSONRPCEmptyMessage) session.send( @@ -922,8 +730,6 @@ public class StreamableHttpServerTransport(private val configuration: Configurat retry = configuration.retryInterval?.inWholeMilliseconds, data = "", ) - } catch (e: CancellationException) { - throw e } catch (e: Exception) { _onError(e) } @@ -938,13 +744,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } -/** Writes an already serialized body so a client accepting only `text/event-stream` still receives [status]. */ -internal suspend fun ApplicationCall.reject( - status: HttpStatusCode, - code: Int, - message: String, - id: RequestId? = null, -) { - val error = JSONRPCError(id = id, error = RPCError(code = code, message = message)) - respondText(McpJson.encodeToString(error), ContentType.Application.Json, status) +internal suspend fun ApplicationCall.reject(status: HttpStatusCode, code: Int, message: String) { + this.response.status(status) + this.respond(JSONRPCError(id = null, error = RPCError(code = code, message = message))) } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensions.kt index a05daef..c2cb2c4 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,3 +1,5 @@ +@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 deleted file mode 100644 index a63c2e4..0000000 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/utils/ToolNameValidation.kt +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index c6bf72b..0000000 --- a/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/FeatureRegistryTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.matchers.shouldBe -import kotlin.test.Test -import kotlin.test.assertFailsWith - -private data class TestFeature(override val key: FeatureKey, val payload: String = "") : Feature - -private class RecordingListener : FeatureListener { - val updatedKeys = mutableListOf() - - override fun onFeatureUpdated(featureKey: String) { - updatedKeys.add(featureKey) - } -} - -class FeatureRegistryTest { - - private val registry = FeatureRegistry("Tool") - private val listener = RecordingListener() - - init { - registry.addListener(listener) - } - - @Test - fun `add should register feature and notify listener`() { - val feature = TestFeature("a") - - registry.add(feature) - - registry.values shouldBe mapOf("a" to feature) - listener.updatedKeys shouldBe listOf("a") - } - - @Test - fun `add should throw when key is already registered`() { - registry.add(TestFeature("a")) - - val exception = assertFailsWith { - registry.add(TestFeature("a", payload = "override")) - } - - exception.message shouldBe "Tool \"a\" is already registered. Remove it first to replace it." - } - - @Test - fun `add should keep original feature and not notify when duplicate is rejected`() { - val original = TestFeature("a", payload = "original") - registry.add(original) - - assertFailsWith { - registry.add(TestFeature("a", payload = "override")) - } - - registry.values shouldBe mapOf("a" to original) - listener.updatedKeys shouldBe listOf("a") - } - - @Test - fun `addAll should register all features and notify per feature`() { - val a = TestFeature("a") - val b = TestFeature("b") - - registry.addAll(listOf(a, b)) - - registry.values shouldBe mapOf("a" to a, "b" to b) - listener.updatedKeys shouldBe listOf("a", "b") - } - - @Test - fun `addAll should throw on duplicate keys within batch and register nothing`() { - val exception = assertFailsWith { - registry.addAll(listOf(TestFeature("a"), TestFeature("b"), TestFeature("a"))) - } - - exception.message shouldBe "Duplicate Tool keys in batch: [a]" - registry.values shouldBe emptyMap() - listener.updatedKeys shouldBe emptyList() - } - - @Test - fun `addAll should throw when any key is already registered and register nothing`() { - val existing = TestFeature("existing") - registry.add(existing) - - val exception = assertFailsWith { - registry.addAll(listOf(TestFeature("new"), TestFeature("existing", payload = "override"))) - } - - exception.message shouldBe "Tools already registered: [existing]. Remove them first to replace them." - registry.values shouldBe mapOf("existing" to existing) - listener.updatedKeys shouldBe listOf("existing") - } - - @Test - fun `add should succeed after the existing feature is removed`() { - registry.add(TestFeature("a", payload = "original")) - registry.remove("a") - - registry.add(TestFeature("a", payload = "replacement")) - - registry.values shouldBe mapOf("a" to TestFeature("a", payload = "replacement")) - listener.updatedKeys shouldBe listOf("a", "a", "a") - } -} diff --git a/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionAssertCapabilityTest.kt b/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionAssertCapabilityTest.kt deleted file mode 100644 index a172e96..0000000 --- a/kotlin-sdk-server/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionAssertCapabilityTest.kt +++ /dev/null @@ -1,147 +0,0 @@ -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 7639bc6..28e21b9 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,6 +14,7 @@ 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 deleted file mode 100644 index ea38219..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionTest.kt +++ /dev/null @@ -1,428 +0,0 @@ -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 deleted file mode 100644 index b218b47..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidationTest.kt +++ /dev/null @@ -1,438 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema -import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema -import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.EnumOption -import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema -import io.modelcontextprotocol.kotlin.sdk.types.LegacyTitledEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.McpException -import io.modelcontextprotocol.kotlin.sdk.types.McpJson -import io.modelcontextprotocol.kotlin.sdk.types.PrimitiveSchemaDefinition -import io.modelcontextprotocol.kotlin.sdk.types.RPCError -import io.modelcontextprotocol.kotlin.sdk.types.StringSchema -import io.modelcontextprotocol.kotlin.sdk.types.StringSchemaFormat -import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonArray -import kotlinx.serialization.json.putJsonObject -import org.junit.jupiter.api.assertDoesNotThrow -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue - -/** - * Unit tests for [validateElicitationContent]: pure-function checks of accepted form-mode - * content against the requested schema. - * - * End-to-end tests for the `createElicitation` round trip live in the `integration-test` - * module under `io.modelcontextprotocol.kotlin.sdk.client`. - */ -class ElicitationValidationTest { - - private fun schema(vararg properties: Pair, required: List? = null) = - ElicitRequestParams.RequestedSchema(properties = mapOf(*properties), required = required) - - private fun assertRejected( - schema: ElicitRequestParams.RequestedSchema, - content: JsonObject, - expectedError: String, - ) { - val exception = assertFailsWith { validateElicitationContent(schema, content) } - assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code) - val message = exception.message.orEmpty() - assertTrue( - message.startsWith("Elicitation response content does not match requested schema: "), - "Unexpected message prefix: $message", - ) - assertTrue(expectedError in message, "Expected '$expectedError' in: $message") - } - - @Test - fun `validate content matching schema passes`() { - assertDoesNotThrow { - validateElicitationContent( - schema( - "name" to StringSchema(minLength = 1, maxLength = 10), - "age" to IntegerSchema(minimum = 0, maximum = 120), - "score" to DoubleSchema(minimum = 0.0, maximum = 1.0), - "subscribed" to BooleanSchema(), - "color" to UntitledSingleSelectEnumSchema(enumValues = listOf("red", "green")), - "size" to TitledSingleSelectEnumSchema( - oneOf = listOf(EnumOption("s", "Small"), EnumOption("l", "Large")), - ), - "tags" to UntitledMultiSelectEnumSchema( - items = UntitledMultiSelectEnumSchema.Items(enumValues = listOf("a", "b", "c")), - minItems = 1, - maxItems = 2, - ), - required = listOf("name", "age"), - ), - buildJsonObject { - put("name", "octocat") - put("age", 42) - put("score", 0.5) - put("subscribed", true) - put("color", "red") - put("size", "l") - putJsonArray("tags") { add("a") } - }, - ) - } - } - - @Test - fun `validate missing required property fails`() { - assertRejected( - schema("name" to StringSchema(), required = listOf("name")), - JsonObject(emptyMap()), - "must have required property 'name'", - ) - } - - @Test - fun `validate empty content with no required properties passes`() { - assertDoesNotThrow { - validateElicitationContent(schema("name" to StringSchema()), JsonObject(emptyMap())) - } - } - - @Test - fun `validate wrong type for string property fails`() { - assertRejected( - schema("name" to StringSchema()), - buildJsonObject { put("name", 42) }, - "'name' must be string", - ) - } - - @Test - fun `validate string length constraints fail`() { - assertRejected( - schema("name" to StringSchema(minLength = 3)), - buildJsonObject { put("name", "ab") }, - "'name' must NOT have fewer than 3 characters", - ) - assertRejected( - schema("name" to StringSchema(maxLength = 3)), - buildJsonObject { put("name", "abcd") }, - "'name' must NOT have more than 3 characters", - ) - } - - @Test - fun `validate json string is not accepted for integer boolean or number`() { - // JsonPrimitive("42") is the JSON string "42", not the number 42; the validator must not - // parse string content as a number. - assertRejected( - schema("age" to IntegerSchema()), - buildJsonObject { put("age", "42") }, - "'age' must be integer", - ) - assertRejected( - schema("score" to DoubleSchema()), - buildJsonObject { put("score", "1.5") }, - "'score' must be number", - ) - assertRejected( - schema("subscribed" to BooleanSchema()), - buildJsonObject { put("subscribed", "true") }, - "'subscribed' must be boolean", - ) - } - - @Test - fun `validate integer outside minimum and maximum fails`() { - assertRejected( - schema("age" to IntegerSchema(minimum = 0)), - buildJsonObject { put("age", -1) }, - "'age' must be >= 0", - ) - assertRejected( - schema("age" to IntegerSchema(maximum = 120)), - buildJsonObject { put("age", 200) }, - "'age' must be <= 120", - ) - } - - @Test - fun `validate non-integral value for integer property fails`() { - assertRejected( - schema("age" to IntegerSchema()), - buildJsonObject { put("age", 3.5) }, - "'age' must be integer", - ) - } - - @Test - fun `validate integral number forms are accepted for integer property`() { - // JSON Schema's "integer" matches numbers with a zero fractional part. - assertDoesNotThrow { - validateElicitationContent( - schema("age" to IntegerSchema(minimum = 0, maximum = 200)), - buildJsonObject { put("age", 5.0) }, - ) - } - } - - @Test - fun `validate exponent form is accepted for integer property`() { - val content = McpJson.parseToJsonElement("""{"age": 1e2}""") as JsonObject - assertDoesNotThrow { - validateElicitationContent( - schema("age" to IntegerSchema(minimum = 0, maximum = 200)), - content, - ) - } - } - - @Test - fun `validate number outside range fails`() { - assertRejected( - schema("score" to DoubleSchema(maximum = 1.0)), - buildJsonObject { put("score", 1.5) }, - "'score' must be <= 1.0", - ) - } - - @Test - fun `validate integral value is accepted for number property`() { - assertDoesNotThrow { - validateElicitationContent( - schema("score" to DoubleSchema(minimum = 0.0)), - buildJsonObject { put("score", 5) }, - ) - } - } - - @Test - fun `validate string length counts unicode code points`() { - // JSON Schema string length counts RFC 8259 characters: one emoji is one character - // even when it is two UTF-16 units. - assertDoesNotThrow { - validateElicitationContent( - schema("emoji" to StringSchema(maxLength = 1)), - buildJsonObject { put("emoji", "😀") }, - ) - } - assertRejected( - schema("emoji" to StringSchema(minLength = 2)), - buildJsonObject { put("emoji", "😀") }, - "'emoji' must NOT have fewer than 2 characters", - ) - } - - @Test - fun `validate string format is treated as an annotation`() { - assertDoesNotThrow { - validateElicitationContent( - schema("email" to StringSchema(format = StringSchemaFormat.Email)), - buildJsonObject { put("email", "not-an-email") }, - ) - } - } - - @Test - fun `validate negative zero satisfies an inclusive zero bound`() { - // IEEE 754 comparison: -0.0 == 0.0, so an inclusive minimum of 0.0 admits -0.0. - assertDoesNotThrow { - validateElicitationContent( - schema("score" to DoubleSchema(minimum = 0.0)), - buildJsonObject { put("score", -0.0) }, - ) - } - } - - @Test - fun `validate enum value not in allowed set fails`() { - assertRejected( - schema("color" to UntitledSingleSelectEnumSchema(enumValues = listOf("red", "green"))), - buildJsonObject { put("color", "blue") }, - "'color' must be equal to one of the allowed values", - ) - assertRejected( - schema("size" to TitledSingleSelectEnumSchema(oneOf = listOf(EnumOption("s", "Small")))), - buildJsonObject { put("size", "xl") }, - "'size' must be equal to one of the allowed values", - ) - assertRejected( - schema("legacy" to LegacyTitledEnumSchema(enumValues = listOf("a", "b"))), - buildJsonObject { put("legacy", "c") }, - "'legacy' must be equal to one of the allowed values", - ) - } - - @Test - fun `validate multi-select constraints fail`() { - val tags = UntitledMultiSelectEnumSchema( - items = UntitledMultiSelectEnumSchema.Items(enumValues = listOf("a", "b", "c")), - minItems = 1, - maxItems = 2, - ) - assertRejected( - schema("tags" to tags), - buildJsonObject { put("tags", "a") }, - "'tags' must be array", - ) - assertRejected( - schema("tags" to tags), - buildJsonObject { - putJsonArray("tags") { - add("a") - add(42) - } - }, - "'tags' items must be string", - ) - assertRejected( - schema("tags" to tags), - buildJsonObject { - putJsonArray("tags") { - add("a") - add("z") - } - }, - "'tags' items must be equal to one of the allowed values", - ) - assertRejected( - schema("tags" to tags), - buildJsonObject { putJsonArray("tags") {} }, - "'tags' must NOT have fewer than 1 items", - ) - assertRejected( - schema("tags" to tags), - buildJsonObject { - putJsonArray("tags") { - add("a") - add("b") - add("c") - } - }, - "'tags' must NOT have more than 2 items", - ) - } - - @Test - fun `validate titled multi-select membership uses option consts`() { - val sizes = TitledMultiSelectEnumSchema( - items = TitledMultiSelectEnumSchema.Items( - anyOf = listOf(EnumOption("s", "Small"), EnumOption("l", "Large")), - ), - ) - assertDoesNotThrow { - validateElicitationContent( - schema("sizes" to sizes), - buildJsonObject { putJsonArray("sizes") { add("s") } }, - ) - } - assertRejected( - schema("sizes" to sizes), - buildJsonObject { putJsonArray("sizes") { add("Small") } }, - "'sizes' items must be equal to one of the allowed values", - ) - } - - @Test - fun `validate json null is rejected with the type error for each schema kind`() { - // JsonNull is a JsonPrimitive whose content is "null"; it must fail the type check, not - // accidentally parse as a value. - assertRejected( - schema("name" to StringSchema()), - buildJsonObject { put("name", JsonNull) }, - "'name' must be string", - ) - assertRejected( - schema("age" to IntegerSchema()), - buildJsonObject { put("age", JsonNull) }, - "'age' must be integer", - ) - assertRejected( - schema("subscribed" to BooleanSchema()), - buildJsonObject { put("subscribed", JsonNull) }, - "'subscribed' must be boolean", - ) - } - - @Test - fun `validate non-primitive value is rejected for scalar schemas`() { - assertRejected( - schema("name" to StringSchema()), - buildJsonObject { putJsonArray("name") { add("x") } }, - "'name' must be string", - ) - assertRejected( - schema("age" to IntegerSchema()), - buildJsonObject { putJsonObject("age") {} }, - "'age' must be integer", - ) - } - - @Test - fun `validate number below minimum fails`() { - assertRejected( - schema("score" to DoubleSchema(minimum = 0.0)), - buildJsonObject { put("score", -0.5) }, - "'score' must be >= 0.0", - ) - } - - @Test - fun `validate non-finite value for number property fails`() { - // McpJson is lenient, so an unquoted NaN literal can arrive off the wire; it decodes to - // the same primitive as Double.NaN here. - assertRejected( - schema("score" to DoubleSchema(minimum = 0.0, maximum = 1.0)), - buildJsonObject { put("score", Double.NaN) }, - "'score' must be number", - ) - } - - @Test - fun `validate required property not declared in properties is still required`() { - // JSON Schema's `required` is independent of `properties`. - assertRejected( - schema("name" to StringSchema(), required = listOf("name", "consent")), - buildJsonObject { put("name", "octocat") }, - "must have required property 'consent'", - ) - } - - @Test - fun `validate properties not declared in schema pass`() { - // JSON Schema default: without an additionalProperties restriction, undeclared keys are permitted. - assertDoesNotThrow { - validateElicitationContent( - schema("name" to StringSchema(), required = listOf("name")), - buildJsonObject { - put("name", "octocat") - put("undeclared", 42) - }, - ) - } - } - - @Test - fun `validate reports every violation in one error`() { - val exception = assertFailsWith { - validateElicitationContent( - schema( - "name" to StringSchema(), - "age" to IntegerSchema(), - required = listOf("name"), - ), - buildJsonObject { put("age", "old") }, - ) - } - val message = exception.message.orEmpty() - assertTrue("must have required property 'name'" in message, message) - assertTrue("'age' must be integer" in message, message) - } -} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt index 9888867..05200e5 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 install SSE and coexist with other routes`() = testApplication { + fun `Application mcp should installs SSE and coexist with other routes`() = testApplication { application { - mcp(enableDnsRebindingProtection = false) { testServer() } + mcp { 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 d6b95b4..5ba4448 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(enableDnsRebindingProtection = false) { testServer() } + mcp { testServer() } } } } @@ -91,7 +91,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() { route("/v1") { route("/services") { route("/mcp") { - mcp(enableDnsRebindingProtection = false) { testServer() } + mcp { testServer() } } } } @@ -112,7 +112,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() { routing { route("/api") { - mcp("/mcp-endpoint", enableDnsRebindingProtection = false) { testServer() } + mcp("/mcp-endpoint") { 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 deleted file mode 100644 index 9dc30cf..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBodyTest.kt +++ /dev/null @@ -1,103 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.matchers.shouldBe -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText -import io.ktor.http.HttpStatusCode -import io.ktor.server.response.respondText -import io.ktor.server.routing.post -import io.ktor.server.routing.routing -import io.ktor.server.testing.ApplicationTestBuilder -import io.ktor.server.testing.testApplication -import kotlin.test.Test - -class RequestBodyTest { - - /** Wires a POST endpoint that echoes the body when it fits, or replies 413 when it is too large. */ - private fun ApplicationTestBuilder.installEchoEndpoint(maxBytes: Long) { - install(io.ktor.server.sse.SSE) - application { - routing { - post("/echo") { - val text = try { - call.receiveTextWithLimit(maxBytes) - } catch (e: RequestBodyTooLargeException) { - call.respondText(e.message ?: "too large", status = HttpStatusCode.PayloadTooLarge) - return@post - } - call.respondText(text) - } - } - } - } - - @Test - fun `body under the limit is returned intact`() = testApplication { - installEchoEndpoint(maxBytes = 1024) - val payload = "hello world" - - val response = client.post("/echo") { setBody(payload) } - - response.status shouldBe HttpStatusCode.OK - response.bodyAsText() shouldBe payload - } - - @Test - fun `body exactly at the limit is accepted`() = testApplication { - val payload = "x".repeat(64) - installEchoEndpoint(maxBytes = payload.length.toLong()) - - val response = client.post("/echo") { setBody(payload) } - - response.status shouldBe HttpStatusCode.OK - response.bodyAsText() shouldBe payload - } - - @Test - fun `body exceeding the limit is rejected with 413`() = testApplication { - val payload = "x".repeat(65) - installEchoEndpoint(maxBytes = 64) - - val response = client.post("/echo") { setBody(payload) } - - response.status shouldBe HttpStatusCode.PayloadTooLarge - } - - @Test - fun `large body exceeding the limit is rejected without buffering it whole`() = testApplication { - // 8 MB body against a 4 MB limit: must be rejected. - installEchoEndpoint(maxBytes = 4L * 1024 * 1024) - val payload = "x".repeat(8 * 1024 * 1024) - - val response = client.post("/echo") { setBody(payload) } - - response.status shouldBe HttpStatusCode.PayloadTooLarge - } - - @Test - fun `multibyte UTF-8 body larger than a read chunk decodes intact`() = testApplication { - // Emoji is 4 bytes in UTF-8; a payload larger than the internal read chunk forces the - // accumulated bytes to span multiple chunks. Decoding once at the end must not corrupt - // characters that straddle a chunk boundary. - val unit = "a😀b" // mixes 1- and 4-byte code units so boundaries land mid-character - val payload = unit.repeat(40_000) // well over a 64 KiB read chunk - installEchoEndpoint(maxBytes = payload.encodeToByteArray().size.toLong()) - - val response = client.post("/echo") { setBody(payload) } - - response.status shouldBe HttpStatusCode.OK - response.bodyAsText() shouldBe payload - } - - @Test - fun `body is returned intact when the limit is Long MAX_VALUE`() = testApplication { - installEchoEndpoint(maxBytes = Long.MAX_VALUE) - val payload = "hello world" - - val response = client.post("/echo") { setBody(payload) } - - response.status shouldBe HttpStatusCode.OK - response.bodyAsText() shouldBe payload - } -} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt deleted file mode 100644 index cfcbb62..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SamplingTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index a0756f3..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SseServerTransportTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.matchers.shouldBe -import io.ktor.client.request.post -import io.ktor.client.request.prepareGet -import io.ktor.client.request.setBody -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsChannel -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import io.ktor.server.application.install -import io.ktor.server.routing.post -import io.ktor.server.routing.routing -import io.ktor.server.sse.SSE -import io.ktor.server.sse.ServerSSESession -import io.ktor.server.testing.testApplication -import io.ktor.utils.io.readLine -import io.mockk.mockk -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.test.Test - -class SseServerTransportTest : AbstractKtorExtensionsTest() { - - @Test - fun `handlePostMessage on a not-started transport does not deliver the message`() = testApplication { - // A registered onMessage callback opens the delivery gate, so if the not-initialized branch - // falls through it would wrongly hand the message to the application. It must return instead. - val transport = SseServerTransport("/messages", mockk(relaxed = true)) - val delivered = AtomicBoolean(false) - transport.onMessage { delivered.set(true) } - - application { - routing { - post("/messages") { transport.handlePostMessage(call) } - } - } - - val response = client.post("/messages") { - contentType(ContentType.Application.Json) - setBody("""{"jsonrpc":"2.0","id":1,"method":"ping"}""") - } - - response.status shouldBe HttpStatusCode.InternalServerError - delivered.get() shouldBe false - } - - @Test - fun `SSE POST exceeding maxRequestBodySize is rejected with 413`() = testApplication { - application { - install(SSE) - routing { - mcp(enableDnsRebindingProtection = false, maxRequestBodySize = MAX_BODY) { testServer() } - } - } - - client.prepareGet("/").execute { response -> - val sessionId = response.readSessionId() - requireNotNull(sessionId) { "sessionId not found in SSE endpoint event" } - - // A body one byte over the configured limit must be rejected before processing. - val oversized = "x".repeat((MAX_BODY + 1).toInt()) - val postResponse = client.post("/?sessionId=$sessionId") { - contentType(ContentType.Application.Json) - setBody(oversized) - } - postResponse.status shouldBe HttpStatusCode.PayloadTooLarge - } - } - - private suspend fun HttpResponse.readSessionId(): String? { - val channel = bodyAsChannel() - var eventName: String? = null - while (!channel.isClosedForRead) { - val line = channel.readLine() ?: break - when { - line.startsWith("event:") -> eventName = line.substringAfter("event:").trim() - - line.startsWith("data:") && eventName == "endpoint" -> { - return line.substringAfter("data:").trim().substringAfter("sessionId=").ifEmpty { null } - } - } - } - return null - } - - private companion object { - const val MAX_BODY = 1024L - } -} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StatelessStreamableHttpSessionLifecycleTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StatelessStreamableHttpSessionLifecycleTest.kt deleted file mode 100644 index 3646faf..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StatelessStreamableHttpSessionLifecycleTest.kt +++ /dev/null @@ -1,100 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.matchers.maps.shouldBeEmpty -import io.kotest.matchers.shouldBe -import io.ktor.client.request.HttpRequestBuilder -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import io.ktor.server.testing.testApplication -import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest -import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage -import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION -import io.modelcontextprotocol.kotlin.sdk.types.McpJson -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.toJSON -import kotlin.test.Test -import kotlin.time.Duration.Companion.seconds - -/** - * Verifies that the stateless Streamable HTTP endpoint does not leak server sessions: - * every session created to serve a single request must be closed and removed from the - * session registry once that request completes (https://github.com/modelcontextprotocol/kotlin-sdk/issues/786). - */ -class StatelessStreamableHttpSessionLifecycleTest { - - private fun testServer(): Server = Server( - Implementation("test-server", "1.0"), - ServerOptions(capabilities = ServerCapabilities()), - ) - - @Test - fun `stateless endpoint removes session after each request`() = testApplication { - val server = testServer() - application { - mcpStatelessStreamableHttp { server } - } - - repeat(5) { - val response = client.post("/mcp") { - addStreamableHeaders() - setBody(initializeRequestBody()) - } - response.status shouldBe HttpStatusCode.OK - } - - eventually(5.seconds) { - server.sessions.shouldBeEmpty() - } - } - - @Test - fun `stateless endpoint removes session when the request is rejected`() = testApplication { - val server = testServer() - application { - mcpStatelessStreamableHttp { server } - } - - // Missing "text/event-stream" in Accept makes the transport reject the request. - val response = client.post("/mcp") { - header(HttpHeaders.Host, "localhost") - header(HttpHeaders.Accept, ContentType.Application.Json.toString()) - contentType(ContentType.Application.Json) - setBody(initializeRequestBody()) - } - response.status shouldBe HttpStatusCode.NotAcceptable - - eventually(5.seconds) { - server.sessions.shouldBeEmpty() - } - } - - private fun HttpRequestBuilder.addStreamableHeaders() { - header(HttpHeaders.Host, "localhost") - header( - HttpHeaders.Accept, - listOf(ContentType.Application.Json, ContentType.Text.EventStream).joinToString(", "), - ) - contentType(ContentType.Application.Json) - } - - private fun initializeRequestBody(): String { - val request = InitializeRequest( - InitializeRequestParams( - protocolVersion = LATEST_PROTOCOL_VERSION, - capabilities = ClientCapabilities(), - clientInfo = Implementation(name = "test-client", version = "1.0.0"), - ), - ).toJSON() - - return McpJson.encodeToString(JSONRPCMessage.serializer(), request) - } -} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportTest.kt index c6370a1..dcbb7a4 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,29 +5,17 @@ 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 @@ -45,12 +33,9 @@ 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) @@ -86,13 +71,13 @@ class StdioServerTransportTest { @Test fun `should be safe to close before start`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) + val server = StdioServerTransport(bufferedInput, printOutput) server.close() // initialized guard makes this a no-op; must not throw } @Test fun `should start then close cleanly`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) + val server = StdioServerTransport(bufferedInput, printOutput) server.onError { error -> throw error } @@ -111,7 +96,7 @@ class StdioServerTransportTest { @Test fun `should not read until started`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) + val server = StdioServerTransport(bufferedInput, printOutput) server.onError { error -> throw error } @@ -140,7 +125,7 @@ class StdioServerTransportTest { @Test fun `should read multiple messages`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) + val server = StdioServerTransport(bufferedInput, printOutput) server.onError { error -> throw error } @@ -177,7 +162,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(input = FaultyRawSource(throwable).buffered(), output = printOutput) + val server = StdioServerTransport(FaultyRawSource(throwable).buffered(), printOutput) val capturedError = CompletableDeferred() server.onError { capturedError.complete(it) } server.onMessage {} @@ -191,7 +176,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(input = bufferedInput, output = FaultyRawSink(throwable).buffered()) + val server = StdioServerTransport(bufferedInput, FaultyRawSink(throwable).buffered()) val capturedError = CompletableDeferred() server.onError { capturedError.complete(it) } server.onMessage {} @@ -205,7 +190,7 @@ class StdioServerTransportTest { @Test fun `should call onClose when input EOF is reached`(): Unit = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) + val server = StdioServerTransport(bufferedInput, printOutput) val didClose = CompletableDeferred() server.onError { throw it } server.onClose { didClose.complete(Unit) } @@ -221,7 +206,7 @@ class StdioServerTransportTest { @Test fun `should throw when starting twice`(): Unit = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) + val server = StdioServerTransport(bufferedInput, printOutput) server.onMessage {} server.start() withClue("Server should not start twice") { @@ -232,24 +217,10 @@ 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(input = bufferedInput, output = printOutput) + val server = StdioServerTransport(bufferedInput, printOutput) val capturedErrors = mutableListOf() val receivedMessages = mutableListOf() val secondMessageProcessed = CompletableDeferred() @@ -282,7 +253,7 @@ class StdioServerTransportTest { @Test fun `should not invoke onError for CancellationException in handler`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) + val server = StdioServerTransport(bufferedInput, printOutput) val capturedError = CompletableDeferred() server.onError { capturedError.complete(it) } @@ -310,7 +281,7 @@ class StdioServerTransportTest { @Test fun `should continue receiving valid messages after malformed JSON is skipped`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) + val server = StdioServerTransport(bufferedInput, printOutput) val received = CompletableDeferred() // ReadBuffer silently skips unparseable lines — no onError callback expected server.onError {} @@ -328,309 +299,21 @@ class StdioServerTransportTest { server.close() } - @Test - fun `should throw McpException when send is called before start`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - val ex = shouldThrow { - server.send(PingRequest().toJSON()) - } - ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - } - - @Test - fun `should throw McpException when send is called after close`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - server.onError {} - server.onMessage {} - server.start() - server.close() - - val ex = shouldThrow { - server.send(PingRequest().toJSON()) - } - ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - } - - @Test - fun `should drain in-flight outbound messages on graceful close`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - server.onError {} - server.onMessage {} - server.start() - - val numMessages = 50 - repeat(numMessages) { - server.send(PingRequest().toJSON()) - } - server.close() - - val outputLines = String(output.toByteArray()).lines().count { it.isNotBlank() } - outputLines shouldBe numMessages - } - - @Test - fun `should fail-fast when reader throws non-EOF IOException`() = runIntegrationTest { - val ioError = IOException("transient stream failure") - val server = StdioServerTransport(input = FaultyRawSource(ioError).buffered(), output = printOutput) - val errorCaptured = CompletableDeferred() - val closeCaptured = CompletableDeferred() - server.onError { errorCaptured.complete(it) } - server.onClose { closeCaptured.complete(Unit) } - server.onMessage {} - - server.start() - - errorCaptured.await() shouldBe ioError - closeCaptured.await() - } - - @Test - fun `should close input source on natural EOF`() = runIntegrationTest { - val inputClosed = CompletableDeferred() - val eofSource = object : RawSource { - override fun readAtMostTo(sink: Buffer, byteCount: Long): Long = -1L - override fun close() { - inputClosed.complete(Unit) - } - } - val server = StdioServerTransport(input = eofSource.buffered(), output = printOutput) - val onCloseFired = CompletableDeferred() - server.onError {} - server.onMessage {} - server.onClose { onCloseFired.complete(Unit) } - - server.start() - - inputClosed.await() - onCloseFired.await() - } - - // region: concurrency - - @Test - fun `should be safe under concurrent start and close`() = runIntegrationTest(timeout = 30.seconds) { - val iterations = 2000 - repeat(iterations) { iteration -> - val server = StdioServerTransport(input = Buffer(), output = Buffer()) - server.onError {} - server.onMessage {} - - coroutineScope { - launch(Dispatchers.Default) { - try { - server.start() - } catch (_: IllegalStateException) { - } - } - launch(Dispatchers.Default) { - server.close() - } - } - - withClue("iteration $iteration: send() after race should fail with CONNECTION_CLOSED") { - val ex = shouldThrow { - server.send(PingRequest().toJSON()) - } - ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - } - } - } - - @Test - fun `should suspend send under back-pressure`() = runIntegrationTest(timeout = 30.seconds) { - val unblock = CountDownLatch(1) - val blockingSink = object : RawSink { - override fun write(source: Buffer, byteCount: Long) { - source.clear() - unblock.await() - } - - override fun flush() { - // noop - } - - override fun close() { - unblock.countDown() - } - } - val server = StdioServerTransport(input = bufferedInput, output = blockingSink.buffered()) - server.onError {} - server.onMessage {} - server.start() - - val maxAttempts = 256 - var suspendedAt = -1 - for (i in 0 until maxAttempts) { - val ok = withTimeoutOrNull(100.milliseconds) { server.send(PingRequest().toJSON()) } - if (ok == null) { - suspendedAt = i - break - } - } - assertTrue( - suspendedAt in 0 until maxAttempts, - "send() should have suspended within $maxAttempts attempts, got suspendedAt=$suspendedAt", - ) - // Unblock writer so close() can drain. - unblock.countDown() - server.close() - } - - @Test - fun `should not hang close when handler suspends indefinitely`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - val handlerEntered = CompletableDeferred() - val neverComplete = CompletableDeferred() - server.onError {} - server.onMessage { - handlerEntered.complete(Unit) - neverComplete.await() - } - server.start() - - inputWriter.write(serializeMessage(PingRequest().toJSON())) - inputWriter.flush() - - handlerEntered.await() - server.close() - } - - @Test - fun `should not hang close when onMessage was never registered`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - server.onError {} - server.start() - - inputWriter.write(serializeMessage(PingRequest().toJSON())) - inputWriter.flush() - - delay(200.milliseconds) - - server.close() - } - - // endregion - - // region: scope and dispatcher knobs - - @Test - fun `should honor externally provided CoroutineScope`() = runIntegrationTest { - val externalScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - val server = StdioServerTransport(input = bufferedInput, output = printOutput) { - scope = externalScope - } - val closeCaptured = CompletableDeferred() - server.onError {} - server.onClose { closeCaptured.complete(Unit) } - server.onMessage {} - - server.start() - externalScope.cancel() - - closeCaptured.await() - - val ex = shouldThrow { - server.send(PingRequest().toJSON()) - } - ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - } - - @Test - fun `should use ioDispatcher for reader and writer`() = runIntegrationTest { - val threadName = "stdio-test-io-thread" - val executor = Executors.newFixedThreadPool(2) { r -> Thread(r, threadName) } - try { - val readerThread = CompletableDeferred() - val writerThread = CompletableDeferred() - val unblockReader = CountDownLatch(1) - val recordingInput = object : RawSource { - override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { - if (!readerThread.isCompleted) { - readerThread.complete(Thread.currentThread().name) - } - unblockReader.await() - return -1L - } - - override fun close() { - unblockReader.countDown() - } - } - val recordingOutput = object : RawSink { - override fun write(source: Buffer, byteCount: Long) { - if (!writerThread.isCompleted) { - writerThread.complete(Thread.currentThread().name) - } - source.clear() - } - - override fun flush() { - // noop - } - - override fun close() { - // noop - } - } - val server = StdioServerTransport( - input = recordingInput.buffered(), - output = recordingOutput.buffered(), - ) { - ioDispatcher = executor.asCoroutineDispatcher() - } - server.onError {} - server.onMessage {} - server.start() - - readerThread.await() shouldContain threadName - server.send(PingRequest().toJSON()) - writerThread.await() shouldContain threadName - - server.close() - } finally { - executor.shutdownNow() - } - } - - @Test - fun `should use handlerDispatcher for message handling`() = runIntegrationTest { - val threadName = "stdio-test-handler-thread" - val executor = Executors.newSingleThreadExecutor { r -> Thread(r, threadName) } - try { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) { - handlerDispatcher = executor.asCoroutineDispatcher() - } - val observedThreadName = CompletableDeferred() - server.onError {} - server.onMessage { - observedThreadName.complete(Thread.currentThread().name) - } - server.start() - - inputWriter.write(serializeMessage(PingRequest().toJSON())) - inputWriter.flush() - - observedThreadName.await() shouldContain threadName - server.close() - } finally { - executor.shutdownNow() - } - } - - // endregion - + @Suppress("unused") 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 deleted file mode 100644 index fc076eb..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpResponseDeliveryTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.matchers.shouldBe -import io.ktor.client.request.delete -import io.ktor.client.request.get -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import io.ktor.server.testing.testApplication -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError -import io.modelcontextprotocol.kotlin.sdk.types.McpJson -import io.modelcontextprotocol.kotlin.sdk.types.RPCError -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import kotlin.test.Test - -/** - * A client opening the standalone stream sends `Accept: text/event-stream`, which matches no JSON - * converter. Responses rendered through ContentNegotiation used to reach such a client as an empty - * 406 with the intended status discarded. - */ -class StreamableHttpResponseDeliveryTest { - - private val eventStream = ContentType.Text.EventStream.toString() - - private fun testServer(): Server = Server( - Implementation("test-server", "1.0"), - ServerOptions(capabilities = ServerCapabilities()), - ) - - @Test - fun `stateless GET is rejected with 405 and advertises POST`() = testApplication { - application { mcpStatelessStreamableHttp { testServer() } } - - val response = client.get("/mcp") { - header(HttpHeaders.Host, "localhost") - header(HttpHeaders.Accept, eventStream) - } - - response.assertMethodNotAllowed() - } - - @Test - fun `stateless DELETE is rejected with 405 and advertises POST`() = testApplication { - application { mcpStatelessStreamableHttp { testServer() } } - - val response = client.delete("/mcp") { - header(HttpHeaders.Host, "localhost") - header(HttpHeaders.Accept, eventStream) - } - - response.assertMethodNotAllowed() - } - - @Test - fun `stateful DELETE for an unknown session returns 404`() = testApplication { - application { mcpStreamableHttp { testServer() } } - - val response = client.delete("/mcp") { - header(HttpHeaders.Host, "localhost") - header(HttpHeaders.Accept, eventStream) - header(MCP_SESSION_ID_HEADER, "unknown-session") - } - - response.status shouldBe HttpStatusCode.NotFound - response.decodeError().error.message shouldBe "Session not found" - } - - @Test - fun `POST carrying no request is acknowledged with a bodiless 202`() = testApplication { - application { mcpStatelessStreamableHttp { testServer() } } - - val response = client.post("/mcp") { - header(HttpHeaders.Host, "localhost") - header(HttpHeaders.Accept, "${ContentType.Application.Json}, $eventStream") - contentType(ContentType.Application.Json) - setBody("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") - } - - response.status shouldBe HttpStatusCode.Accepted - response.bodyAsText() shouldBe "" - } - - private suspend fun HttpResponse.assertMethodNotAllowed() { - status shouldBe HttpStatusCode.MethodNotAllowed - headers[HttpHeaders.Allow] shouldBe HttpMethod.Post.value - contentType()?.withoutParameters() shouldBe ContentType.Application.Json - decodeError().error.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - } - - private suspend fun HttpResponse.decodeError(): JSONRPCError = McpJson.decodeFromString(bodyAsText()) -} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt index 43ff605..9cf916a 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 @@ -2,45 +2,31 @@ package io.modelcontextprotocol.kotlin.sdk.server import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.equals.shouldBeEqual -import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.ktor.client.HttpClient 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 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.serialization.kotlinx.json.json -import io.ktor.server.application.ApplicationCall import io.ktor.server.application.install -import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.routing -import io.ktor.server.sse.ServerSSESession import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.testApplication -import io.ktor.sse.ServerSentEvent -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.readLine -import io.ktor.utils.io.readUTF8Line -import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotification -import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse @@ -49,22 +35,10 @@ import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult import io.modelcontextprotocol.kotlin.sdk.types.McpJson import io.modelcontextprotocol.kotlin.sdk.types.Method -import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.Tool import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.toJSON -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withTimeout import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -72,14 +46,11 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.CoroutineContext import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation @@ -103,26 +74,6 @@ class StreamableHttpServerTransportTest { Arguments.of(sizeTestPayload.length.toLong(), HttpStatusCode.BadRequest), Arguments.of(sizeTestPayload.length.toLong() + 1, HttpStatusCode.BadRequest), ) - - @JvmStatic - fun postAcceptHeaderCases(): List = listOf( - Arguments.of("application/json, text/event-stream", HttpStatusCode.OK), - Arguments.of("Application/JSON;q=0.9, TEXT/Event-Stream;charset=utf-8", HttpStatusCode.OK), - Arguments.of("*/*", HttpStatusCode.OK), - Arguments.of("application/*, text/*", HttpStatusCode.OK), - Arguments.of(null, HttpStatusCode.OK), - Arguments.of("application/json", HttpStatusCode.NotAcceptable), - Arguments.of("text/event-stream", HttpStatusCode.NotAcceptable), - Arguments.of("application/jsonp, text/event-stream-bogus", HttpStatusCode.NotAcceptable), - Arguments.of("application/json, text/event-stream;q=0", HttpStatusCode.NotAcceptable), - ) - - @JvmStatic - fun getAcceptHeaderCases() = listOf( - "application/json", - "text/event-stream-bogus", - "text/event-stream;q=0", - ) } private val path = "/transport" @@ -153,90 +104,6 @@ class StreamableHttpServerTransportTest { assertFalse(onMessageCalled.get(), "Transport should not deliver messages when headers are invalid") } - @ParameterizedTest - @MethodSource("postAcceptHeaderCases") - fun `POST Accept header is matched as media ranges`(acceptHeader: String?, expectedStatus: HttpStatusCode) = - testApplication { - configTestServer() - - // Bare client: the ContentNegotiation plugin would supply an Accept header of its own, - // leaving the absent-header case untestable. - val client = createClient {} - - val transport = StreamableHttpServerTransport(enableJsonResponse = true) - val onMessageCalled = AtomicBoolean(false) - transport.onMessage { message -> - onMessageCalled.set(true) - if (message is JSONRPCRequest) { - transport.send(JSONRPCResponse(message.id, EmptyResult())) - } - } - - configureTransportEndpoint(transport) - - val response = client.post(path) { - contentType(ContentType.Application.Json) - acceptHeader?.let { header(HttpHeaders.Accept, it) } - setBody(McpJson.encodeToString(JSONRPCMessage.serializer(), buildInitializeRequestPayload())) - } - - assertEquals(expectedStatus, response.status) - assertEquals( - expectedStatus == HttpStatusCode.OK, - onMessageCalled.get(), - "Only a request that clears the Accept gate may reach the transport", - ) - } - - @Test - fun `POST with an unparsable Accept header is rejected without failing the request`() = testApplication { - configTestServer() - - val client = createClient {} - - val transport = StreamableHttpServerTransport(enableJsonResponse = true) - val onMessageCalled = AtomicBoolean(false) - transport.onMessage { - onMessageCalled.set(true) - } - - configureTransportEndpoint(transport) - - val response = client.post(path) { - contentType(ContentType.Application.Json) - header(HttpHeaders.Accept, "not-a-media-type") - setBody(McpJson.encodeToString(JSONRPCMessage.serializer(), buildInitializeRequestPayload())) - } - - // The exact code is decided by content negotiation once the transport has rejected the request. - assertTrue(response.status.value in 400..499, "Expected a client error, got ${response.status}") - assertFalse(onMessageCalled.get(), "Transport should not deliver messages when headers are invalid") - } - - @ParameterizedTest - @MethodSource("getAcceptHeaderCases") - fun `GET whose Accept header admits no event-stream range is rejected`(acceptHeader: String) = testApplication { - configTestServer() - - val client = createClient {} - - val transport = StreamableHttpServerTransport(enableJsonResponse = true) - - application { - routing { - get(path) { - transport.handleGetRequest(FakeServerSSESession(call, call.coroutineContext), call) - } - } - } - - val response = client.get(path) { - header(HttpHeaders.Accept, acceptHeader) - } - - assertEquals(HttpStatusCode.NotAcceptable, response.status) - } - @Test fun `initialization request establishes session and returns json response`() = testApplication { configTestServer() @@ -272,7 +139,7 @@ class StreamableHttpServerTransportTest { } @Test - fun `second initialization request returns JSON-RPC error with request id`() = testApplication { + fun `second initialization request returns an HTTP error`() = testApplication { configTestServer() val client = createTestClient() @@ -295,74 +162,13 @@ class StreamableHttpServerTransportTest { firstResponse.status shouldBe HttpStatusCode.OK - val secondRequest = buildInitializeRequestPayload().copy(id = RequestId("second-init")) val secondResponse = client.post(path) { addStreamableHeaders() header("mcp-session-id", firstResponse.headers[MCP_SESSION_ID_HEADER]) - setBody(secondRequest) - } - - 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 + secondResponse.status shouldBe HttpStatusCode.BadRequest } @Test @@ -380,15 +186,18 @@ class StreamableHttpServerTransportTest { configureTransportEndpoint(transport) + val initPayload = buildInitializeRequestPayload() val initResponse = client.post(path) { addStreamableHeaders() - setBody(buildInitializeRequestPayload()) + setBody(initPayload) } 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) @@ -406,65 +215,6 @@ class StreamableHttpServerTransportTest { } response.status shouldBe HttpStatusCode.BadRequest - response.body().id shouldBe RequestId("test-1") - } - - @Test - fun `batch with an initialization request echoes the initialize id when rejected`() = testApplication { - configTestServer() - - val client = createTestClient() - - val transport = StreamableHttpServerTransport(enableJsonResponse = true) - transport.onMessage { } - - configureTransportEndpoint(transport) - - val initPayload = buildInitializeRequestPayload() - val response = client.post(path) { - addStreamableHeaders() - setBody( - encodeMessages( - listOf( - initPayload, - JSONRPCRequest(id = RequestId("extra"), method = Method.Defined.ToolsList.value), - ), - ), - ) - } - - response.status shouldBe HttpStatusCode.BadRequest - val error = response.body() - error.error.message shouldBe "Invalid Request: Only one initialization request is allowed" - error.id shouldBe initPayload.id - } - - @Test - fun `non-init request before initialization echoes the request id`() = testApplication { - configTestServer() - - val client = createTestClient() - - val transport = StreamableHttpServerTransport(enableJsonResponse = true) - transport.onMessage { } - - configureTransportEndpoint(transport) - - val response = client.post(path) { - addStreamableHeaders() - setBody( - encodeMessages( - listOf( - JSONRPCRequest(id = RequestId("before-init"), method = Method.Defined.ToolsList.value), - ), - ), - ) - } - - response.status shouldBe HttpStatusCode.BadRequest - val error = response.body() - error.error.message shouldBe "Bad Request: Server not initialized" - error.id shouldBe RequestId("before-init") } @Test @@ -570,306 +320,6 @@ class StreamableHttpServerTransportTest { assertEquals("second", secondMeta?.get("label")?.jsonPrimitive?.content)*/ } - @Test - fun `json response waits for a response produced after message delivery returns`() = testApplication { - configTestServer() - - val client = createTestClient() - - val transport = StreamableHttpServerTransport( - StreamableHttpServerTransport.Configuration(enableJsonResponse = true), - ) - // Simulates concurrent dispatch: message delivery returns before the handler responds. - val handlerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - try { - transport.onMessage { message -> - if (message is JSONRPCRequest) { - if (message.method == Method.Defined.Initialize.value) { - transport.send(JSONRPCResponse(message.id, EmptyResult())) - } else { - handlerScope.launch { - delay(100) - transport.send(JSONRPCResponse(message.id, EmptyResult())) - } - } - } - } - - configureTransportEndpoint(transport) - - val initResponse = client.post(path) { - addStreamableHeaders() - setBody(buildInitializeRequestPayload()) - } - initResponse.status shouldBe HttpStatusCode.OK - - val request = JSONRPCRequest(id = RequestId("async-1"), method = Method.Defined.ToolsList.value) - val response = withTimeout(10.seconds) { - client.post(path) { - addStreamableHeaders() - header(MCP_SESSION_ID_HEADER, initResponse.headers[MCP_SESSION_ID_HEADER]) - setBody(encodeMessages(listOf(request))) - } - } - - response.status shouldBe HttpStatusCode.OK - response.body() shouldBe JSONRPCResponse(request.id) - } finally { - handlerScope.cancel() - } - } - - @Test - fun `json response for a batch waits until every asynchronously produced response is ready`() = testApplication { - configTestServer() - - val client = createTestClient() - - val transport = StreamableHttpServerTransport( - StreamableHttpServerTransport.Configuration(enableJsonResponse = true), - ) - val firstRequest = JSONRPCRequest(id = RequestId("slow"), method = Method.Defined.ToolsList.value) - val secondRequest = JSONRPCRequest(id = RequestId("fast"), method = Method.Defined.ResourcesList.value) - - // Simulates concurrent dispatch with out-of-order completion: the second request responds - // first; the POST must stay open until the slower first response settles too. - val handlerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - try { - transport.onMessage { message -> - if (message is JSONRPCRequest) { - if (message.method == Method.Defined.Initialize.value) { - transport.send(JSONRPCResponse(message.id, EmptyResult())) - } else { - handlerScope.launch { - delay(if (message.id == firstRequest.id) 200 else 50) - transport.send(JSONRPCResponse(message.id, EmptyResult())) - } - } - } - } - - configureTransportEndpoint(transport) - - val initResponse = client.post(path) { - addStreamableHeaders() - setBody(buildInitializeRequestPayload()) - } - initResponse.status shouldBe HttpStatusCode.OK - - val response = withTimeout(10.seconds) { - client.post(path) { - addStreamableHeaders() - header(MCP_SESSION_ID_HEADER, initResponse.headers[MCP_SESSION_ID_HEADER]) - setBody(encodeMessages(listOf(firstRequest, secondRequest))) - } - } - - response.status shouldBe HttpStatusCode.OK - val responses = response.body>() - responses.map { it.id }.toSet() shouldBe setOf(firstRequest.id, secondRequest.id) - } finally { - handlerScope.cancel() - } - } - - @Test - fun `json response POST completes with 202 when its only request is cancelled`() = testApplication { - configTestServer() - - val client = createTestClient() - - val transport = StreamableHttpServerTransport( - StreamableHttpServerTransport.Configuration(enableJsonResponse = true), - ) - val requestDelivered = CompletableDeferred() - // Concurrent dispatch: delivery returns before a response is produced. This request is never - // answered here, modelling a handler cancelled by notifications/cancelled. - transport.onMessage { message -> - if (message is JSONRPCRequest) { - if (message.method == Method.Defined.Initialize.value) { - transport.send(JSONRPCResponse(message.id, EmptyResult())) - } else { - requestDelivered.complete(Unit) - } - } - } - - configureTransportEndpoint(transport) - - val clientScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - try { - val initResponse = client.post(path) { - addStreamableHeaders() - setBody(buildInitializeRequestPayload()) - } - initResponse.status shouldBe HttpStatusCode.OK - val sessionId = initResponse.headers[MCP_SESSION_ID_HEADER] - - val request = JSONRPCRequest(id = RequestId("cancel-me"), method = Method.Defined.ToolsList.value) - val requestPost = clientScope.async { - client.post(path) { - addStreamableHeaders() - header(MCP_SESSION_ID_HEADER, sessionId) - setBody(encodeMessages(listOf(request))) - } - } - - // The request must be registered in-flight before it can be cancelled. - withTimeout(5.seconds) { requestDelivered.await() } - - val cancellation = CancelledNotification( - CancelledNotificationParams(requestId = request.id, reason = "client cancelled"), - ) - val cancelResponse = client.post(path) { - addStreamableHeaders() - header(MCP_SESSION_ID_HEADER, sessionId) - setBody(encodeMessages(listOf(cancellation.toJSON()))) - } - cancelResponse.status shouldBe HttpStatusCode.Accepted - - // Without retirement of the cancelled id this await never completes and the timeout fires. - val response = withTimeout(10.seconds) { requestPost.await() } - response.status shouldBe HttpStatusCode.Accepted - } finally { - clientScope.cancel() - } - } - - @Test - fun `json response for a batch returns only the non-cancelled responses`() = testApplication { - configTestServer() - - val client = createTestClient() - - val transport = StreamableHttpServerTransport( - StreamableHttpServerTransport.Configuration(enableJsonResponse = true), - ) - val answeredA = JSONRPCRequest(id = RequestId("a"), method = Method.Defined.ToolsList.value) - val answeredB = JSONRPCRequest(id = RequestId("b"), method = Method.Defined.ToolsList.value) - val cancelledC = JSONRPCRequest(id = RequestId("c"), method = Method.Defined.ToolsList.value) - - val cancelledDelivered = CompletableDeferred() - val handlerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - try { - transport.onMessage { message -> - if (message is JSONRPCRequest) { - when { - message.method == Method.Defined.Initialize.value -> - transport.send(JSONRPCResponse(message.id, EmptyResult())) - - message.id == answeredA.id || message.id == answeredB.id -> - handlerScope.launch { transport.send(JSONRPCResponse(message.id, EmptyResult())) } - - message.id == cancelledC.id -> cancelledDelivered.complete(Unit) - } - } - } - - configureTransportEndpoint(transport) - - val initResponse = client.post(path) { - addStreamableHeaders() - setBody(buildInitializeRequestPayload()) - } - initResponse.status shouldBe HttpStatusCode.OK - val sessionId = initResponse.headers[MCP_SESSION_ID_HEADER] - - val batchPost = handlerScope.async { - client.post(path) { - addStreamableHeaders() - header(MCP_SESSION_ID_HEADER, sessionId) - setBody(encodeMessages(listOf(answeredA, answeredB, cancelledC))) - } - } - - withTimeout(5.seconds) { cancelledDelivered.await() } - - val cancellation = CancelledNotification(CancelledNotificationParams(requestId = cancelledC.id)) - client.post(path) { - addStreamableHeaders() - header(MCP_SESSION_ID_HEADER, sessionId) - setBody(encodeMessages(listOf(cancellation.toJSON()))) - }.status shouldBe HttpStatusCode.Accepted - - val response = withTimeout(10.seconds) { batchPost.await() } - response.status shouldBe HttpStatusCode.OK - val responses = response.body>() - responses.map { it.id }.toSet() shouldBe setOf(answeredA.id, answeredB.id) - } finally { - handlerScope.cancel() - } - } - - @Test - fun `json response send for a retired request id is dropped without error`() = runTest { - val transport = StreamableHttpServerTransport(enableJsonResponse = true) - // No stream is mapped for this id (already retired by disconnect/cancellation cleanup); - // a late terminal response must be dropped quietly, not raise "No connection established". - transport.send(JSONRPCResponse(RequestId("retired"), EmptyResult())) - } - - @Test - fun `sse response send for an unknown request id still errors`() = runTest { - val transport = StreamableHttpServerTransport(enableJsonResponse = false) - assertFailsWith { - transport.send(JSONRPCResponse(RequestId("unknown"), EmptyResult())) - } - } - - @Test - fun `sse terminal response is dropped without leaking when the per-request stream is gone`() = testApplication { - configTestServer() - val client = createTestClient() - - val transport = StreamableHttpServerTransport( - StreamableHttpServerTransport.Configuration(enableJsonResponse = false), - ) - transport.setSessionIdGenerator(null) // stateless: accept requests without an init handshake - val requestId = RequestId("held-request") - val delivered = CompletableDeferred() - val release = CompletableDeferred() - transport.onMessage { message -> - if (message is JSONRPCRequest) { - delivered.complete(Unit) - release.await() // keep the POST and its stream mapping alive until the test releases it - } - } - - application { - routing { - post(path) { - transport.handlePostRequest(FakeServerSSESession(call, call.coroutineContext), call) - } - } - } - - val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - try { - val post = scope.async { - client.post(path) { - addStreamableHeaders() - setBody( - encodeMessages( - listOf(JSONRPCRequest(id = requestId, method = Method.Defined.ToolsList.value)), - ), - ) - } - } - withTimeout(5.seconds) { delivered.await() } - - // Drop the per-request SSE stream while the request is still mapped in-flight; the handler's - // terminal response is now undeliverable and must be dropped quietly rather than raise - // "No connection established" (which would also strand the request mappings). - transport.closeSseStream(requestId) - transport.send(JSONRPCResponse(requestId, EmptyResult())) - - post.cancel() - } finally { - release.complete(Unit) - scope.cancel() - } - } - @ParameterizedTest @MethodSource("invalidPayloads") fun `POST with a null or empty body returns an error`(payload: String?) = testApplication { @@ -944,34 +394,6 @@ class StreamableHttpServerTransportTest { response.status shouldBe HttpStatusCode.PayloadTooLarge } - @Test - fun `POST with oversized chunked body and no Content-Length returns 413`() = testApplication { - configTestServer() - - val client = createTestClient() - - val transport = StreamableHttpServerTransport( - StreamableHttpServerTransport.Configuration(enableJsonResponse = true, maxRequestBodySize = 1024), - ) - transport.onMessage { message -> - if (message is JSONRPCRequest) { - transport.send(JSONRPCResponse(message.id, EmptyResult())) - } - } - - configureTransportEndpoint(transport) - - // Stream the body as a channel so the client uses chunked transfer-encoding with no - // Content-Length header: the size limit must hold without trusting the (absent) header. - val oversized = ByteReadChannel("x".repeat(4096).encodeToByteArray()) - val response = client.post(path) { - addStreamableHeaders() - setBody(oversized) - } - - response.status shouldBe HttpStatusCode.PayloadTooLarge - } - @ParameterizedTest @MethodSource("maxBodySizeTestCases") fun `POST with custom max request body size validates payload size`( @@ -1011,156 +433,6 @@ 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" - - 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 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) - }.execute { response -> - // Verify Mcp-Session-Id is present on the SSE response - response.status shouldBe HttpStatusCode.OK - response.headers[MCP_SESSION_ID_HEADER] shouldBe sessionId - - // Verify the stream is alive by reading at least one line (flush event) - val channel = response.bodyAsChannel() - val firstLine = channel.readLine() - firstLine.shouldNotBeNull() - channel.isClosedForRead shouldBe false - } - } - private fun ApplicationTestBuilder.configureTransportEndpoint(transport: StreamableHttpServerTransport) { application { routing { @@ -1215,12 +487,3 @@ class StreamableHttpServerTransportTest { } } } - -/** No-op [ServerSSESession] double that lets a POST route drive [StreamableHttpServerTransport] in SSE mode. */ -private class FakeServerSSESession( - override val call: ApplicationCall, - override val coroutineContext: CoroutineContext, -) : ServerSSESession { - override suspend fun send(event: ServerSentEvent) {} - override suspend fun close() {} -} diff --git a/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 deleted file mode 100644 index f6760ee..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/utils/ToolNameValidationTest.kt +++ /dev/null @@ -1,123 +0,0 @@ -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 bbb0388..902665c 100644 --- a/kotlin-sdk-testing/build.gradle.kts +++ b/kotlin-sdk-testing/build.gradle.kts @@ -4,7 +4,6 @@ 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) @@ -15,8 +14,10 @@ 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 ae5f487..e6d19f7 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,8 +27,9 @@ 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. * - * @param sendChannel The channel used to send JSON-RPC messages. - * @param receiveChannel The channel used to receive JSON-RPC messages. + * @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 dispatcher The coroutine dispatcher for the event loop. Defaults to [Dispatchers.Default]. */ @ExperimentalMcpApi @@ -74,19 +75,11 @@ public class ChannelTransport( /** * Creates a pair of interconnected [ChannelTransport] objects for bidirectional communication. * - * 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) - * ``` + * 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. * * @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( @@ -113,6 +106,7 @@ 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 4aae932..7fc41bb 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,6 +186,7 @@ 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 13bf272..59faee4 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,3 +1,25 @@ -package io.modelcontextprotocol.kotlin.sdk +@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. + */ -private class Stub +package io.modelcontextprotocol.kotlin.sdk diff --git a/samples/README.md b/samples/README.md index f831a74..9789378 100644 --- a/samples/README.md +++ b/samples/README.md @@ -9,7 +9,6 @@ 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 | @@ -31,13 +30,6 @@ 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 b58eef2..38cb588 100644 --- a/samples/kotlin-mcp-client/gradle/libs.versions.toml +++ b/samples/kotlin-mcp-client/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] -anthropic = "2.62.0" -kotlin = "2.4.20" -ktor = "3.5.2" -mcp-kotlin = "0.15.0" -shadow = "9.6.1" -slf4j = "2.0.19" +anthropic = "2.18.0" +kotlin = "2.2.21" +ktor = "3.2.3" +mcp-kotlin = "0.10.0" +shadow = "9.4.1" +slf4j = "2.0.17" [libraries] anthropic-java = { group = "com.anthropic", name = "anthropic-java", version.ref = "anthropic" } diff --git a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.jar b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.jar index eddabd2..d997cfc 100644 Binary files a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.jar and b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties index ad7845b..c61a118 100644 --- a/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties +++ b/samples/kotlin-mcp-client/gradle/wrapper/gradle-wrapper.properties @@ -1,9 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.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 249efbb..739907d 100755 --- a/samples/kotlin-mcp-client/gradlew +++ b/samples/kotlin-mcp-client/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# gradlew start up script for POSIX generated by Gradle. +# Gradle start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh gradlew +# ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # 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 +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/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 8508ef6..e509b2d 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 gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal 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 -"%COMSPEC%" /c exit 1 +goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,18 +65,29 @@ 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 +goto fail :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 +@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" %* -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +: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 diff --git a/samples/kotlin-mcp-server/gradle/libs.versions.toml b/samples/kotlin-mcp-server/gradle/libs.versions.toml index d46c2f1..1c59a88 100644 --- a/samples/kotlin-mcp-server/gradle/libs.versions.toml +++ b/samples/kotlin-mcp-server/gradle/libs.versions.toml @@ -1,9 +1,9 @@ [versions] -kotlin = "2.4.10" -ktor = "3.5.2" -mcp-kotlin = "0.15.0" -slf4j = "2.0.19" -shadow = "9.6.1" +kotlin = "2.2.21" +ktor = "3.3.3" +mcp-kotlin = "0.10.0" +slf4j = "2.0.17" +shadow = "9.4.1" [libraries] ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } diff --git a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.jar b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.jar index eddabd2..d997cfc 100644 Binary files a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.jar and b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties index ad7845b..c61a118 100644 --- a/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/kotlin-mcp-server/gradle/wrapper/gradle-wrapper.properties @@ -1,9 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.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 249efbb..739907d 100755 --- a/samples/kotlin-mcp-server/gradlew +++ b/samples/kotlin-mcp-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# gradlew start up script for POSIX generated by Gradle. +# Gradle start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh gradlew +# ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # 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 +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/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 8508ef6..e509b2d 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 gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal 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 -"%COMSPEC%" /c exit 1 +goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,18 +65,29 @@ 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 +goto fail :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 +@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" %* -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +: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 diff --git a/samples/kotlinlang-mcp-server/Dockerfile b/samples/kotlinlang-mcp-server/Dockerfile deleted file mode 100644 index ada7e56..0000000 --- a/samples/kotlinlang-mcp-server/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index df0dd3e..0000000 --- a/samples/kotlinlang-mcp-server/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# 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 deleted file mode 100644 index f46f006..0000000 --- a/samples/kotlinlang-mcp-server/build.gradle.kts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 24a5976..0000000 --- a/samples/kotlinlang-mcp-server/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 0c11d3b..0000000 --- a/samples/kotlinlang-mcp-server/gradle/libs.versions.toml +++ /dev/null @@ -1,31 +0,0 @@ -[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 deleted file mode 100644 index eddabd2..0000000 Binary files a/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties b/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index ad7845b..0000000 --- a/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100755 index 249efbb..0000000 --- a/samples/kotlinlang-mcp-server/gradlew +++ /dev/null @@ -1,248 +0,0 @@ -#!/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 deleted file mode 100644 index 8508ef6..0000000 --- a/samples/kotlinlang-mcp-server/gradlew.bat +++ /dev/null @@ -1,82 +0,0 @@ -@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 deleted file mode 100644 index bf867e0..0000000 --- a/samples/kotlinlang-mcp-server/settings.gradle.kts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index e50b1ad..0000000 --- a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/Application.kt +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 0814342..0000000 --- a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/KotlinlangServer.kt +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index 0d0db42..0000000 --- a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClient.kt +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 88c465d..0000000 --- a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModels.kt +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 7ba8de1..0000000 --- a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/cache/TtlCache.kt +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 0c61e13..0000000 --- a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/config/ServerConfig.kt +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index a8495b2..0000000 --- a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/PageFetcher.kt +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index ab0a8ff..0000000 --- a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/UrlMapper.kt +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index d4a5097..0000000 --- a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPage.kt +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 886f3f7..0000000 --- a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlang.kt +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 4908da5..0000000 --- a/samples/kotlinlang-mcp-server/src/main/resources/application.conf +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 0ffe8a3..0000000 --- a/samples/kotlinlang-mcp-server/src/main/resources/logback.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - %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 deleted file mode 100644 index c6786fb..0000000 --- a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/KotlinlangServerTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 4f9dff6..0000000 --- a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClientTest.kt +++ /dev/null @@ -1,166 +0,0 @@ -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 deleted file mode 100644 index 10dbe5b..0000000 --- a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModelsTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index a191fe7..0000000 --- a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/cache/TtlCacheTest.kt +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index 5714602..0000000 --- a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/config/ServerConfigTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 1cd3e6b..0000000 --- a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/PageFetcherTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index c2076a3..0000000 --- a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/UrlMapperTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 18f0cfe..0000000 --- a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPageTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index 7e10420..0000000 --- a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlangTest.kt +++ /dev/null @@ -1,231 +0,0 @@ -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 5f35f74..a9fced7 100644 --- a/samples/simple-streamable-server/gradle/libs.versions.toml +++ b/samples/simple-streamable-server/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] -kotlin = "2.4.20" -ktor = "3.5.2" -junit = "6.1.3" -mcp-kotlin = "0.15.0" -slf4j = "2.0.19" -shadow = "9.6.1" +kotlin = "2.2.21" +ktor = "3.2.3" +junit = "6.0.3" +mcp-kotlin = "0.10.0" +slf4j = "2.0.17" +shadow = "9.4.1" [libraries] ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } diff --git a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar index eddabd2..d997cfc 100644 Binary files a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar and b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties index ad7845b..c61a118 100644 --- a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties @@ -1,9 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.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 249efbb..739907d 100755 --- a/samples/simple-streamable-server/gradlew +++ b/samples/simple-streamable-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# gradlew start up script for POSIX generated by Gradle. +# Gradle start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh gradlew +# ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # 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 +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/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 8508ef6..e509b2d 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 gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal 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 -"%COMSPEC%" /c exit 1 +goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,18 +65,29 @@ 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 +goto fail :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 +@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" %* -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +: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 diff --git a/samples/weather-stdio-server/gradle/libs.versions.toml b/samples/weather-stdio-server/gradle/libs.versions.toml index 9d609a9..0b75f09 100644 --- a/samples/weather-stdio-server/gradle/libs.versions.toml +++ b/samples/weather-stdio-server/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] -coroutines = "1.11.0" -kotlin = "2.4.20" -ktor = "3.5.2" -logging = "8.0.4" -mcp-kotlin = "0.15.0" -shadow = "9.6.1" -slf4j = "2.0.19" +coroutines = "1.10.2" +kotlin = "2.2.21" +ktor = "3.2.3" +logging = "8.0.01" +mcp-kotlin = "0.10.0" +shadow = "9.4.1" +slf4j = "2.0.17" [libraries] kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging-jvm", version.ref = "logging" } diff --git a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar index eddabd2..d997cfc 100644 Binary files a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar and b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties index ad7845b..c61a118 100644 --- a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties +++ b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.properties @@ -1,9 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.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 249efbb..739907d 100755 --- a/samples/weather-stdio-server/gradlew +++ b/samples/weather-stdio-server/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# gradlew start up script for POSIX generated by Gradle. +# Gradle start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh gradlew +# ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # 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 +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/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 8508ef6..e509b2d 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 gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal 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 -"%COMSPEC%" /c exit 1 +goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,18 +65,29 @@ 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 +goto fail :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 +@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" %* -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +: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 diff --git a/test-utils/build.gradle.kts b/test-utils/build.gradle.kts index c6baf2d..91574e0 100644 --- a/test-utils/build.gradle.kts +++ b/test-utils/build.gradle.kts @@ -4,7 +4,6 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { id("mcp.multiplatform") - id("mcp.detekt") } kotlin { @@ -12,8 +11,10 @@ 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 deleted file mode 100644 index 431f3d9..0000000 --- a/test-utils/detekt-baseline-main.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - 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 new file mode 100644 index 0000000..5abd0b4 --- /dev/null +++ b/test-utils/detekt-baseline.xml @@ -0,0 +1,8 @@ + + + + + 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 1dcf585..a37e6e3 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,6 +16,7 @@ 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 ff9292e..e7f5798 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,6 +18,7 @@ public object TypeScriptRunner { * @param logPrefix Prefix for log lines * @return The started Process */ + @Suppress("LongParameterList") public fun run( typescriptDir: File, scriptPath: String, @@ -53,6 +54,7 @@ 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 a80c825..71fcfa6 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,6 +14,7 @@ import kotlin.time.Duration.Companion.seconds */ public fun killProcessOnPort(port: Int) { val killCommand = if (isWindows) { + @Suppress("MaxLineLength") listOf( "cmd.exe", "/c",