diff --git a/.claude/skills/kdoc/SKILL.md b/.claude/skills/kdoc/SKILL.md deleted file mode 100644 index cfacfc3..0000000 --- a/.claude/skills/kdoc/SKILL.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -name: kdoc -description: "Add KDoc documentation to Kotlin public API. Use whenever the user asks to document Kotlin code, add KDoc, generate API docs, mentions undocumented public declarations, or wants to improve existing documentation. Also trigger when the user says 'add docs', 'document this class/file/module', 'write KDoc', or asks about missing documentation in Kotlin code." ---- - -# 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. - -## Tag placement - -| 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` | - -## Style - -- 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. - -## Examples - -**Class with mixed primary-constructor parameters:** - -````kotlin -/** - * Manages active client sessions and their lifecycle. - * - * @property maxSessions upper limit on concurrent sessions - * @param scope coroutine scope for session lifecycle tasks - * @param secret used internally for session token signing - */ -public class SessionManager( - public val maxSessions: Int, // public val → @property - scope: CoroutineScope, // no val/var → @param - private val secret: ByteArray, // private val → @param -) -```` - -**Body and interface properties (inline `/** */`):** - -````kotlin -/** Represents an entity that includes additional metadata. */ -public interface WithMeta { - /** Optional metadata attached to this entity. */ - public val meta: JsonObject? -} - -public class Connection(public val id: String) { - /** Capabilities negotiated during the handshake, or `null` before initialize completes. */ - public val capabilities: ServerCapabilities? get() = ... -} -```` - -**Function with Example block:** - -````kotlin -/** - * Registers a new tool with the given handler. - * - * Example: - * ```kotlin - * server.addTool(name = "echo", description = "Echoes input back") { request -> - * CallToolResult(content = listOf(TextContent("Echo: ${request.arguments}"))) - * } - * ``` - * - * @param name unique tool identifier - * @param description human-readable tool description - * @param handler suspend function invoked when the tool is called - * @return the registered tool definition - */ -```` diff --git a/.claude/skills/mcp-docs/SKILL.md b/.claude/skills/mcp-docs/SKILL.md deleted file mode 100644 index 074e6f9..0000000 --- a/.claude/skills/mcp-docs/SKILL.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -name: mcp-docs -description: > - Fetch live MCP specification and docs from modelcontextprotocol.io. Training data may be - outdated — default to using this skill when the query touches MCP protocol in any way. - Trigger for: implementing or debugging MCP features (sampling, elicitation, completion, tools, - resources, prompts, transports); SEPs or spec requirements; MCP transport behavior (SSE, - Streamable HTTP, reconnection, retry); authorization/OAuth; conformance test failures; - capability negotiation; message schemas. Skip only for pure Kotlin, build system, or - refactoring tasks with zero MCP protocol dependency. When in doubt, trigger. -user_invocable: true ---- - -# MCP Documentation Skill - -Provides access to the official MCP specification and docs from modelcontextprotocol.io. The spec evolves faster than training data — always consult live documentation for protocol specifics like message schemas, required fields, capability negotiation, error handling, and transport requirements. - -## When to use - -**Proactively** (without being asked): -- Implementing a feature defined in the MCP spec -- Reviewing code that implements MCP protocol behavior -- Fixing conformance test failures - -**On demand** (user asks or invokes `/mcp-docs`): -- Questions about how a specific MCP feature works -- Clarifying spec ambiguities -- Looking up SEPs (Specification Enhancement Proposals) - -## How to fetch documentation - -### Strategy: MCP tool first, llms.txt fallback - -**Step 1 — Try the MCP search tool.** If `search_model_context_protocol` is available, use it: - -``` -search_model_context_protocol(query: "") -``` - -One call, semantically ranked results. Fastest path. - -**Step 2 — Fallback to llms.txt + WebFetch.** If the MCP tool is not available: - -1. Fetch the documentation index: - ``` - WebFetch("https://modelcontextprotocol.io/llms.txt", "Return the full content as-is") - ``` - -2. Identify the relevant page URL(s) from the index. Key sections: - - **Specification**: `specification/...` — protocol details, message formats, transports, lifecycle - - **SEPs**: `seps/...` — specification enhancement proposals - - **Learning**: `docs/learn/...` — architecture, concepts - - **Development**: `docs/develop/...` — building clients/servers - -3. Fetch the specific page: - ``` - WebFetch("", "Return the full content of this documentation page") - ``` - -### Version handling - -Always use the latest spec version by default. Only use a specific version or draft when the user explicitly requests it (e.g., "check the 2025-03-26 spec" or "what does the draft say"). - -### Search guidance - -- Protocol message formats, required fields, capability negotiation → **Specification** section -- Feature motivation, design rationale → relevant **SEP** -- High-level concepts and architecture → **Learning** resources -- Use your judgment on query count. A field-level question may need one page; implementing a feature may need 2-3. - -## Presenting information - -- **Always cite sources.** Include links to spec pages used: `(source: )` -- **Spec over training data** for specific protocol details (schemas, fields, error codes, capabilities). Training data is fine for general MCP concepts. -- **Preserve RFC 2119 language.** The spec uses MUST/SHOULD/MAY — keep this distinction when explaining requirements. -- **Be concise.** Focus on the details relevant to the user's task. Avoid dumping entire spec pages — extract what matters for the specific question or implementation at hand. diff --git a/.editorconfig b/.editorconfig index d8964b9..5c283ed 100644 --- a/.editorconfig +++ b/.editorconfig @@ -30,7 +30,6 @@ ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equa ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 4 ktlint_standard_chain-method-continuation = disabled ktlint_ignore_back_ticked_identifier = true -ktlint_standard_backing-property-naming = disabled ktlint_standard_multiline-expression-wrapping = disabled ktlint_standard_when-entry-bracing = disabled diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b951eaa..8d02aac 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,109 +1,20 @@ -# yaml-language-server: $schema=https://json.schemastore.org/dependabot-2.0.json -# Configuration: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# Configuration options: +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#about-the-dependabotyml-file version: 2 - updates: - # Root Gradle project (includes buildSrc) - package-ecosystem: "gradle" directory: "/" schedule: interval: "weekly" - day: "monday" - commit-message: - prefix: "chore(deps):" labels: - "dependencies" - "kotlin" - open-pull-requests-limit: 10 - groups: - ktor: - patterns: - - "io.ktor:*" - kotlinx: - patterns: - - "org.jetbrains.kotlinx:*" - kotlin: - patterns: - - "org.jetbrains.kotlin:*" - kotest: - patterns: - - "io.kotest:*" - testing: - patterns: - - "io.mockk:*" - - "dev.mokksy:*" - - "org.awaitility:*" - - "org.junit.jupiter:*" - infrastructure: - patterns: - - "io.netty:*" - - "io.github.oshai:*" - - "org.slf4j:*" - gradle-plugins: - patterns: - - "dev.detekt" - - "org.jetbrains.kotlinx.kover" - - "org.jlleitschuh.gradle.ktlint" - - "org.jetbrains.kotlinx.knit" - - "org.jetbrains.kotlinx.binary-compatibility-validator" - - "org.openapi.generator" - - "org.jetbrains.dokka:*" - - "com.vanniktech:*" - # Samples (independent Gradle builds) - - package-ecosystem: "gradle" - directories: - - "/samples/*" - schedule: - interval: "weekly" - day: "monday" - commit-message: - prefix: "chore(deps):" - labels: - - "dependencies" - - "kotlin" - - "samples" - open-pull-requests-limit: 10 - groups: - other-dependencies: - patterns: - - "*" - exclude-patterns: - - "io.ktor*" - - "org.jetbrains.kotlin*" - - "io.modelcontextprotocol*" - - # npm (integration tests) - - package-ecosystem: "npm" - directory: "/integration-test/src/jvmTest/typescript" - schedule: - interval: "weekly" - day: "monday" - commit-message: - prefix: "chore(deps):" - labels: - - "dependencies" - - "javascript" - open-pull-requests-limit: 10 - groups: - all-dependencies: - patterns: - - "*" - - # GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - day: "monday" - commit-message: - prefix: "chore(deps):" labels: - "dependencies" - "github-actions" - open-pull-requests-limit: 10 - groups: - all-actions: - patterns: - - "*" diff --git a/.github/workflows/apidocs.yaml b/.github/workflows/apidocs.yaml index 67770a7..603ab3a 100644 --- a/.github/workflows/apidocs.yaml +++ b/.github/workflows/apidocs.yaml @@ -7,10 +7,6 @@ on: # Allow running this workflow manually from the Actions tab workflow_dispatch: -env: - JAVA_VERSION: 21 - JAVA_DISTRIBUTION: temurin - jobs: deploy: @@ -24,27 +20,30 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 + - uses: actions/checkout@v6 with: - java-version: ${{ env.JAVA_VERSION }} - distribution: ${{ env.JAVA_DISTRIBUTION }} + submodules: true # Fetch Hugo themes (true OR recursive) + fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' cache: gradle - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v5 - name: Generate Dokka Site run: |- - ./gradlew knit docs:classes docs:knitCheck dokkaGenerate + ./gradlew clean dokkaGenerate - name: Upload artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@v4 with: path: build/dokka/html - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/automations.yml b/.github/workflows/automations.yml deleted file mode 100644 index 60a9d3b..0000000 --- a/.github/workflows/automations.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: Automations - -on: - schedule: - # Runs every day at 2:00 AM UTC - - cron: '0 2 * * *' - workflow_dispatch: - inputs: - DRY_RUN: - type: boolean - description: 'Run in dry run mode' - default: true - -permissions: - actions: write - # contents: write # only for delete-branch option - issues: write - pull-requests: write - -env: - DRY_RUN: true - MOVE_TO_STALE_DAYS: 30 - CLOSE_AFTER_STALE_DAYS: 15 - -jobs: - mark-stale: - name: Mark/unmark stale PRs and issues - runs-on: ubuntu-latest - steps: - - uses: actions/stale@v11 - with: - debug-only: ${{ env.DRY_RUN }} - exempt-draft-pr: true - exempt-issue-labels: 'awaiting-approval,work-in-progress' - stale-issue-label: 'stale' - stale-pr-label: 'stale' - - # --- Timing --- - # Job 1 marks/unmarks, but never closes. - days-before-issue-stale: ${{ env.MOVE_TO_STALE_DAYS }} - days-before-issue-close: -1 - days-before-pr-stale: ${{ env.MOVE_TO_STALE_DAYS }} - days-before-pr-close: -1 - - remove-stale-when-updated: true - remove-pr-stale-when-updated: true - - # --- Messages --- - stale-issue-message: > - 👋 **This issue has been marked as Stale.** - - This issue has had no activity for **${{ env.MOVE_TO_STALE_DAYS }} days**. - - — 🤖 Stale Bot - stale-pr-message: > - 👋 **This PR has been marked as Stale.** - - This PR has had no activity for **${{ env.MOVE_TO_STALE_DAYS }} days**. - - — 🤖 Stale Bot - - operations-per-run: 100 - ascending: true - - close-stale: - name: Close stale PRs and issues - runs-on: ubuntu-latest - steps: - - uses: actions/stale@v11 - with: - debug-only: ${{ env.DRY_RUN }} - only-labels: 'stale' - stale-issue-label: 'stale' - stale-pr-label: 'stale' - - # --- Timing --- - # Job 2 only closes already marked items. - days-before-issue-stale: -1 - days-before-pr-stale: -1 - days-before-issue-close: ${{ env.CLOSE_AFTER_STALE_DAYS }} - days-before-pr-close: ${{ env.CLOSE_AFTER_STALE_DAYS }} - - # --- Messages --- - close-issue-message: > - 👋 **This issue has been automatically closed.** - - This issue was labeled with `stale` and has had no activity - for **${{ env.CLOSE_AFTER_STALE_DAYS }} days** since that. - - — 🤖 Stale Bot - close-pr-message: > - 👋 **This PR has been automatically closed.** - - This PR was labeled with `stale` and has had no activity - for **${{ env.CLOSE_AFTER_STALE_DAYS }} days** since that. - - If this is still relevant, please feel free to reopen it - or create a new PR. - - — 🤖 Stale Bot - - operations-per-run: 100 - ascending: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ff99581..81af8ac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,12 +13,14 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: - contents: read + checks: write + pull-requests: write # only required if `comment: true` was enabled env: JAVA_VERSION: 21 JAVA_DISTRIBUTION: temurin - GRADLE_OPTS: "-Dorg.gradle.daemon=false" + JAVA_OPTS: "-Dfile.encoding=UTF-8 -Djava.awt.headless=true" + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dkotlin.compiler.execution.strategy=in-process" jobs: build: @@ -34,26 +36,31 @@ jobs: include: - os: ubuntu-latest job-name: "Linux Tests" - gradle-tasks: "build" + gradle-tasks: "build ktlintCheck koverLog koverHtmlReport publishToMavenLocal -Pversion=1-SNAPSHOT" + max-workers: 3 + + - os: windows-latest + job-name: "Windows Tests" + gradle-tasks: "jvmTest -x :integration-test:jvmTest" max-workers: 3 - os: macos-latest job-name: "iOS Simulator tests" - gradle-tasks: "iosSimulatorArm64Test" + gradle-tasks: "linkIosSimulatorArm64 iosSimulatorArm64Test" 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,31 +70,25 @@ jobs: run: npm ci - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v5 with: validate-wrappers: true add-job-summary: 'always' cache-read-only: ${{ github.ref != 'refs/heads/main' }} + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} gradle-home-cache-includes: | caches notifications + sdks ../.konan/** - name: ${{ matrix.job-name }} run: |- - ./gradlew ${{ matrix.gradle-tasks }} --max-workers ${{ matrix.max-workers }} --continue --rerun-tasks - - - name: Coverage Report - if: ${{ success() && matrix.os == 'ubuntu-latest' }} - run: ./gradlew koverLog koverHtmlReport koverXmlReport - - - name: Publish to Maven Local - if: ${{ success() && matrix.os == 'ubuntu-latest' }} - run: ./gradlew publishToMavenLocal -Pversion=1-SNAPSHOT + ./gradlew ${{ matrix.gradle-tasks }} -x :conformance-test:test --max-workers ${{ matrix.max-workers }} --continue --rerun-tasks --configure-on-demand --parallel - name: Upload Reports - if: failure() - uses: actions/upload-artifact@v7 + if: always() + uses: actions/upload-artifact@v6 with: name: reports-build-${{ matrix.os }} path: | @@ -99,85 +100,52 @@ jobs: with: annotate_only: true detailed_summary: true + flaky_summary: true group_suite: true include_empty_in_summary: false + include_time_in_summary: true report_paths: '**/test-results/**/TEST-*.xml' truncate_stack_traces: false - - name: Archive Maven Local Repository if: ${{ success() && matrix.os == 'ubuntu-latest' }} run: | - cd ~/.m2/repository/io/modelcontextprotocol && zip -r $GITHUB_WORKSPACE/local-maven-repository.zip . + cd ~/.m2/repository && zip -r $GITHUB_WORKSPACE/local-maven-repository.zip . - name: Upload Maven Local Repository if: ${{ success() && matrix.os == 'ubuntu-latest' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: local-maven-repository path: local-maven-repository.zip retention-days: 1 - knit: - runs-on: ubuntu-latest - name: Knit - timeout-minutes: 10 - permissions: - contents: read - - steps: - - uses: actions/checkout@v7 - - - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 - with: - java-version: ${{ env.JAVA_VERSION }} - distribution: ${{ env.JAVA_DISTRIBUTION }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 - with: - validate-wrappers: true - add-job-summary: 'always' - cache-read-only: ${{ github.ref != 'refs/heads/main' }} - gradle-home-cache-includes: | - caches - notifications - sdks - ../.konan/** - - - name: Run Knit - run: |- - ./gradlew knit docs:classes docs:knitCheck --max-workers 3 --continue --rerun-tasks - - samples: needs: [ build ] runs-on: ubuntu-latest - permissions: - contents: read strategy: fail-fast: false matrix: sample: - kotlin-mcp-client - kotlin-mcp-server - - simple-streamable-server - weather-stdio-server name: "Build Sample: ${{ matrix.sample }}" timeout-minutes: 10 + env: + JAVA_OPTS: "-Xmx8g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx6g" steps: - - uses: actions/checkout@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@v5 with: add-job-summary: 'always' cache-read-only: true @@ -188,15 +156,15 @@ jobs: ../.konan/** - name: Download Maven Local Repository - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v7 with: name: local-maven-repository path: . - name: Extract Maven Local Repository run: | - mkdir -p ~/.m2/repository/io/modelcontextprotocol - unzip -o local-maven-repository.zip -d ~/.m2/repository/io/modelcontextprotocol + mkdir -p ~/.m2/repository + unzip -o local-maven-repository.zip -d ~/.m2/repository - name: "Build Sample: ${{ matrix.sample }}" working-directory: ./samples/${{ matrix.sample }} @@ -204,7 +172,7 @@ jobs: - name: Upload Reports if: ${{ !cancelled() }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: reports-samples-snapshot-${{ matrix.sample }} path: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2e7c58f..fba2019 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,14 +1,16 @@ name: "CodeQL Advanced" on: + push: + branches: [ "main" ] pull_request: branches: [ "main" ] schedule: - - cron: '0 7 * * 1' # Monday 8:00 CET (7:00 UTC) + - cron: '0 4 * * 0' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: JAVA_VERSION: 21 @@ -16,42 +18,63 @@ env: jobs: analyze: - name: Analyze + name: Analyze (${{ matrix.language }}) runs-on: ubuntu-latest timeout-minutes: 30 permissions: + # required for all workflows security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read contents: read + strategy: + fail-fast: false + matrix: + language: [ java-kotlin ] + steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@v6 + with: + fetch-depth: 0 - 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 }} + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.38.0 + uses: github/codeql-action/init@v4 with: - languages: java-kotlin + languages: ${{ matrix.language }} build-mode: manual - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v5 with: validate-wrappers: true cache-read-only: true - name: Build Kotlin sources run: | - ./gradlew compileKotlinJvm :conformance-test:compileKotlin \ + ./gradlew \ + :kotlin-sdk-core:compileKotlinJvm \ + :kotlin-sdk-client:compileKotlinJvm \ + :kotlin-sdk-server:compileKotlinJvm \ + :kotlin-sdk:compileKotlinJvm \ + :integration-test:compileKotlinJvm \ + :conformance-test:testClasses \ -Pkotlin.incremental=false \ - --no-daemon --rerun-tasks --stacktrace + --no-daemon --stacktrace --rerun-tasks - name: Analyze - uses: github/codeql-action/analyze@v4.38.0 + uses: github/codeql-action/analyze@v4 with: - category: '/language:java-kotlin' + category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index b3e3935..2c7b1ef 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -7,166 +7,67 @@ on: push: branches: [ main ] -env: - JAVA_VERSION: '21' - JAVA_DISTRIBUTION: temurin - NODE_VERSION: '22' - - concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} # Cancel only when the run is NOT on `main` branch cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: - server: - runs-on: ubuntu-latest - name: Conformance Server Tests - timeout-minutes: 20 - - steps: - - uses: actions/checkout@v7 - - - name: Set up JDK - uses: actions/setup-java@v6 - with: - java-version: ${{ env.JAVA_VERSION }} - distribution: ${{ env.JAVA_DISTRIBUTION }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 - with: - cache-read-only: ${{ github.ref != 'refs/heads/main' }} - - - name: Build - run: ./gradlew :conformance-test:installDist - - - name: Start server - run: | - MCP_PORT=3001 conformance-test/build/install/conformance-test/bin/conformance-test & - for i in $(seq 1 30); do - if curl -s -o /dev/null http://localhost:3001/mcp; then - echo "Server is ready" - break - fi - sleep 1 - done - - - name: Run conformance tests - uses: modelcontextprotocol/conformance@v0.1.16 - with: - mode: server - url: http://localhost:3001/mcp - suite: active - node-version: ${{ env.NODE_VERSION }} - expected-failures: ./conformance-test/conformance-baseline.yml - - client: - runs-on: ubuntu-latest - name: "Conformance Client Tests: ${{ matrix.scenario }}" + run-conformance: + runs-on: ${{ matrix.os }} + name: Run Conformance Tests on ${{ matrix.os }} timeout-minutes: 20 + env: + JAVA_OPTS: "-Xmx8g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx6g" strategy: fail-fast: false matrix: - scenario: - - initialize - - tools_call - - elicitation-sep1034-client-defaults - - sse-retry + include: + - os: ubuntu-latest + max-workers: 3 + - os: windows-latest + max-workers: 3 + - os: macos-latest + max-workers: 2 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - - name: Set up JDK - uses: actions/setup-java@v6 + - name: Set up JDK 21 + uses: actions/setup-java@v5 with: - java-version: ${{ env.JAVA_VERSION }} - distribution: ${{ env.JAVA_DISTRIBUTION }} + java-version: '21' + distribution: 'temurin' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed + + - name: Setup Conformance Tests + working-directory: conformance-test + run: |- + npm install -g @modelcontextprotocol/conformance@0.1.8 - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 + uses: gradle/actions/setup-gradle@v5 with: + add-job-summary: 'always' cache-read-only: ${{ github.ref != 'refs/heads/main' }} + gradle-home-cache-includes: | + caches + notifications + sdks + ../.konan/** - - name: Build - run: ./gradlew :conformance-test:installDist + - name: Run Conformance Tests + run: |- + ./gradlew :conformance-test:test --no-daemon --max-workers ${{ matrix.max-workers }} - - name: Run conformance tests - uses: modelcontextprotocol/conformance@v0.1.16 + - name: Upload Conformance Results + if: always() + uses: actions/upload-artifact@v6 with: - mode: client - command: conformance-test/build/install/conformance-test/bin/conformance-client - scenario: ${{ matrix.scenario }} - node-version: ${{ env.NODE_VERSION }} - expected-failures: ./conformance-test/conformance-baseline.yml - - auth: - runs-on: ubuntu-latest - name: Conformance Auth Tests - timeout-minutes: 20 - - steps: - - uses: actions/checkout@v7 - - - name: Set up JDK - uses: actions/setup-java@v6 - with: - java-version: ${{ env.JAVA_VERSION }} - distribution: ${{ env.JAVA_DISTRIBUTION }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 - with: - cache-read-only: ${{ github.ref != 'refs/heads/main' }} - - - name: Build - run: ./gradlew :conformance-test:installDist - - - name: Run conformance tests - uses: modelcontextprotocol/conformance@v0.1.16 - with: - mode: client - command: conformance-test/build/install/conformance-test/bin/conformance-client - suite: auth - node-version: ${{ env.NODE_VERSION }} - expected-failures: ./conformance-test/conformance-baseline.yml - - auth-scenarios: - runs-on: ubuntu-latest - name: "Conformance Auth Scenario: ${{ matrix.scenario }}" - timeout-minutes: 20 - - strategy: - fail-fast: false - matrix: - scenario: - - auth/client-credentials-jwt - - auth/client-credentials-basic - - auth/cross-app-access-complete-flow - - steps: - - uses: actions/checkout@v7 - - - name: Set up JDK - uses: actions/setup-java@v6 - with: - java-version: ${{ env.JAVA_VERSION }} - distribution: ${{ env.JAVA_DISTRIBUTION }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6.3.0 - with: - cache-read-only: ${{ github.ref != 'refs/heads/main' }} - - - name: Build - run: ./gradlew :conformance-test:installDist - - - name: Run conformance tests - uses: modelcontextprotocol/conformance@v0.1.16 - with: - mode: client - command: conformance-test/build/install/conformance-test/bin/conformance-client - scenario: ${{ matrix.scenario }} - node-version: ${{ env.NODE_VERSION }} - expected-failures: ./conformance-test/conformance-baseline.yml + name: conformance-results-${{ matrix.os }} + path: conformance-test/results/ diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml deleted file mode 100644 index e7e94c3..0000000 --- a/.github/workflows/dependabot-auto-merge.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Auto-merge Dependabot - -on: pull_request - -permissions: - contents: write - pull-requests: write - -jobs: - auto-merge: - if: github.actor == 'dependabot[bot]' - runs-on: ubuntu-latest - steps: - - uses: dependabot/fetch-metadata@v3 - id: metadata - - - if: >- - steps.metadata.outputs.package-ecosystem == 'github_actions' || - steps.metadata.outputs.package-ecosystem == 'npm_and_yarn' - run: gh pr merge --auto --squash "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml index f01f9c8..5acfce8 100644 --- a/.github/workflows/gradle-publish.yml +++ b/.github/workflows/gradle-publish.yml @@ -1,3 +1,10 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a package using Gradle and then publish it to GitHub packages when a release is created +# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#Publishing-using-gradle + name: Release on: @@ -10,30 +17,39 @@ jobs: runs-on: macos-latest environment: release env: - GRADLE_OPTS: "-Dorg.gradle.daemon=false" + JAVA_OPTS: "-Xmx4g -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=768m -Djava.awt.headless=true" + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dkotlin.compiler.execution.strategy=in-process" permissions: contents: write 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@v5 - - name: Check - run: ./gradlew check --no-configuration-cache + - name: Clean Build with Gradle + run: ./gradlew clean build -x :conformance-test:test --rerun-tasks --max-workers 2 --continue --configure-on-demand --parallel + + - name: Publish Test Report + uses: mikepenz/action-junit-report@v6 + if: ${{ !cancelled() }} + with: + annotate_only: true + detailed_summary: true + flaky_summary: true + group_suite: true + include_empty_in_summary: false + include_time_in_summary: true + report_paths: '**/test-results/**/TEST-*.xml' + truncate_stack_traces: false - name: Publish to Maven Central Portal id: publish diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 7463a9e..7fac388 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -19,7 +19,6 @@ concurrency: env: JAVA_VERSION: 21 JAVA_DISTRIBUTION: temurin - GRADLE_OPTS: "-Dorg.gradle.daemon=false" jobs: build: @@ -30,22 +29,23 @@ jobs: sample: - kotlin-mcp-client - kotlin-mcp-server - - simple-streamable-server - weather-stdio-server name: Build Sample (${{ matrix.sample }}) timeout-minutes: 10 + env: + JAVA_OPTS: "-Xmx8g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx6g" steps: - - uses: actions/checkout@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@v5 with: validate-wrappers: true add-job-summary: 'always' @@ -53,11 +53,11 @@ jobs: - name: "Build Sample: ${{ matrix.sample }}" working-directory: ./samples/${{ matrix.sample }} - run: ./gradlew build + run: ./gradlew --no-daemon build - name: Upload Reports - if: failure() - uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v6 with: name: reports-samples-${{ matrix.sample }} path: | diff --git a/.gitignore b/.gitignore index 805579c..a8c18dd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,15 +11,13 @@ build/ .idea/compiler.xml .idea/libraries/ .idea +!.idea/icon.png *.iws *.iml *.ipr out/ !**/src/main/**/out/ !**/src/test/**/out/ -!.idea/icon.png -!.idea/detekt.xml -!.idea/externalDependencies.xml ### Kotlin ### .kotlin diff --git a/AGENTS.md b/AGENTS.md index 6ef38be..29aacef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,82 +1,101 @@ -# AGENTS: Development Guidelines for AI Contributors +# kotlin-mcp-sdk -This file provides guidance to AI coding agents working with code in this repository. +MCP Kotlin SDK — Kotlin Multiplatform implementation of the Model Context Protocol. -## Project +## Repository Layout +- `kotlin-sdk-core`: Core protocol types, transport abstractions, WebSocket implementation +- `kotlin-sdk-client`: Client transports (STDIO, SSE, StreamableHttp, WebSocket) +- `kotlin-sdk-server`: Server transports + Ktor integration (STDIO, SSE, WebSocket) +- `kotlin-sdk`: Umbrella module that aggregates all three above +- `kotlin-sdk-test`: Shared test utilities +- `samples/`: Three sample projects (composite builds) -Kotlin Multiplatform SDK for the [Model Context Protocol (MCP)](https://modelcontextprotocol.io). Targets JVM, Native (macOS/Linux/Windows), JS, and Wasm. Implements both MCP clients and servers with pluggable transports. +## General Guidance +- Avoid changing public API signatures. Run `./gradlew apiCheck` before every commit. +- **Explicit API mode** is strict: all public APIs must have explicit visibility modifiers and return types. +- Anything under an `internal` directory is not part of the public API and may change freely. +- Package structure follows: `io.modelcontextprotocol.kotlin.sdk.*` +- The SDK targets Kotlin 2.2+ and JVM 1.8+ as minimum. -## Build Commands +## Building and Testing +1. Build the project: + ```bash + ./gradlew build + ``` +2. Run tests. To save time, run only the module you changed: + ```bash + ./gradlew :kotlin-sdk-core:test + ./gradlew :kotlin-sdk-client:test + ./gradlew :kotlin-sdk-server:test + ``` +3. For platform-specific tests: + ```bash + ./gradlew jsTest + ./gradlew wasmJsTest + ``` +4. Check binary compatibility (required before commit): + ```bash + ./gradlew apiCheck + ``` +5. If you intentionally changed public APIs, update the API dump: + ```bash + ./gradlew apiDump + ``` -```bash -./gradlew ktlintCheck # Lint check -./gradlew detekt # Static analysis -./gradlew ktlintFormat # Auto-fix lint issues -./gradlew apiCheck # Check public API compatibility (run before committing) -./gradlew apiDump # Update API dump after intentional API changes -./gradlew koverLog # Print coverage summary -./gradlew koverXmlReport # Generate XML coverage report -``` - -Run a single test class: -```bash -./gradlew :kotlin-sdk-server:jvmTest --tests "io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransportTest" -``` - -Run a single test method: -```bash -./gradlew :kotlin-sdk-core:jvmTest --tests "*.ProtocolTest.should preserve existing meta when adding progress token" -``` - -Conformance tests (requires Node.js + npx): -```bash -./conformance-test/run-conformance.sh all # All suites sequentially -./conformance-test/run-conformance.sh server # Server suite only -./conformance-test/run-conformance.sh client # Client core suite (initialize, tools_call, elicitation, sse-retry) -./conformance-test/run-conformance.sh client-auth # Client auth suite (20 OAuth scenarios) -``` - -## Module Structure - -``` -kotlin-sdk-core Core: protocol types, JSON-RPC, Transport abstraction, Protocol base class -kotlin-sdk-client Client: MCP client implementation (tools, prompts, resources, sampling) -kotlin-sdk-server Server: MCP server implementation (feature registries, sessions) -kotlin-sdk-testing ChannelTransport for in-memory client-server testing -kotlin-sdk Umbrella module aggregating client + server -test-utils Shared test helpers (mockk, kotest, awaitility) -conformance-test MCP specification compliance tests (external runner) -integration-test End-to-end tests -samples/ Runnable examples (servers and clients) -``` - -## Architecture - -Layered design: - -``` -Application (tools, prompts, resources) - → Client / Server (typed MCP operations, capability enforcement) - → Protocol (JSON-RPC framing, request correlation, timeouts) - → Transport (pluggable I/O: stdio, SSE, Streamable HTTP, WebSocket) -``` +## Tests +- All tests for each module are located in `src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/` +- Platform-specific tests go in `src/jvmTest/`, `src/jsTest/`, etc. +- Use Kotest assertions (`shouldBe`, `shouldContain`, etc.) for readable test failures. +- For nullable objects with nested properties, prefer `shouldNotBeNull { ... }` blocks: + ```kotlin + content.annotations shouldNotBeNull { + priority shouldBe 1.0 + } + ``` +- Use `shouldMatchJson` from Kotest for JSON validation. +- Mock Ktor HTTP clients using `MockEngine` from `io.ktor:ktor-client-mock`. +- Always add tests for new features or bug fixes, even if not explicitly requested. ## Code Conventions -- **Explicit API mode (Strict)**: all public declarations must have explicit visibility modifiers and return types -- **DSL builders**: prefer DSL builder style (lambda blocks) over direct constructors -- **Multi-dollar interpolation**: use `$$"""..."""` for strings containing literal `$` (e.g., JSON with `$ref`, `$defs`) -- **`McpJson` has `explicitNulls = false`**: null properties must be absent from JSON, not `"field": null` -- **Logging**: use `io.github.oshai.kotlinlogging.KotlinLogging` -- **Structured concurrency**: every coroutine must have a bounded scope and lifetime; always re-throw `CancellationException`; never hold `Mutex` across suspension points +### Multiplatform Patterns +- Use `expect`/`actual` pattern for platform-specific implementations in `utils.*` files. +- Test changes on JVM first, then verify platform-specific behavior if needed. +- Supported targets: JVM (1.8+), JS/Wasm, iOS, watchOS, tvOS. -## Testing +### Serialization +- Use Kotlinx Serialization with explicit `@Serializable` annotations. +- Custom serializers should be registered in the companion object. +- JSON config is defined in `McpJson.kt` — use it consistently. -- **JVM first**: run `jvmTest` instead of `test` — full multiplatform test runs are slow and rarely needed during development -- **Test naming**: use backtick syntax — `fun \`should return 200 OK\`()` -- **Frameworks**: JUnit 5 (runner) + Kotest (assertions) + MockK (mocking) + Awaitility (async waits) -- **In-memory testing**: `ChannelTransport.createLinkedPair()` connects client and server without network +### Concurrency and State +- Use `kotlinx.atomicfu` for thread-safe operations across platforms. +- Prefer coroutines over callbacks where possible. +- Transport implementations extend `AbstractTransport` for consistent callback management. -## Skills +### Error Handling +- Use sealed classes for representing different result states. +- Map errors to JSON-RPC error codes in protocol handlers. +- Log errors using `io.github.oshai.kotlinlogging.KotlinLogging`. -- `/mcp-docs` — fetches live MCP specification and docs; use when implementing or debugging MCP protocol features +### Logging +- Use `KotlinLogging.logger {}` for structured logging. +- Never log sensitive data (credentials, tokens). + +## Pull Requests +- Base all PRs on the `main` branch. +- PR title format: Brief description of the change +- Include in PR description: + - **What changed?** + - **Why? Motivation and context** + - **Breaking changes?** (if any) +- Run `./gradlew apiCheck` before submitting. +- Link PR to related issue (except for minor docs/typo fixes). +- Follow [Kotlin Coding Conventions](https://kotlinlang.org/docs/reference/coding-conventions.html). + +## Review Checklist +- `./gradlew apiCheck` must pass. +- `./gradlew test` must succeed for affected modules. +- New tests added for any new feature or bug fix. +- Documentation updated for user-facing changes. +- No breaking changes to public APIs without discussion. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index aefadcf..356493b 100644 --- a/README.md +++ b/README.md @@ -1,318 +1,108 @@ # MCP Kotlin SDK + [![Maven Central](https://img.shields.io/maven-central/v/io.modelcontextprotocol/kotlin-sdk.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/io.modelcontextprotocol/kotlin-sdk) [![Build](https://github.com/modelcontextprotocol/kotlin-sdk/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/modelcontextprotocol/kotlin-sdk/actions/workflows/build.yml) - +[![Conformance Tests](https://github.com/modelcontextprotocol/kotlin-sdk/actions/workflows/conformance.yml/badge.svg)](https://github.com/modelcontextprotocol/kotlin-sdk/actions/workflows/conformance.yml) [![Kotlin](https://img.shields.io/badge/kotlin-2.2+-blueviolet.svg?logo=kotlin)](http://kotlinlang.org) [![Kotlin Multiplatform](https://img.shields.io/badge/Platforms-%20JVM%20%7C%20Wasm%2FJS%20%7C%20Native%20-blueviolet?logo=kotlin)](https://kotlinlang.org/docs/multiplatform.html) [![JVM](https://img.shields.io/badge/JVM-11+-red.svg?logo=jvm)](http://java.com) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) - -Kotlin Multiplatform SDK for the [Model Context Protocol](https://modelcontextprotocol.io). -It enables Kotlin applications targeting JVM, Native, JS, and Wasm to implement MCP clients and servers using a -standardized protocol interface. - -## Table of Contents - - - -* [Overview](#overview) -* [Installation](#installation) - * [Artifacts](#artifacts) - * [Gradle setup (JVM)](#gradle-setup-jvm) - * [Multiplatform](#multiplatform) - * [Ktor dependencies](#ktor-dependencies) -* [Quickstart](#quickstart) - * [Creating a Client](#creating-a-client) - * [Creating a Server](#creating-a-server) -* [Core Concepts](#core-concepts) - * [MCP Primitives](#mcp-primitives) - * [Capabilities](#capabilities) - * [Server Capabilities](#server-capabilities) - * [Client Capabilities](#client-capabilities) - * [Server Features](#server-features) - * [Prompts](#prompts) - * [Resources](#resources) - * [Tools](#tools) - * [Completion](#completion) - * [Logging](#logging) - * [Pagination](#pagination) - * [Client Features](#client-features) - * [Roots](#roots) - * [Sampling](#sampling) -* [Transports](#transports) - * [STDIO Transport](#stdio-transport) - * [Streamable HTTP Transport](#streamable-http-transport) - * [SSE Transport](#sse-transport) - * [WebSocket Transport](#websocket-transport) - * [ChannelTransport (testing)](#channeltransport-testing) -* [Connecting your server](#connecting-your-server) -* [Examples](#examples) -* [Documentation](#documentation) -* [Contributing](#contributing) -* [License](#license) - - +Kotlin Multiplatform implementation of the [Model Context Protocol](https://modelcontextprotocol.io) (MCP), +providing both client and server capabilities for integrating with LLM surfaces across various platforms. ## Overview The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. -This Kotlin SDK implements the MCP specification, making it easy to: +This SDK implements the MCP specification for Kotlin, +enabling you to build applications that can communicate using MCP on the JVM, WebAssembly and iOS. -- Build MCP **clients** that can connect to any MCP server -- Create MCP **servers** that expose resources, prompts, and tools -- Target **JVM, Native, JS, and Wasm** from a single codebase -- Use standard transports like **stdio**, **SSE**, **Streamable HTTP**, and **WebSocket** -- Handle MCP protocol messages and lifecycle events with coroutine-friendly APIs +- Build MCP clients that can connect to any MCP server +- Create MCP servers that expose resources, prompts and tools +- Use standard transports like stdio, SSE, and WebSocket +- Handle all MCP protocol messages and lifecycle events + +## Samples + +- [kotlin-mcp-server](./samples/kotlin-mcp-server): demonstrates a multiplatform (JVM, Wasm) MCP server setup with various features and transports. +- [weather-stdio-server](./samples/weather-stdio-server): shows how to build a Kotlin MCP server providing weather forecast and alerts using STDIO transport. +- [kotlin-mcp-client](./samples/kotlin-mcp-client): demonstrates building an interactive Kotlin MCP client that connects to an MCP server via STDIO and integrates with Anthropic’s API. ## Installation -### Artifacts - -- `io.modelcontextprotocol:kotlin-sdk` – umbrella SDK (client + server APIs) -- `io.modelcontextprotocol:kotlin-sdk-client` – client-only APIs -- `io.modelcontextprotocol:kotlin-sdk-server` – server-only APIs - -### Gradle setup (JVM) - -Add the Maven Central repository and the SDK dependency: +Add the new repository to your build file: ```kotlin repositories { mavenCentral() } +``` +Add the dependency: + +```kotlin dependencies { // See the badge above for the latest version implementation("io.modelcontextprotocol:kotlin-sdk:$mcpVersion") } ``` - -Use _kotlin-sdk-client_ or _kotlin-sdk-server_ if you only need one side of the API: - +MCP SDK uses [Ktor](https://ktor.io/), but does not come with a specific engine dependency. +You should add [Ktor client](https://ktor.io/docs/client-dependencies.html#engine-dependency) +and/or [Ktor server](https://ktor.io/docs/client-dependencies.html#engine-dependency) dependency +to your project yourself, e.g.: ```kotlin dependencies { - implementation("io.modelcontextprotocol:kotlin-sdk-client:$mcpVersion") - implementation("io.modelcontextprotocol:kotlin-sdk-server:$mcpVersion") -} -``` - -### Multiplatform - -In a Kotlin Multiplatform project you can add the SDK to commonMain: - -```kotlin -commonMain { - dependencies { - // Works as a common dependency as well as the platform one - implementation("io.modelcontextprotocol:kotlin-sdk:$mcpVersion") - } -} -``` - -### Ktor dependencies - -The Kotlin MCP SDK uses [Ktor](https://ktor.io/), but it does not add Ktor engine dependencies transitively. -You need to declare -Ktor [client](https://ktor.io/docs/client-dependencies.html#engine-dependency)/[server](https://ktor.io/docs/server-dependencies.html) -dependencies yourself (or reuse the ones already used in your project), -for example: - -```kotlin -dependencies { - // MCP client with Ktor + // for client: implementation("io.ktor:ktor-client-cio:$ktorVersion") - implementation("io.modelcontextprotocol:kotlin-sdk-client:$mcpVersion") - - // MCP server with Ktor + // for server: implementation("io.ktor:ktor-server-netty:$ktorVersion") - implementation("io.modelcontextprotocol:kotlin-sdk-server:$mcpVersion") + } ``` -## Quickstart - -Let's create a simple MCP client and server to demonstrate the basic usage of the Kotlin SDK. - - +## Quick Start ### Creating a Client -Create an MCP client that connects to a server via Streamable HTTP transport and lists available tools: - ```kotlin -import io.ktor.client.HttpClient -import io.ktor.client.plugins.sse.SSE import io.modelcontextprotocol.kotlin.sdk.client.Client -import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import kotlinx.coroutines.runBlocking +import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport +import io.modelcontextprotocol.kotlin.sdk.Implementation -fun main(args: Array) = runBlocking { - val url = args.firstOrNull() ?: "http://localhost:3000/mcp" - - val httpClient = HttpClient { install(SSE) } - - val client = Client( - clientInfo = Implementation( - name = "example-client", - version = "1.0.0" - ) +val client = Client( + clientInfo = Implementation( + name = "example-client", + version = "1.0.0" ) +) - val transport = StreamableHttpClientTransport( - client = httpClient, - url = url - ) +val transport = StdioClientTransport( + inputStream = processInputStream, + outputStream = processOutputStream +) - // Connect to server - client.connect(transport) +// Connect to server +client.connect(transport) - // List available tools - val tools = client.listTools().tools +// List available resources +val resources = client.listResources() - println(tools) -} +// Read a specific resource +val resourceContent = client.readResource( + ReadResourceRequest(uri = "file:///example.txt") +) ``` - - ### Creating a Server -Create an MCP server that exposes a simple tool and runs on an embedded Ktor server with Streamable HTTP transport. -For a full working project with all required dependencies, see -the [simple-streamable-server](samples/simple-streamable-server) sample. - - - ```kotlin -import io.ktor.server.cio.CIO -import io.ktor.server.engine.embeddedServer import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions -import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp -import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put +import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities -fun main(args: Array) { - val port = args.firstOrNull()?.toIntOrNull() ?: 3000 - val mcpServer = Server( - serverInfo = Implementation( - name = "example-server", - version = "1.0.0" - ), - options = ServerOptions( - capabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = true), - ), - ) - ) - - mcpServer.addTool( - name = "example-tool", - description = "An example tool", - inputSchema = ToolSchema( - properties = buildJsonObject { - put("input", buildJsonObject { put("type", "string") }) - } - ) - ) { request -> - CallToolResult(content = listOf(TextContent("Hello, world!"))) - } - - embeddedServer(CIO, host = "127.0.0.1", port = port) { - mcpStreamableHttp { - mcpServer - } - }.start(wait = true) -} -``` - - - -You can run the server and then connect to it using the client or test with the MCP Inspector: - -```bash -npx -y @modelcontextprotocol/inspector -``` - -In the inspector UI, connect to `http://localhost:3000/mcp`. - -## Core Concepts - -### MCP Primitives - -The MCP protocol defines core primitives that enable communication between servers and clients: - -| Primitive | Server Role | Client Role | Description | -|---------------|---------------------------------------------------|----------------------------------------|---------------------------------------------------| -| **Prompts** | Provides prompt templates with optional arguments | Requests and uses prompts | Interactive templates for LLM interactions | -| **Resources** | Exposes data sources (files, API responses, etc.) | Reads and subscribes to resources | Contextual data for augmenting LLM context | -| **Tools** | Defines executable functions | Calls tools to perform actions | Functions the LLM can invoke to take actions | -| **Sampling** | Requests LLM completions from client | Executes LLM calls and returns results | Server-initiated LLM requests (reverse direction) | - -### Capabilities - -Capabilities define what features a server or client supports. They are declared during initialization and determine -what operations are available. - -#### Server Capabilities - -Servers declare their capabilities to inform clients what features they provide: - -| Capability | Feature Flags | Description | -|----------------|-------------------------------|------------------------------------------------------------| -| `prompts` | `listChanged` | Prompt template management and notifications | -| `resources` | `subscribe`
`listChanged` | Resource exposure, subscriptions, and update notifications | -| `tools` | `listChanged` | Tool discovery, execution, and list change notifications | -| `logging` | - | Server logging to client console | -| `completions` | - | Argument autocompletion suggestions | -| `experimental` | Custom properties | Non-standard experimental features | - -#### Client Capabilities - -Clients declare their capabilities to inform servers what features they support: - -| Capability | Feature Flags | Description | -|----------------|-------------------|-------------------------------------------------------------| -| `sampling` | - | Client can sample from an LLM (execute model requests) | -| `roots` | `listChanged` | Client exposes root directories and can notify of changes | -| `elicitation` | - | Client can display schema/form dialogs for structured input | -| `experimental` | Custom properties | Non-standard experimental features | - -### Server Features - -The `Server` API lets you wire prompts, resources, and tools with only a few lines of Kotlin. Each feature is registered -up front and then resolved lazily when a client asks for it, so your handlers stay small and suspendable. - -#### Prompts - -Prompts are user-controlled templates that clients discover via `prompts/list` and fetch with `prompts/get` when a user -chooses one (think slash commands or saved flows). They’re best for repeatable, structured starters rather than ad-hoc -model calls. - - - -```kotlin val server = Server( serverInfo = Implementation( name = "example-server", @@ -320,607 +110,101 @@ val server = Server( ), options = ServerOptions( capabilities = ServerCapabilities( - prompts = ServerCapabilities.Prompts(listChanged = true), - ), - ) -) - -server.addPrompt( - name = "code-review", - description = "Ask the model to review a diff", - arguments = listOf( - PromptArgument(name = "diff", description = "Unified diff", required = true), - ), -) { request -> - GetPromptResult( - description = "Quick code review helper", - messages = listOf( - PromptMessage( - role = Role.User, - content = TextContent(text = "Review this change:\n${request.arguments?.get("diff")}"), - ), - ), + resources = ServerCapabilities.Resources( + subscribe = true, + listChanged = true + ) + ) ) +){ + "This server provides example resources and demonstrates MCP capabilities." } -``` - - - - - -Use prompts for anything that deserves a template: bug triage questions, onboarding checklists, or saved searches. Set -`listChanged = true` only if your prompt catalog can change at runtime and your server will emit -`notifications/prompts/list_changed` when it does. - -#### Resources - -Resources are application-driven context that clients discover via `resources/list` or `resources/templates/list`, then -fetch with `resources/read`. Register each one with a stable URI and return a `ReadResourceResult` when asked—contents -can be text or binary blobs. - - - -```kotlin -val server = Server( - serverInfo = Implementation( - name = "example-server", - version = "1.0.0" - ), - options = ServerOptions( - capabilities = ServerCapabilities( - resources = ServerCapabilities.Resources(subscribe = true, listChanged = true), - ), - ) -) +// Add a resource server.addResource( - uri = "note://release/latest", - name = "Release notes", - description = "Last deployment summary", - mimeType = "text/markdown", + uri = "file:///example.txt", + name = "Example Resource", + description = "An example text file", + mimeType = "text/plain" ) { request -> ReadResourceResult( contents = listOf( TextResourceContents( - text = "Ship 42 reached production successfully.", + text = "This is the content of the example resource.", uri = request.uri, - mimeType = "text/markdown", - ), - ), + mimeType = "text/plain" + ) + ) ) } + +// Start server with stdio transport +val transport = StdioServerTransport() +server.connect(transport) ``` - - - - -Resources can be static text, generated JSON, or blobs—anything the client can surface to the user or inject into the -model context. Set `subscribe = true` if you emit `notifications/resources/updated` for changes to specific URIs, and -`listChanged = true` if you’ll send `notifications/resources/list_changed` when the catalog itself changes. - -#### Tools - -Tools are model-controlled capabilities the client exposes to the model. Clients discover them via `tools/list`, invoke -them with `tools/call`, and your handlers receive JSON arguments, can emit streaming logs or progress, and return a -`CallToolResult`. Keep a human in the loop for sensitive operations. - - +### Using SSE Transport +Directly in Ktor's `Application`: ```kotlin -val server = Server( - serverInfo = Implementation( - name = "example-server", - version = "1.0.0" - ), - options = ServerOptions( - capabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = true), - ), - ) -) - -server.addTool( - name = "echo", - description = "Return whatever the user sent back to them", -) { request -> - val text = request.arguments?.get("text")?.jsonPrimitive?.content ?: "(empty)" - CallToolResult(content = listOf(TextContent(text = "Echo: $text"))) -} -``` - - - - - -Register as many tools as you need—long-running jobs can report progress via the request context, and tools can also -trigger sampling (see below) when they need the client’s LLM. Set `listChanged = true` only if your tool catalog can -change at runtime and your server will emit `notifications/tools/list_changed` when it does. - -#### Completion - -Completion provides argument suggestions for prompts or resource templates. Declare the `completions` capability and -handle `completion/complete` requests to return up to 100 ranked values (include `total`/`hasMore` if you paginate). - - - -```kotlin -val server = Server( - serverInfo = Implementation( - name = "example-server", - version = "1.0.0" - ), - options = ServerOptions( - capabilities = ServerCapabilities( - completions = ServerCapabilities.Completions, - ), - ) -) - -val session = server.createSession( - StdioServerTransport( - inputStream = System.`in`.asSource().buffered(), - outputStream = System.out.asSink().buffered() - ) -) - -session.setRequestHandler(Method.Defined.CompletionComplete) { request, _ -> - val options = listOf("kotlin", "compose", "coroutine") - val matches = options.filter { it.startsWith(request.argument.value.lowercase()) } - - CompleteResult( - completion = CompleteResult.Completion( - values = matches.take(3), - total = matches.size, - hasMore = matches.size > 3, - ), - ) -} -``` - - - - - -Use `context.arguments` to refine suggestions for dependent fields (e.g., framework list filtered by chosen language). - -#### Logging - -Logging lets the server stream structured log notifications to the client using RFC 5424 levels (`debug` → `emergency`). -Declare the `logging` capability; clients can raise the minimum level with `logging/setLevel`, and the server emits -`notifications/message` with severity, optional logger name, and JSON data. - - - -```kotlin -val server = Server( - serverInfo = Implementation("example-server", "1.0.0"), - options = ServerOptions( - capabilities = ServerCapabilities( - logging = ServerCapabilities.Logging, - ), - ) -) - -val session = server.createSession( - StdioServerTransport( - inputStream = System.`in`.asSource().buffered(), - outputStream = System.out.asSink().buffered() - ) -) - -session.sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = LoggingLevel.Info, - logger = "startup", - data = buildJsonObject { put("message", "Server started") }, - ), - ), -) -``` - - - - - -Keep logs free of sensitive data, and expect clients to surface them in their own UI. - -#### Pagination - -List operations return paginated results with an opaque `nextCursor`, clients echo that cursor to fetch the next page. -Supported list calls: `resources/list`, `resources/templates/list`, `prompts/list`, and `tools/list`. -Treat cursors as opaque—don’t parse or persist them across sessions. - - - -```kotlin -val server = Server( - serverInfo = Implementation("example-server", "1.0.0"), - options = ServerOptions( - capabilities = ServerCapabilities( - resources = ServerCapabilities.Resources(), - ), - ) -) - -val session = server.createSession( - StdioServerTransport( - inputStream = System.`in`.asSource().buffered(), - outputStream = System.out.asSink().buffered() - ) -) - -val resources = listOf( - Resource(uri = "note://1", name = "Note 1", description = "First"), - Resource(uri = "note://2", name = "Note 2", description = "Second"), - Resource(uri = "note://3", name = "Note 3", description = "Third"), -) -val pageSize = 2 - -session.setRequestHandler(Method.Defined.ResourcesList) { request, _ -> - val start = request.params?.cursor?.toIntOrNull() ?: 0 - val page = resources.drop(start).take(pageSize) - val next = if (start + page.size < resources.size) (start + page.size).toString() else null - - ListResourcesResult( - resources = page, - nextCursor = next, - ) -} -``` - - - - - -Include `nextCursor` only when more items remain an absent cursor ends pagination. - -### Client Features - -Clients advertise their capabilities (roots, sampling, elicitation, etc.) during initialization. After that they can -serve requests from the server while still initiating calls such as `listTools` or `callTool`. - -#### Roots - -Roots let the client declare where the server is allowed to operate. Declare the `roots` capability, respond to -`roots/list`, and emit `notifications/roots/list_changed` if you set `listChanged = true`. URIs **must** be `file://` -paths. - - - -```kotlin -val client = Client( - clientInfo = Implementation("demo-client", "1.0.0"), - options = ClientOptions( - capabilities = ClientCapabilities(roots = ClientCapabilities.Roots(listChanged = true)), - ), -) - -client.addRoot( - uri = "file:///Users/demo/projects", - name = "Projects", -) -client.sendRootsListChanged() -``` - - - - - -Call `addRoot`/`removeRoot` whenever your file system view changes, and use `sendRootsListChanged()` to notify the -server. Keep root lists user-controlled and revoke entries that are no longer authorized. - -#### Sampling - -Sampling lets the server ask the client to call its preferred LLM. Declare the `sampling` capability (and -`sampling.tools` if you allow tool-enabled sampling), and handle `sampling/createMessage`. Keep a human in the loop for -approvals. - - - -```kotlin -val client = Client( - clientInfo = Implementation("demo-client", "1.0.0"), - options = ClientOptions( - capabilities = ClientCapabilities( - sampling = buildJsonObject { putJsonObject("tools") { } }, // drop tools if you don't support tool use - ), - ), -) - -client.setRequestHandler(Method.Defined.SamplingCreateMessage) { request, _ -> - val content = request.messages.lastOrNull()?.content?.firstOrNull() - val prompt = if (content is TextContent) content.text else "your topic" - CreateMessageResult( - model = "gpt-4o-mini", - role = Role.Assistant, - content = TextContent(text = "Here is a short note about $prompt"), - ) -} -``` - - - - - -Inside the handler you can pick any model/provider, require approvals, or reject the request. If you don’t support tool -use, omit `sampling.tools` from capabilities. - -[//]: # (TODO: add elicitation section) - -[//]: # (#### Elicitation) - -## Transports - -All transports share the same API surface, so you can change deployment style without touching business logic. Pick the -transport that best matches where the server runs. - -### STDIO Transport - -`StdioClientTransport` and `StdioServerTransport` tunnel MCP messages over stdin/stdout—perfect for editor plugins or -CLI tooling that spawns a helper process. No networking setup is required. - -### Streamable HTTP Transport - -`StreamableHttpClientTransport` and the Ktor `mcpStreamableHttp()` / `mcpStatelessStreamableHttp()` helpers expose MCP -over a single HTTP endpoint with optional JSON-only or SSE streaming responses. This is the recommended choice for -remote deployments and integrates nicely with proxies or service meshes. - -These helpers automatically install `ContentNegotiation` with `McpJson` — do not install it yourself, or a warning will -be logged. Both accept a `path` parameter (default: `"/mcp"`) to mount the endpoint at any URL: - - - - -```kotlin -embeddedServer(CIO, port = 3000) { - mcpStreamableHttp(path = "/api/mcp") { - MyServer() - } -}.start(wait = true) -``` - - - - -**CORS for browser-based clients (e.g. MCP Inspector):** if you connect from a browser-based -client you need to install the Ktor CORS plugin so that MCP-specific headers are allowed and exposed: - -```kotlin -install(CORS) { - anyHost() // restrict to specific origins in production - allowMethod(HttpMethod.Options) - allowMethod(HttpMethod.Get) - allowMethod(HttpMethod.Post) - allowMethod(HttpMethod.Delete) - allowNonSimpleContentTypes = true - allowHeader("Mcp-Session-Id") - allowHeader("Mcp-Protocol-Version") - exposeHeader("Mcp-Session-Id") - exposeHeader("Mcp-Protocol-Version") -} -``` - -### SSE Transport - -Server-Sent Events remain available for backwards compatibility with older MCP clients. Two Ktor helpers are provided: - -- **`Application.mcp { }`** — installs SSE and `ContentNegotiation` with `McpJson` automatically, then registers MCP - endpoints at `/`. Do not install `ContentNegotiation` yourself — the SDK handles it. -- **`Route.mcp { }`** — registers MCP endpoints at the current route path; requires `install(SSE)` in the application - first. Use this to host MCP alongside other routes or under a path prefix: - - - - -```kotlin -embeddedServer(CIO, port = 3000) { - install(SSE) - routing { - route("/api/mcp") { - mcp { MyServer() } +fun Application.module() { + mcp { + Server( + serverInfo = Implementation( + name = "example-sse-server", + version = "1.0.0" + ), + options = ServerOptions( + capabilities = ServerCapabilities( + prompts = ServerCapabilities.Prompts(listChanged = null), + resources = ServerCapabilities.Resources(subscribe = null, listChanged = null) + ) + ), + ) { + "This SSE server provides prompts and resources via Server-Sent Events." } } -}.start(wait = true) +} ``` - - - -Prefer Streamable HTTP for new projects. - -### WebSocket Transport - -`WebSocketClientTransport` plus the matching server utilities provide full-duplex, low-latency connections—useful when -you expect lots of notifications or long-running sessions behind a reverse proxy that already terminates WebSockets. - -### ChannelTransport (testing) - -`ChannelTransport` provides a simple, non-networked transport for testing and local development. -It uses Kotlin coroutines channels to provide a full-duplex connection between a client and server, -allowing for easy testing of MCP functionality without the need for network setup. - -## Connecting your server - -1. Start a sample HTTP server on port 3000: - - ```bash - ./gradlew :samples:kotlin-mcp-server:run - ``` - -2. Connect with the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) or Claude Desktop/Code: - - ```bash - npx -y @modelcontextprotocol/inspector --connect http://localhost:3000 - # or - claude mcp add --transport http kotlin-mcp http://localhost:3000 - ``` - -3. In the Inspector, confirm prompts, tools, resources, completions, and logs show up. Iterate locally until you’re - ready to host the server wherever you prefer. - -## Examples - -The [samples](./samples) directory contains runnable projects demonstrating -MCP server and client implementations with various transports. -See the [samples overview](./samples/README.md) for a comparison table and detailed descriptions. - -## Documentation -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/modelcontextprotocol/kotlin-sdk) - -- [API Reference](https://kotlin.sdk.modelcontextprotocol.io/) -- [Model Context Protocol documentation](https://modelcontextprotocol.io) -- [MCP specification](https://modelcontextprotocol.io/specification/latest) - +``` ## Contributing Please see the [contribution guide](CONTRIBUTING.md) and the [Code of conduct](CODE_OF_CONDUCT.md) before contributing. ## License -This project is licensed under Apache 2.0 for new contributions, with existing code under MIT—see the [LICENSE](LICENSE) -file for details. +This project is licensed under Apache 2.0 for new contributions, with existing code under MIT—see the [LICENSE](LICENSE) file for details. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 5029242..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,21 +0,0 @@ -# Security Policy - -Thank you for helping keep the Model Context Protocol and its ecosystem secure. - -## Reporting Security Issues - -If you discover a security vulnerability in this repository, please report it through -the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) -for this repository. - -Please **do not** report security vulnerabilities through public GitHub issues, discussions, -or pull requests. - -## What to Include - -To help us triage and respond quickly, please include: - -- A description of the vulnerability -- Steps to reproduce the issue -- The potential impact -- Any suggested fixes (optional) diff --git a/build.gradle.kts b/build.gradle.kts index 2c2dbe3..bcdf96f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,13 +8,11 @@ dependencies { dokka(project(":kotlin-sdk-core")) dokka(project(":kotlin-sdk-client")) dokka(project(":kotlin-sdk-server")) - dokka(project(":kotlin-sdk-testing")) kover(project(":kotlin-sdk-core")) kover(project(":kotlin-sdk-client")) kover(project(":kotlin-sdk-server")) - kover(project(":kotlin-sdk-testing")) - kover(project(":integration-test")) + "kover"(project(":integration-test")) } subprojects { @@ -51,7 +49,7 @@ kover { } verify { rule { - minBound(73) + minBound(65) } } } 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..a676db7 100644 --- a/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts +++ b/buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts @@ -5,7 +5,6 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { kotlin("multiplatform") @@ -16,8 +15,6 @@ plugins { kotlin { compilerOptions { - languageVersion = KotlinVersion.KOTLIN_2_1 - apiVersion = KotlinVersion.KOTLIN_2_1 freeCompilerArgs = listOf( "-Wextra", @@ -42,11 +39,18 @@ kotlin { forkEvery = 100 testLogging { exceptionFormat = TestExceptionFormat.SHORT + showStandardStreams = true events("failed") } systemProperty("kotest.output.ansi", "true") + reports { + junitXml.required.set(true) + junitXml.includeSystemOutLog.set(true) + junitXml.includeSystemErrLog.set(true) + } } } + macosX64() macosArm64() linuxX64() linuxArm64() diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml deleted file mode 100644 index a82b404..0000000 --- a/config/detekt/detekt.yml +++ /dev/null @@ -1,841 +0,0 @@ -config: - validation: true - 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 - LongMethod: - active: false - allowedLines: 110 - LongParameterList: - active: false - allowedFunctionParameters: 8 - allowedConstructorParameters: 8 - ignoreDefaultParameters: false - ignoreDataClasses: true - ignoreAnnotatedParameter: [] - MethodOverloading: - active: false - allowedOverloads: 6 - NamedArguments: - active: false - allowedArguments: 3 - ignoreMethods: [] - ignoreArgumentsMatchingNames: false - NestedBlockDepth: - active: true - allowedDepth: 4 - NestedScopeFunctions: - active: false - allowedDepth: 1 - functions: - - 'kotlin.apply' - - 'kotlin.run' - - 'kotlin.with' - - 'kotlin.let' - - 'kotlin.also' - ReplaceSafeCallChainWithRun: - active: false - StringLiteralDuplication: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - allowedDuplications: 3 - ignoreAnnotation: true - allowedWithLengthLessThan: 5 - ignoreStringsRegex: '$^' - TooManyFunctions: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - allowedFunctionsPerFile: 20 - allowedFunctionsPerClass: 20 - allowedFunctionsPerInterface: 20 - allowedFunctionsPerObject: 20 - allowedFunctionsPerEnum: 11 - ignoreDeprecated: true - ignorePrivate: true - ignoreInternal: true - ignoreOverridden: false - ignoreAnnotatedFunctions: [] - -coroutines: - active: true - CoroutineLaunchedInTestWithoutRunTest: - active: false - GlobalCoroutineUsage: - active: true - InjectDispatcher: - active: false - dispatcherNames: - - 'IO' - - 'Default' - - 'Unconfined' - RedundantSuspendModifier: - active: true - SleepInsteadOfDelay: - active: true - SuspendFunInFinallySection: - active: false - SuspendFunSwallowedCancellation: - active: false - SuspendFunWithCoroutineScopeReceiver: - active: false - aliases: ['SuspendFunctionOnCoroutineScope'] - SuspendFunWithFlowReturnType: - active: true - -empty-blocks: - active: true - EmptyCatchBlock: - active: true - allowedExceptionNameRegex: '_|(ignore|expected).*' - EmptyClassBlock: - active: true - EmptyDefaultConstructor: - active: true - EmptyDoWhileBlock: - active: true - EmptyElseBlock: - active: true - EmptyFinallyBlock: - active: true - EmptyForBlock: - active: true - EmptyFunctionBlock: - active: true - ignoreOverridden: false - EmptyIfBlock: - active: true - EmptyInitBlock: - active: true - EmptyKotlinFile: - active: true - EmptySecondaryConstructor: - active: true - EmptyTryBlock: - active: true - EmptyWhenBlock: - active: true - EmptyWhileBlock: - active: true - -exceptions: - active: true - ErrorUsageWithThrowable: - active: false - ExceptionRaisedInUnexpectedLocation: - active: true - methodNames: - - 'equals' - - 'finalize' - - 'hashCode' - - 'toString' - InstanceOfCheckForException: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - NotImplementedDeclaration: - active: true - ObjectExtendsThrowable: - active: true - PrintStackTrace: - active: true - RethrowCaughtException: - active: true - ReturnFromFinally: - active: true - ignoreLabeled: false - SwallowedException: - active: true - ignoredExceptionTypes: - - 'InterruptedException' - - 'MalformedURLException' - - 'NumberFormatException' - - 'ParseException' - allowedExceptionNameRegex: '_|(ignore|expected).*' - ThrowingExceptionFromFinally: - active: true - ThrowingExceptionInMain: - active: false - ThrowingExceptionsWithoutMessageOrCause: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - exceptions: - - 'ArrayIndexOutOfBoundsException' - - 'Exception' - - 'IllegalArgumentException' - - 'IllegalMonitorStateException' - - 'IllegalStateException' - - 'IndexOutOfBoundsException' - - 'NullPointerException' - - 'RuntimeException' - - 'Throwable' - ThrowingNewInstanceOfSameException: - active: true - TooGenericExceptionCaught: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - exceptionNames: - - 'ArrayIndexOutOfBoundsException' - - 'Error' - - 'Exception' - - 'IllegalMonitorStateException' - - 'IndexOutOfBoundsException' - - 'NullPointerException' - - 'RuntimeException' - - 'Throwable' - allowedExceptionNameRegex: '_|(ignore|expected).*' - TooGenericExceptionThrown: - active: false - exceptionNames: - - 'Error' - - 'Exception' - - 'RuntimeException' - - 'Throwable' - -naming: - active: true - BooleanPropertyNaming: - active: false - allowedPattern: '^(is|has|are)' - ClassNaming: - active: true - aliases: ['ClassName'] - classPattern: '[A-Z][a-zA-Z0-9]*' - ConstructorParameterNaming: - active: true - parameterPattern: '[a-z][A-Za-z0-9]*' - privateParameterPattern: '[a-z][A-Za-z0-9]*' - excludeClassPattern: '$^' - EnumNaming: - active: true - aliases: ['EnumEntryName'] - enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' - ForbiddenClassName: - active: false - forbiddenName: [] - FunctionNameMaxLength: - active: false - aliases: ['FunctionMaxNameLength'] - maximumFunctionNameLength: 30 - FunctionNameMinLength: - active: false - aliases: ['FunctionMinNameLength'] - minimumFunctionNameLength: 3 - FunctionNaming: - active: true - aliases: ['FunctionName'] - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - functionPattern: '[a-z][a-zA-Z0-9]*' - excludeClassPattern: '$^' - FunctionParameterNaming: - active: true - parameterPattern: '[a-z][A-Za-z0-9]*' - excludeClassPattern: '$^' - InvalidPackageDeclaration: - active: true - aliases: ['PackageDirectoryMismatch'] - rootPackage: '' - requireRootInDeclaration: false - LambdaParameterNaming: - active: false - parameterPattern: '[a-z][A-Za-z0-9]*|_' - MatchingDeclarationName: - active: false - mustBeFirst: true - multiplatformTargets: - - 'ios' - - 'android' - - 'js' - - 'jvm' - - 'native' - - 'iosArm64' - - 'iosX64' - - 'macosX64' - - 'mingwX64' - - 'linuxX64' - MemberNameEqualsClassName: - active: true - ignoreOverridden: true - NoNameShadowing: - active: true - NonBooleanPropertyPrefixedWithIs: - active: true - ObjectPropertyNaming: - active: true - aliases: ['ObjectPropertyName'] - constantPattern: '[A-Za-z][_A-Za-z0-9]*' - propertyPattern: '[A-Za-z][_A-Za-z0-9]*' - privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' - PackageNaming: - active: true - aliases: ['PackageName'] - packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' - TopLevelPropertyNaming: - active: true - constantPattern: '[A-Z][_A-Z0-9]*' - propertyPattern: '[A-Za-z][_A-Za-z0-9]*' - privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' - VariableMaxLength: - active: false - maximumVariableNameLength: 64 - VariableMinLength: - active: false - minimumVariableNameLength: 1 - VariableNaming: - active: false - aliases: ['PropertyName'] - variablePattern: '[a-z][A-Za-z0-9]*' - privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' - excludeClassPattern: '$^' - -performance: - active: true - ArrayPrimitive: - active: true - CouldBeSequence: - active: false - allowedOperations: 3 - ForEachOnRange: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - SpreadOperator: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - UnnecessaryInitOnArray: - active: false - UnnecessaryPartOfBinaryExpression: - active: true - UnnecessaryTemporaryInstantiation: - active: true - UnnecessaryTypeCasting: - active: true - -potential-bugs: - active: true - AvoidReferentialEquality: - active: true - forbiddenTypePatterns: - - 'kotlin.String' - CastNullableToNonNullableType: - active: true - ignorePlatformTypes: true - CastToNullableType: - active: false - CharArrayToStringCall: - active: false - Deprecation: - active: false - aliases: ['DEPRECATION'] - DontDowncastCollectionTypes: - active: false - DoubleMutabilityForCollection: - active: true - aliases: ['DoubleMutability'] - mutableTypes: - - 'kotlin.collections.MutableList' - - 'kotlin.collections.MutableMap' - - 'kotlin.collections.MutableSet' - - 'java.util.ArrayList' - - 'java.util.LinkedHashSet' - - 'java.util.HashSet' - - 'java.util.LinkedHashMap' - - 'java.util.HashMap' - ElseCaseInsteadOfExhaustiveWhen: - active: false - ignoredSubjectTypes: [] - EqualsAlwaysReturnsTrueOrFalse: - active: true - EqualsWithHashCodeExist: - active: true - ExitOutsideMain: - active: false - ExplicitGarbageCollectionCall: - active: true - HasPlatformType: - active: true - IgnoredReturnValue: - active: true - restrictToConfig: true - returnValueAnnotations: - - 'CheckResult' - - '*.CheckResult' - - 'CheckReturnValue' - - '*.CheckReturnValue' - ignoreReturnValueAnnotations: - - 'CanIgnoreReturnValue' - - '*.CanIgnoreReturnValue' - returnValueTypes: - - 'kotlin.Function*' - - 'kotlin.sequences.Sequence' - - 'kotlinx.coroutines.flow.*Flow' - - 'java.util.stream.*Stream' - ignoreFunctionCall: [] - ImplicitDefaultLocale: - active: true - ImplicitUnitReturnType: - active: false - ignoreAnnotated: ['Test'] - allowExplicitReturnType: true - InvalidRange: - active: true - IteratorHasNextCallsNextMethod: - active: true - IteratorNotThrowingNoSuchElementException: - active: true - LateinitUsage: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - ignoreOnClassesPattern: '' - MapGetWithNotNullAssertionOperator: - active: true - MissingPackageDeclaration: - active: true - excludes: ['**/*.kts'] - MissingSuperCall: - active: false - mustInvokeSuperAnnotations: - - 'androidx.annotation.CallSuper' - - 'javax.annotation.OverridingMethodsMustInvokeSuper' - MissingUseCall: - active: false - ignoreClass: - - 'java.io.ByteArrayInputStream' - - 'java.io.ByteArrayOutputStream' - NullCheckOnMutableProperty: - active: false - NullableToStringCall: - active: false - PropertyUsedBeforeDeclaration: - active: false - UnconditionalJumpStatementInLoop: - active: false - UnnamedParameterUse: - active: false - allowAdjacentDifferentTypeParams: true - allowSingleParamUse: true - ignoreArgumentsMatchingNames: true - ignoreFunctionCall: [] - UnnecessaryNotNullCheck: - active: false - UnnecessaryNotNullOperator: - active: true - UnnecessarySafeCall: - active: true - UnreachableCatchBlock: - active: true - UnreachableCode: - active: true - UnsafeCallOnNullableType: - active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] - UnsafeCast: - active: true - aliases: ['UNCHECKED_CAST'] - UnusedUnaryOperator: - active: true - UselessPostfixExpression: - active: true - WrongEqualsTypeParameter: - active: true - -style: - active: true - AbstractClassCanBeConcreteClass: - active: true - AbstractClassCanBeInterface: - active: true - AlsoCouldBeApply: - active: false - BracesOnIfStatements: - active: false - singleLine: 'never' - multiLine: 'always' - BracesOnWhenStatements: - active: false - singleLine: 'necessary' - multiLine: 'consistent' - CanBeNonNullable: - active: false - CascadingCallWrapping: - active: false - includeElvis: true - ClassOrdering: - active: false - CollapsibleIfStatements: - active: false - DataClassContainsFunctions: - active: false - conversionFunctionPrefix: - - 'to' - allowOperators: false - DataClassShouldBeImmutable: - active: false - DestructuringDeclarationWithTooManyEntries: - active: true - maxDestructuringEntries: 3 - DoubleNegativeExpression: - active: false - DoubleNegativeLambda: - active: false - negativeFunctions: - - reason: 'Use `takeIf` instead.' - value: 'takeUnless' - - reason: 'Use `all` instead.' - value: 'none' - negativeFunctionNameParts: - - 'not' - - 'non' - EqualsNullCall: - active: true - EqualsOnSignatureLine: - active: false - ExplicitCollectionElementAccessMethod: - active: false - ExplicitItLambdaMultipleParameters: - active: true - ExplicitItLambdaParameter: - active: false - ExpressionBodySyntax: - active: false - includeLineWrapping: false - ForbiddenAnnotation: - active: true - annotations: - - reason: 'it is a java annotation. Use `Suppress` instead.' - value: 'java.lang.SuppressWarnings' - - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' - value: 'java.lang.Deprecated' - - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' - value: 'java.lang.annotation.Documented' - - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' - value: 'java.lang.annotation.Target' - - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' - value: 'java.lang.annotation.Retention' - - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' - value: 'java.lang.annotation.Repeatable' - ForbiddenComment: - active: true - comments: - - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' - value: 'FIXME:' - - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' - value: 'STOPSHIP:' - - reason: 'Forbidden TODO todo marker in comment, please do the changes.' - value: 'TODO:' - allowedPatterns: '' - ForbiddenImport: - active: false - forbiddenImports: [] - allowedImports: [] - ForbiddenMethodCall: - active: true - methods: - - reason: 'print does not allow you to configure the output stream. Use a logger instead.' - value: 'kotlin.io.print' - - reason: 'println does not allow you to configure the output stream. Use a logger instead.' - value: 'kotlin.io.println' - - reason: 'using `BigDecimal(Double)` can result in unexpected floating point precision behavior. Use `BigDecimal.valueOf(Double)` or `String.toBigDecimalOrNull()` instead.' - value: 'java.math.BigDecimal.(kotlin.Double)' - - reason: 'using `BigDecimal(String)` can result in a `NumberFormatException`. Use `String.toBigDecimalOrNull()`' - value: 'java.math.BigDecimal.(kotlin.String)' - - reason: 'It is marked as obsolete. Use `kotlin.time.measureTime` instead.' - value: 'kotlin.system.measureTimeMillis' - ForbiddenNamedParam: - active: false - methods: [] - ForbiddenOptIn: - active: false - markerClasses: [] - ForbiddenSuppress: - active: false - rules: [] - ForbiddenVoid: - active: true - ignoreOverridden: false - ignoreUsageInGenerics: false - FunctionOnlyReturningConstant: - active: true - ignoreOverridableFunction: true - ignoreActualFunction: true - excludedFunctions: [] - LoopWithTooManyJumpStatements: - active: true - maxJumpCount: 1 - MagicNumber: - active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts'] - ignoreNumbers: - - '-1' - - '0' - - '1' - - '2' - ignoreHashCodeFunction: true - ignorePropertyDeclaration: true - ignoreLocalVariableDeclaration: true - ignoreConstantDeclaration: true - ignoreCompanionObjectPropertyDeclaration: true - ignoreAnnotation: false - ignoreNamedArgument: true - ignoreEnums: false - ignoreRanges: false - ignoreExtensionFunctions: true - MandatoryBracesLoops: - active: false - MaxChainedCallsOnSameLine: - active: false - maxChainedCalls: 5 - MaxLineLength: - active: false - maxLineLength: 120 - excludePackageStatements: true - excludeImportStatements: true - excludeCommentStatements: false - excludeRawStrings: true - MayBeConstant: - active: true - ModifierOrder: - active: true - MultilineLambdaItParameter: - active: false - MultilineRawStringIndentation: - active: false - indentSize: 4 - trimmingMethods: - - 'trimIndent' - - 'trimMargin' - NestedClassesVisibility: - active: true - NewLineAtEndOfFile: - active: true - NoTabs: - active: false - NullableBooleanCheck: - active: false - ObjectLiteralToLambda: - active: true - OptionalAbstractKeyword: - active: true - OptionalUnit: - active: false - ProtectedMemberInFinalClass: - active: true - RangeUntilInsteadOfRangeTo: - active: true - RedundantConstructorKeyword: - active: false - RedundantExplicitType: - active: false - RedundantHigherOrderMapUsage: - active: true - RedundantVisibilityModifier: - active: false - ReturnCount: - active: false - max: 2 - excludedFunctions: - - 'equals' - excludeLabeled: false - excludeReturnFromLambda: true - excludeGuardClauses: false - SafeCast: - active: true - SerialVersionUIDInSerializableClass: - active: true - SpacingAfterPackageAndImports: - active: false - StringShouldBeRawString: - active: false - maxEscapedCharacterCount: 2 - ignoredCharacters: [] - ThrowsCount: - active: false - max: 2 - excludeGuardClauses: false - TrailingWhitespace: - active: false - TrimMultilineRawString: - active: false - trimmingMethods: - - 'trimIndent' - - 'trimMargin' - UnderscoresInNumericLiterals: - active: false - acceptableLength: 4 - allowNonStandardGrouping: false - UnnecessaryAny: - active: false - UnnecessaryApply: - active: true - UnnecessaryBackticks: - active: false - UnnecessaryBracesAroundTrailingLambda: - active: false - UnnecessaryFilter: - active: true - UnnecessaryFullyQualifiedName: - active: true - UnnecessaryInheritance: - active: true - UnnecessaryInnerClass: - active: false - UnnecessaryLet: - active: false - UnnecessaryParentheses: - active: false - allowForUnclearPrecedence: false - UnnecessaryReversed: - active: false - UnusedImport: - active: false - additionalOperatorSet: [] - UnusedParameter: - active: true - aliases: ['UNUSED_PARAMETER', 'unused'] - allowedNames: 'ignored|expected' - UnusedPrivateClass: - active: true - aliases: ['unused'] - UnusedPrivateFunction: - active: true - aliases: ['unused'] - allowedNames: '' - UnusedPrivateProperty: - active: true - aliases: ['unused'] - allowedNames: 'ignored|expected|serialVersionUID' - UnusedVariable: - active: true - aliases: ['UNUSED_VARIABLE', 'unused'] - allowedNames: 'ignored|_' - UseAnyOrNoneInsteadOfFind: - active: true - UseArrayLiteralsInAnnotations: - active: true - UseCheckNotNull: - active: true - UseCheckOrError: - active: true - UseDataClass: - active: false - allowVars: false - UseEmptyCounterpart: - active: false - UseIfEmptyOrIfBlank: - active: false - UseIfInsteadOfWhen: - active: false - ignoreWhenContainingVariableDeclaration: false - UseIsNullOrEmpty: - active: true - UseLet: - active: false - UseOrEmpty: - active: true - UseRequire: - active: true - UseRequireNotNull: - active: true - UseSumOfInsteadOfFlatMapSize: - active: false - UselessCallOnNotNull: - active: true - UtilityClassWithPublicConstructor: - active: true - VarCouldBeVal: - active: true - aliases: ['CanBeVal'] - ignoreLateinitVar: false - WildcardImport: - active: false - excludeImports: - - 'java.util.*' diff --git a/conformance-test/.gitignore b/conformance-test/.gitignore deleted file mode 100644 index 6628455..0000000 --- a/conformance-test/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/results/ diff --git a/conformance-test/README.md b/conformance-test/README.md deleted file mode 100644 index 9b827bc..0000000 --- a/conformance-test/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# MCP Conformance Tests - -Conformance tests for the Kotlin MCP SDK. Uses the external -[`@modelcontextprotocol/conformance`](https://www.npmjs.com/package/@modelcontextprotocol/conformance) -runner (pinned to **0.1.15**) to validate compliance with the MCP specification. - -## Prerequisites - -- **JDK 17+** -- **Node.js 18+** and `npx` (for the conformance runner) -- **curl** (used to poll server readiness) - -## Quick Start - -Run **all** suites (server, client core, client auth) from the project root: - -```bash -./conformance-test/run-conformance.sh all -``` - -## Commands - -``` -./conformance-test/run-conformance.sh [extra-args...] -``` - -| Command | What it does | -|---------------|--------------------------------------------------------------------------------------| -| `list` | [List scenarios available in MCP Conformance Test Framework][list-scenarios-command] | -| `server` | Starts the Ktor conformance server, runs the server test suite against it | -| `client` | Runs the client test suite (`initialize`, `tools_call`, `elicitation`, `sse-retry`) | -| `client-auth` | Runs the client auth test suite (20 OAuth scenarios) | -| `all` | Runs all three suites sequentially | - -Any `[extra-args]` are forwarded to the conformance runner (e.g. `--verbose`). - -## What the Script Does - -1. **Builds** the module via `./gradlew :conformance-test:installDist` -2. For `server` — starts the conformance server on `localhost:3001`, polls until ready -3. Invokes `npx @modelcontextprotocol/conformance@0.1.15` with the appropriate arguments -4. Saves results to `conformance-test/results//` -5. Cleans up the server process on exit -6. Exits non-zero if any suite fails - -## Environment Variables - -| Variable | Default | Description | -|------------|---------|---------------------------------| -| `MCP_PORT` | `3001` | Port for the conformance server | - -## Project Structure - -``` -conformance-test/ -├── run-conformance.sh # Single entry point script -├── conformance-baseline.yml # Expected failures for known SDK limitations -└── src/main/kotlin/.../conformance/ - ├── ConformanceServer.kt # Ktor server entry point (StreamableHTTP, DNS rebinding, EventStore) - ├── ConformanceClient.kt # Scenario-based client entry point (MCP_CONFORMANCE_SCENARIO routing) - ├── ConformanceTools.kt # 18 tool registrations - ├── ConformanceResources.kt # 5 resource registrations (static, binary, template, watched, dynamic) - ├── ConformancePrompts.kt # 5 prompt registrations (simple, args, image, embedded, dynamic) - ├── ConformanceCompletions.kt # completion/complete handler - ├── InMemoryEventStore.kt # EventStore impl for SSE resumability (SEP-1699) - └── auth/ # OAuth client for 20 auth scenarios - ├── registration.kt # Scenario handler registration - ├── utils.kt # Shared utilities: JSON instance, constants, extractOrigin() - ├── discovery.kt # Protected Resource Metadata + AS Metadata discovery - ├── pkce.kt # PKCE code verifier/challenge generation + AS capability check - ├── tokenExchange.kt # Token endpoint interaction (exchange code, error handling) - ├── authCodeFlow.kt # Main Authorization Code flow handler (runAuthClient + interceptor) - ├── scopeHandling.kt # Scope selection strategy + step-up 403 handling - ├── clientRegistration.kt # Client registration logic (pre-reg, CIMD, dynamic) - ├── JWTScenario.kt # Client Credentials JWT scenario - ├── basicScenario.kt # Client Credentials Basic scenario - └── crossAppAccessScenario.kt # Cross-App Access (SEP-990) scenario -``` - -## Test Suites - -### Server Suite - -Tests the conformance server against all server scenarios: - -| Category | Scenarios | -|-------------|-------------------------------------------------------------------------------------------------------------------------------------| -| Lifecycle | initialize, ping | -| Tools | text, image, audio, embedded, multiple, progress, logging, error, sampling, elicitation, dynamic, reconnection, JSON Schema 2020-12 | -| Resources | list, read-text, read-binary, templates, subscribe, dynamic | -| Prompts | simple, with-args, with-image, with-embedded-resource, dynamic | -| Completions | complete | -| Security | DNS rebinding protection | - -### Client Core Suite - -| Scenario | Description | -|---------------------------------------|-----------------------------------------------| -| `initialize` | Connect, list tools, close | -| `tools_call` | Connect, call `add_numbers(a=5, b=3)`, close | -| `elicitation-sep1034-client-defaults` | Elicitation with `applyDefaults` capability | -| `sse-retry` | Call `test_reconnection`, verify reconnection | - -### Client Auth Suite - -17 OAuth Authorization Code scenarios + 2 Client Credentials scenarios (`jwt`, `basic`) + 1 Cross-App Access scenario = 20 total. - -> [!NOTE] -> Auth scenarios are implemented using Ktor's `HttpClient` plugins (`HttpSend` interceptor, -> `ktor-client-auth`) as a standalone OAuth client. They do not use the SDK's built-in auth support. - -## Known SDK Limitations - -Scenarios, expected to fail due to current SDK limitations, are tracked in -[`conformance-baseline.yml`](conformance-baseline.yml). -These failures reveal SDK gaps and are intentionally not fixed in this module. - -[list-scenarios-command]: https://github.com/modelcontextprotocol/conformance/tree/main?tab=readme-ov-file#list-available-scenarios diff --git a/conformance-test/build.gradle.kts b/conformance-test/build.gradle.kts index a96f4fb..1877d09 100644 --- a/conformance-test/build.gradle.kts +++ b/conformance-test/build.gradle.kts @@ -1,34 +1,39 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + plugins { kotlin("jvm") - application -} - -application { - mainClass.set("io.modelcontextprotocol.kotlin.sdk.conformance.ConformanceServerKt") -} - -tasks.register("conformanceClientScripts") { - mainClass.set("io.modelcontextprotocol.kotlin.sdk.conformance.ConformanceClientKt") - applicationName = "conformance-client" - outputDir = tasks.named("startScripts").get().outputDir - classpath = tasks.named("jar").get().outputs.files + configurations.named("runtimeClasspath").get() -} - -tasks.named("installDist") { - dependsOn("conformanceClientScripts") -} - -tasks.named("clean") { - delete("results") } dependencies { - implementation(project(":kotlin-sdk")) - implementation(libs.ktor.server.cio) - implementation(libs.ktor.server.content.negotiation) - implementation(libs.ktor.serialization) - implementation(libs.ktor.client.cio) - implementation(libs.ktor.client.auth) - implementation(libs.kotlin.logging) - runtimeOnly(libs.slf4j.simple) + testImplementation(project(":kotlin-sdk")) + testImplementation(project(":test-utils")) + testImplementation(kotlin("test")) + testImplementation(libs.kotlin.logging) + testImplementation(libs.ktor.client.cio) + testImplementation(libs.ktor.server.cio) + testImplementation(libs.ktor.server.websockets) + testRuntimeOnly(libs.slf4j.simple) +} + +tasks.test { + useJUnitPlatform() + + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = true + showExceptions = true + showCauses = true + showStackTraces = true + exceptionFormat = TestExceptionFormat.FULL + } + + doFirst { + systemProperty("test.classpath", classpath.asPath) + + println("\n" + "=".repeat(60)) + println("MCP CONFORMANCE TESTS") + println("=".repeat(60)) + println("These tests validate compliance with the MCP specification.") + println("=".repeat(60) + "\n") + } } diff --git a/conformance-test/conformance-baseline.yml b/conformance-test/conformance-baseline.yml deleted file mode 100644 index f349b8c..0000000 --- a/conformance-test/conformance-baseline.yml +++ /dev/null @@ -1,5 +0,0 @@ -# Conformance test baseline - expected failures -# Add entries here as tests are identified as known SDK limitations -server: [] - -client: [] diff --git a/conformance-test/run-conformance.sh b/conformance-test/run-conformance.sh deleted file mode 100755 index 279254e..0000000 --- a/conformance-test/run-conformance.sh +++ /dev/null @@ -1,194 +0,0 @@ -#!/bin/bash -# Script to run MCP conformance tests for the Kotlin SDK. -# -# Usage: ./conformance-test/run-conformance.sh [extra-args...] -# Commands: server | client | client-auth | all - -set -uo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1; pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." || exit 1; pwd)" - -CONFORMANCE_VERSION="0.1.16" -PORT="${MCP_PORT:-3001}" -SERVER_URL="http://localhost:${PORT}/mcp" -RESULTS_DIR="$SCRIPT_DIR/results" -SERVER_DIST="$SCRIPT_DIR/build/install/conformance-test/bin/conformance-test" -CLIENT_DIST="$SCRIPT_DIR/build/install/conformance-test/bin/conformance-client" - -SERVER_PID="" - -# shellcheck disable=SC2317 -cleanup() { - if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then - echo "Stopping server (PID: $SERVER_PID)..." - kill "$SERVER_PID" 2>/dev/null || true - wait "$SERVER_PID" 2>/dev/null || true - fi -} -trap cleanup EXIT - -build() { - echo "Building conformance-test distributions..." - cd "$PROJECT_ROOT" || return 1 - ./gradlew :conformance-test:installDist --quiet - cd "$SCRIPT_DIR" || return 1 - echo "Build complete." -} - -start_server() { - echo "Starting conformance server on port $PORT..." - MCP_PORT="$PORT" "$SERVER_DIST" & - SERVER_PID=$! - - echo "Waiting for server to be ready..." - local retries=0 - local max_retries=30 - while ! curl -sf "$SERVER_URL" > /dev/null 2>&1; do - retries=$((retries + 1)) - if [ "$retries" -ge "$max_retries" ]; then - echo "ERROR: Server failed to start after $max_retries attempts" - return 1 - fi - sleep 0.5 - done - echo "Server is ready (PID: $SERVER_PID)." -} - -stop_server() { - if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then - echo "Stopping server (PID: $SERVER_PID)..." - kill "$SERVER_PID" 2>/dev/null || true - wait "$SERVER_PID" 2>/dev/null || true - SERVER_PID="" - fi -} - -run_list_scenarios() { - local output_dir="$RESULTS_DIR/list" - mkdir -p "$output_dir" - echo "" - echo "==========================================" - echo " List Available Scenarios" - echo "==========================================" - local rc=0 - npx "@modelcontextprotocol/conformance@$CONFORMANCE_VERSION" list \ - "$@" > "$output_dir/scenarios.txt" || rc=$? - - cat "$output_dir/scenarios.txt" - return $rc -} - -run_server_suite() { - local output_dir="$RESULTS_DIR/server" - mkdir -p "$output_dir" - echo "" - echo "==========================================" - echo " Running SERVER conformance tests" - echo "==========================================" - start_server || return 1 - local rc=0 - npx "@modelcontextprotocol/conformance@$CONFORMANCE_VERSION" server \ - --url "$SERVER_URL" \ - --output-dir "$output_dir" \ - --expected-failures "$SCRIPT_DIR/conformance-baseline.yml" \ - "$@" || rc=$? - stop_server - return $rc -} - -run_client_suite() { - local output_dir="$RESULTS_DIR/client" - mkdir -p "$output_dir" - echo "" - echo "==========================================" - echo " Running CLIENT conformance tests" - echo "==========================================" - local scenarios=("initialize" "tools_call" "elicitation-sep1034-client-defaults" "sse-retry") - local rc=0 - for scenario in "${scenarios[@]}"; do - npx "@modelcontextprotocol/conformance@$CONFORMANCE_VERSION" client \ - --command "$CLIENT_DIST" \ - --scenario "$scenario" \ - --output-dir "$output_dir" \ - --expected-failures "$SCRIPT_DIR/conformance-baseline.yml" \ - "$@" || rc=$? - done - return $rc -} - -run_client_auth_suite() { - local output_dir="$RESULTS_DIR/client-auth" - mkdir -p "$output_dir" - echo "" - echo "==========================================" - echo " Running CLIENT (auth) conformance tests" - echo "==========================================" - local rc=0 - npx "@modelcontextprotocol/conformance@$CONFORMANCE_VERSION" client \ - --command "$CLIENT_DIST" \ - --suite auth \ - --output-dir "$output_dir" \ - --expected-failures "$SCRIPT_DIR/conformance-baseline.yml" \ - "$@" || rc=$? - - local extra_scenarios=("auth/client-credentials-jwt" "auth/client-credentials-basic" "auth/cross-app-access-complete-flow") - for scenario in "${extra_scenarios[@]}"; do - npx "@modelcontextprotocol/conformance@$CONFORMANCE_VERSION" client \ - --command "$CLIENT_DIST" \ - --scenario "$scenario" \ - --output-dir "$output_dir" \ - --expected-failures "$SCRIPT_DIR/conformance-baseline.yml" \ - "$@" || rc=$? - done - return $rc -} - -# ============================================================================ -# Main -# ============================================================================ - -COMMAND="${1:-}" -shift 2>/dev/null || true - -if [ -z "$COMMAND" ]; then - echo "Usage: $0 [extra-args...]" - echo "Commands: list | server | client | client-auth | all" - exit 1 -fi - -build - -EXIT_CODE=0 - -case "$COMMAND" in - list) - run_list_scenarios "$@" || EXIT_CODE=1 - ;; - server) - run_server_suite "$@" || EXIT_CODE=1 - ;; - client) - run_client_suite "$@" || EXIT_CODE=1 - ;; - client-auth) - run_client_auth_suite "$@" || EXIT_CODE=1 - ;; - all) - run_server_suite "$@" || EXIT_CODE=1 - run_client_suite "$@" || EXIT_CODE=1 - run_client_auth_suite "$@" || EXIT_CODE=1 - ;; - *) - echo "Unknown command: $COMMAND" - echo "Commands: list | server | client | client-auth | all" - exit 1 - ;; -esac - -echo "" -echo "==========================================" -echo " Results saved to: $RESULTS_DIR" -echo "==========================================" - -exit $EXIT_CODE diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceClient.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceClient.kt deleted file mode 100644 index dba24ff..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceClient.kt +++ /dev/null @@ -1,199 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance - -import io.github.oshai.kotlinlogging.KotlinLogging -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.sse.SSE -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.conformance.auth.registerAuthScenarios -import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest -import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlin.system.exitProcess -import kotlin.time.Duration.Companion.seconds - -private val logger = KotlinLogging.logger {} - -typealias ScenarioHandler = suspend (serverUrl: String) -> Unit - -internal val scenarioHandlers = mutableMapOf() - -// ============================================================================ -// Main entry point -// ============================================================================ - -fun main(args: Array) { - val scenarioName = System.getenv("MCP_CONFORMANCE_SCENARIO") - val serverUrl = args.lastOrNull() - - // Register all scenario handlers - registerCoreScenarios() - registerAuthScenarios() - - if (scenarioName == null || serverUrl == null) { - logger.error { "Usage: MCP_CONFORMANCE_SCENARIO= conformance-client " } - logger.error { "\nThe MCP_CONFORMANCE_SCENARIO env var is set automatically by the conformance runner." } - logger.error { "\nAvailable scenarios:" } - for (name in scenarioHandlers.keys.sorted()) { - logger.error { " - $name" } - } - exitProcess(1) - } - - val handler = scenarioHandlers[scenarioName] - if (handler == null) { - logger.error { "Unknown scenario: $scenarioName" } - logger.error { "\nAvailable scenarios:" } - for (name in scenarioHandlers.keys.sorted()) { - logger.error { " - $name" } - } - exitProcess(1) - } - - try { - runBlocking { - handler(serverUrl) - } - exitProcess(0) - } catch (e: Exception) { - logger.error(e) { "Error: ${e.message}" } - exitProcess(1) - } -} - -// ============================================================================ -// Shared HTTP client factory -// ============================================================================ - -private fun createHttpClient(): HttpClient = HttpClient(CIO) { - install(SSE) - install(HttpTimeout) { - requestTimeoutMillis = 30.seconds.inWholeMilliseconds - } -} - -// ============================================================================ -// Basic scenarios (initialize, tools_call) -// ============================================================================ - -private suspend fun runBasicClient(serverUrl: String) { - createHttpClient().use { httpClient -> - val transport = StreamableHttpClientTransport(httpClient, serverUrl) - val client = Client( - clientInfo = Implementation("test-client", "1.0.0"), - options = ClientOptions(capabilities = ClientCapabilities()), - ) - client.connect(transport) - client.close() - } -} - -private suspend fun runToolsCallClient(serverUrl: String) { - createHttpClient().use { httpClient -> - val transport = StreamableHttpClientTransport(httpClient, serverUrl) - val client = Client( - clientInfo = Implementation("test-client", "1.0.0"), - options = ClientOptions(capabilities = ClientCapabilities()), - ) - client.connect(transport) - - val tools = client.listTools() - val addTool = tools.tools.find { it.name == "add_numbers" } - if (addTool != null) { - client.callTool( - CallToolRequest( - CallToolRequestParams( - name = "add_numbers", - arguments = buildJsonObject { - put("a", 5) - put("b", 3) - }, - ), - ), - ) - } - - client.close() - } -} - -// ============================================================================ -// Elicitation defaults scenario -// ============================================================================ - -private suspend fun runElicitationDefaultsClient(serverUrl: String) { - createHttpClient().use { httpClient -> - val transport = StreamableHttpClientTransport(httpClient, serverUrl) - val client = Client( - clientInfo = Implementation("elicitation-defaults-test-client", "1.0.0"), - options = ClientOptions( - capabilities = ClientCapabilities( - elicitation = ClientCapabilities.elicitation, - ), - ), - ) - - // Register elicitation handler that returns empty content — SDK should fill in defaults - client.setElicitationHandler { _ -> - ElicitResult( - action = ElicitResult.Action.Accept, - content = JsonObject(emptyMap()), - ) - } - - client.connect(transport) - - val tools = client.listTools() - val testTool = tools.tools.find { it.name == "test_client_elicitation_defaults" } - ?: error("Test tool not found: test_client_elicitation_defaults") - - client.callTool( - CallToolRequest(CallToolRequestParams(name = testTool.name)), - ) - - client.close() - } -} - -// ============================================================================ -// SSE retry scenario -// ============================================================================ - -private suspend fun runSSERetryClient(serverUrl: String) { - createHttpClient().use { httpClient -> - val transport = StreamableHttpClientTransport(httpClient, serverUrl) - val client = Client( - clientInfo = Implementation("sse-retry-test-client", "1.0.0"), - options = ClientOptions(capabilities = ClientCapabilities()), - ) - client.connect(transport) - - client.listTools() - - client.callTool( - CallToolRequest(CallToolRequestParams(name = "test_reconnection")), - ) - - client.close() - } -} - -// ============================================================================ -// Register core scenarios -// ============================================================================ - -private fun registerCoreScenarios() { - scenarioHandlers["initialize"] = ::runBasicClient - scenarioHandlers["tools_call"] = ::runToolsCallClient - scenarioHandlers["elicitation-sep1034-client-defaults"] = ::runElicitationDefaultsClient - scenarioHandlers["sse-retry"] = ::runSSERetryClient -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceCompletions.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceCompletions.kt deleted file mode 100644 index 6297139..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceCompletions.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance - -import io.modelcontextprotocol.kotlin.sdk.server.Server -import io.modelcontextprotocol.kotlin.sdk.types.CompleteRequest -import io.modelcontextprotocol.kotlin.sdk.types.CompleteResult -import io.modelcontextprotocol.kotlin.sdk.types.Method - -fun Server.registerConformanceCompletions() { - onConnect { - val session = sessions.values.lastOrNull() ?: return@onConnect - session.setRequestHandler(Method.Defined.CompletionComplete) { _, _ -> - CompleteResult(CompleteResult.Completion(values = emptyList(), total = 0, hasMore = false)) - } - } -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformancePrompts.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformancePrompts.kt deleted file mode 100644 index 6c591e4..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformancePrompts.kt +++ /dev/null @@ -1,114 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance - -import io.modelcontextprotocol.kotlin.sdk.server.Server -import io.modelcontextprotocol.kotlin.sdk.types.EmbeddedResource -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult -import io.modelcontextprotocol.kotlin.sdk.types.ImageContent -import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument -import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage -import io.modelcontextprotocol.kotlin.sdk.types.Role -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents -import kotlinx.coroutines.delay -import kotlin.time.Duration.Companion.milliseconds - -fun Server.registerConformancePrompts() { - // 1. Simple prompt - addPrompt( - name = "test_simple_prompt", - description = "test_simple_prompt", - ) { - GetPromptResult( - messages = listOf( - PromptMessage(Role.User, TextContent("This is a simple prompt for testing.")), - ), - ) - } - - // 2. Prompt with arguments - addPrompt( - name = "test_prompt_with_arguments", - description = "test_prompt_with_arguments", - arguments = listOf( - PromptArgument(name = "arg1", description = "First test argument", required = true), - PromptArgument(name = "arg2", description = "Second test argument", required = true), - ), - ) { request -> - val arg1 = request.arguments?.get("arg1") ?: "" - val arg2 = request.arguments?.get("arg2") ?: "" - GetPromptResult( - messages = listOf( - PromptMessage( - Role.User, - TextContent("Prompt with arguments: arg1='$arg1', arg2='$arg2'"), - ), - ), - ) - } - - // 3. Prompt with image - addPrompt( - name = "test_prompt_with_image", - description = "test_prompt_with_image", - ) { - GetPromptResult( - messages = listOf( - PromptMessage(Role.User, ImageContent(data = PNG_BASE64, mimeType = "image/png")), - PromptMessage(Role.User, TextContent("Please analyze the image above.")), - ), - ) - } - - // 4. Prompt with embedded resource - addPrompt( - name = "test_prompt_with_embedded_resource", - description = "test_prompt_with_embedded_resource", - arguments = listOf( - PromptArgument(name = "resourceUri", description = "URI of the resource to embed", required = true), - ), - ) { request -> - val resourceUri = request.arguments?.get("resourceUri") ?: "test://embedded-resource" - GetPromptResult( - messages = listOf( - PromptMessage( - Role.User, - EmbeddedResource( - resource = TextResourceContents( - text = "Embedded resource content for testing", - uri = resourceUri, - mimeType = "text/plain", - ), - ), - ), - PromptMessage(Role.User, TextContent("Please process the embedded resource above.")), - ), - ) - } - - // 5. Dynamic prompt - val server = this - addPrompt( - name = "test_dynamic_prompt", - description = "test_dynamic_prompt", - ) { - // Add a temporary prompt, triggering listChanged - server.addPrompt( - name = "test_dynamic_prompt_temp", - description = "Temporary dynamic prompt", - ) { - GetPromptResult( - messages = listOf( - PromptMessage(Role.User, TextContent("Temporary prompt response")), - ), - ) - } - delay(100.milliseconds) - // Remove the temporary prompt, triggering listChanged again - server.removePrompt("test_dynamic_prompt_temp") - GetPromptResult( - messages = listOf( - PromptMessage(Role.User, TextContent("Dynamic prompt executed successfully")), - ), - ) - } -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceResources.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceResources.kt deleted file mode 100644 index 89d83f6..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceResources.kt +++ /dev/null @@ -1,122 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance - -import io.modelcontextprotocol.kotlin.sdk.server.Server -import io.modelcontextprotocol.kotlin.sdk.types.BlobResourceContents -import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult -import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents -import kotlinx.coroutines.delay -import kotlin.time.Duration.Companion.milliseconds - -fun Server.registerConformanceResources() { - // 1. Static text resource - addResource( - uri = "test://static-text", - name = "static-text", - description = "A static text resource for testing", - mimeType = "text/plain", - ) { - ReadResourceResult( - listOf( - TextResourceContents( - text = "This is the content of the static text resource.", - uri = "test://static-text", - mimeType = "text/plain", - ), - ), - ) - } - - // 2. Static binary resource - addResource( - uri = "test://static-binary", - name = "static-binary", - description = "A static binary resource for testing", - mimeType = "image/png", - ) { - ReadResourceResult( - listOf( - BlobResourceContents( - blob = PNG_BASE64, - uri = "test://static-binary", - mimeType = "image/png", - ), - ), - ) - } - - // 3. Template resource - addResourceTemplate( - uriTemplate = "test://template/{id}/data", - name = "template", - description = "A template resource for testing", - mimeType = "application/json", - ) { request, variables -> - val id = variables["id"] - ReadResourceResult( - listOf( - TextResourceContents( - text = """{"id": "$id"}""", - uri = request.uri, - mimeType = "application/json", - ), - ), - ) - } - - // 4. Watched resource - addResource( - uri = "test://watched-resource", - name = "watched-resource", - description = "A watched resource for testing", - mimeType = "text/plain", - ) { - ReadResourceResult( - listOf( - TextResourceContents( - text = "Watched resource content.", - uri = "test://watched-resource", - mimeType = "text/plain", - ), - ), - ) - } - - // 5. Dynamic resource - val server = this - addResource( - uri = "test://dynamic-resource", - name = "dynamic-resource", - description = "A dynamic resource for testing", - mimeType = "text/plain", - ) { - // Add a temporary resource, triggering listChanged - server.addResource( - uri = "test://dynamic-resource-temp", - name = "dynamic-resource-temp", - description = "Temporary dynamic resource", - mimeType = "text/plain", - ) { - ReadResourceResult( - listOf( - TextResourceContents( - text = "Temporary resource content.", - uri = "test://dynamic-resource-temp", - mimeType = "text/plain", - ), - ), - ) - } - delay(100.milliseconds) - // Remove the temporary resource, triggering listChanged again - server.removeResource("test://dynamic-resource-temp") - ReadResourceResult( - listOf( - TextResourceContents( - text = "Dynamic resource content.", - uri = "test://dynamic-resource", - mimeType = "text/plain", - ), - ), - ) - } -} 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 deleted file mode 100644 index 8a18957..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt +++ /dev/null @@ -1,38 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance - -import io.ktor.server.cio.CIO -import io.ktor.server.engine.embeddedServer -import io.modelcontextprotocol.kotlin.sdk.server.Server -import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions -import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities - -fun main() { - val port = System.getenv("MCP_PORT")?.toIntOrNull() ?: 3001 - embeddedServer(CIO, port = port) { - mcpStreamableHttp( - eventStore = InMemoryEventStore(), - ) { - createConformanceServer() - } - }.start(wait = true) -} - -fun createConformanceServer(): Server = Server( - serverInfo = Implementation("mcp-kotlin-sdk-conformance", "0.1.0"), - options = ServerOptions( - ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = true), - resources = ServerCapabilities.Resources(subscribe = true, listChanged = true), - prompts = ServerCapabilities.Prompts(listChanged = true), - logging = ServerCapabilities.Logging, - completions = ServerCapabilities.Completions, - ), - ), -) { - registerConformanceTools() - registerConformanceResources() - registerConformancePrompts() - registerConformanceCompletions() -} 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 deleted file mode 100644 index c81c98c..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTools.kt +++ /dev/null @@ -1,454 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance - -import io.github.oshai.kotlinlogging.KotlinLogging -import io.modelcontextprotocol.kotlin.sdk.server.Server -import io.modelcontextprotocol.kotlin.sdk.types.AudioContent -import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema -import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult -import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest -import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema -import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.EmbeddedResource -import io.modelcontextprotocol.kotlin.sdk.types.EnumOption -import io.modelcontextprotocol.kotlin.sdk.types.ImageContent -import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema -import io.modelcontextprotocol.kotlin.sdk.types.LegacyTitledEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel -import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification -import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams -import io.modelcontextprotocol.kotlin.sdk.types.ProgressNotification -import io.modelcontextprotocol.kotlin.sdk.types.ProgressNotificationParams -import io.modelcontextprotocol.kotlin.sdk.types.Role -import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage -import io.modelcontextprotocol.kotlin.sdk.types.StringSchema -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents -import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema -import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema -import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema -import kotlinx.coroutines.delay -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.double -import kotlinx.serialization.json.jsonPrimitive -import kotlin.time.Duration.Companion.milliseconds - -// Minimal 1x1 PNG (base64) -internal const val PNG_BASE64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" - -// Minimal WAV (base64) -internal const val WAV_BASE64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=" - -private val logger = KotlinLogging.logger {} - -fun Server.registerConformanceTools() { - // 1. Simple text - addTool( - name = "test_simple_text", - description = "test_simple_text", - ) { - CallToolResult(listOf(TextContent("Simple text content"))) - } - - // 2. Image content - addTool( - name = "test_image_content", - description = "test_image_content", - ) { - CallToolResult(listOf(ImageContent(data = PNG_BASE64, mimeType = "image/png"))) - } - - // 3. Audio content - addTool( - name = "test_audio_content", - description = "test_audio_content", - ) { - CallToolResult(listOf(AudioContent(data = WAV_BASE64, mimeType = "audio/wav"))) - } - - // 4. Embedded resource - addTool( - name = "test_embedded_resource", - description = "test_embedded_resource", - ) { - CallToolResult( - listOf( - EmbeddedResource( - resource = TextResourceContents( - text = "This is an embedded resource content.", - uri = "test://embedded-resource", - mimeType = "text/plain", - ), - ), - ), - ) - } - - // 5. Multiple content types - addTool( - name = "test_multiple_content_types", - description = "test_multiple_content_types", - ) { - CallToolResult( - listOf( - TextContent("Simple text content"), - ImageContent(data = PNG_BASE64, mimeType = "image/png"), - EmbeddedResource( - resource = TextResourceContents( - text = "This is an embedded resource content.", - uri = "test://embedded-resource", - mimeType = "text/plain", - ), - ), - ), - ) - } - - // 6. Progress tool - addTool( - name = "test_tool_with_progress", - description = "test_tool_with_progress", - ) { request -> - val progressToken = request.meta?.progressToken - if (progressToken != null) { - notification( - ProgressNotification( - ProgressNotificationParams( - progressToken, - 0.0, - 100.0, - "Completed step 0 of 100", - ), - ), - ) - delay(50.milliseconds) - notification( - ProgressNotification( - ProgressNotificationParams( - progressToken, - 50.0, - 100.0, - "Completed step 50 of 100", - ), - ), - ) - delay(50.milliseconds) - notification( - ProgressNotification( - ProgressNotificationParams( - progressToken, - 100.0, - 100.0, - "Completed step 100 of 100", - ), - ), - ) - } - CallToolResult(listOf(TextContent("Simple text content"))) - } - - // 7. Error handling - addTool( - name = "test_error_handling", - description = "test_error_handling", - ) { - throw Exception("This tool intentionally returns an error for testing") - } - - // 8. Sampling - addTool( - name = "test_sampling", - description = "test_sampling", - inputSchema = ToolSchema( - properties = buildJsonObject { - put("prompt", buildJsonObject { put("type", JsonPrimitive("string")) }) - }, - required = listOf("prompt"), - ), - ) { request -> - val prompt = request.arguments?.get("prompt")?.jsonPrimitive?.content ?: "Hello" - val result = createMessage( - CreateMessageRequest( - CreateMessageRequestParams( - maxTokens = 10000, - messages = listOf(SamplingMessage(Role.User, listOf(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))) - } - - // 9. Elicitation - addTool( - name = "test_elicitation", - description = "test_elicitation", - inputSchema = ToolSchema( - properties = buildJsonObject { - put("message", buildJsonObject { put("type", JsonPrimitive("string")) }) - }, - required = listOf("message"), - ), - ) { request -> - val message = request.arguments?.get("message")?.jsonPrimitive?.content ?: "Please provide input" - val schema = ElicitRequestParams.RequestedSchema( - properties = mapOf( - "username" to StringSchema(description = "User's response"), - "email" to StringSchema(description = "User's email address"), - ), - required = listOf("username", "email"), - ) - val result = createElicitation(message, schema) - CallToolResult(listOf(TextContent("User response: "))) - } - - // 10. Elicitation SEP1034 (defaults) - addTool( - name = "test_elicitation_sep1034_defaults", - description = "test_elicitation_sep1034_defaults", - ) { - val schema = ElicitRequestParams.RequestedSchema( - properties = mapOf( - "name" to StringSchema(description = "User name", default = "John Doe"), - "age" to IntegerSchema(description = "User age", default = 30), - "score" to DoubleSchema(description = "User score", default = 95.5), - "status" to UntitledSingleSelectEnumSchema( - description = "User status", - enumValues = listOf("active", "inactive", "pending"), - default = "active", - ), - "verified" to BooleanSchema(description = "Verification status", default = true), - ), - required = listOf("name", "age", "score", "status", "verified"), - ) - val result = createElicitation( - "Please review and update the form fields with defaults", - schema, - ) - CallToolResult(listOf(TextContent(result.content.toString()))) - } - - // 11. Elicitation SEP1330 enums - addTool( - name = "test_elicitation_sep1330_enums", - description = "test_elicitation_sep1330_enums", - ) { - val schema = ElicitRequestParams.RequestedSchema( - properties = mapOf( - // Untitled single-select - "untitledSingle" to UntitledSingleSelectEnumSchema( - enumValues = listOf("option1", "option2", "option3"), - ), - // Titled single-select - "titledSingle" to TitledSingleSelectEnumSchema( - oneOf = listOf( - EnumOption(const = "value1", title = "First Option"), - EnumOption(const = "value2", title = "Second Option"), - EnumOption(const = "value3", title = "Third Option"), - ), - ), - // Legacy titled (deprecated) — uses enum + enumNames - @Suppress("DEPRECATION") - "legacyEnum" - to LegacyTitledEnumSchema( - enumValues = listOf("opt1", "opt2", "opt3"), - enumNames = listOf("Option One", "Option Two", "Option Three"), - ), - // Untitled multi-select - "untitledMulti" to UntitledMultiSelectEnumSchema( - items = UntitledMultiSelectEnumSchema.Items( - enumValues = listOf("option1", "option2", "option3"), - ), - ), - // Titled multi-select - "titledMulti" to TitledMultiSelectEnumSchema( - items = TitledMultiSelectEnumSchema.Items( - anyOf = listOf( - EnumOption(const = "value1", title = "First Choice"), - EnumOption(const = "value2", title = "Second Choice"), - EnumOption(const = "value3", title = "Third Choice"), - ), - ), - ), - ), - ) - val result = createElicitation( - "Please review and update the form fields with defaults", - schema, - ) - CallToolResult(listOf(TextContent("Elicitation completed: action=${result.action}, content=${result.content}"))) - } - - // 12. Dynamic tool - val server = this - addTool( - name = "test_dynamic_tool", - description = "test_dynamic_tool", - ) { - // Add a temporary tool, triggering listChanged - server.addTool( - name = "test_dynamic_tool_temp", - description = "Temporary dynamic tool", - ) { - CallToolResult(listOf(TextContent("Temporary tool response"))) - } - delay(100.milliseconds) - // Remove the temporary tool, triggering listChanged again - server.removeTool("test_dynamic_tool_temp") - CallToolResult(listOf(TextContent("Dynamic tool executed successfully"))) - } - - // 13. Logging tool - addTool( - name = "test_tool_with_logging", - description = "test_tool_with_logging", - ) { - logger.debug { "[test_tool_with_logging] Sending message 1" } - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = LoggingLevel.Info, - data = JsonPrimitive("Tool execution started"), - logger = "conformance", - ), - ), - ) - delay(50.milliseconds) - logger.debug { "[test_tool_with_logging] Sending message #2" } - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = LoggingLevel.Info, - data = JsonPrimitive("Tool processing data"), - logger = "conformance", - ), - ), - ) - delay(50.milliseconds) - logger.debug { "[test_tool_with_logging] Sending message 3" } - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = LoggingLevel.Info, - data = JsonPrimitive("Tool execution completed"), - logger = "conformance", - ), - ), - ) - - CallToolResult(listOf(TextContent("Simple text content"))) - } - - // 14. add_numbers — used by tools_call client scenario - addTool( - name = "add_numbers", - description = "Adds two numbers together", - inputSchema = ToolSchema( - properties = buildJsonObject { - put("a", buildJsonObject { put("type", JsonPrimitive("number")) }) - put("b", buildJsonObject { put("type", JsonPrimitive("number")) }) - }, - required = listOf("a", "b"), - ), - ) { request -> - val a = request.arguments?.get("a")?.jsonPrimitive?.double ?: 0.0 - val b = request.arguments?.get("b")?.jsonPrimitive?.double ?: 0.0 - val sum = a + b - CallToolResult(listOf(TextContent("The sum of $a and $b is $sum"))) - } - - // 15. test_reconnection — SEP-1699, closes SSE stream to test client reconnection - addTool( - name = "test_reconnection", - description = "Tests SSE stream disconnection and client reconnection (SEP-1699)", - ) { - // SDK limitation: cannot access the JSONRPC request ID from the tool handler - // to close the SSE stream. Return success text; this test may fail at the - // conformance runner level because the stream isn't actually closed. - delay(100.milliseconds) - CallToolResult( - listOf( - TextContent( - "Reconnection test completed successfully. " + - "If you received this, the client properly reconnected after stream closure.", - ), - ), - ) - } - - // 16. json_schema_2020_12_tool — SEP-1613 - addTool( - name = "json_schema_2020_12_tool", - description = "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)", - inputSchema = ToolSchema( - properties = buildJsonObject { - put( - "name", - buildJsonObject { - put("type", JsonPrimitive("string")) - }, - ) - put( - "address", - buildJsonObject { - put("type", JsonPrimitive("object")) - put( - "properties", - buildJsonObject { - put("street", buildJsonObject { put("type", JsonPrimitive("string")) }) - put("city", buildJsonObject { put("type", JsonPrimitive("string")) }) - }, - ) - }, - ) - }, - ), - ) { request -> - CallToolResult( - listOf(TextContent("JSON Schema 2020-12 tool called with: ${request.arguments}")), - ) - } - - // 17. test_client_elicitation_defaults — used by elicitation-sep1034-client-defaults scenario - addTool( - name = "test_client_elicitation_defaults", - description = "test_client_elicitation_defaults", - ) { - val schema = ElicitRequestParams.RequestedSchema( - properties = mapOf( - "name" to StringSchema(description = "User name", default = "John Doe"), - "age" to IntegerSchema(description = "User age", default = 30), - "score" to DoubleSchema(description = "User score", default = 95.5), - "status" to UntitledSingleSelectEnumSchema( - description = "User status", - enumValues = listOf("active", "inactive", "pending"), - default = "active", - ), - "verified" to BooleanSchema(description = "Verification status", default = true), - ), - required = emptyList(), - ) - val result = createElicitation( - "Please review and update the form fields with defaults", - schema, - ) - CallToolResult(listOf(TextContent("Elicitation completed: action=${result.action}, content=${result.content}"))) - } - - // 18. test-tool — simple tool used by auth scenarios - addTool( - name = "test-tool", - description = "Simple test tool for auth scenarios", - ) { - CallToolResult(listOf(TextContent("Test tool executed successfully"))) - } -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/InMemoryEventStore.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/InMemoryEventStore.kt deleted file mode 100644 index ca7abb3..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/InMemoryEventStore.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance - -import io.modelcontextprotocol.kotlin.sdk.server.EventStore -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage -import java.util.concurrent.ConcurrentHashMap -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -@OptIn(ExperimentalUuidApi::class) -class InMemoryEventStore : EventStore { - - private val events = ConcurrentHashMap>() - private val streamEvents = ConcurrentHashMap>() - - override suspend fun storeEvent(streamId: String, message: JSONRPCMessage): String { - val eventId = "$streamId::${System.currentTimeMillis()}_${Uuid.random()}" - events[eventId] = message to streamId - streamEvents.getOrPut(streamId) { mutableListOf() }.add(eventId) - return eventId - } - - override suspend fun replayEventsAfter( - lastEventId: String, - sender: suspend (eventId: String, message: JSONRPCMessage) -> Unit, - ): String { - val streamId = getStreamIdForEventId(lastEventId) - ?: error("Unknown event ID: $lastEventId") - val eventIds = streamEvents[streamId] ?: return streamId - - var found = false - for (eventId in eventIds) { - if (!found) { - if (eventId == lastEventId) found = true - continue - } - val (message, _) = events[eventId] ?: continue - sender(eventId, message) - } - return streamId - } - - override suspend fun getStreamIdForEventId(eventId: String): String? { - val idx = eventId.indexOf("::") - return if (idx >= 0) eventId.substring(0, idx) else null - } -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/JWTScenario.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/JWTScenario.kt deleted file mode 100644 index 9a20681..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/JWTScenario.kt +++ /dev/null @@ -1,189 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance.auth - -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.sse.SSE -import io.ktor.client.request.forms.submitForm -import io.ktor.http.Parameters -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.Implementation -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put -import java.security.KeyFactory -import java.security.Signature -import java.security.spec.PKCS8EncodedKeySpec -import java.util.Base64 -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -// Client Credentials JWT scenario -internal suspend fun runClientCredentialsJwt(serverUrl: String) { - val ctx = conformanceContext() - val clientId = ctx.requiredString("client_id") - val privateKeyPem = ctx.requiredString("private_key_pem") - val signingAlgorithm = ctx["signing_algorithm"]?.jsonPrimitive?.content ?: "ES256" - - val httpClient = HttpClient(CIO) { - install(SSE) - followRedirects = false - } - - httpClient.use { client -> - val resourceMetadata = discoverResourceMetadata(client, serverUrl) - val authServer = resourceMetadata["authorization_servers"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.content - ?: error("No authorization_servers in resource metadata") - val oauthMetadata = fetchOAuthMetadata(client, authServer) - val tokenEndpoint = oauthMetadata["token_endpoint"]?.jsonPrimitive?.content - ?: error("No token_endpoint in AS metadata") - val issuer = oauthMetadata["issuer"]?.jsonPrimitive?.content - ?: error("No issuer in AS metadata") - - // Create JWT client assertion - val assertion = createJwtAssertion(clientId, issuer, privateKeyPem, signingAlgorithm) - - // Exchange for token - val tokenResponse = client.submitForm( - url = tokenEndpoint, - formParameters = Parameters.build { - append("grant_type", "client_credentials") - append("client_id", clientId) - append("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") - append("client_assertion", assertion) - }, - ) - val accessToken = extractAccessToken(tokenResponse) - - withBearerToken(accessToken) { authedClient -> - val transport = StreamableHttpClientTransport(authedClient, serverUrl) - val client = Client( - clientInfo = Implementation("conformance-client-credentials-jwt", "1.0.0"), - options = ClientOptions(capabilities = ClientCapabilities()), - ) - client.connect(transport) - client.listTools() - client.close() - } - } -} - -// JWT Assertion -@OptIn(ExperimentalUuidApi::class) -private fun createJwtAssertion( - clientId: String, - audience: String, - privateKeyPem: String, - algorithm: String, -): String { - val header = buildJsonObject { - put("alg", algorithm) - put("typ", "JWT") - }.toString() - - val now = System.currentTimeMillis() / 1000 - val payload = buildJsonObject { - put("iss", clientId) - put("sub", clientId) - put("aud", audience) - put("iat", now) - put("exp", now + 300) - put("jti", Uuid.random().toString()) - }.toString() - - val headerB64 = Base64.getUrlEncoder().withoutPadding().encodeToString(header.toByteArray()) - val payloadB64 = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.toByteArray()) - val signingInput = "$headerB64.$payloadB64" - - val signature = signJwt(signingInput, privateKeyPem, algorithm) - return "$signingInput.$signature" -} - -private fun signJwt(input: String, privateKeyPem: String, algorithm: String): String { - val pemBody = privateKeyPem - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replace("-----BEGIN EC PRIVATE KEY-----", "") - .replace("-----END EC PRIVATE KEY-----", "") - .replace("-----BEGIN RSA PRIVATE KEY-----", "") - .replace("-----END RSA PRIVATE KEY-----", "") - .replace("\n", "") - .replace("\r", "") - .trim() - - val keyBytes = Base64.getDecoder().decode(pemBody) - val keySpec = PKCS8EncodedKeySpec(keyBytes) - - val (keyAlgorithm, signatureAlgorithm) = when (algorithm) { - "ES256" -> "EC" to "SHA256withECDSA" - "RS256" -> "RSA" to "SHA256withRSA" - else -> error("Unsupported signing algorithm: $algorithm") - } - - val keyFactory = KeyFactory.getInstance(keyAlgorithm) - val privateKey = keyFactory.generatePrivate(keySpec) - - val sig = Signature.getInstance(signatureAlgorithm) - sig.initSign(privateKey) - sig.update(input.toByteArray()) - val rawSignature = sig.sign() - - // For EC, convert DER to raw r||s format for JWS - val signatureBytes = if (keyAlgorithm == "EC") { - derToRawEcSignature(rawSignature) - } else { - rawSignature - } - - return Base64.getUrlEncoder().withoutPadding().encodeToString(signatureBytes) -} - -private fun derToRawEcSignature(der: ByteArray): ByteArray { - // DER format: 0x30 len 0x02 rLen r 0x02 sLen s - require(der.size >= 2) { "DER signature too short" } - - var offset = 2 // skip SEQUENCE tag and length - if (der[1].toInt() and 0x80 != 0) { - offset += (der[1].toInt() and 0x7f) - } - - // Read r - require(offset < der.size) { "DER signature truncated before r tag" } - check(der[offset] == 0x02.toByte()) { "Expected INTEGER tag for r" } - offset++ - require(offset < der.size) { "DER signature truncated before r length" } - val rLen = der[offset].toInt() and 0xff - offset++ - require(offset + rLen <= der.size) { "DER signature truncated in r value" } - val r = der.copyOfRange(offset, offset + rLen) - offset += rLen - - // Read s - require(offset < der.size) { "DER signature truncated before s tag" } - check(der[offset] == 0x02.toByte()) { "Expected INTEGER tag for s" } - offset++ - require(offset < der.size) { "DER signature truncated before s length" } - val sLen = der[offset].toInt() and 0xff - offset++ - require(offset + sLen <= der.size) { "DER signature truncated in s value" } - val s = der.copyOfRange(offset, offset + sLen) - - // Each component should be 32 bytes for P-256 - val componentLen = 32 - val result = ByteArray(componentLen * 2) - - // Copy r (may need padding or trimming of leading zero) - val rStart = if (r.size > componentLen) r.size - componentLen else 0 - val rDest = if (r.size < componentLen) componentLen - r.size else 0 - r.copyInto(result, rDest, rStart, r.size) - - // Copy s - val sStart = if (s.size > componentLen) s.size - componentLen else 0 - val sDest = componentLen + if (s.size < componentLen) componentLen - s.size else 0 - s.copyInto(result, sDest, sStart, s.size) - - return result -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/authCodeFlow.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/authCodeFlow.kt deleted file mode 100644 index c59e8e0..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/authCodeFlow.kt +++ /dev/null @@ -1,154 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance.auth - -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.HttpSend -import io.ktor.client.plugins.plugin -import io.ktor.client.plugins.sse.SSE -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -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.CallToolRequest -import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import java.util.UUID - -internal suspend fun runAuthClient(serverUrl: String) { - val httpClient = HttpClient(CIO) { - install(SSE) - followRedirects = false - } - - var accessToken: String? = null - var authAttempts = 0 - // Cache discovery and credentials across retries - var cachedDiscovery: DiscoveryResult? = null - var cachedCredentials: ClientCredentials? = null - - httpClient.plugin(HttpSend).intercept { request -> - // Add existing token if available - if (accessToken != null) { - request.headers.remove(HttpHeaders.Authorization) - request.headers.append(HttpHeaders.Authorization, "Bearer $accessToken") - } - - val response = execute(request) - val status = response.response.status - - // Determine if we need to (re-)authorize - val needsAuth = status == HttpStatusCode.Unauthorized - val wwwAuth = response.response.headers[HttpHeaders.WWWAuthenticate] ?: "" - val stepUpScope = if (status == HttpStatusCode.Forbidden) parseStepUpScope(wwwAuth) else null - val needsStepUp = stepUpScope != null - - if ((needsAuth || needsStepUp) && authAttempts < 3) { - authAttempts++ - - // Discover metadata (cache across retries) - if (cachedDiscovery == null) { - val resourceMetadataUrl = extractParam(wwwAuth, "resource_metadata") - cachedDiscovery = discoverOAuthMetadata(httpClient, serverUrl, resourceMetadataUrl) - } - val discovery: DiscoveryResult = cachedDiscovery - - // Validate PRM resource matches server URL (RFC 8707) - val discoveredResource = discovery.resourceUrl - if (discoveredResource != null) { - val normalizedResource = discoveredResource.trimEnd('/') - val normalizedServerUrl = serverUrl.trimEnd('/') - val matches = normalizedServerUrl == normalizedResource || - normalizedServerUrl.startsWith("$normalizedResource/") - require(matches) { - "PRM resource mismatch: resource='$discoveredResource' does not match server URL='$serverUrl'" - } - } - - val metadata = discovery.asMetadata - - val authEndpoint = metadata["authorization_endpoint"]?.jsonPrimitive?.content - ?: error("No authorization_endpoint in metadata") - val tokenEndpoint = metadata["token_endpoint"]?.jsonPrimitive?.content - ?: error("No token_endpoint in metadata") - - val tokenEndpointAuthMethods = metadata["token_endpoint_auth_methods_supported"] - ?.jsonArray?.map { it.jsonPrimitive.content } - ?: listOf("client_secret_post") - val tokenAuthMethod = tokenEndpointAuthMethods.firstOrNull() ?: "client_secret_post" - - // Verify PKCE support - verifyPkceSupport(metadata) - - // Resolve client credentials (cache across retries) - if (cachedCredentials == null) { - cachedCredentials = resolveClientCredentials(httpClient, metadata) - } - val creds: ClientCredentials = cachedCredentials - - // Determine scope - val scope = if (needsStepUp) { - stepUpScope - } else { - val wwwAuthScope = extractParam(wwwAuth, "scope") - selectScope(wwwAuthScope, discovery.scopesSupported) - } - - // PKCE - val codeVerifier = generateCodeVerifier() - val codeChallenge = generateCodeChallenge(codeVerifier) - - // CSRF state parameter - val state = UUID.randomUUID().toString() - - // Build authorization URL - val authUrl = buildAuthorizationUrl( - authEndpoint, - creds.clientId, - CALLBACK_URL, - codeChallenge, - scope, - discovery.resourceUrl, - state, - ) - - // Follow the authorization redirect to get auth code - val authCode = followAuthorizationRedirect(httpClient, authUrl, CALLBACK_URL, state) - - // Exchange code for tokens - accessToken = exchangeCodeForTokens( - httpClient, - tokenEndpoint, - authCode, - creds.clientId, - creds.clientSecret, - CALLBACK_URL, - codeVerifier, - tokenAuthMethod, - discovery.resourceUrl, - ) - - // Retry the original request with the token - request.headers.remove(HttpHeaders.Authorization) - request.headers.append(HttpHeaders.Authorization, "Bearer $accessToken") - execute(request) - } else { - response - } - } - - httpClient.use { client -> - val transport = StreamableHttpClientTransport(client, serverUrl) - val mcpClient = Client( - clientInfo = Implementation("test-auth-client", "1.0.0"), - options = ClientOptions(capabilities = ClientCapabilities()), - ) - mcpClient.connect(transport) - mcpClient.listTools() - mcpClient.callTool(CallToolRequest(CallToolRequestParams(name = "test-tool"))) - mcpClient.close() - } -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/basicScenario.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/basicScenario.kt deleted file mode 100644 index a0d6d86..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/basicScenario.kt +++ /dev/null @@ -1,54 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance.auth - -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.sse.SSE -import io.ktor.client.request.forms.submitForm -import io.ktor.client.request.header -import io.ktor.http.HttpHeaders -import io.ktor.http.Parameters -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.Implementation -import java.util.Base64 - -// Client Credentials Basic scenario -internal suspend fun runClientCredentialsBasic(serverUrl: String) { - val ctx = conformanceContext() - val clientId = ctx.requiredString("client_id") - val clientSecret = ctx.requiredString("client_secret") - - val httpClient = HttpClient(CIO) { - install(SSE) - followRedirects = false - } - - httpClient.use { client -> - val tokenEndpoint = discoverTokenEndpoint(client, serverUrl) - - // Exchange credentials for token using Basic auth - val basicAuth = Base64.getEncoder().encodeToString("$clientId:$clientSecret".toByteArray()) - val tokenResponse = client.submitForm( - url = tokenEndpoint, - formParameters = Parameters.build { - append("grant_type", "client_credentials") - }, - ) { - header(HttpHeaders.Authorization, "Basic $basicAuth") - } - val accessToken = extractAccessToken(tokenResponse) - - withBearerToken(accessToken) { authedClient -> - val transport = StreamableHttpClientTransport(authedClient, serverUrl) - val client = Client( - clientInfo = Implementation("conformance-client-credentials-basic", "1.0.0"), - options = ClientOptions(capabilities = ClientCapabilities()), - ) - client.connect(transport) - client.listTools() - client.close() - } - } -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/clientRegistration.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/clientRegistration.kt deleted file mode 100644 index 8e84a15..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/clientRegistration.kt +++ /dev/null @@ -1,73 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance.auth - -import io.ktor.client.HttpClient -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.contentType -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put - -internal data class ClientCredentials(val clientId: String, val clientSecret: String?) - -/** - * Resolve client credentials per spec priority: - * 1. Pre-registered (from MCP_CONFORMANCE_CONTEXT) - * 2. CIMD (client_id_metadata_document_supported) - * 3. Dynamic registration (registration_endpoint) - * 4. Error - */ -internal suspend fun resolveClientCredentials(httpClient: HttpClient, asMetadata: JsonObject): ClientCredentials { - // 1. Pre-registered - val contextJson = System.getenv("MCP_CONFORMANCE_CONTEXT") - if (contextJson != null) { - val ctx = json.parseToJsonElement(contextJson).jsonObject - val clientId = ctx["client_id"]?.jsonPrimitive?.content - if (clientId != null) { - val clientSecret = ctx["client_secret"]?.jsonPrimitive?.content - return ClientCredentials(clientId, clientSecret) - } - } - - // 2. CIMD - val cimdSupported = asMetadata["client_id_metadata_document_supported"] - ?.jsonPrimitive?.content?.toBoolean() ?: false - if (cimdSupported) { - return ClientCredentials(CIMD_CLIENT_METADATA_URL, null) - } - - // 3. Dynamic registration - val registrationEndpoint = asMetadata["registration_endpoint"]?.jsonPrimitive?.content - if (registrationEndpoint != null) { - return dynamicClientRegistration(httpClient, registrationEndpoint) - } - - error("No way to register client: no pre-registered credentials, CIMD not supported, and no registration_endpoint") -} - -private suspend fun dynamicClientRegistration( - httpClient: HttpClient, - registrationEndpoint: String, -): ClientCredentials { - val regBody = buildJsonObject { - put("client_name", "test-auth-client") - put("redirect_uris", buildJsonArray { add(kotlinx.serialization.json.JsonPrimitive(CALLBACK_URL)) }) - put("grant_types", buildJsonArray { add(kotlinx.serialization.json.JsonPrimitive("authorization_code")) }) - put("response_types", buildJsonArray { add(kotlinx.serialization.json.JsonPrimitive("code")) }) - put("token_endpoint_auth_method", "client_secret_post") - } - - val response = httpClient.post(registrationEndpoint) { - contentType(ContentType.Application.Json) - setBody(regBody.toString()) - } - val regJson = json.parseToJsonElement(response.bodyAsText()).jsonObject - val clientId = regJson["client_id"]?.jsonPrimitive?.content ?: error("No client_id in registration response") - val clientSecret = regJson["client_secret"]?.jsonPrimitive?.content - return ClientCredentials(clientId, clientSecret) -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/crossAppAccessScenario.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/crossAppAccessScenario.kt deleted file mode 100644 index db4b7f0..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/crossAppAccessScenario.kt +++ /dev/null @@ -1,82 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance.auth - -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.sse.SSE -import io.ktor.client.request.forms.submitForm -import io.ktor.client.request.header -import io.ktor.http.HttpHeaders -import io.ktor.http.Parameters -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.Implementation -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import java.util.Base64 - -// SEP-990 Enterprise Managed OAuth: Cross-App Access complete flow -internal suspend fun runCrossAppAccess(serverUrl: String) { - val ctx = conformanceContext() - val clientId = ctx.requiredString("client_id") - val clientSecret = ctx.requiredString("client_secret") - val idpIdToken = ctx.requiredString("idp_id_token") - val idpTokenEndpoint = ctx.requiredString("idp_token_endpoint") - - val httpClient = HttpClient(CIO) { - install(SSE) - followRedirects = false - } - - httpClient.use { client -> - // Discover PRM + AS metadata - val resourceMeta = discoverResourceMetadata(client, serverUrl) - val resourceUrl = resourceMeta["resource"]?.jsonPrimitive?.content - ?: error("No resource in resource metadata") - val authServer = resourceMeta["authorization_servers"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.content - ?: error("No authorization_servers in resource metadata") - val asMeta = fetchOAuthMetadata(client, authServer) - val tokenEndpoint = asMeta["token_endpoint"]?.jsonPrimitive?.content - ?: error("No token_endpoint in AS metadata") - - // RFC 8693 Token Exchange at IDP: exchange ID token for ID-JAG - val idpResponse = client.submitForm( - url = idpTokenEndpoint, - formParameters = Parameters.build { - append("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") - append("subject_token", idpIdToken) - append("subject_token_type", "urn:ietf:params:oauth:token-type:id_token") - append("requested_token_type", "urn:ietf:params:oauth:token-type:id-jag") - append("audience", authServer) - append("resource", resourceUrl) - }, - ) - val idJag = extractAccessToken(idpResponse) - - // RFC 7523 JWT Bearer Grant at AS with Basic auth - val basicAuth = Base64.getEncoder().encodeToString("$clientId:$clientSecret".toByteArray()) - val asResponse = client.submitForm( - url = tokenEndpoint, - formParameters = Parameters.build { - append("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") - append("assertion", idJag) - }, - ) { - header(HttpHeaders.Authorization, "Basic $basicAuth") - } - val accessToken = extractAccessToken(asResponse) - - // Use access token for MCP requests - withBearerToken(accessToken) { authedClient -> - val transport = StreamableHttpClientTransport(authedClient, serverUrl) - val mcpClient = Client( - clientInfo = Implementation("conformance-cross-app-access", "1.0.0"), - options = ClientOptions(capabilities = ClientCapabilities()), - ) - mcpClient.connect(transport) - mcpClient.listTools() - mcpClient.close() - } - } -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/discovery.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/discovery.kt deleted file mode 100644 index 73979db..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/discovery.kt +++ /dev/null @@ -1,48 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance.auth - -import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText -import io.ktor.http.isSuccess -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive - -internal data class DiscoveryResult( - val asMetadata: JsonObject, - val resourceUrl: String?, - val scopesSupported: List?, -) - -internal suspend fun discoverOAuthMetadata( - httpClient: HttpClient, - serverUrl: String, - resourceMetadataUrl: String?, -): DiscoveryResult { - // Get resource metadata - val resourceMeta = if (resourceMetadataUrl != null) { - val resp = httpClient.get(resourceMetadataUrl) - if (!resp.status.isSuccess()) { - error("Failed to fetch resource metadata from $resourceMetadataUrl: ${resp.status}") - } - json.parseToJsonElement(resp.bodyAsText()).jsonObject - } else { - discoverResourceMetadata(httpClient, serverUrl) - } - - val resourceUrl = resourceMeta["resource"]?.jsonPrimitive?.content - val scopesSupported = resourceMeta["scopes_supported"] - ?.jsonArray?.map { it.jsonPrimitive.content } - val authServer = resourceMeta["authorization_servers"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.content - - val oauthMeta = if (authServer != null) { - fetchOAuthMetadata(httpClient, authServer) - } else { - // Fallback: try well-known on server URL origin - val origin = extractOrigin(serverUrl) - fetchOAuthMetadata(httpClient, origin) - } - - return DiscoveryResult(oauthMeta, resourceUrl, scopesSupported) -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/pkce.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/pkce.kt deleted file mode 100644 index e1c5419..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/pkce.kt +++ /dev/null @@ -1,30 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance.auth - -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import java.security.MessageDigest -import java.util.Base64 - -internal fun generateCodeVerifier(): String { - val bytes = ByteArray(32) - java.security.SecureRandom().nextBytes(bytes) - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) -} - -internal fun generateCodeChallenge(verifier: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII)) - return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) -} - -/** - * Verify that the AS metadata advertises S256 in code_challenge_methods_supported. - * Abort if PKCE S256 is not supported. - */ -internal fun verifyPkceSupport(asMetadata: JsonObject) { - val methods = asMetadata["code_challenge_methods_supported"] - ?.jsonArray?.map { it.jsonPrimitive.content } - require(methods != null && "S256" in methods) { - "Authorization server does not support PKCE S256 (code_challenge_methods_supported: $methods)" - } -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/registration.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/registration.kt deleted file mode 100644 index 4964f20..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/registration.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance.auth - -import io.modelcontextprotocol.kotlin.sdk.conformance.scenarioHandlers - -// Registration -fun registerAuthScenarios() { - val authScenarios = listOf( - "auth/metadata-default", - "auth/metadata-var1", - "auth/metadata-var2", - "auth/metadata-var3", - "auth/basic-cimd", - "auth/scope-from-www-authenticate", - "auth/scope-from-scopes-supported", - "auth/scope-omitted-when-undefined", - "auth/scope-step-up", - "auth/scope-retry-limit", - "auth/token-endpoint-auth-basic", - "auth/token-endpoint-auth-post", - "auth/token-endpoint-auth-none", - "auth/resource-mismatch", - "auth/pre-registration", - "auth/2025-03-26-oauth-metadata-backcompat", - "auth/2025-03-26-oauth-endpoint-fallback", - ) - for (name in authScenarios) { - scenarioHandlers[name] = ::runAuthClient - } - scenarioHandlers["auth/client-credentials-jwt"] = ::runClientCredentialsJwt - scenarioHandlers["auth/client-credentials-basic"] = ::runClientCredentialsBasic - scenarioHandlers["auth/cross-app-access-complete-flow"] = ::runCrossAppAccess -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/scopeHandling.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/scopeHandling.kt deleted file mode 100644 index 33b69ce..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/scopeHandling.kt +++ /dev/null @@ -1,29 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance.auth - -internal fun extractParam(wwwAuth: String, param: String): String? { - val regex = Regex("""$param="([^"]+)"""") - return regex.find(wwwAuth)?.groupValues?.get(1) -} - -/** - * Select scope per MCP spec priority: - * 1. scope from WWW-Authenticate header - * 2. scopes_supported from Protected Resource Metadata (space-joined) - * 3. null (omit scope entirely) - */ -internal fun selectScope(wwwAuthScope: String?, scopesSupported: List?): String? { - if (wwwAuthScope != null) return wwwAuthScope - if (!scopesSupported.isNullOrEmpty()) return scopesSupported.joinToString(" ") - return null -} - -/** - * Detect 403 with error="insufficient_scope" and extract the new scope. - * Returns the scope string if step-up is needed, null otherwise. - */ -internal fun parseStepUpScope(wwwAuth: String?): String? { - if (wwwAuth == null) return null - val error = extractParam(wwwAuth, "error") - if (error != "insufficient_scope") return null - return extractParam(wwwAuth, "scope") -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/tokenExchange.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/tokenExchange.kt deleted file mode 100644 index 0c79bb9..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/tokenExchange.kt +++ /dev/null @@ -1,174 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance.auth - -import io.ktor.client.HttpClient -import io.ktor.client.request.forms.submitForm -import io.ktor.client.request.get -import io.ktor.client.request.header -import io.ktor.client.statement.bodyAsText -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.Parameters -import io.ktor.http.isSuccess -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import java.net.URI -import java.net.URLEncoder -import java.util.Base64 - -internal fun buildAuthorizationUrl( - authEndpoint: String, - clientId: String, - redirectUri: String, - codeChallenge: String, - scope: String?, - resource: String?, - state: String, -): String { - val params = buildString { - append("response_type=code") - append("&client_id=${URLEncoder.encode(clientId, "UTF-8")}") - append("&redirect_uri=${URLEncoder.encode(redirectUri, "UTF-8")}") - append("&code_challenge=${URLEncoder.encode(codeChallenge, "UTF-8")}") - append("&code_challenge_method=S256") - append("&state=${URLEncoder.encode(state, "UTF-8")}") - if (scope != null) { - append("&scope=${URLEncoder.encode(scope, "UTF-8")}") - } - if (resource != null) { - append("&resource=${URLEncoder.encode(resource, "UTF-8")}") - } - } - return if (authEndpoint.contains("?")) "$authEndpoint&$params" else "$authEndpoint?$params" -} - -internal suspend fun followAuthorizationRedirect( - httpClient: HttpClient, - authUrl: String, - expectedCallbackUrl: String, - expectedState: String, -): String { - val response = httpClient.get(authUrl) - - if (response.status == HttpStatusCode.Found || - response.status == HttpStatusCode.MovedPermanently || - response.status == HttpStatusCode.TemporaryRedirect || - response.status == HttpStatusCode.SeeOther - ) { - val location = response.headers[HttpHeaders.Location] - ?: error("No Location header in redirect response") - - require(location.startsWith(expectedCallbackUrl)) { - "Redirect location does not match expected callback URL" - } - - val uri = URI(location) - val queryParams = uri.query?.split("&")?.mapNotNull { - val parts = it.split("=", limit = 2) - if (parts.size == 2) parts[0] to java.net.URLDecoder.decode(parts[1], "UTF-8") else null - }?.toMap() ?: emptyMap() - - val returnedState = queryParams["state"] - require(returnedState == expectedState) { - "State parameter mismatch in authorization redirect" - } - - return queryParams["code"] ?: error("No authorization code in redirect response") - } - - error("Expected redirect from auth endpoint, got ${response.status}") -} - -internal suspend fun exchangeCodeForTokens( - httpClient: HttpClient, - tokenEndpoint: String, - code: String, - clientId: String, - clientSecret: String?, - redirectUri: String, - codeVerifier: String, - tokenAuthMethod: String, - resource: String?, -): String { - val response = when (tokenAuthMethod) { - "client_secret_basic" -> { - val basicAuth = Base64.getEncoder() - .encodeToString("$clientId:${clientSecret ?: ""}".toByteArray()) - httpClient.submitForm( - url = tokenEndpoint, - formParameters = Parameters.build { - append("grant_type", "authorization_code") - append("code", code) - append("redirect_uri", redirectUri) - append("code_verifier", codeVerifier) - if (resource != null) { - append("resource", resource) - } - }, - ) { - header(HttpHeaders.Authorization, "Basic $basicAuth") - } - } - - "none" -> { - httpClient.submitForm( - url = tokenEndpoint, - formParameters = Parameters.build { - append("grant_type", "authorization_code") - append("code", code) - append("client_id", clientId) - append("redirect_uri", redirectUri) - append("code_verifier", codeVerifier) - if (resource != null) { - append("resource", resource) - } - }, - ) - } - - else -> { - // client_secret_post (default) - httpClient.submitForm( - url = tokenEndpoint, - formParameters = Parameters.build { - append("grant_type", "authorization_code") - append("code", code) - append("client_id", clientId) - if (clientSecret != null) { - append("client_secret", clientSecret) - } - append("redirect_uri", redirectUri) - append("code_verifier", codeVerifier) - if (resource != null) { - append("resource", resource) - } - }, - ) - } - } - - // Check HTTP status - if (!response.status.isSuccess()) { - val body = response.bodyAsText() - val errorDetail = try { - val obj = json.parseToJsonElement(body).jsonObject - val err = obj["error"]?.jsonPrimitive?.content ?: "unknown" - val desc = obj["error_description"]?.jsonPrimitive?.content - if (desc != null) "$err: $desc" else err - } catch (_: Exception) { - body - } - error("Token exchange failed (${response.status}): $errorDetail") - } - - val tokenJson = json.parseToJsonElement(response.bodyAsText()).jsonObject - - // Check for error field in response body (some servers return 200 with error) - val errorField = tokenJson["error"]?.jsonPrimitive?.content - if (errorField != null) { - val desc = tokenJson["error_description"]?.jsonPrimitive?.content - error("Token exchange error: $errorField${if (desc != null) " - $desc" else ""}") - } - - return tokenJson["access_token"]?.jsonPrimitive?.content - ?: error("No access_token in token response") -} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/utils.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/utils.kt deleted file mode 100644 index 4bc75a0..0000000 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/utils.kt +++ /dev/null @@ -1,120 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.conformance.auth - -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.HttpSend -import io.ktor.client.plugins.plugin -import io.ktor.client.plugins.sse.SSE -import io.ktor.client.request.get -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsText -import io.ktor.http.HttpHeaders -import io.ktor.http.isSuccess -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import java.net.URI -import kotlin.text.ifEmpty - -internal val json = Json { ignoreUnknownKeys = true } - -internal fun conformanceContext(): JsonObject { - val contextJson = System.getenv("MCP_CONFORMANCE_CONTEXT") - ?: error("MCP_CONFORMANCE_CONTEXT not set") - return json.parseToJsonElement(contextJson).jsonObject -} - -internal fun JsonObject.requiredString(key: String): String = this[key]?.jsonPrimitive?.content ?: error("Missing $key") - -internal const val CIMD_CLIENT_METADATA_URL = "https://conformance-test.local/client-metadata.json" -internal const val CALLBACK_URL = "http://localhost:3000/callback" - -internal fun extractOrigin(url: String): String { - val uri = URI(url) - return "${uri.scheme}://${uri.host}${if (uri.port > 0) ":${uri.port}" else ""}" -} - -internal suspend fun discoverResourceMetadata(httpClient: HttpClient, serverUrl: String): JsonObject { - val origin = extractOrigin(serverUrl) - val path = URI(serverUrl).path.ifEmpty { "/" } - - // Try RFC 9728 format first: /.well-known/oauth-protected-resource/ - val wellKnownUrl = "$origin/.well-known/oauth-protected-resource$path" - val response = httpClient.get(wellKnownUrl) - if (response.status.isSuccess()) { - return json.parseToJsonElement(response.bodyAsText()).jsonObject - } - - // Fallback: try root - val fallbackUrl = "$origin/.well-known/oauth-protected-resource" - val fallbackResponse = httpClient.get(fallbackUrl) - if (!fallbackResponse.status.isSuccess()) { - error( - "Failed to discover resource metadata at $wellKnownUrl (${response.status}) and $fallbackUrl (${fallbackResponse.status})", - ) - } - return json.parseToJsonElement(fallbackResponse.bodyAsText()).jsonObject -} - -internal suspend fun fetchOAuthMetadata(httpClient: HttpClient, authServerUrl: String): JsonObject { - val origin = extractOrigin(authServerUrl) - val path = URI(authServerUrl).path.ifEmpty { "/" } - - // RFC 8414 §3: /.well-known/oauth-authorization-server/ - val oauthUrl = "$origin/.well-known/oauth-authorization-server$path" - val oauthResponse = httpClient.get(oauthUrl) - if (oauthResponse.status.isSuccess()) { - return json.parseToJsonElement(oauthResponse.bodyAsText()).jsonObject - } - - // OIDC Discovery with path insertion: /.well-known/openid-configuration/ - val oidcPathUrl = "$origin/.well-known/openid-configuration$path" - val oidcPathResponse = httpClient.get(oidcPathUrl) - if (oidcPathResponse.status.isSuccess()) { - return json.parseToJsonElement(oidcPathResponse.bodyAsText()).jsonObject - } - - // Fallback: OpenID Connect discovery (issuer + /.well-known/openid-configuration) - val oidcUrl = "$authServerUrl/.well-known/openid-configuration" - val oidcResponse = httpClient.get(oidcUrl) - if (oidcResponse.status.isSuccess()) { - return json.parseToJsonElement(oidcResponse.bodyAsText()).jsonObject - } - - error( - "Failed to fetch OAuth metadata from $oauthUrl (${oauthResponse.status}) and $oidcUrl (${oidcResponse.status})", - ) -} - -internal suspend fun discoverTokenEndpoint(httpClient: HttpClient, serverUrl: String): String { - val resourceMetadata = discoverResourceMetadata(httpClient, serverUrl) - val authServer = resourceMetadata["authorization_servers"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.content - ?: error("No authorization_servers in resource metadata") - - val oauthMetadata = fetchOAuthMetadata(httpClient, authServer) - return oauthMetadata["token_endpoint"]?.jsonPrimitive?.content - ?: error("No token_endpoint") -} - -internal suspend fun extractAccessToken(tokenResponse: HttpResponse): String { - if (!tokenResponse.status.isSuccess()) { - error("Token request failed: ${tokenResponse.status}") - } - val tokenJson = json.parseToJsonElement(tokenResponse.bodyAsText()).jsonObject - return tokenJson["access_token"]?.jsonPrimitive?.content - ?: error("No access_token in token response") -} - -internal suspend fun withBearerToken(accessToken: String, block: suspend (HttpClient) -> T): T { - val client = HttpClient(CIO) { - install(SSE) - } - client.plugin(HttpSend).intercept { request -> - request.headers.remove(HttpHeaders.Authorization) - request.headers.append(HttpHeaders.Authorization, "Bearer $accessToken") - execute(request) - } - return client.use { block(it) } -} diff --git a/conformance-test/src/test/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceClient.kt b/conformance-test/src/test/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceClient.kt new file mode 100644 index 0000000..09ea50b --- /dev/null +++ b/conformance-test/src/test/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceClient.kt @@ -0,0 +1,85 @@ +package io.modelcontextprotocol.kotlin.sdk.conformance + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.sse.SSE +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport +import io.modelcontextprotocol.kotlin.sdk.shared.Transport +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +private val logger = KotlinLogging.logger {} + +fun main(args: Array) { + require(args.isNotEmpty()) { + "Server URL must be provided as an argument" + } + + val serverUrl = args.last() + logger.info { "Connecting to test server at: $serverUrl" } + + val httpClient = HttpClient(CIO) { + install(SSE) + } + val transport: Transport = StreamableHttpClientTransport(httpClient, serverUrl) + + val client = Client( + clientInfo = Implementation( + name = "kotlin-conformance-client", + version = "1.0.0", + ), + ) + + var exitCode = 0 + + runBlocking { + try { + client.connect(transport) + logger.info { "✅ Connected to server successfully" } + + try { + val tools = client.listTools() + logger.info { "Available tools: ${tools.tools.map { it.name }}" } + + if (tools.tools.isNotEmpty()) { + val toolName = tools.tools.first().name + logger.info { "Calling tool: $toolName" } + + val result = client.callTool( + CallToolRequest( + params = CallToolRequestParams( + name = toolName, + arguments = buildJsonObject { + put("input", JsonPrimitive("test")) + }, + ), + ), + ) + logger.info { "Tool result: ${result.content}" } + } + } catch (e: Exception) { + logger.debug(e) { "Error during tool operations (may be expected for some scenarios)" } + } + + logger.info { "✅ Client operations completed successfully" } + } catch (e: Exception) { + logger.error(e) { "❌ Client failed" } + exitCode = 1 + } finally { + try { + transport.close() + } catch (e: Exception) { + logger.warn(e) { "Error closing transport" } + } + httpClient.close() + } + } + + kotlin.system.exitProcess(exitCode) +} diff --git a/conformance-test/src/test/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt b/conformance-test/src/test/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt new file mode 100644 index 0000000..01bdd40 --- /dev/null +++ b/conformance-test/src/test/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceServer.kt @@ -0,0 +1,422 @@ +package io.modelcontextprotocol.kotlin.sdk.conformance + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.install +import io.ktor.server.cio.CIO +import io.ktor.server.engine.embeddedServer +import io.ktor.server.request.header +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.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.WebSocketMcpServerTransport +import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport +import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +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.McpJson +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.ReadResourceResult +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import io.modelcontextprotocol.kotlin.sdk.types.Role +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +private val logger = KotlinLogging.logger {} +private val serverTransports = ConcurrentHashMap() +private val jsonFormat = Json { ignoreUnknownKeys = true } + +private const val SESSION_CREATION_TIMEOUT_MS = 2000L +private const val REQUEST_TIMEOUT_MS = 10_000L +private const val MESSAGE_QUEUE_CAPACITY = 256 + +private fun isInitializeRequest(json: JsonElement): Boolean = + json is JsonObject && json["method"]?.jsonPrimitive?.contentOrNull == "initialize" + +fun main(args: Array) { + val port = args.getOrNull(0)?.toIntOrNull() ?: 3000 + + logger.info { "Starting MCP Conformance Server on port $port" } + + embeddedServer(CIO, port = port, host = "127.0.0.1") { + install(WebSockets) + + routing { + webSocket("/ws") { + logger.info { "WebSocket connection established" } + val transport = WebSocketMcpServerTransport(this) + val server = createConformanceServer() + + try { + server.createSession(transport) + } catch (e: Exception) { + logger.error(e) { "Error in WebSocket session" } + throw e + } + } + + get("/mcp") { + val sessionId = call.request.header("mcp-session-id") + ?: run { + call.respond(HttpStatusCode.BadRequest, "Missing mcp-session-id header") + return@get + } + val transport = serverTransports[sessionId] + ?: run { + call.respond(HttpStatusCode.BadRequest, "Invalid mcp-session-id") + return@get + } + transport.stream(call) + } + + post("/mcp") { + val sessionId = call.request.header("mcp-session-id") + val requestBody = call.receiveText() + + logger.debug { "Received request with sessionId: $sessionId" } + logger.trace { "Request body: $requestBody" } + + val jsonElement = try { + jsonFormat.parseToJsonElement(requestBody) + } catch (e: Exception) { + logger.error(e) { "Failed to parse request body as JSON" } + call.respond( + HttpStatusCode.BadRequest, + jsonFormat.encodeToString( + JsonObject.serializer(), + buildJsonObject { + put("jsonrpc", "2.0") + put( + "error", + buildJsonObject { + put("code", -32700) + put("message", "Parse error: ${e.message}") + }, + ) + put("id", JsonNull) + }, + ), + ) + return@post + } + + val transport = sessionId?.let { serverTransports[it] } + if (transport != null) { + logger.debug { "Using existing transport for session: $sessionId" } + transport.handleRequest(call, jsonElement) + } else { + if (isInitializeRequest(jsonElement)) { + val newSessionId = UUID.randomUUID().toString() + logger.info { "Creating new session with ID: $newSessionId" } + + val newTransport = HttpServerTransport(newSessionId) + serverTransports[newSessionId] = newTransport + + val mcpServer = createConformanceServer() + call.response.header("mcp-session-id", newSessionId) + + val sessionReady = CompletableDeferred() + CoroutineScope(Dispatchers.IO).launch { + try { + mcpServer.createSession(newTransport) + sessionReady.complete(Unit) + } catch (e: Exception) { + logger.error(e) { "Failed to create session" } + serverTransports.remove(newSessionId) + newTransport.close() + sessionReady.completeExceptionally(e) + } + } + + val sessionCreated = withTimeoutOrNull(SESSION_CREATION_TIMEOUT_MS) { + sessionReady.await() + } + + if (sessionCreated == null) { + logger.error { "Session creation timed out" } + serverTransports.remove(newSessionId) + call.respond( + HttpStatusCode.InternalServerError, + jsonFormat.encodeToString( + JsonObject.serializer(), + buildJsonObject { + put("jsonrpc", "2.0") + put( + "error", + buildJsonObject { + put("code", -32000) + put("message", "Session creation timed out") + }, + ) + put("id", JsonNull) + }, + ), + ) + return@post + } + + newTransport.handleRequest(call, jsonElement) + } else { + logger.warn { "Invalid request: no session ID or not an initialization request" } + call.respond( + HttpStatusCode.BadRequest, + jsonFormat.encodeToString( + JsonObject.serializer(), + buildJsonObject { + put("jsonrpc", "2.0") + put( + "error", + buildJsonObject { + put("code", -32000) + put("message", "Bad Request: No valid session ID provided") + }, + ) + put("id", JsonNull) + }, + ), + ) + } + } + } + + delete("/mcp") { + val sessionId = call.request.header("mcp-session-id") + val transport = sessionId?.let { serverTransports[it] } + if (transport != null) { + logger.info { "Terminating session: $sessionId" } + serverTransports.remove(sessionId) + transport.close() + call.respond(HttpStatusCode.OK) + } else { + logger.warn { "Invalid session termination request: $sessionId" } + call.respond(HttpStatusCode.BadRequest, "Invalid or missing session ID") + } + } + } + }.start(wait = true) +} + +private fun createConformanceServer(): Server { + val server = Server( + Implementation( + name = "kotlin-conformance-server", + version = "1.0.0", + ), + ServerOptions( + capabilities = ServerCapabilities( + tools = ServerCapabilities.Tools(listChanged = true), + resources = ServerCapabilities.Resources(subscribe = true, listChanged = true), + prompts = ServerCapabilities.Prompts(listChanged = true), + ), + ), + ) + + server.addTool( + name = "test-tool", + description = "A test tool for conformance testing", + inputSchema = ToolSchema( + properties = buildJsonObject { + put( + "input", + buildJsonObject { + put("type", "string") + put("description", "Test input parameter") + }, + ) + }, + required = listOf("input"), + ), + ) { request -> + val input = (request.params.arguments?.get("input") as? JsonPrimitive)?.content ?: "no input" + CallToolResult( + content = listOf(TextContent("Tool executed with input: $input")), + ) + } + + server.addResource( + uri = "test://test-resource", + name = "Test Resource", + description = "A test resource for conformance testing", + mimeType = "text/plain", + ) { request -> + ReadResourceResult( + contents = listOf( + TextResourceContents("Test resource content", request.params.uri, "text/plain"), + ), + ) + } + + server.addPrompt( + name = "test-prompt", + description = "A test prompt for conformance testing", + arguments = listOf( + PromptArgument( + name = "arg", + description = "Test argument", + required = false, + ), + ), + ) { + GetPromptResult( + messages = listOf( + PromptMessage( + role = Role.User, + content = TextContent("Test prompt content"), + ), + ), + description = "Test prompt description", + ) + } + + return server +} + +private class HttpServerTransport(private val sessionId: String) : AbstractTransport() { + private val logger = KotlinLogging.logger {} + private val pendingResponses = ConcurrentHashMap>() + private val messageQueue = Channel(MESSAGE_QUEUE_CAPACITY) + + suspend fun stream(call: ApplicationCall) { + logger.debug { "Starting SSE stream for session $sessionId" } + call.response.apply { + header("Cache-Control", "no-cache") + header("Connection", "keep-alive") + } + call.respondTextWriter(ContentType.Text.EventStream) { + try { + while (true) { + val msg = messageQueue.receiveCatching().getOrNull() ?: break + write("event: message\ndata: ${McpJson.encodeToString(msg)}\n\n") + flush() + } + } catch (e: Exception) { + logger.warn(e) { "SSE stream terminated for session $sessionId" } + } finally { + logger.debug { "SSE stream closed for session $sessionId" } + } + } + } + + suspend fun handleRequest(call: ApplicationCall, requestBody: JsonElement) { + try { + val message = McpJson.decodeFromJsonElement(requestBody) + logger.debug { "Handling ${message::class.simpleName}: $requestBody" } + + when (message) { + is JSONRPCRequest -> { + val idKey = when (val id = message.id) { + is RequestId.NumberId -> id.value.toString() + is RequestId.StringId -> id.value + } + val responseDeferred = CompletableDeferred() + pendingResponses[idKey] = responseDeferred + + _onMessage.invoke(message) + + val response = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { responseDeferred.await() } + if (response != null) { + call.respondText(McpJson.encodeToString(response), ContentType.Application.Json) + } else { + pendingResponses.remove(idKey) + logger.warn { "Timeout for request $idKey" } + call.respondText( + McpJson.encodeToString( + JSONRPCError( + message.id, + RPCError(RPCError.ErrorCode.REQUEST_TIMEOUT, "Request timed out"), + ), + ), + ContentType.Application.Json, + ) + } + } + + else -> call.respond(HttpStatusCode.Accepted) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error(e) { "Error handling request" } + if (!call.response.isCommitted) { + call.respondText( + McpJson.encodeToString( + JSONRPCError( + RequestId(0), + RPCError(RPCError.ErrorCode.INTERNAL_ERROR, "Internal error: ${e.message}"), + ), + ), + ContentType.Application.Json, + HttpStatusCode.InternalServerError, + ) + } + } + } + + override suspend fun start() { + logger.debug { "Started transport for session $sessionId" } + } + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + when (message) { + is JSONRPCResponse -> { + val idKey = when (val id = message.id) { + is RequestId.NumberId -> id.value.toString() + is RequestId.StringId -> id.value + } + pendingResponses.remove(idKey)?.complete(message) ?: run { + logger.warn { "No pending response for ID $idKey, queueing" } + messageQueue.send(message) + } + } + + else -> messageQueue.send(message) + } + } + + override suspend fun close() { + logger.debug { "Closing transport for session $sessionId" } + messageQueue.close() + pendingResponses.clear() + _onClose.invoke() + } +} diff --git a/conformance-test/src/test/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTest.kt b/conformance-test/src/test/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTest.kt new file mode 100644 index 0000000..7754ff3 --- /dev/null +++ b/conformance-test/src/test/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/ConformanceTest.kt @@ -0,0 +1,340 @@ +package io.modelcontextprotocol.kotlin.sdk.conformance + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.modelcontextprotocol.kotlin.test.utils.NPX +import io.modelcontextprotocol.kotlin.test.utils.findFreePort +import io.modelcontextprotocol.kotlin.test.utils.startLogging +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import org.junit.jupiter.api.TestInstance +import java.io.BufferedReader +import java.io.InputStreamReader +import java.lang.management.ManagementFactory +import java.net.HttpURLConnection +import java.net.URI +import java.util.concurrent.TimeUnit +import kotlin.io.path.createTempFile +import kotlin.properties.Delegates + +private val logger = KotlinLogging.logger {} + +val processStderrLogger = KotlinLogging.logger(name = "stderr") +val processStdoutLogger = KotlinLogging.logger(name = "stdout") + +private const val CONFORMANCE_VERSION = "0.1.8" + +enum class TransportType { + SSE, + WEBSOCKET, +} + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ConformanceTest { + + private var serverProcess: Process? = null + private var serverPort: Int by Delegates.notNull() + private val serverErrorOutput = mutableListOf() + private val maxErrorLines = 500 + + companion object { + private val SERVER_SCENARIOS = listOf( + "server-initialize", + "tools-list", + "tools-call-simple-text", + "resources-list", + "prompts-list", + // TODO: Fix + // - resources-read-text + // - prompts-get-simple + ) + + private val CLIENT_SCENARIOS = listOf( + "initialize", + // TODO: Fix + // "tools-call", + ) + + private val SERVER_TRANSPORT_TYPES = listOf( + TransportType.SSE, + // TODO: Fix +// TransportType.WEBSOCKET, + ) + + private val CLIENT_TRANSPORT_TYPES = listOf( + TransportType.SSE, + TransportType.WEBSOCKET, + ) + + private const val DEFAULT_TEST_TIMEOUT_SECONDS = 30L + private const val DEFAULT_SERVER_STARTUP_TIMEOUT_SECONDS = 10 + private const val INITIAL_BACKOFF_MS = 50L + private const val MAX_BACKOFF_MS = 500L + private const val BACKOFF_MULTIPLIER = 1.5 + private const val CONNECTION_TIMEOUT_MS = 500 + private const val GRACEFUL_SHUTDOWN_SECONDS = 5L + private const val FORCE_SHUTDOWN_SECONDS = 2L + + private fun getRuntimeClasspath(): String = ManagementFactory.getRuntimeMXBean().classPath + + private fun getTestClasspath(): String = System.getProperty("test.classpath") ?: getRuntimeClasspath() + + private fun waitForServerReady( + url: String, + timeoutSeconds: Int = DEFAULT_SERVER_STARTUP_TIMEOUT_SECONDS, + ): Boolean { + val deadline = System.currentTimeMillis() + (timeoutSeconds * 1000) + var lastError: Exception? = null + var backoffMs = INITIAL_BACKOFF_MS + + while (System.currentTimeMillis() < deadline) { + try { + val connection = URI(url).toURL().openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connectTimeout = CONNECTION_TIMEOUT_MS + connection.readTimeout = CONNECTION_TIMEOUT_MS + connection.connect() + + val responseCode = connection.responseCode + connection.disconnect() + logger.debug { "Server responded with code: $responseCode" } + return true + } catch (e: Exception) { + lastError = e + Thread.sleep(backoffMs) + backoffMs = (backoffMs * BACKOFF_MULTIPLIER).toLong().coerceAtMost(MAX_BACKOFF_MS) + } + } + + logger.error { "Server did not start within $timeoutSeconds seconds. Last error: ${lastError?.message}" } + return false + } + } + + @BeforeAll + fun startServer() { + serverPort = findFreePort() + val serverUrl = "http://127.0.0.1:$serverPort/mcp" + + logger.info { "Starting conformance test server on port $serverPort" } + + val processBuilder = ProcessBuilder( + "java", + "-cp", + getRuntimeClasspath(), + "io.modelcontextprotocol.kotlin.sdk.conformance.ConformanceServerKt", + serverPort.toString(), + ) + + val process = processBuilder.start() + serverProcess = process + + // capture stderr in the background + Thread { + try { + BufferedReader(InputStreamReader(process.errorStream)).use { reader -> + reader.lineSequence().forEach { line -> + synchronized(serverErrorOutput) { + if (serverErrorOutput.size >= maxErrorLines) { + serverErrorOutput.removeAt(0) + } + serverErrorOutput.add(line) + } + logger.debug { "Server stderr: $line" } + } + } + } catch (e: Exception) { + logger.trace(e) { "Error reading server stderr" } + } + }.apply { + name = "server-stderr-reader" + isDaemon = true + }.start() + + logger.info { "Waiting for server to start..." } + val serverReady = waitForServerReady(serverUrl) + + if (!serverReady) { + val errorInfo = synchronized(serverErrorOutput) { + if (serverErrorOutput.isNotEmpty()) { + "\n\nServer error output:\n${serverErrorOutput.joinToString("\n")}" + } else { + "" + } + } + serverProcess?.destroyForcibly() + throw IllegalStateException( + "Server failed to start within $DEFAULT_SERVER_STARTUP_TIMEOUT_SECONDS seconds. " + + "Check if port $serverPort is available.$errorInfo", + ) + } + + logger.info { "Server started successfully at $serverUrl" } + } + + @AfterAll + fun stopServer() { + serverProcess?.also { process -> + logger.info { "Stopping conformance test server (PID: ${process.pid()})" } + + try { + process.destroy() + val terminated = process.waitFor(GRACEFUL_SHUTDOWN_SECONDS, TimeUnit.SECONDS) + + if (!terminated) { + logger.warn { "Server did not terminate gracefully, forcing shutdown..." } + process.destroyForcibly() + process.waitFor(FORCE_SHUTDOWN_SECONDS, TimeUnit.SECONDS) + } else { + logger.info { "Server stopped gracefully" } + } + } catch (e: Exception) { + logger.error(e) { "Error stopping server process" } + } finally { + serverProcess = null + } + } ?: logger.debug { "No server process to stop" } + } + + @TestFactory + fun `MCP Server Conformance Tests`(): List = SERVER_TRANSPORT_TYPES.flatMap { transportType -> + SERVER_SCENARIOS.map { scenario -> + DynamicTest.dynamicTest("Server [$transportType]: $scenario") { + runServerConformanceTest(scenario, transportType) + } + } + } + + @TestFactory + fun `MCP Client Conformance Tests`(): List = CLIENT_TRANSPORT_TYPES.flatMap { transportType -> + CLIENT_SCENARIOS.map { scenario -> + DynamicTest.dynamicTest("Client [$transportType]: $scenario") { + runClientConformanceTest(scenario, transportType) + } + } + } + + private fun runServerConformanceTest(scenario: String, transportType: TransportType) { + val serverUrl = when (transportType) { + TransportType.SSE -> { + "http://127.0.0.1:$serverPort/mcp" + } + + TransportType.WEBSOCKET -> { + "ws://127.0.0.1:$serverPort/ws" + } + } + + val processBuilder = ProcessBuilder( + NPX, + "@modelcontextprotocol/conformance@$CONFORMANCE_VERSION", + "server", + "--url", + serverUrl, + "--scenario", + scenario, + ) + + runConformanceTest("server", scenario, processBuilder, transportType) + } + + private fun runClientConformanceTest(scenario: String, transportType: TransportType) { + val testClasspath = getTestClasspath() + + // Create an argfile to avoid Windows command line length limits + val argFile = createTempFile(suffix = ".args").toFile() + argFile.deleteOnExit() + + val mainClass = when (transportType) { + TransportType.SSE -> { + argFile.writeText( + buildString { + appendLine("-cp") + appendLine(testClasspath) + appendLine("io.modelcontextprotocol.kotlin.sdk.conformance.ConformanceClientKt") + }, + ) + "http://127.0.0.1:$serverPort/mcp" + } + + TransportType.WEBSOCKET -> { + argFile.writeText( + buildString { + appendLine("-cp") + appendLine(testClasspath) + appendLine("io.modelcontextprotocol.kotlin.sdk.conformance.WebSocketConformanceClientKt") + }, + ) + "ws://127.0.0.1:$serverPort/ws" + } + } + + val clientCommand = listOf( + "java", + "@${argFile.absolutePath}", + mainClass, + ) + + val processBuilder = ProcessBuilder( + NPX, + "@modelcontextprotocol/conformance@$CONFORMANCE_VERSION", + "client", + "--command", + clientCommand.joinToString(" "), + "--scenario", + scenario, + ) + + runConformanceTest("client", scenario, processBuilder, transportType) + } + + private fun runConformanceTest( + type: String, + scenario: String, + processBuilder: ProcessBuilder, + transportType: TransportType, + ) { + val capitalizedType = type.replaceFirstChar { it.uppercase() } + logger.info { "Running $type conformance test [$transportType]: $scenario" } + + val timeoutSeconds = + System.getenv("CONFORMANCE_TEST_TIMEOUT_SECONDS")?.toLongOrNull() ?: DEFAULT_TEST_TIMEOUT_SECONDS + + val process = processBuilder.start() + + process.errorStream.startLogging( + logger = processStderrLogger, + name = "test(PID=${process.pid()})", + ) + process.inputStream.startLogging( + logger = processStdoutLogger, + name = "test(PID=${process.pid()})", + ) + + val completed = process.waitFor(timeoutSeconds, TimeUnit.SECONDS) + + if (!completed) { + logger.error { + "$capitalizedType conformance test [$transportType] '$scenario' timed out after $timeoutSeconds seconds" + } + process.destroyForcibly() + throw AssertionError( + "❌ $capitalizedType conformance test [$transportType] '$scenario' timed out after $timeoutSeconds seconds", + ) + } + + when (val exitCode = process.exitValue()) { + 0 -> logger.info { "✅ $capitalizedType conformance test [$transportType] '$scenario' passed!" } + + else -> { + logger.error { + "$capitalizedType conformance test [$transportType] '$scenario' failed with exit code: $exitCode" + } + throw AssertionError( + "❌ $capitalizedType conformance test [$transportType] '$scenario' failed (exit code: $exitCode). Check test output above for details.", + ) + } + } + } +} diff --git a/conformance-test/src/test/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/WebSocketConformanceClient.kt b/conformance-test/src/test/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/WebSocketConformanceClient.kt new file mode 100644 index 0000000..f112319 --- /dev/null +++ b/conformance-test/src/test/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/WebSocketConformanceClient.kt @@ -0,0 +1,104 @@ +package io.modelcontextprotocol.kotlin.sdk.conformance + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocket +import io.ktor.websocket.WebSocketSession +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.shared.MCP_SUBPROTOCOL +import io.modelcontextprotocol.kotlin.sdk.shared.WebSocketMcpTransport +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +private val logger = KotlinLogging.logger {} + +class WebSocketClientTransport(override val session: WebSocketSession) : WebSocketMcpTransport() { + override suspend fun initializeSession() { + logger.debug { "WebSocket client session initialized" } + } +} + +fun main(args: Array) { + require(args.isNotEmpty()) { + "Server WebSocket URL must be provided as an argument" + } + + val serverUrl = args.last() + logger.info { "Connecting to WebSocket test server at: $serverUrl" } + + val httpClient = HttpClient(CIO) { + install(WebSockets) + } + + var exitCode = 0 + + runBlocking { + try { + httpClient.webSocket(serverUrl, request = { + headers.append("Sec-WebSocket-Protocol", MCP_SUBPROTOCOL) + }) { + val transport = WebSocketClientTransport(this) + + val client = Client( + clientInfo = Implementation( + name = "kotlin-conformance-client-websocket", + version = "1.0.0", + ), + ) + + try { + client.connect(transport) + logger.info { "✅ Connected to server successfully" } + + try { + val tools = client.listTools() + logger.info { "Available tools: ${tools.tools.map { it.name }}" } + + if (tools.tools.isNotEmpty()) { + val toolName = tools.tools.first().name + logger.info { "Calling tool: $toolName" } + + val result = client.callTool( + CallToolRequest( + params = CallToolRequestParams( + name = toolName, + arguments = buildJsonObject { + put("input", JsonPrimitive("test")) + }, + ), + ), + ) + logger.info { "Tool result: ${result.content}" } + } + } catch (e: Exception) { + logger.debug(e) { "Error during tool operations (may be expected for some scenarios)" } + } + + logger.info { "✅ Client operations completed successfully" } + } catch (e: Exception) { + logger.error(e) { "❌ Client failed" } + exitCode = 1 + } finally { + try { + transport.close() + } catch (e: Exception) { + logger.warn(e) { "Error closing transport" } + } + } + } + } catch (e: Exception) { + logger.error(e) { "❌ WebSocket connection failed" } + exitCode = 1 + } finally { + httpClient.close() + } + } + + kotlin.system.exitProcess(exitCode) +} diff --git a/conformance-test/src/main/resources/simplelogger.properties b/conformance-test/src/test/resources/simplelogger.properties similarity index 82% rename from conformance-test/src/main/resources/simplelogger.properties rename to conformance-test/src/test/resources/simplelogger.properties index 16141d3..c6f3b90 100644 --- a/conformance-test/src/main/resources/simplelogger.properties +++ b/conformance-test/src/test/resources/simplelogger.properties @@ -5,5 +5,6 @@ org.slf4j.simpleLogger.showDateTime=false # Log level for specific packages or classes org.slf4j.simpleLogger.log.io.ktor.server=DEBUG +org.slf4j.simpleLogger.log.stdout=TRACE +org.slf4j.simpleLogger.log.stderr=TRACE org.slf4j.simpleLogger.log.io.modelcontextprotocol=DEBUG -org.slf4j.simpleLogger.log.io.modelcontextprotocol.kotlin.sdk.conformance=INFO diff --git a/context7.json b/context7.json deleted file mode 100644 index 5fde250..0000000 --- a/context7.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://context7.com/schema/context7.json", - "projectTitle": "Model Context Protocol Kotlin SDK", - "description": "Kotlin Multiplatform SDK for MCP — client and server implementations", - "excludeFolders": [ - ".github", - "buildSrc", - "config", - "gradle/wrapper" - ], - "excludeFiles": [ - "CONTRIBUTING.md", - "LICENSE", - "SECURITY.md", - "gradlew", - "gradlew.bat", - ".editorconfig", - "knit.properties", - "gradle.properties", - "settings.gradle.kts", - "conformance-baseline.yml", - "run-conformance.sh", - "detekt-baseline.xml", - "detekt-baseline-test.xml", - "detekt-baseline-main.xml", - "detekt-baseline-commonMainSourceSet.xml", - "detekt-baseline-commonTestSourceSet.xml" - ] -} \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 8eba6c8..0000000 --- a/docs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -src/ diff --git a/docs/build.gradle.kts b/docs/build.gradle.kts deleted file mode 100644 index 1857ef1..0000000 --- a/docs/build.gradle.kts +++ /dev/null @@ -1,37 +0,0 @@ -plugins { - kotlin("jvm") - alias(libs.plugins.knit) -} - -dependencies { - implementation(project(":kotlin-sdk")) - implementation(libs.ktor.server.cio) - implementation(libs.ktor.serialization) - implementation(libs.ktor.server.content.negotiation) -} - -tasks.matching { - it.name == "ktlintMainSourceSetCheck" || it.name == "ktlintMainSourceSetFormat" -}.configureEach { - enabled = false -} - -knit { - rootDir = project.rootDir - files = files(project.rootDir.resolve("README.md")) - defaultLineSeparator = "\n" - siteRoot = "" // Disable site root validation -} - -// Only run knitCheck when explicitly requested, not as part of build/check -tasks.named("knitCheck") { - onlyIf { - gradle.startParameter.taskNames.any { - it.contains("knitCheck") || it.contains("knit") - } - } -} - -tasks.clean { - delete("src") -} diff --git a/docs/moduledoc.md b/docs/moduledoc.md index 05ce370..508fef9 100644 --- a/docs/moduledoc.md +++ b/docs/moduledoc.md @@ -1,17 +1,21 @@ # MCP Kotlin SDK -Kotlin Multiplatform implementation of the Model Context Protocol (MCP). The SDK focuses on clear, explicit APIs, -small building blocks, and first-class coroutine support so clients and servers share the same well-typed messages and -transports. +Kotlin SDK for the Model Context Protocol (MCP). +This is a Kotlin Multiplatform library that helps you build MCP clients and servers that speak the same protocol and +share the same types. +The SDK focuses on clarity, small building blocks, and first‑class coroutine support. -Use the umbrella `kotlin-sdk` artifact to bring in everything at once, or depend on the focused modules directly: +Use the umbrella `kotlin-sdk` artifact when you want a single dependency that brings the core types plus both client and +server toolkits. If you only need one side, depend on `kotlin-sdk-client` or `kotlin-sdk-server` directly. + +Gradle (Kotlin DSL): ```kotlin dependencies { - // All-in-one bundle + // Convenience bundle with everything you need to start implementation("io.modelcontextprotocol:kotlin-sdk:") - // Or pick sides explicitly + // Or pick modules explicitly implementation("io.modelcontextprotocol:kotlin-sdk-client:") implementation("io.modelcontextprotocol:kotlin-sdk-server:") } @@ -21,74 +25,79 @@ dependencies { ## Module kotlin-sdk-core -Foundation shared by both sides: +Foundational, platform‑agnostic pieces: -- Protocol data model for MCP requests, results, notifications, capabilities, and content types. -- `McpJson` (kotlinx.serialization) with MCP-friendly defaults plus helpers for converting native values to JSON. -- Transport abstractions (`Transport`, `AbstractTransport`, `WebSocketMcpTransport`) and streaming `ReadBuffer`. -- `Protocol` base class that handles JSON-RPC framing, correlation, progress tokens, and capability assertions. +- Protocol data model and JSON serialization (kotlinx.serialization) +- Request/response and notification types used by both sides of MCP +- Coroutine‑friendly protocol engine and utilities +- Transport abstractions shared by client and server -Use `core` when you need the raw types or want to author a custom transport. Public APIs are explicit to keep the shared -surface stable across platforms. +You typically do not use `core` directly in application code; it is pulled in by the client/server modules. Use it +explicitly if you only need the protocol types or plan to implement a custom transport. --- ## Module kotlin-sdk-client -High-level client for connecting to MCP servers and invoking their features: +High‑level client API for connecting to an MCP server and invoking its tools, prompts, and resources. Ships with several +transports: -- `Client` runtime (or `mcpClient` helper) performs the MCP handshake and exposes `serverCapabilities`, - `serverVersion`, and `serverInstructions`. -- Typed operations for tools, prompts, resources, completion, logging, roots, sampling, and elicitation with capability - enforcement. -- Transports: `StdioClientTransport`, `SseClientTransport`, `WebSocketClientTransport`, and `StreamableHttpClientTransport`, - plus Ktor client extensions for quick wiring. +- WebSocketClientTransport – low latency, full‑duplex +- SSEClientTransport – Server‑Sent Events over HTTP +- StdioClientTransport – CLI‑friendly stdio bridge +- StreamableHttpClientTransport – simple HTTP streaming -Minimal WebSocket client: +A minimal client: ```kotlin -val client = mcpClient( - clientInfo = Implementation("sample-client", "1.0.0"), - clientOptions = ClientOptions(ClientCapabilities(tools = ClientCapabilities.Tools())), - transport = WebSocketClientTransport("ws://localhost:8080/mcp") +val client = Client( + clientInfo = Implementation(name = "sample-client", version = "1.0.0") ) +client.connect(WebSocketClientTransport("ws://localhost:8080/mcp")) + val tools = client.listTools() -val result = client.callTool("echo", mapOf("text" to "Hello, MCP!")) -println(result.content) +val result = client.callTool( + name = "echo", + arguments = mapOf("text" to "Hello, MCP!") +) ``` --- ## Module kotlin-sdk-server -Server toolkit for exposing MCP tools, prompts, and resources: +Lightweight server toolkit for hosting MCP tools, prompts, and resources. It provides a small, composable API and +ready‑to‑use transports: -- `Server` runtime coordinates sessions, initialization flow, and capability enforcement with registries for tools, - prompts, resources, and templates. -- Transports: `StdioServerTransport` for CLI/editor bridges; Ktor extensions (`mcp` for SSE + POST back-channel and - `mcpWebSocket` for WebSocket) for HTTP hosting. -- Built-in notifications for list changes and resource subscriptions when capabilities enable them. +- StdioServerTransport – integrates well with CLIs and editors +- SSE/WebSocket helpers for Ktor – easy HTTP deployment -Minimal Ktor SSE server: +Register tools and run over stdio: ```kotlin -fun Application.module() { - mcp { - Server( - serverInfo = Implementation("sample-server", "1.0.0"), - options = ServerOptions(ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = true), - resources = ServerCapabilities.Resources(listChanged = true, subscribe = true), - )), - ) { - addTool(name = "echo", description = "Echo text back") { request -> - val text = request.params.arguments?.get("text")?.jsonPrimitive?.content ?: "" - CallToolResult(content = listOf(TextContent("You said: $text"))) - } - } - } + +val server = Server( + serverInfo = Implementation(name = "sample-server", version = "1.0.0"), + options = ServerOptions(ServerCapabilities()) +) + +server.addTool( + name = "echo", + description = "Echoes the provided text" +) { request -> + // Build and return a CallToolResult from request.arguments + // (see CallToolResult and related types in kotlin-sdk-core) + /* ... */ } + +// Bridge the protocol over stdio +val transport = StdioServerTransport( + inputStream = kotlinx.io.files.Path("/dev/stdin").source(), + outputStream = kotlinx.io.files.Path("/dev/stdout").sink() +) +// Start transport and wire it with the server using provided helpers in the SDK. ``` -Pick the module that matches your role, or use the umbrella artifact to get both sides with the shared core. +For HTTP deployments, use the Ktor extensions included in the module to expose an MCP WebSocket or SSE endpoint with a +few lines of code. diff --git a/gradle.properties b/gradle.properties index a5655c3..c4128f5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,11 +1,17 @@ org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 org.gradle.parallel=true org.gradle.caching=true +# Dokka +org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled +org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true # Kotlin kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx4G +# Build JS targets using npm package manager https://kotlinlang.org/docs/js-project-setup.html#npm-dependencies +kotlin.js.yarn=false # MPP +kotlin.mpp.enableCInteropCommonization=true kotlin.native.ignoreDisabledTargets=true group=io.modelcontextprotocol -version=0.15.0 +version=0.8.3 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a8d034f..fed638b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,33 +1,31 @@ [versions] # plugins version -atomicfu = "0.33.0" -binaryCompatibilityValidatorPlugin = "0.18.2" -detekt = "2.0.0-alpha.6" -dokka = "2.2.0" -kotlin = "2.4.10" -kover = "0.9.9" -ktlint = "14.2.0" -knit = "0.5.1" -mavenPublish = "0.37.0" -netty = "4.2.18.Final" +kotlin = "2.2.21" +dokka = "2.1.0" +atomicfu = "0.29.0" +ktlint = "14.0.1" +kover = "0.9.4" +netty = "4.2.9.Final" +mavenPublish = "0.35.0" +binaryCompatibilityValidatorPlugin = "0.18.1" +openapi-generator = "7.18.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.0.7" +kotlinx-io = "0.8.2" +ktor = "3.2.3" +logging = "7.0.14" +mockk = "1.14.7" +mokksy = "0.6.2" +serialization = "1.9.0" +slf4j = "2.0.17" +junit="5.14.2" [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" } @@ -44,13 +42,11 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx- # Ktor ktor-client-apache5 = { group = "io.ktor", name = "ktor-client-apache5", version.ref = "ktor" } -ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" } ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } -ktor-server-auth = { group = "io.ktor", name = "ktor-server-auth", version.ref = "ktor" } ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse", version.ref = "ktor" } ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" } @@ -59,14 +55,13 @@ ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", v awaitility = { group = "org.awaitility", name = "awaitility-kotlin", version.ref = "awaitility" } kotest-assertions-core = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" } kotest-assertions-json = { group = "io.kotest", name = "kotest-assertions-json", version.ref = "kotest" } -kotest-assertions-ktor = { group = "io.kotest", name = "kotest-assertions-ktor", version.ref = "kotest" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } 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" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } # Samples @@ -77,4 +72,4 @@ ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = " 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..f8e1ee3 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..bad7c24 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.2.0-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..adff685 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/HEAD/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..e509b2d 100644 --- a/gradlew.bat +++ b/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/integration-test/build.gradle.kts b/integration-test/build.gradle.kts index 7a3a4a3..d5b8ef8 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 { @@ -20,19 +19,13 @@ kotlin { implementation(libs.kotlinx.coroutines.test) implementation(libs.ktor.client.cio) implementation(libs.ktor.server.cio) - implementation(libs.ktor.server.content.negotiation) - implementation(libs.ktor.serialization) implementation(libs.ktor.server.websockets) - implementation(libs.ktor.server.auth) implementation(libs.ktor.server.test.host) - implementation(libs.ktor.server.content.negotiation) - implementation(libs.ktor.serialization) } } jvmTest { dependencies { implementation(project(":test-utils")) - implementation(project(":kotlin-sdk-testing")) } } } 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..0726fa8 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 @@ -1,30 +1,21 @@ package io.modelcontextprotocol.kotlin.sdk.client -import io.kotest.matchers.shouldBe 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.AbstractTransport import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions -import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest 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 @@ -43,28 +34,20 @@ import io.modelcontextprotocol.kotlin.sdk.types.Root import io.modelcontextprotocol.kotlin.sdk.types.RootsListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.SUPPORTED_PROTOCOL_VERSIONS import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.StringSchema import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema 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 import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout -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 kotlinx.serialization.json.putJsonObject import kotlin.coroutines.cancellation.CancellationException import kotlin.test.Test import kotlin.test.assertEquals @@ -73,7 +56,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 +95,7 @@ class ClientTest { ), options = ClientOptions( capabilities = ClientCapabilities( - sampling = ClientCapabilities.sampling, + sampling = EmptyJsonObject, ), ), ) @@ -159,7 +141,7 @@ class ClientTest { ), options = ClientOptions( capabilities = ClientCapabilities( - sampling = ClientCapabilities.sampling, + sampling = EmptyJsonObject, ), ), ) @@ -284,8 +266,8 @@ class ClientTest { client.connect(failingTransport) } - exception.code shouldBe -32600 - exception.message shouldBe "Invalid Request" + assertEquals(-32600, exception.code) + assertEquals("MCP error -32600: Invalid Request", exception.message) assertTrue(closed) } @@ -346,7 +328,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), ), ) @@ -434,14 +416,13 @@ class ClientTest { clientInfo = Implementation(name = "test client without capability", version = "1.0"), options = ClientOptions( capabilities = ClientCapabilities(), + // enforceStrictCapabilities = true // TODO() ), ) - val (clientTransport2, serverTransport2) = InMemoryTransport.createLinkedPair() - listOf( - launch { clientWithoutCapability.connect(clientTransport2) }, - launch { server.createSession(serverTransport2) }, - ).joinAll() + clientWithoutCapability.connect(clientTransport) + // Using the same transport pair might not be realistic - in a real scenario you'd create another pair. + // Adjust if necessary. // This should fail val ex = assertFailsWith { @@ -546,15 +527,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 +552,6 @@ class ClientTest { def.await() runCatching { job.cancel("Cancelled by test") } defCancel.await() - cancelledOnWire.await() defTimeOut.await() } @@ -619,9 +590,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 +621,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 +658,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 +881,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 +901,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 @@ -967,7 +951,11 @@ class ClientTest { serverSession.createElicitation( message = "Please provide your GitHub username", requestedSchema = ElicitRequestParams.RequestedSchema( - properties = mapOf("name" to StringSchema()), + properties = buildJsonObject { + putJsonObject("name") { + put("type", "string") + } + }, required = listOf("name"), ), ) @@ -1064,14 +1052,18 @@ class ClientTest { Implementation(name = "test client", version = "1.0"), ClientOptions( capabilities = ClientCapabilities( - elicitation = ClientCapabilities.Elicitation(), + elicitation = EmptyJsonObject, ), ), ) val elicitationMessage = "Please provide your GitHub username" val requestedSchema = ElicitRequestParams.RequestedSchema( - properties = mapOf("name" to StringSchema()), + properties = buildJsonObject { + putJsonObject("name") { + put("type", "string") + } + }, required = listOf("name"), ) @@ -1082,8 +1074,7 @@ class ClientTest { client.setElicitationHandler { request -> assertEquals(elicitationMessage, request.params.message) - val formParams = request.params as ElicitRequestFormParams - assertEquals(requestedSchema, formParams.requestedSchema) + assertEquals(requestedSchema, request.params.requestedSchema) ElicitResult( action = elicitationResultAction, @@ -1123,455 +1114,4 @@ class ClientTest { assertEquals(elicitationResultAction, result.action) 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() - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Accept, content = JsonObject(emptyMap())) - } - - val result = serverSession.createElicitation(message = "fill defaults", requestedSchema = schema) - - assertEquals(ElicitResult.Action.Accept, result.action) - val content = result.content!! - assertEquals(JsonPrimitive("John Doe"), content["name"]) - assertEquals(JsonPrimitive(30), content["age"]) - assertEquals(JsonPrimitive(95.5), content["score"]) - assertEquals(JsonPrimitive("active"), content["status"]) - assertEquals(JsonPrimitive(true), content["verified"]) - - client.close() - } - - @Test - fun `should apply elicitation defaults only for missing fields preserving user values`() = runTest { - val schema = defaultsTestSchema() - val userContent = buildJsonObject { put("name", "Custom Name") } - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Accept, content = userContent) - } - - val result = serverSession.createElicitation(message = "partial", requestedSchema = schema) - - val content = result.content!! - assertEquals(JsonPrimitive("Custom Name"), content["name"]) - assertEquals(JsonPrimitive(30), content["age"]) - assertEquals(JsonPrimitive(95.5), content["score"]) - assertEquals(JsonPrimitive("active"), content["status"]) - assertEquals(JsonPrimitive(true), content["verified"]) - - client.close() - } - - @Test - fun `should not apply elicitation defaults when action is decline`() = runTest { - val schema = defaultsTestSchema() - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Decline) - } - - val result = serverSession.createElicitation(message = "decline", requestedSchema = schema) - - assertEquals(ElicitResult.Action.Decline, result.action) - assertNull(result.content) - - client.close() - } - - @Test - fun `should not apply elicitation defaults when action is cancel`() = runTest { - val schema = defaultsTestSchema() - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Cancel) - } - - val result = serverSession.createElicitation(message = "cancel", requestedSchema = schema) - - assertEquals(ElicitResult.Action.Cancel, result.action) - assertNull(result.content) - - client.close() - } - - @Test - fun `should not modify content when schema has no defaults`() = runTest { - val schema = ElicitRequestParams.RequestedSchema( - properties = mapOf( - "name" to StringSchema(description = "name"), - "age" to IntegerSchema(description = "age"), - ), - ) - val emptyContent = JsonObject(emptyMap()) - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Accept, content = emptyContent) - } - - val result = serverSession.createElicitation(message = "no defaults", requestedSchema = schema) - - assertEquals(ElicitResult.Action.Accept, result.action) - assertTrue(result.content!!.isEmpty()) - - client.close() - } - - @Test - fun `should apply elicitation defaults for multi-select enum schemas`() = runTest { - val schema = ElicitRequestParams.RequestedSchema( - properties = mapOf( - "tags" to UntitledMultiSelectEnumSchema( - description = "tags", - items = UntitledMultiSelectEnumSchema.Items(enumValues = listOf("a", "b", "c")), - default = listOf("a", "b"), - ), - "options" to TitledMultiSelectEnumSchema( - description = "options", - items = TitledMultiSelectEnumSchema.Items( - anyOf = listOf( - io.modelcontextprotocol.kotlin.sdk.types.EnumOption("x", "X"), - io.modelcontextprotocol.kotlin.sdk.types.EnumOption("y", "Y"), - ), - ), - default = listOf("x"), - ), - ), - ) - val (client, serverSession) = setupElicitationPair { - ElicitResult(action = ElicitResult.Action.Accept, content = JsonObject(emptyMap())) - } - - val result = serverSession.createElicitation(message = "multi-select", requestedSchema = schema) - - val content = result.content!! - val tags = content["tags"]!! - assertIs(tags) - assertEquals(2, tags.size) - assertEquals("a", tags[0].jsonPrimitive.content) - assertEquals("b", tags[1].jsonPrimitive.content) - - val options = content["options"]!! - assertIs(options) - assertEquals(1, options.size) - assertEquals("x", options[0].jsonPrimitive.content) - - 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"), - "age" to IntegerSchema(description = "User age", default = 30), - "score" to DoubleSchema(description = "User score", default = 95.5), - "status" to UntitledSingleSelectEnumSchema( - description = "User status", - enumValues = listOf("active", "inactive", "pending"), - default = "active", - ), - "verified" to BooleanSchema(description = "Verification status", default = true), - ), - ) - - private suspend fun setupElicitationPair( - handler: (io.modelcontextprotocol.kotlin.sdk.types.ElicitRequest) -> ElicitResult, - ): Pair = kotlinx.coroutines.coroutineScope { - val client = Client( - Implementation(name = "test client", version = "1.0"), - ClientOptions(capabilities = ClientCapabilities(elicitation = ClientCapabilities.Elicitation())), - ) - client.setElicitationHandler(handler) - - 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() - - client to serverSessionResult.await() - } } diff --git a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaClientTest.kt b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaClientTest.kt new file mode 100644 index 0000000..f9ef4f3 --- /dev/null +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaClientTest.kt @@ -0,0 +1,1042 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.CreateElicitationRequest +import io.modelcontextprotocol.kotlin.sdk.CreateElicitationResult +import io.modelcontextprotocol.kotlin.sdk.CreateMessageResult +import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.InitializeRequest +import io.modelcontextprotocol.kotlin.sdk.InitializeResult +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.ListResourcesRequest +import io.modelcontextprotocol.kotlin.sdk.ListResourcesResult +import io.modelcontextprotocol.kotlin.sdk.ListRootsRequest +import io.modelcontextprotocol.kotlin.sdk.ListToolsRequest +import io.modelcontextprotocol.kotlin.sdk.ListToolsResult +import io.modelcontextprotocol.kotlin.sdk.LoggingLevel +import io.modelcontextprotocol.kotlin.sdk.LoggingMessageNotification +import io.modelcontextprotocol.kotlin.sdk.Method +import io.modelcontextprotocol.kotlin.sdk.Role +import io.modelcontextprotocol.kotlin.sdk.Root +import io.modelcontextprotocol.kotlin.sdk.SUPPORTED_PROTOCOL_VERSIONS +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.Tool +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.AbstractTransport +import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport +import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.test.fail + +class OldSchemaClientTest { + @Test + fun `should initialize with matching protocol version`() = runTest { + var initialised = false + val clientTransport = object : AbstractTransport() { + override suspend fun start() {} + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + if (message !is JSONRPCRequest) return + initialised = true + val result = InitializeResult( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = ServerCapabilities(), + serverInfo = Implementation( + name = "test", + version = "1.0", + ), + ) + + val response = JSONRPCResponse( + id = message.id, + result = result, + ) + + _onMessage.invoke(response) + } + + override suspend fun close() { + } + } + + val client = Client( + clientInfo = Implementation( + name = "test client", + version = "1.0", + ), + options = ClientOptions( + capabilities = ClientCapabilities( + sampling = EmptyJsonObject, + ), + ), + ) + + client.connect(clientTransport) + assertTrue(initialised) + } + + @Test + fun `should initialize with supported older protocol version`() = runTest { + val oldVersion = SUPPORTED_PROTOCOL_VERSIONS[1] + val clientTransport = object : AbstractTransport() { + override suspend fun start() {} + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + if (message !is JSONRPCRequest) return + check(message.method == Method.Defined.Initialize.value) + + val result = InitializeResult( + protocolVersion = oldVersion, + capabilities = ServerCapabilities(), + serverInfo = Implementation( + name = "test", + version = "1.0", + ), + ) + + val response = JSONRPCResponse( + id = message.id, + result = result, + ) + _onMessage.invoke(response) + } + + override suspend fun close() { + } + } + + val client = Client( + clientInfo = Implementation( + name = "test client", + version = "1.0", + ), + options = ClientOptions( + capabilities = ClientCapabilities( + sampling = EmptyJsonObject, + ), + ), + ) + + client.connect(clientTransport) + assertEquals( + Implementation("test", "1.0"), + client.serverVersion, + ) + } + + @Test + fun `should reject unsupported protocol version`() = runTest { + var closed = false + val clientTransport = object : AbstractTransport() { + override suspend fun start() {} + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + if (message !is JSONRPCRequest) return + check(message.method == Method.Defined.Initialize.value) + + val result = InitializeResult( + protocolVersion = "invalid-version", + capabilities = ServerCapabilities(), + serverInfo = Implementation( + name = "test", + version = "1.0", + ), + ) + + val response = JSONRPCResponse( + id = message.id, + result = result, + ) + + _onMessage.invoke(response) + } + + override suspend fun close() { + closed = true + } + } + + val client = Client( + clientInfo = Implementation( + name = "test client", + version = "1.0", + ), + options = ClientOptions(), + ) + + assertFailsWith("Server's protocol version is not supported: invalid-version") { + client.connect(clientTransport) + } + + assertTrue(closed) + } + + @Test + fun `should reject due to non cancellation exception`() = runTest { + var closed = false + val failingTransport = object : AbstractTransport() { + override suspend fun start() {} + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + if (message !is JSONRPCRequest) return + check(message.method == Method.Defined.Initialize.value) + throw IllegalStateException("Test error") + } + + override suspend fun close() { + closed = true + } + } + + val client = Client( + clientInfo = Implementation( + name = "test client", + version = "1.0", + ), + options = ClientOptions(), + ) + + val exception = assertFailsWith { + client.connect(failingTransport) + } + + assertEquals("Error connecting to transport: Test error", exception.message) + + assertTrue(closed) + } + + @Test + fun `should respect server capabilities`() = runTest { + val serverOptions = ServerOptions( + capabilities = ServerCapabilities( + resources = ServerCapabilities.Resources(null, null), + tools = ServerCapabilities.Tools(null), + ), + ) + val server = Server( + Implementation(name = "test server", version = "1.0"), + serverOptions, + ) + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + val client = Client( + clientInfo = Implementation(name = "test client", version = "1.0"), + options = ClientOptions( + capabilities = ClientCapabilities(sampling = EmptyJsonObject), + ), + ) + + 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, + capabilities = ServerCapabilities( + resources = ServerCapabilities.Resources(null, null), + tools = ServerCapabilities.Tools(null), + ), + serverInfo = Implementation(name = "test", version = "1.0"), + ) + } + + serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> + ListResourcesResult(resources = emptyList(), nextCursor = null) + } + + serverSession.setRequestHandler(Method.Defined.ToolsList) { _, _ -> + ListToolsResult(tools = emptyList(), nextCursor = null) + } + // Server supports resources and tools, but not prompts + val caps = client.serverCapabilities + assertEquals(ServerCapabilities.Resources(null, null), caps?.resources) + assertEquals(ServerCapabilities.Tools(null), caps?.tools) + assertTrue(caps?.prompts == null) // or check that prompts are absent + + // These should not throw + client.listResources() + client.listTools() + + // This should fail because prompts are not supported + val ex = assertFailsWith { + client.listPrompts() + } + assertTrue(ex.message?.contains("Server does not support prompts") == true) + } + + @Test + fun `should respect client notification capabilities`() = runTest { + val server = Server( + Implementation(name = "test server", version = "1.0"), + ServerOptions(capabilities = ServerCapabilities()), + ) + + val client = Client( + clientInfo = Implementation(name = "test client", version = "1.0"), + options = ClientOptions( + capabilities = ClientCapabilities( + roots = ClientCapabilities.Roots(listChanged = true), + ), + ), + ) + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + listOf( + launch { + client.connect(clientTransport) + println("Client connected") + }, + launch { + server.createSession(serverTransport) + println("Server connected") + }, + ).joinAll() + + // This should not throw because the client supports roots.listChanged + client.sendRootsListChanged() + + // Create a new client without the roots.listChanged capability + val clientWithoutCapability = Client( + clientInfo = Implementation(name = "test client without capability", version = "1.0"), + options = ClientOptions( + capabilities = ClientCapabilities(), + // enforceStrictCapabilities = true // TODO() + ), + ) + + clientWithoutCapability.connect(clientTransport) + // Using the same transport pair might not be realistic - in a real scenario you'd create another pair. + // Adjust if necessary. + + // This should fail + val ex = assertFailsWith { + clientWithoutCapability.sendRootsListChanged() + } + assertTrue(ex.message?.startsWith("Client does not support") == true) + } + + @Test + fun `should respect server notification capabilities`() = runTest { + val server = Server( + Implementation(name = "test server", version = "1.0"), + ServerOptions( + capabilities = ServerCapabilities( + logging = EmptyJsonObject, + resources = ServerCapabilities.Resources(listChanged = true, subscribe = null), + ), + ), + ) + + val client = Client( + clientInfo = Implementation(name = "test client", version = "1.0"), + options = ClientOptions( + capabilities = ClientCapabilities(), + ), + ) + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + val serverSessionResult = CompletableDeferred() + + listOf( + launch { + client.connect(clientTransport) + println("Client connected") + }, + launch { + serverSessionResult.complete(server.createSession(serverTransport)) + println("Server connected") + }, + ).joinAll() + + val serverSession = serverSessionResult.await() + // These should not throw + val jsonObject = buildJsonObject { + put("name", "John") + put("age", 30) + put("isStudent", false) + } + serverSession.sendLoggingMessage( + LoggingMessageNotification( + params = LoggingMessageNotification.Params( + level = LoggingLevel.info, + data = jsonObject, + ), + ), + ) + serverSession.sendResourceListChanged() + + // This should fail because the server doesn't have the tools capability + val ex = assertFailsWith { + serverSession.sendToolListChanged() + } + assertTrue(ex.message?.contains("Server does not support notifying of tool list changes") == true) + } + + @Test + fun `should handle client cancelling a request`() = runTest { + val server = Server( + Implementation(name = "test server", version = "1.0"), + ServerOptions( + capabilities = ServerCapabilities( + resources = ServerCapabilities.Resources( + listChanged = null, + subscribe = null, + ), + ), + ), + ) + + val def = CompletableDeferred() + val defTimeOut = CompletableDeferred() + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + val client = Client( + clientInfo = Implementation(name = "test client", version = "1.0"), + options = ClientOptions(capabilities = ClientCapabilities()), + ) + + val serverSessionResult = CompletableDeferred() + + listOf( + launch { + client.connect(clientTransport) + println("Client connected") + }, + launch { + serverSessionResult.complete(server.createSession(serverTransport)) + println("Server connected") + }, + ).joinAll() + + val serverSession = serverSessionResult.await() + + serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> + // Simulate delay + def.complete(Unit) + try { + delay(1000) + } catch (e: CancellationException) { + defTimeOut.complete(Unit) + throw e + } + ListResourcesResult(resources = emptyList()) + fail("Shouldn't have been called") + } + + val defCancel = CompletableDeferred() + val job = launch { + try { + client.listResources() + } catch (e: CancellationException) { + defCancel.complete(Unit) + assertEquals("Cancelled by test", e.message) + } + } + def.await() + runCatching { job.cancel("Cancelled by test") } + defCancel.await() + defTimeOut.await() + } + + @Test + fun `should handle request timeout`() = runTest { + val server = Server( + Implementation(name = "test server", version = "1.0"), + ServerOptions( + capabilities = ServerCapabilities( + resources = ServerCapabilities.Resources( + listChanged = null, + subscribe = null, + ), + ), + ), + ) + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + val client = Client( + clientInfo = Implementation(name = "test client", version = "1.0"), + options = ClientOptions(capabilities = ClientCapabilities()), + ) + + val serverSessionResult = CompletableDeferred() + + listOf( + launch { + client.connect(clientTransport) + println("Client connected") + }, + launch { + serverSessionResult.complete(server.createSession(serverTransport)) + println("Server connected") + }, + ).joinAll() + + val serverSession = serverSessionResult.await() + serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> + // Simulate a delayed response + // Wait ~100ms unless canceled + try { + withTimeout(100L) { + // Just delay here, if timeout is 0 on the client side, this won't return in time + delay(100) + } + } catch (_: Exception) { + // If aborted, just rethrow or return early + } + ListResourcesResult(resources = emptyList()) + } + + // Request with 1 msec timeout should fail immediately + val ex = assertFailsWith { + withTimeout(1) { + client.listResources() + } + } + assertTrue(ex is TimeoutCancellationException) + } + + @Test + fun `should only allow setRequestHandler for declared capabilities`() = runTest { + val client = Client( + clientInfo = Implementation( + name = "test client", + version = "1.0", + ), + options = ClientOptions( + capabilities = ClientCapabilities( + sampling = EmptyJsonObject, + ), + ), + ) + + client.setRequestHandler( + Method.Defined.SamplingCreateMessage, + ) { + _, + _, + -> + CreateMessageResult( + model = "test-model", + role = Role.assistant, + content = TextContent( + text = "Test response", + ), + ) + } + + assertFailsWith("Client does not support roots capability (required for RootsList)") { + client.setRequestHandler(Method.Defined.RootsList) { _, _ -> null } + } + } + + @Test + fun `JSONRPCRequest with ToolsList method and default params returns list of tools`() = runTest { + val serverOptions = ServerOptions( + capabilities = ServerCapabilities( + tools = ServerCapabilities.Tools(null), + ), + ) + val server = Server( + Implementation(name = "test server", version = "1.0"), + serverOptions, + ) + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + val client = Client( + clientInfo = Implementation(name = "test client", version = "1.0"), + options = ClientOptions( + capabilities = ClientCapabilities(sampling = EmptyJsonObject), + ), + ) + + var receivedMessage: JSONRPCMessage? = null + clientTransport.onMessage { msg -> + receivedMessage = msg + } + + val serverSessionResult = CompletableDeferred() + + listOf( + launch { + client.connect(clientTransport) + println("Client connected") + }, + launch { + serverSessionResult.complete(server.createSession(serverTransport)) + println("Server connected") + }, + ).joinAll() + + val serverSession = serverSessionResult.await() + + serverSession.setRequestHandler(Method.Defined.Initialize) { _, _ -> + InitializeResult( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = ServerCapabilities( + resources = ServerCapabilities.Resources(null, null), + tools = ServerCapabilities.Tools(null), + ), + serverInfo = Implementation(name = "test", version = "1.0"), + ) + } + + val serverListToolsResult = ListToolsResult( + tools = listOf( + Tool( + name = "testTool", + title = "testTool title", + description = "testTool description", + annotations = null, + inputSchema = Tool.Input(), + outputSchema = null, + ), + ), + nextCursor = null, + ) + + serverSession.setRequestHandler(Method.Defined.ToolsList) { _, _ -> + serverListToolsResult + } + + val serverCapabilities = client.serverCapabilities + assertEquals(ServerCapabilities.Tools(null), serverCapabilities?.tools) + + val request = JSONRPCRequest( + method = Method.Defined.ToolsList.value, + ) + clientTransport.send(request) + + assertIs(receivedMessage) + val receivedAsResponse = receivedMessage as JSONRPCResponse + assertEquals(request.id, receivedAsResponse.id) + assertEquals(request.jsonrpc, receivedAsResponse.jsonrpc) + assertEquals(serverListToolsResult, receivedAsResponse.result) + } + + @Test + fun `listRoots returns list of roots`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + roots = ClientCapabilities.Roots(null), + ), + ), + ) + + val clientRoots = listOf( + Root(uri = "file:///test-root", name = "testRoot"), + ) + + client.addRoots(clientRoots) + + 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) + println("Client connected") + }, + launch { + serverSessionResult.complete(server.createSession(serverTransport)) + println("Server connected") + }, + ).joinAll() + + val serverSession = serverSessionResult.await() + + val clientCapabilities = serverSession.clientCapabilities + assertEquals(ClientCapabilities.Roots(null), clientCapabilities?.roots) + + val listRootsResult = serverSession.listRoots() + + assertEquals(listRootsResult.roots, clientRoots) + } + + @Test + fun `addRoot should throw when roots capability is not supported`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities(), + ), + ) + + // Verify that adding a root throws an exception + val exception = assertFailsWith { + client.addRoot(uri = "file:///test-root1", name = "testRoot1") + } + assertEquals("Client does not support roots capability.", exception.message) + } + + @Test + fun `removeRoot should throw when roots capability is not supported`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities(), + ), + ) + + // Verify that removing a root throws an exception + val exception = assertFailsWith { + client.removeRoot(uri = "file:///test-root1") + } + assertEquals("Client does not support roots capability.", exception.message) + } + + @Test + fun `removeRoot should remove a root`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + roots = ClientCapabilities.Roots(null), + ), + ), + ) + + // Add some roots + client.addRoots( + listOf( + Root(uri = "file:///test-root1", name = "testRoot1"), + Root(uri = "file:///test-root2", name = "testRoot2"), + ), + ) + + // Remove a root + val result = client.removeRoot("file:///test-root1") + + // Verify the root was removed + assertTrue(result, "Root should be removed successfully") + } + + @Test + fun `removeRoots should remove multiple roots`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + roots = ClientCapabilities.Roots(null), + ), + ), + ) + + // Add some roots + client.addRoots( + listOf( + Root(uri = "file:///test-root1", name = "testRoot1"), + Root(uri = "file:///test-root2", name = "testRoot2"), + ), + ) + + // Remove multiple roots + val result = client.removeRoots( + listOf("file:///test-root1", "file:///test-root2"), + ) + + // Verify the root was removed + assertEquals(2, result, "Both roots should be removed") + } + + @Test + fun `sendRootsListChanged should notify server`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + roots = ClientCapabilities.Roots(listChanged = true), + ), + ), + ) + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + val server = Server( + serverInfo = Implementation(name = "test server", version = "1.0"), + options = ServerOptions( + capabilities = ServerCapabilities(), + ), + ) + + // Track notifications + var rootListChangedNotificationReceived = false + + val serverSessionResult = CompletableDeferred() + + listOf( + launch { + client.connect(clientTransport) + println("Client connected") + }, + launch { + serverSessionResult.complete(server.createSession(serverTransport)) + println("Server connected") + }, + ).joinAll() + + val serverSession = serverSessionResult.await() + serverSession.setNotificationHandler( + Method.Defined.NotificationsRootsListChanged, + ) { + rootListChangedNotificationReceived = true + CompletableDeferred(Unit) + } + + client.sendRootsListChanged() + + assertTrue( + rootListChangedNotificationReceived, + "Notification should be sent when sendRootsListChanged is called", + ) + } + + @Test + fun `should reject server elicitation when elicitation capability is not supported`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities(), + ), + ) + + 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) + println("Client connected") + }, + launch { + serverSessionResult.complete(server.createSession(serverTransport)) + println("Server connected") + }, + ).joinAll() + + val serverSession = serverSessionResult.await() + + // Verify that creating an elicitation throws an exception + val exception = assertFailsWith { + serverSession.createElicitation( + message = "Please provide your GitHub username", + requestedSchema = CreateElicitationRequest.RequestedSchema( + properties = buildJsonObject { + putJsonObject("name") { + put("type", "string") + } + }, + required = listOf("name"), + ), + ) + } + assertEquals( + "Client does not support elicitation (required for elicitation/create)", + exception.message, + ) + } + + @Test + fun `should handle logging setLevel request`() = runTest { + val server = Server( + Implementation(name = "test server", version = "1.0"), + ServerOptions( + capabilities = ServerCapabilities( + logging = EmptyJsonObject, + ), + ), + ) + + val client = Client( + clientInfo = Implementation(name = "test client", version = "1.0"), + options = ClientOptions( + capabilities = ClientCapabilities(), + ), + ) + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + val receivedMessages = mutableListOf() + client.setNotificationHandler( + Method.Defined.NotificationsMessage, + ) { notification -> + receivedMessages.add(notification) + CompletableDeferred(Unit) + } + + val serverSessionResult = CompletableDeferred() + + listOf( + launch { + client.connect(clientTransport) + println("Client connected") + }, + launch { + serverSessionResult.complete(server.connect(serverTransport)) + println("Server connected") + }, + ).joinAll() + + val serverSession = serverSessionResult.await() + + // Set logging level to warning + val minLevel = LoggingLevel.warning + val result = client.setLoggingLevel(minLevel) + assertEquals(null, result.meta) + + // Send messages of different levels + val testMessages = listOf( + LoggingLevel.debug to "Debug - should be filtered", + LoggingLevel.info to "Info - should be filtered", + LoggingLevel.warning to "Warning - should pass", + LoggingLevel.error to "Error - should pass", + ) + + testMessages.forEach { (level, message) -> + serverSession.sendLoggingMessage( + LoggingMessageNotification( + params = LoggingMessageNotification.Params( + level = level, + data = buildJsonObject { put("message", message) }, + ), + ), + ) + } + + delay(100) + + // Only warning and error should be received + assertEquals(2, receivedMessages.size, "Should receive only 2 messages (warning and error)") + + // Verify all received messages have severity >= minLevel + receivedMessages.forEach { message -> + val messageSeverity = message.params.level.ordinal + assertTrue( + messageSeverity >= minLevel.ordinal, + "Received message with level ${message.params.level} should have severity >= $minLevel", + ) + } + } + + @Test + fun `should handle server elicitation`() = runTest { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions( + capabilities = ClientCapabilities( + elicitation = EmptyJsonObject, + ), + ), + ) + + val elicitationMessage = "Please provide your GitHub username" + val requestedSchema = CreateElicitationRequest.RequestedSchema( + properties = buildJsonObject { + putJsonObject("name") { + put("type", "string") + } + }, + required = listOf("name"), + ) + + val elicitationResultAction = CreateElicitationResult.Action.accept + val elicitationResultContent = buildJsonObject { + put("name", "octocat") + } + + client.setElicitationHandler { request -> + assertEquals(elicitationMessage, request.params.message) + assertEquals(requestedSchema, request.params.requestedSchema) + + CreateElicitationResult( + action = elicitationResultAction, + content = elicitationResultContent, + ) + } + + 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) + println("Client connected") + }, + launch { + serverSessionResult.complete(server.createSession(serverTransport)) + println("Server connected") + }, + ).joinAll() + + val serverSession = serverSessionResult.await() + + val result = serverSession.createElicitation( + message = elicitationMessage, + requestedSchema = requestedSchema, + ) + + assertEquals(elicitationResultAction, result.action) + assertEquals(elicitationResultContent, result.content) + } +} diff --git a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaSseTransportTest.kt b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaSseTransportTest.kt new file mode 100644 index 0000000..3c2d75a --- /dev/null +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaSseTransportTest.kt @@ -0,0 +1,123 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.ktor.client.HttpClient +import io.ktor.server.application.install +import io.ktor.server.cio.CIO +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.mcp +import io.modelcontextprotocol.kotlin.sdk.shared.BaseTransportTest +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Ignore +import kotlin.test.Test +import io.ktor.client.plugins.sse.SSE as ClientSSE +import io.ktor.server.sse.SSE as ServerSSE + +class OldSchemaSseTransportTest : BaseTransportTest() { + + private suspend fun EmbeddedServer<*, *>.actualPort() = engine.resolvedConnectors().first().port + + private lateinit var mcpServer: Server + + @BeforeTest + fun setUp() { + mcpServer = Server( + serverInfo = Implementation( + name = "test-server", + version = "1.0", + ), + options = ServerOptions(ServerCapabilities()), + ) + } + + @Test + @Ignore // Ignored because it doesn’t work with wasm/js in Ktor 3.2.3 + fun `should start then close cleanly`() = runTest { + val server = embeddedServer(CIO, port = 0) { + install(ServerSSE) + routing { + mcp { mcpServer } + } + }.startSuspend(wait = false) + + val actualPort = server.actualPort() + + val transport = HttpClient { + install(ClientSSE) + }.mcpSseTransport { + url { + host = "localhost" + this.port = actualPort + } + } + + try { + testTransportRead(transport) + } finally { + server.stopSuspend() + } + } + + @Ignore + @Test + fun `should read messages`() = runTest { + val server = embeddedServer(CIO, port = 0) { + install(ServerSSE) + routing { + mcp { mcpServer } + } + }.startSuspend(wait = false) + + val actualPort = server.actualPort() + + val transport = HttpClient { + install(ClientSSE) + }.mcpSseTransport { + url { + host = "localhost" + this.port = actualPort + } + } + + try { + testTransportRead(transport) + } finally { + server.stopSuspend() + } + } + + @Ignore + @Test + fun `test sse path not root path`() = runTest { + val server = embeddedServer(CIO, port = 0) { + install(ServerSSE) + routing { + mcp { mcpServer } + } + }.startSuspend(wait = false) + + val actualPort = server.actualPort() + + val transport = HttpClient { + install(ClientSSE) + }.mcpSseTransport { + url { + host = "localhost" + this.port = actualPort + pathSegments = listOf("sse") + } + } + + try { + testTransportRead(transport) + } finally { + server.stopSuspend() + } + } +} diff --git a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseTransportTest.kt b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseTransportTest.kt index 8abf75d..d82ff47 100644 --- a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseTransportTest.kt +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseTransportTest.kt @@ -12,10 +12,9 @@ import io.modelcontextprotocol.kotlin.sdk.server.mcp import io.modelcontextprotocol.kotlin.sdk.shared.BaseTransportTest import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext import kotlin.test.BeforeTest +import kotlin.test.Ignore import kotlin.test.Test import io.ktor.client.plugins.sse.SSE as ClientSSE import io.ktor.server.sse.SSE as ServerSSE @@ -38,6 +37,7 @@ class SseTransportTest : BaseTransportTest() { } @Test + @Ignore // Ignored because it doesn’t work with wasm/js in Ktor 3.2.3 fun `should start then close cleanly`() = runTest { val server = embeddedServer(CIO, port = 0) { install(ServerSSE) @@ -58,12 +58,13 @@ class SseTransportTest : BaseTransportTest() { } try { - testTransportOpenClose(transport) + testTransportRead(transport) } finally { server.stopSuspend() } } + @Ignore @Test fun `should read messages`() = runTest { val server = embeddedServer(CIO, port = 0) { @@ -84,28 +85,20 @@ class SseTransportTest : BaseTransportTest() { } } - val client = Client( - clientInfo = Implementation(name = "test-client", version = "1.0"), - options = ClientOptions(), - ) - try { - withContext(Dispatchers.Default) { - client.connect(transport) - client.ping() - } + testTransportRead(transport) } finally { - client.close() server.stopSuspend() } } + @Ignore @Test fun `test sse path not root path`() = runTest { val server = embeddedServer(CIO, port = 0) { install(ServerSSE) routing { - mcp("/sse") { mcpServer } + mcp { mcpServer } } }.startSuspend(wait = false) @@ -121,18 +114,9 @@ class SseTransportTest : BaseTransportTest() { } } - val client = Client( - clientInfo = Implementation(name = "test-client", version = "1.0"), - options = ClientOptions(), - ) - try { - withContext(Dispatchers.Default) { - client.connect(transport) - client.ping() - } + testTransportRead(transport) } finally { - client.close() server.stopSuspend() } } diff --git a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/OldSchemaInMemoryTransportTest.kt b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/OldSchemaInMemoryTransportTest.kt new file mode 100644 index 0000000..5f31bc1 --- /dev/null +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/OldSchemaInMemoryTransportTest.kt @@ -0,0 +1,110 @@ +package io.modelcontextprotocol.kotlin.sdk.integration + +import io.modelcontextprotocol.kotlin.sdk.InitializedNotification +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport +import io.modelcontextprotocol.kotlin.sdk.toJSON +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class OldSchemaInMemoryTransportTest { + private lateinit var clientTransport: InMemoryTransport + private lateinit var serverTransport: InMemoryTransport + + @BeforeTest + fun setUp() { + val (client, server) = InMemoryTransport.createLinkedPair() + clientTransport = client + serverTransport = server + } + + @Test + fun `should create linked pair`() { + assertNotNull(clientTransport) + assertNotNull(serverTransport) + } + + @Test + fun `should start without error`() = runTest { + clientTransport.start() + serverTransport.start() + // If no exception is thrown, the test passes + } + + @Test + fun `should send message from client to server`() = runTest { + val message = InitializedNotification() + + var receivedMessage: JSONRPCMessage? = null + serverTransport.onMessage { msg -> + receivedMessage = msg + } + + val rpcNotification = message.toJSON() + clientTransport.send(rpcNotification) + assertEquals(rpcNotification, receivedMessage) + } + + @Test + fun `should send message from server to client`() = runTest { + val message = InitializedNotification() + .toJSON() + + var receivedMessage: JSONRPCMessage? = null + clientTransport.onMessage { msg -> + receivedMessage = msg + } + + serverTransport.send(message) + assertEquals(message, receivedMessage) + } + + @Test + fun `should handle close`() = runTest { + var clientClosed = false + var serverClosed = false + + clientTransport.onClose { + clientClosed = true + } + + serverTransport.onClose { + serverClosed = true + } + + clientTransport.close() + assertTrue(clientClosed) + assertTrue(serverClosed) + } + + @Test + fun `should throw error when sending after close`() = runTest { + clientTransport.close() + + assertFailsWith { + clientTransport.send( + InitializedNotification().toJSON(), + ) + } + } + + @Test + fun `should queue messages sent before start`() = runTest { + val message = InitializedNotification() + .toJSON() + + var receivedMessage: JSONRPCMessage? = null + serverTransport.onMessage { msg -> + receivedMessage = msg + } + + clientTransport.send(message) + serverTransport.start() + assertEquals(message, receivedMessage) + } +} diff --git a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/BaseTransportTest.kt b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/BaseTransportTest.kt index a6c6e3d..d5849eb 100644 --- a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/BaseTransportTest.kt +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/BaseTransportTest.kt @@ -1,12 +1,14 @@ package io.modelcontextprotocol.kotlin.sdk.shared -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.matchers.shouldBe import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.PingRequest import io.modelcontextprotocol.kotlin.sdk.types.toJSON import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue import kotlin.test.fail import kotlin.time.Duration.Companion.seconds @@ -21,18 +23,12 @@ abstract class BaseTransportTest { transport.onClose { didClose = true } transport.start() + delay(1.seconds) - // Verify transport stays open after start (use eventually for cross-platform reliability) - eventually(2.seconds) { - didClose shouldBe false - } + assertFalse(didClose, "Transport should not be closed immediately after start") transport.close() - - // Verify transport is closed after close() call - eventually(2.seconds) { - didClose shouldBe true - } + assertTrue(didClose, "Transport should be closed after close() call") } protected suspend fun testTransportRead(transport: Transport) { @@ -64,7 +60,7 @@ abstract class BaseTransportTest { finished.await() - messages shouldBe readMessages + assertEquals(messages, readMessages, "Assert messages received") transport.close() } diff --git a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/InMemoryTransport.kt b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/InMemoryTransport.kt index 2c15635..eabdf28 100644 --- a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/InMemoryTransport.kt +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/InMemoryTransport.kt @@ -36,11 +36,11 @@ class InMemoryTransport : AbstractTransport() { val other = otherTransport otherTransport = null other?.close() - invokeOnCloseCallback() + _onClose.invoke() } override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { - val other = checkNotNull(otherTransport) { "Not connected" } + val other = otherTransport ?: throw IllegalStateException("Not connected") other._onMessage.invoke(message) } diff --git a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/OldSchemaBaseTransportTest.kt b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/OldSchemaBaseTransportTest.kt new file mode 100644 index 0000000..d2d0402 --- /dev/null +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/OldSchemaBaseTransportTest.kt @@ -0,0 +1,63 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.modelcontextprotocol.kotlin.sdk.InitializedNotification +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.PingRequest +import io.modelcontextprotocol.kotlin.sdk.toJSON +import kotlinx.coroutines.CompletableDeferred +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.fail + +abstract class OldSchemaBaseTransportTest { + + protected suspend fun testTransportOpenClose(transport: Transport) { + transport.onError { error -> + fail("Unexpected error: $error") + } + + var didClose = false + transport.onClose { didClose = true } + + transport.start() + assertFalse(didClose, "Transport should not be closed immediately after start") + + transport.close() + assertTrue(didClose, "Transport should be closed after close() call") + } + + protected suspend fun testTransportRead(transport: Transport) { + transport.onError { error -> + error.printStackTrace() + fail("Unexpected error: $error") + } + + val messages = listOf( + PingRequest().toJSON(), + InitializedNotification().toJSON(), + ) + + val readMessages = mutableListOf() + val finished = CompletableDeferred() + + transport.onMessage { message -> + readMessages.add(message) + if (message == messages.last()) { + finished.complete(Unit) + } + } + + transport.start() + + for (message in messages) { + transport.send(message) + } + + finished.await() + + assertEquals(messages, readMessages, "Assert messages received") + + transport.close() + } +} diff --git a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/OldSchemaInMemoryTransport.kt b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/OldSchemaInMemoryTransport.kt new file mode 100644 index 0000000..9a0d4d3 --- /dev/null +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/OldSchemaInMemoryTransport.kt @@ -0,0 +1,47 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage + +/** + * In-memory transport for creating clients and servers that talk to each other within the same process. + */ +class OldSchemaInMemoryTransport : AbstractTransport() { + private var otherTransport: OldSchemaInMemoryTransport? = null + private val messageQueue: MutableList = mutableListOf() + + /** + * Creates a pair of linked in-memory transports that can communicate with each other. + * One should be passed to a Client and one to a Server. + */ + companion object { + fun createLinkedPair(): Pair { + val clientTransport = OldSchemaInMemoryTransport() + val serverTransport = OldSchemaInMemoryTransport() + clientTransport.otherTransport = serverTransport + serverTransport.otherTransport = clientTransport + return Pair(clientTransport, serverTransport) + } + } + + override suspend fun start() { + // Process any messages that were queued before start was called + while (messageQueue.isNotEmpty()) { + messageQueue.removeFirstOrNull()?.let { message -> + _onMessage.invoke(message) // todo? + } + } + } + + override suspend fun close() { + val other = otherTransport + otherTransport = null + other?.close() + _onClose.invoke() + } + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + val other = otherTransport ?: throw IllegalStateException("Not connected") + + other._onMessage.invoke(message) + } +} 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..be06cff 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 @@ -1,19 +1,14 @@ package io.modelcontextprotocol.kotlin.sdk.client -import io.kotest.assertions.nondeterministic.eventually import io.modelcontextprotocol.kotlin.sdk.shared.BaseTransportTest import io.modelcontextprotocol.kotlin.sdk.types.Implementation 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.delay 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,14 +17,13 @@ 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.milliseconds import kotlin.time.Duration.Companion.seconds @Timeout(30, unit = TimeUnit.SECONDS) @@ -63,11 +57,8 @@ class StdioClientTransportTest : BaseTransportTest() { ) // The error in stderr should cause connecting to fail - // Use explicit timeout to avoid blocking indefinitely due to kotlinx.io cancellation issue (#514) assertThrows { - withTimeout(5.seconds) { - client.connect(transport) - } + client.connect(transport) } process.destroyForcibly() @@ -98,57 +89,16 @@ class StdioClientTransportTest : BaseTransportTest() { transport.onClose { didClose.store(true) } transport.start() + delay(1.seconds) - // Verify transport stays open after start (use eventually for cross-platform reliability) - eventually(2.seconds) { - assertFalse(didClose.load(), "Transport should not be closed immediately after start") - } + assertFalse(didClose.load(), "Transport should not be closed immediately after start") // Destroy process BEFORE close() to unblock stdin reader process.destroyForcibly() + delay(100.milliseconds) // Give time for EOF to propagate transport.close() - // Verify transport is closed after close() call - eventually(2.seconds) { - assertTrue(didClose.load(), "Transport should be closed after close() call") - } - } - - @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") } @@ -190,22 +140,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/AbstractAuthenticationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/AbstractAuthenticationTest.kt deleted file mode 100644 index ec42041..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/AbstractAuthenticationTest.kt +++ /dev/null @@ -1,187 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.integration - -import io.kotest.matchers.shouldBe -import io.ktor.client.HttpClient -import io.ktor.client.request.basicAuth -import io.ktor.client.request.get -import io.ktor.http.HttpStatusCode -import io.ktor.serialization.kotlinx.json.json -import io.ktor.server.application.Application -import io.ktor.server.application.ApplicationCall -import io.ktor.server.application.install -import io.ktor.server.auth.Authentication -import io.ktor.server.auth.UserIdPrincipal -import io.ktor.server.auth.authenticate -import io.ktor.server.auth.basic -import io.ktor.server.auth.principal -import io.ktor.server.engine.embeddedServer -import io.ktor.server.plugins.contentnegotiation.ContentNegotiation -import io.ktor.server.routing.Route -import io.ktor.server.routing.routing -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.shared.Transport -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.McpJson -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.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents -import io.modelcontextprotocol.kotlin.test.utils.actualPort -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import kotlin.test.Test -import kotlin.time.Duration.Companion.seconds -import io.ktor.client.engine.cio.CIO as ClientCIO -import io.ktor.server.cio.CIO as ServerCIO -import io.ktor.server.sse.SSE as ServerSSE - -/** - * Base class for MCP authentication integration tests. - */ -abstract class AbstractAuthenticationTest { - - protected companion object { - const val HOST = "127.0.0.1" - const val AUTH_REALM = "mcp-auth" - const val WHOAMI_URI = "whoami://me" - const val VALID_USER = "test-user" - const val VALID_PASSWORD = "valid-password-123" - const val INVALID_USER = "invalid-user" - const val INVALID_PASSWORD = "invalid-password" - } - - /** - * Installs Ktor plugins required by the transport under test. - */ - protected open fun Application.configurePlugins() { - install(ServerSSE) - // ContentNegotiation is required by the StreamableHttp transport for JSON body handling. - // Installing it for SSE tests as well is harmless. - install(ContentNegotiation) { json(McpJson) } - } - - /** - * Registers the MCP server on the given route. - */ - abstract fun Route.registerMcpServer(serverFactory: ApplicationCall.() -> Server) - - /** - * Creates a client transport configured with the given credentials. - */ - abstract fun createClientTransport(baseUrl: String, user: String, pass: String): Transport - - @Test - fun `mcp behind basic auth rejects unauthenticated requests with 401`(): Unit = runBlocking(Dispatchers.IO) { - val server = startAuthenticatedServer() - - val httpClient = HttpClient(ClientCIO) { expectSuccess = false } - try { - httpClient.get("http://$HOST:${server.actualPort()}").status shouldBe HttpStatusCode.Unauthorized - } finally { - httpClient.close() - server.stopSuspend(1000, 2000) - } - } - - @Test - fun `mcp rejects requests with invalid credentials`(): Unit = runBlocking(Dispatchers.IO) { - val server = startAuthenticatedServer() - - val httpClient = HttpClient(ClientCIO) { - expectSuccess = false - } - try { - httpClient.get("http://$HOST:${server.actualPort()}") { - basicAuth(INVALID_USER, INVALID_PASSWORD) - }.status shouldBe HttpStatusCode.Unauthorized - } finally { - httpClient.close() - server.stopSuspend(1000, 2000) - } - } - - @Test - fun `authenticated mcp client can read resource scoped to principal`(): Unit = runBlocking(Dispatchers.IO) { - val server = startAuthenticatedServer() - - val baseUrl = "http://$HOST:${server.actualPort()}" - var mcpClient: Client? = null - try { - mcpClient = Client(Implementation(name = "test-client", version = "1.0.0")) - withTimeout(5.seconds) { - mcpClient.connect(createClientTransport(baseUrl, VALID_USER, VALID_PASSWORD)) - } - - val result = mcpClient.readResource( - ReadResourceRequest(ReadResourceRequestParams(uri = WHOAMI_URI)), - ) - - result.contents shouldBe listOf( - TextResourceContents( - text = VALID_USER, - uri = WHOAMI_URI, - mimeType = "text/plain", - ), - ) - } finally { - mcpClient?.close() - server.stopSuspend(1000, 2000) - } - } - - private suspend fun startAuthenticatedServer() = embeddedServer(ServerCIO, host = HOST, port = 0) { - configurePlugins() - installBasicAuth() - routing { - authenticate(AUTH_REALM) { - registerMcpServer { - createMcpServer { principal()?.name } - } - } - } - }.startSuspend(wait = false) - - private fun Application.installBasicAuth() { - install(Authentication) { - basic(AUTH_REALM) { - validate { credentials -> - if (credentials.name == VALID_USER && credentials.password == VALID_PASSWORD) { - UserIdPrincipal(credentials.name) - } else { - null - } - } - } - } - } - - protected fun createMcpServer(principalProvider: () -> String?): Server = Server( - serverInfo = Implementation(name = "test-server", version = "1.0.0"), - options = ServerOptions( - capabilities = ServerCapabilities( - resources = ServerCapabilities.Resources(), - ), - ), - ).apply { - addResource( - uri = WHOAMI_URI, - name = "Current User", - description = "Returns the name of the authenticated user", - mimeType = "text/plain", - ) { - ReadResourceResult( - contents = listOf( - TextResourceContents( - text = principalProvider() ?: "anonymous", - uri = WHOAMI_URI, - mimeType = "text/plain", - ), - ), - ) - } - } -} 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..fbaca72 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 @@ -1,17 +1,11 @@ package io.modelcontextprotocol.kotlin.sdk.integration.kotlin import io.kotest.assertions.withClue -import io.kotest.matchers.shouldBe 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 @@ -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 @@ -274,7 +266,7 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() { ), ), ) { request -> - val args = request.params.arguments.orEmpty() + val args = request.params.arguments ?: emptyMap() val arg1 = args["requiredArg1"] ?: throw IllegalArgumentException( "Missing required argument: requiredArg1", ) @@ -696,78 +688,13 @@ 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("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) + val expectedMessage = "MCP error -32603: Prompt not found: non-existent-prompt" assertEquals( - listOf(null, "cursor-2", "cursor-3"), - receivedCursors.toList(), - "Client must forward each nextCursor into the next request", + RPCError.ErrorCode.INTERNAL_ERROR, + exception.code, + "Exception code should be INTERNAL_ERROR: ${RPCError.ErrorCode.INTERNAL_ERROR}", ) - 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) + assertEquals(expectedMessage, exception.message, "Unexpected error message for non-existent prompt") } } 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..fc08810 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,9 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows -import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.Ignore import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -77,6 +71,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, @@ -154,6 +165,7 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() { assertEquals(testResourceContent, content.text, "Resource content should match") } + @Ignore("Blocked by https://github.com/modelcontextprotocol/kotlin-sdk/issues/249") @Test fun testSubscribeAndUnsubscribe() { runBlocking(Dispatchers.IO) { @@ -203,11 +215,14 @@ abstract class AbstractResourceIntegrationTest : KotlinTestBase() { } } + val expectedMessage = "MCP error -32603: Resource not found: test://nonexistent.txt" + assertEquals( - RPCError.ErrorCode.RESOURCE_NOT_FOUND, + RPCError.ErrorCode.INTERNAL_ERROR, exception.code, - "Exception code should be RESOURCE_NOT_FOUND: ${RPCError.ErrorCode.RESOURCE_NOT_FOUND}", + "Exception code should be INTERNAL_ERROR: ${RPCError.ErrorCode.INTERNAL_ERROR}", ) + assertEquals(expectedMessage, exception.message, "Unexpected error message for invalid resource URI") } @Test @@ -299,76 +314,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/kotlin/KotlinTestBase.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/KotlinTestBase.kt index 2f8b387..43b1117 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/KotlinTestBase.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/KotlinTestBase.kt @@ -3,24 +3,18 @@ package io.modelcontextprotocol.kotlin.sdk.integration.kotlin import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.sse.SSE -import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.install import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer -import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.routing.routing import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport -import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport -import io.modelcontextprotocol.kotlin.sdk.server.StreamableHttpServerTransport import io.modelcontextprotocol.kotlin.sdk.server.mcp -import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.McpJson import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.test.utils.Retry import kotlinx.coroutines.runBlocking @@ -50,8 +44,7 @@ abstract class KotlinTestBase { protected lateinit var serverEngine: EmbeddedServer<*, *> // Transport selection - protected enum class TransportKind { SSE, STDIO, STREAMABLE_HTTP } - + protected enum class TransportKind { SSE, STDIO } protected open val transportKind: TransportKind = TransportKind.STDIO // STDIO-specific fields @@ -59,16 +52,13 @@ abstract class KotlinTestBase { private var stdioClientInput: Source? = null private var stdioClientOutput: Sink? = null - // StreamableHTTP-specific fields - private var streamableHttpServerTransport: StreamableHttpServerTransport? = null - protected abstract fun configureServerCapabilities(): ServerCapabilities protected abstract fun configureServer() @BeforeEach fun setUp() { setupServer() - if (transportKind == TransportKind.SSE || transportKind == TransportKind.STREAMABLE_HTTP) { + if (transportKind == TransportKind.SSE) { await .ignoreExceptions() .until { @@ -108,19 +98,6 @@ abstract class KotlinTestBase { ) client.connect(transport) } - - TransportKind.STREAMABLE_HTTP -> { - val transport = StreamableHttpClientTransport( - client = HttpClient(CIO) { - install(SSE) - }, - url = "http://$host:$port/mcp", - ) - client = Client( - Implementation("test", "1.0"), - ) - client.connect(transport) - } } } @@ -134,67 +111,35 @@ abstract class KotlinTestBase { configureServer() - when (transportKind) { - TransportKind.SSE -> { - serverEngine = embeddedServer(ServerCIO, host = host, port = port) { - install(ServerSSE) - routing { - mcp { server } - } - }.start(wait = false) - } - - TransportKind.STREAMABLE_HTTP -> { - // Create StreamableHTTP server transport - // Using JSON response mode for simpler testing (no SSE session required) - val transport = StreamableHttpServerTransport( - StreamableHttpServerTransport.Configuration( - enableJsonResponse = true, // Use JSON response mode for testing - ), - ) - // Use stateless mode to skip session validation for simpler testing - transport.setSessionIdGenerator(null) - streamableHttpServerTransport = transport - - // IMPORTANT: Create server session BEFORE starting the HTTP server - // This ensures message handlers are set up before any requests come in - runBlocking { - server.createSession(transport) + if (transportKind == TransportKind.SSE) { + serverEngine = embeddedServer(ServerCIO, host = host, port = port) { + install(ServerSSE) + routing { + mcp { server } } + }.start(wait = false) + } else { + // Create in-memory stdio pipes: client->server and server->client + val clientToServerOut = PipedOutputStream() + val clientToServerIn = PipedInputStream(clientToServerOut) - // Start embedded server with routing for StreamableHTTP - serverEngine = embeddedServer(ServerCIO, host = host, port = port) { - // Install ContentNegotiation for JSON serialization - install(ContentNegotiation) { - json(McpJson) - } - mcpStreamableHttp(path = "/mcp") { server } - }.start(wait = false) - } + val serverToClientOut = PipedOutputStream() + val serverToClientIn = PipedInputStream(serverToClientOut) - TransportKind.STDIO -> { - // Create in-memory stdio pipes: client->server and server->client - val clientToServerOut = PipedOutputStream() - val clientToServerIn = PipedInputStream(clientToServerOut) + // Server transport reads from client and writes to client + val serverTransport = StdioServerTransport( + inputStream = clientToServerIn.asSource().buffered(), + outputStream = serverToClientOut.asSink().buffered(), + ) + stdioServerTransport = serverTransport - val serverToClientOut = PipedOutputStream() - val serverToClientIn = PipedInputStream(serverToClientOut) + // Prepare client-side streams for later client initialization + stdioClientInput = serverToClientIn.asSource().buffered() + stdioClientOutput = clientToServerOut.asSink().buffered() - // Server transport reads from client and writes to client - val serverTransport = StdioServerTransport( - inputStream = clientToServerIn.asSource().buffered(), - outputStream = serverToClientOut.asSink().buffered(), - ) - stdioServerTransport = serverTransport - - // Prepare client-side streams for later client initialization - stdioClientInput = serverToClientIn.asSource().buffered() - stdioClientOutput = clientToServerOut.asSink().buffered() - - // Start server transport by connecting the server - runBlocking { - server.createSession(serverTransport) - } + // Start server transport by connecting the server + runBlocking { + server.createSession(serverTransport) } } } @@ -215,39 +160,24 @@ abstract class KotlinTestBase { } // stop server - when (transportKind) { - TransportKind.SSE, TransportKind.STREAMABLE_HTTP -> { - if (::serverEngine.isInitialized) { - try { - serverEngine.stop(500, 1000) - } catch (e: Exception) { - println("Warning: Error during server stop: ${e.message}") - } - } - if (transportKind == TransportKind.STREAMABLE_HTTP) { - streamableHttpServerTransport?.let { - try { - runBlocking { it.close() } - } catch (e: Exception) { - println("Warning: Error during streamable http server stop: ${e.message}") - } finally { - streamableHttpServerTransport = null - } - } + if (transportKind == TransportKind.SSE) { + if (::serverEngine.isInitialized) { + try { + serverEngine.stop(500, 1000) + } catch (e: Exception) { + println("Warning: Error during server stop: ${e.message}") } } - - TransportKind.STDIO -> { - stdioServerTransport?.let { - try { - runBlocking { it.close() } - } catch (e: Exception) { - println("Warning: Error during stdio server stop: ${e.message}") - } finally { - stdioServerTransport = null - stdioClientInput = null - stdioClientOutput = null - } + } else { + stdioServerTransport?.let { + try { + runBlocking { it.close() } + } catch (e: Exception) { + println("Warning: Error during stdio server stop: ${e.message}") + } finally { + stdioServerTransport = null + stdioClientInput = null + stdioClientOutput = null } } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/OldSchemaAbstractPromptIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/OldSchemaAbstractPromptIntegrationTest.kt new file mode 100644 index 0000000..1c7c130 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/OldSchemaAbstractPromptIntegrationTest.kt @@ -0,0 +1,698 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.kotlin + +import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest +import io.modelcontextprotocol.kotlin.sdk.GetPromptResult +import io.modelcontextprotocol.kotlin.sdk.PromptArgument +import io.modelcontextprotocol.kotlin.sdk.PromptMessage +import io.modelcontextprotocol.kotlin.sdk.Role +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +abstract class OldSchemaAbstractPromptIntegrationTest : OldSchemaKotlinTestBase() { + + private val basicPromptName = "basic-prompt" + private val basicPromptDescription = "A basic prompt for testing" + + private val complexPromptName = "multimodal-prompt" + private val complexPromptDescription = "A prompt with multiple content types" + private val conversationPromptName = "conversation" + private val conversationPromptDescription = "A prompt with multiple messages and roles" + private val strictPromptName = "strict-prompt" + private val strictPromptDescription = "A prompt with required arguments" + + private val largePromptName = "large-prompt" + private val largePromptDescription = "A very large prompt for testing" + private val largePromptContent = "X".repeat(100_000) // 100KB of data + + private val specialCharsPromptName = "special-chars-prompt" + private val specialCharsPromptDescription = "A prompt with special characters" + private val specialCharsContent = "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t" + + override fun configureServerCapabilities(): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities = + ServerCapabilities( + prompts = ServerCapabilities.Prompts( + listChanged = true, + ), + ) + + override fun configureServer() { + // basic prompt with a name parameter + server.addPrompt( + name = basicPromptName, + description = basicPromptDescription, + arguments = listOf( + PromptArgument( + name = "name", + description = "The name to greet", + required = true, + ), + ), + ) { request -> + val name = request.params.arguments?.get("name") ?: "World" + + GetPromptResult( + description = basicPromptDescription, + messages = listOf( + PromptMessage( + role = Role.user, + content = TextContent(text = "Hello, $name!"), + ), + PromptMessage( + role = Role.assistant, + content = TextContent(text = "Greetings, $name! How can I assist you today?"), + ), + ), + ) + } + + // special chars prompt + server.addPrompt( + name = specialCharsPromptName, + description = specialCharsPromptDescription, + arguments = listOf( + PromptArgument( + name = "special", + description = "Special characters to include", + required = false, + ), + ), + ) { request -> + val special = request.params.arguments?.get("special") ?: specialCharsContent + + GetPromptResult( + description = specialCharsPromptDescription, + messages = listOf( + PromptMessage( + role = Role.user, + content = TextContent(text = "Special characters: $special"), + ), + PromptMessage( + role = Role.assistant, + content = TextContent(text = "Received special characters: $special"), + ), + ), + ) + } + + // very large prompt + server.addPrompt( + name = largePromptName, + description = largePromptDescription, + arguments = listOf( + PromptArgument( + name = "size", + description = "Size multiplier", + required = false, + ), + ), + ) { request -> + val size = request.params.arguments?.get("size")?.toIntOrNull() ?: 1 + val content = largePromptContent.repeat(size) + + GetPromptResult( + description = largePromptDescription, + messages = listOf( + PromptMessage( + role = Role.user, + content = TextContent(text = "Generate a large response"), + ), + PromptMessage( + role = Role.assistant, + content = TextContent(text = content), + ), + ), + ) + } + + // complext prompt + server.addPrompt( + name = complexPromptName, + description = complexPromptDescription, + arguments = listOf( + PromptArgument(name = "arg1", description = "Argument 1", required = true), + PromptArgument(name = "arg2", description = "Argument 2", required = true), + PromptArgument(name = "arg3", description = "Argument 3", required = true), + PromptArgument(name = "arg4", description = "Argument 4", required = false), + PromptArgument(name = "arg5", description = "Argument 5", required = false), + PromptArgument(name = "arg6", description = "Argument 6", required = false), + PromptArgument(name = "arg7", description = "Argument 7", required = false), + PromptArgument(name = "arg8", description = "Argument 8", required = false), + PromptArgument(name = "arg9", description = "Argument 9", required = false), + PromptArgument(name = "arg10", description = "Argument 10", required = false), + ), + ) { request -> + // validate required arguments + val requiredArgs = listOf("arg1", "arg2", "arg3") + for (argName in requiredArgs) { + if (request.params.arguments?.get(argName) == null) { + throw IllegalArgumentException("Missing required argument: $argName") + } + } + + val args = mutableMapOf() + for (i in 1..10) { + val argName = "arg$i" + val argValue = request.params.arguments?.get(argName) + if (argValue != null) { + args[argName] = argValue + } + } + + GetPromptResult( + description = complexPromptDescription, + messages = listOf( + PromptMessage( + role = Role.user, + content = TextContent( + text = "Arguments: ${ + args.entries.joinToString { + "${it.key}=${it.value}" + } + }", + ), + ), + PromptMessage( + role = Role.assistant, + content = TextContent(text = "Received ${args.size} arguments"), + ), + ), + ) + } + + // prompt with multiple messages and roles + server.addPrompt( + name = conversationPromptName, + description = conversationPromptDescription, + arguments = listOf( + PromptArgument( + name = "topic", + description = "The topic of the conversation", + required = false, + ), + ), + ) { request -> + val topic = request.params.arguments?.get("topic") ?: "weather" + + GetPromptResult( + description = conversationPromptDescription, + messages = listOf( + PromptMessage( + role = Role.user, + content = TextContent(text = "Let's talk about the $topic."), + ), + PromptMessage( + role = Role.assistant, + content = TextContent( + text = "Sure, I'd love to discuss the $topic. What would you like to know?", + ), + ), + PromptMessage( + role = Role.user, + content = TextContent(text = "What's your opinion on the $topic?"), + ), + PromptMessage( + role = Role.assistant, + content = TextContent( + text = "As an AI, I don't have personal opinions," + + " but I can provide information about $topic.", + ), + ), + PromptMessage( + role = Role.user, + content = TextContent(text = "That's helpful, thank you!"), + ), + PromptMessage( + role = Role.assistant, + content = TextContent( + text = "You're welcome! Let me know if you have more questions about $topic.", + ), + ), + ), + ) + } + + // prompt with strict required arguments + server.addPrompt( + name = strictPromptName, + description = strictPromptDescription, + arguments = listOf( + PromptArgument( + name = "requiredArg1", + description = "First required argument", + required = true, + ), + PromptArgument( + name = "requiredArg2", + description = "Second required argument", + required = true, + ), + PromptArgument( + name = "optionalArg", + description = "Optional argument", + required = false, + ), + ), + ) { request -> + val args = request.params.arguments ?: emptyMap() + val arg1 = args["requiredArg1"] ?: throw IllegalArgumentException( + "Missing required argument: requiredArg1", + ) + val arg2 = args["requiredArg2"] ?: throw IllegalArgumentException( + "Missing required argument: requiredArg2", + ) + val optArg = args["optionalArg"] ?: "default" + + GetPromptResult( + description = strictPromptDescription, + messages = listOf( + PromptMessage( + role = Role.user, + content = TextContent(text = "Required arguments: $arg1, $arg2. Optional: $optArg"), + ), + PromptMessage( + role = Role.assistant, + content = TextContent(text = "I received your arguments: $arg1, $arg2, and $optArg"), + ), + ), + ) + } + } + + @Test + fun testListPrompts() = runBlocking(Dispatchers.IO) { + val result = client.listPrompts() + + assertNotNull(result, "List prompts result should not be null") + assertTrue(result.prompts.isNotEmpty(), "Prompts list should not be empty") + + val testPrompt = result.prompts.find { it.name == basicPromptName } + assertNotNull(testPrompt, "Test prompt should be in the list") + assertEquals( + basicPromptDescription, + testPrompt.description, + "Prompt description should match", + ) + + val arguments = testPrompt.arguments ?: error("Prompt arguments should not be null") + assertTrue(arguments.isNotEmpty(), "Prompt arguments should not be empty") + + val nameArg = arguments.find { it.name == "name" } + assertNotNull(nameArg, "Name argument should be in the list") + assertEquals( + "The name to greet", + nameArg.description, + "Argument description should match", + ) + assertEquals(true, nameArg.required, "Argument required flag should match") + } + + @Test + fun testGetPrompt() = runBlocking(Dispatchers.IO) { + val testName = "Alice" + val result = client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + name = basicPromptName, + arguments = mapOf("name" to testName), + ), + ), + ) + + assertNotNull(result, "Get prompt result should not be null") + assertEquals( + basicPromptDescription, + result.description, + "Prompt description should match", + ) + + assertTrue(result.messages.isNotEmpty(), "Prompt messages should not be empty") + assertEquals(2, result.messages.size, "Prompt should have 2 messages") + + val userMessage = result.messages.find { it.role == Role.user } + assertNotNull(userMessage, "User message should be in the list") + val userContent = userMessage.content as? TextContent + assertNotNull(userContent, "User message content should be TextContent") + assertNotNull(userContent.text, "User message text should not be null") + assertEquals( + "Hello, $testName!", + userContent.text, + "User message content should match", + ) + + val assistantMessage = result.messages.find { it.role == Role.assistant } + assertNotNull(assistantMessage, "Assistant message should be in the list") + val assistantContent = assistantMessage.content as? TextContent + assertNotNull(assistantContent, "Assistant message content should be TextContent") + assertNotNull(assistantContent.text, "Assistant message text should not be null") + assertEquals( + "Greetings, $testName! How can I assist you today?", + assistantContent.text, + "Assistant message content should match", + ) + } + + @Test + fun testMissingRequiredArguments() = runBlocking(Dispatchers.IO) { + val promptsList = client.listPrompts() + assertNotNull(promptsList, "Prompts list should not be null") + val strictPrompt = promptsList.prompts.find { it.name == strictPromptName } + assertNotNull(strictPrompt, "Strict prompt should be in the list") + + val argsDef = strictPrompt.arguments ?: error("Prompt arguments should not be null") + val requiredArgs = argsDef.filter { it.required == true } + assertEquals( + 2, + requiredArgs.size, + "Strict prompt should have 2 required arguments", + ) + + // test missing required arg + val exception = assertThrows { + runBlocking { + client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + name = strictPromptName, + arguments = mapOf("requiredArg1" to "value1"), + ), + ), + ) + } + } + + assertEquals( + true, + exception.message?.contains("requiredArg2"), + "Exception should mention the missing argument", + ) + + // test with no args + val exception2 = assertThrows { + runBlocking { + client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + name = strictPromptName, + arguments = emptyMap(), + ), + ), + ) + } + } + + assertEquals( + exception2.message?.contains("requiredArg"), + true, + "Exception should mention a missing required argument", + ) + + // test with all required args + val result = client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + name = strictPromptName, + arguments = mapOf( + "requiredArg1" to "value1", + "requiredArg2" to "value2", + ), + ), + ), + ) + + assertNotNull(result, "Get prompt result should not be null") + assertEquals(2, result.messages.size, "Prompt should have 2 messages") + + val userMessage = result.messages.find { it.role == Role.user } + assertNotNull(userMessage, "User message should be in the list") + val userContent = userMessage.content as? TextContent + assertNotNull(userContent, "User message content should be TextContent") + val userText = requireNotNull(userContent.text) + assertTrue(userText.contains("value1"), "Message should contain first argument") + assertTrue(userText.contains("value2"), "Message should contain second argument") + } + + @Test + fun testMultipleMessagesAndRoles() = runBlocking(Dispatchers.IO) { + val topic = "climate change" + val result = client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + name = conversationPromptName, + arguments = mapOf("topic" to topic), + ), + ), + ) + + assertNotNull(result, "Get prompt result should not be null") + assertEquals( + conversationPromptDescription, + result.description, + "Prompt description should match", + ) + + assertTrue(result.messages.isNotEmpty(), "Prompt messages should not be empty") + assertEquals(6, result.messages.size, "Prompt should have 6 messages") + + val userMessages = result.messages.filter { it.role == Role.user } + val assistantMessages = result.messages.filter { it.role == Role.assistant } + + assertEquals(3, userMessages.size, "Should have 3 user messages") + assertEquals(3, assistantMessages.size, "Should have 3 assistant messages") + + for (i in 0 until result.messages.size) { + val expectedRole = if (i % 2 == 0) Role.user else Role.assistant + assertEquals( + expectedRole, + result.messages[i].role, + "Message $i should have role $expectedRole", + ) + } + + for (message in result.messages) { + val content = message.content as? TextContent + assertNotNull(content, "Message content should be TextContent") + val text = requireNotNull(content.text) + + // Either the message contains the topic or it's a generic conversation message + val containsTopic = text.contains(topic) + val isGenericMessage = text.contains("thank you") || text.contains("welcome") + + assertTrue( + containsTopic || isGenericMessage, + "Message should either contain the topic or be a generic conversation message", + ) + } + } + + @Test + fun testBasicPrompt() = runBlocking(Dispatchers.IO) { + val testName = "Alice" + val result = client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + name = basicPromptName, + arguments = mapOf("name" to testName), + ), + ), + ) + + assertNotNull(result, "Get prompt result should not be null") + assertEquals(basicPromptDescription, result.description, "Prompt description should match") + + assertEquals(2, result.messages.size, "Prompt should have 2 messages") + + val userMessage = result.messages.find { it.role == Role.user } + assertNotNull(userMessage, "User message should be in the list") + val userContent = userMessage.content as? TextContent + assertNotNull(userContent, "User message content should be TextContent") + assertEquals("Hello, $testName!", userContent.text, "User message content should match") + + val assistantMessage = result.messages.find { it.role == Role.assistant } + assertNotNull(assistantMessage, "Assistant message should be in the list") + val assistantContent = assistantMessage.content as? TextContent + assertNotNull(assistantContent, "Assistant message content should be TextContent") + assertEquals( + "Greetings, $testName! How can I assist you today?", + assistantContent.text, + "Assistant message content should match", + ) + } + + @Test + fun testComplexPromptWithManyArguments() = runBlocking(Dispatchers.IO) { + val arguments = (1..10).associate { i -> "arg$i" to "value$i" } + + val result = client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + name = complexPromptName, + arguments = arguments, + ), + ), + ) + + assertNotNull(result, "Get prompt result should not be null") + assertEquals(complexPromptDescription, result.description, "Prompt description should match") + + assertEquals(2, result.messages.size, "Prompt should have 2 messages") + + val userMessage = result.messages.find { it.role == Role.user } + assertNotNull(userMessage, "User message should be in the list") + val userContent = userMessage.content as? TextContent + assertNotNull(userContent, "User message content should be TextContent") + + // verify all arguments + val text = userContent.text ?: "" + for (i in 1..10) { + assertTrue(text.contains("arg$i=value$i"), "Message should contain arg$i=value$i") + } + + val assistantMessage = result.messages.find { it.role == Role.assistant } + assertNotNull(assistantMessage, "Assistant message should be in the list") + val assistantContent = assistantMessage.content as? TextContent + assertNotNull(assistantContent, "Assistant message content should be TextContent") + assertEquals( + "Received 10 arguments", + assistantContent.text, + "Assistant message should indicate 10 arguments", + ) + } + + @Test + fun testLargePrompt() = runBlocking(Dispatchers.IO) { + val result = client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + name = largePromptName, + arguments = mapOf("size" to "1"), + ), + ), + ) + + assertNotNull(result, "Get prompt result should not be null") + assertEquals(largePromptDescription, result.description, "Prompt description should match") + + assertEquals(2, result.messages.size, "Prompt should have 2 messages") + + val assistantMessage = result.messages.find { it.role == Role.assistant } + assertNotNull(assistantMessage, "Assistant message should be in the list") + val assistantContent = assistantMessage.content as? TextContent + assertNotNull(assistantContent, "Assistant message content should be TextContent") + val text = assistantContent.text ?: "" + assertEquals(100_000, text.length, "Assistant message should be 100KB in size") + } + + @Test + fun testSpecialCharacters() = runBlocking(Dispatchers.IO) { + val result = client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + name = specialCharsPromptName, + arguments = mapOf("special" to specialCharsContent), + ), + ), + ) + + assertNotNull(result, "Get prompt result should not be null") + assertEquals(specialCharsPromptDescription, result.description, "Prompt description should match") + + assertEquals(2, result.messages.size, "Prompt should have 2 messages") + + val userMessage = result.messages.find { it.role == Role.user } + assertNotNull(userMessage, "User message should be in the list") + val userContent = userMessage.content as? TextContent + assertNotNull(userContent, "User message content should be TextContent") + val userText = userContent.text ?: "" + assertTrue(userText.contains(specialCharsContent), "User message should contain special characters") + + val assistantMessage = result.messages.find { it.role == Role.assistant } + assertNotNull(assistantMessage, "Assistant message should be in the list") + val assistantContent = assistantMessage.content as? TextContent + assertNotNull(assistantContent, "Assistant message content should be TextContent") + val assistantText = assistantContent.text ?: "" + assertTrue( + assistantText.contains(specialCharsContent), + "Assistant message should contain special characters", + ) + } + + @Test + fun testConcurrentPromptRequests() = runTest { + val concurrentCount = 10 + val results = mutableListOf() + + runBlocking { + repeat(concurrentCount) { index -> + launch { + val promptName = when (index % 4) { + 0 -> basicPromptName + 1 -> complexPromptName + 2 -> largePromptName + else -> specialCharsPromptName + } + + val arguments = when (promptName) { + basicPromptName -> mapOf("name" to "User$index") + complexPromptName -> mapOf("arg1" to "v1", "arg2" to "v2", "arg3" to "v3") + largePromptName -> mapOf("size" to "1") + else -> mapOf("special" to "!@#$%^&*()") + } + + val result = client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + name = promptName, + arguments = arguments, + ), + ), + ) + + synchronized(results) { + results.add(result) + } + } + } + } + + assertEquals(concurrentCount, results.size, "All concurrent operations should complete") + + results.forEach { result -> + assertNotNull(result, "Result should not be null") + assertTrue(result.messages.isNotEmpty(), "Result messages should not be empty") + } + } + + @Test + fun testNonExistentPrompt() = runTest { + val nonExistentPromptName = "non-existent-prompt" + + val exception = assertThrows { + runBlocking { + client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + name = nonExistentPromptName, + arguments = mapOf("name" to "Test"), + ), + ), + ) + } + } + + val msg = exception.message ?: "" + val expectedMessage = "MCP error -32603: Prompt not found: non-existent-prompt" + + assertEquals(expectedMessage, msg, "Unexpected error message for non-existent prompt") + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/OldSchemaAbstractResourceIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/OldSchemaAbstractResourceIntegrationTest.kt new file mode 100644 index 0000000..7cc5f10 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/OldSchemaAbstractResourceIntegrationTest.kt @@ -0,0 +1,319 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.kotlin + +import io.modelcontextprotocol.kotlin.sdk.BlobResourceContents +import io.modelcontextprotocol.kotlin.sdk.ReadResourceRequest +import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.SubscribeRequest +import io.modelcontextprotocol.kotlin.sdk.TextResourceContents +import io.modelcontextprotocol.kotlin.sdk.UnsubscribeRequest +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequestParams +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull +import org.junit.jupiter.api.assertThrows +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.Ignore +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +abstract class OldSchemaAbstractResourceIntegrationTest : OldSchemaKotlinTestBase() { + + private val testResourceUri = "test://example.txt" + private val testResourceName = "Test Resource" + private val testResourceDescription = "A test resource for integration testing" + private val testResourceContent = "This is the content of the test resource." + + private val binaryResourceUri = "test://image.png" + private val binaryResourceName = "Binary Resource" + private val binaryResourceDescription = "A binary resource for testing" + private val binaryResourceContent = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + + private val largeResourceUri = "test://large.txt" + private val largeResourceName = "Large Resource" + private val largeResourceDescription = "A large text resource for testing" + private val largeResourceContent = "X".repeat(100_000) // 100KB of data + + private val dynamicResourceUri = "test://dynamic.txt" + private val dynamicResourceName = "Dynamic Resource" + private val dynamicResourceContent = AtomicBoolean(false) + + override fun configureServerCapabilities(): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities = + ServerCapabilities( + resources = ServerCapabilities.Resources( + subscribe = true, + listChanged = true, + ), + ) + + override fun configureServer() { + 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 = 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, + description = binaryResourceDescription, + mimeType = "image/png", + ) { request -> + ReadResourceResult( + contents = listOf( + BlobResourceContents( + blob = binaryResourceContent, + uri = request.params.uri, + mimeType = "image/png", + ), + ), + ) + } + + server.addResource( + uri = largeResourceUri, + name = largeResourceName, + description = largeResourceDescription, + mimeType = "text/plain", + ) { request -> + ReadResourceResult( + contents = listOf( + TextResourceContents( + text = largeResourceContent, + uri = request.params.uri, + mimeType = "text/plain", + ), + ), + ) + } + + server.addResource( + uri = dynamicResourceUri, + name = dynamicResourceName, + description = "A resource that can be updated", + mimeType = "text/plain", + ) { request -> + ReadResourceResult( + contents = listOf( + TextResourceContents( + text = if (dynamicResourceContent.get()) "Updated content" else "Original content", + uri = request.params.uri, + mimeType = "text/plain", + ), + ), + ) + } + } + + @Test + fun testListResources() = runBlocking(Dispatchers.IO) { + val result = client.listResources() + + assertNotNull(result, "List resources result should not be null") + assertNull(actual = result.meta, "List resources meta should be null") + assertTrue(result.resources.isNotEmpty(), "Resources list should not be empty") + + val testResource = result.resources.find { it.uri == testResourceUri } + assertNotNull(testResource, "Test resource should be in the list") + assertEquals(testResourceName, testResource.name, "Resource name should match") + assertEquals(testResourceDescription, testResource.description, "Resource description should match") + } + + @Test + fun testReadResource() = runBlocking(Dispatchers.IO) { + val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = testResourceUri))) + + assertNotNull(result, "Read resource result should not be null") + assertNull(actual = result.meta, "Read resource result meta should be null") + assertTrue(result.contents.isNotEmpty(), "Resource contents should not be empty") + + val content = result.contents.firstOrNull() as? TextResourceContents + assertNotNull(content, "Resource content should be TextResourceContents") + assertEquals(testResourceContent, content.text, "Resource content should match") + } + + @Ignore("Blocked by https://github.com/modelcontextprotocol/kotlin-sdk/issues/249") + @Test + fun testSubscribeAndUnsubscribe() { + runBlocking(Dispatchers.IO) { + val subscribeResult = + client.subscribeResource(SubscribeRequest(SubscribeRequestParams(uri = testResourceUri))) + assertNotNull(subscribeResult, "Subscribe result should not be null") + + val unsubscribeResult = + client.unsubscribeResource(UnsubscribeRequest(UnsubscribeRequestParams(uri = testResourceUri))) + assertNotNull(unsubscribeResult, "Unsubscribe result should not be null") + } + } + + @Test + fun testBinaryResource() = runBlocking(Dispatchers.IO) { + val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = binaryResourceUri))) + + assertNotNull(result, "Read resource result should not be null") + assertTrue(result.contents.isNotEmpty(), "Resource contents should not be empty") + + val content = result.contents.firstOrNull() as? BlobResourceContents + assertNotNull(content, "Resource content should be BlobResourceContents") + assertEquals(binaryResourceContent, content.blob, "Binary resource content should match") + assertEquals("image/png", content.mimeType, "MIME type should match") + } + + @Test + fun testLargeResource() = runBlocking(Dispatchers.IO) { + val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = largeResourceUri))) + + assertNotNull(result, "Read resource result should not be null") + assertTrue(result.contents.isNotEmpty(), "Resource contents should not be empty") + + val content = result.contents.firstOrNull() as? TextResourceContents + assertNotNull(content, "Resource content should be TextResourceContents") + assertEquals(100_000, content.text.length, "Large resource content length should match") + assertEquals("X".repeat(100_000), content.text, "Large resource content should match") + } + + @Test + fun testInvalidResourceUri() = runTest { + val invalidUri = "test://nonexistent.txt" + + val exception = assertThrows { + runBlocking { + client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = invalidUri))) + } + } + + val msg = exception.message ?: "" + val expectedMessage = + "MCP error -32603: Resource not found: test://nonexistent.txt" + + assertEquals(expectedMessage, msg, "Unexpected error message for invalid resource URI") + } + + @Test + fun testDynamicResource() = runBlocking(Dispatchers.IO) { + val initialResult = client.readResource( + ReadResourceRequest(ReadResourceRequestParams(uri = dynamicResourceUri)), + ) + assertNotNull(initialResult, "Initial read result should not be null") + val initialContent = (initialResult.contents.firstOrNull() as? TextResourceContents)?.text + assertEquals("Original content", initialContent, "Initial content should match") + + // update resource + dynamicResourceContent.set(true) + + val updatedResult = client.readResource( + ReadResourceRequest(ReadResourceRequestParams(uri = dynamicResourceUri)), + ) + assertNotNull(updatedResult, "Updated read result should not be null") + val updatedContent = (updatedResult.contents.firstOrNull() as? TextResourceContents)?.text + assertEquals("Updated content", updatedContent, "Updated content should match") + } + + @Test + fun testResourceAddAndRemove() = runBlocking(Dispatchers.IO) { + val initialList = client.listResources() + assertNotNull(initialList, "Initial list result should not be null") + val initialCount = initialList.resources.size + + val newResourceUri = "test://new-resource.txt" + server.addResource( + uri = newResourceUri, + name = "New Resource", + description = "A newly added resource", + mimeType = "text/plain", + ) { request -> + ReadResourceResult( + contents = listOf( + TextResourceContents( + text = "New resource content", + uri = request.params.uri, + mimeType = "text/plain", + ), + ), + ) + } + + val updatedList = client.listResources() + assertNotNull(updatedList, "Updated list result should not be null") + val updatedCount = updatedList.resources.size + + assertEquals(initialCount + 1, updatedCount, "Resource count should increase by 1") + val newResource = updatedList.resources.find { it.uri == newResourceUri } + assertNotNull(newResource, "New resource should be in the list") + + server.removeResource(newResourceUri) + + val finalList = client.listResources() + assertNotNull(finalList, "Final list result should not be null") + val finalCount = finalList.resources.size + + assertEquals(initialCount, finalCount, "Resource count should return to initial value") + val removedResource = finalList.resources.find { it.uri == newResourceUri } + assertEquals(null, removedResource, "Resource should be removed from the list") + } + + @Test + fun testConcurrentResourceOperations() = runTest { + val concurrentCount = 10 + val results = mutableListOf() + + runBlocking { + repeat(concurrentCount) { index -> + launch { + val uri = when (index % 3) { + 0 -> testResourceUri + 1 -> binaryResourceUri + else -> largeResourceUri + } + + val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = uri))) + synchronized(results) { + results.add(result) + } + } + } + } + + assertEquals(concurrentCount, results.size, "All concurrent operations should complete") + results.forEach { result -> + assertNotNull(result, "Result should not be null") + assertTrue(result.contents.isNotEmpty(), "Result contents should not be empty") + } + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/OldSchemaAbstractToolIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/OldSchemaAbstractToolIntegrationTest.kt new file mode 100644 index 0000000..cd12d79 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/OldSchemaAbstractToolIntegrationTest.kt @@ -0,0 +1,820 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.kotlin + +import io.kotest.assertions.json.shouldEqualJson +import io.modelcontextprotocol.kotlin.sdk.CallToolRequest +import io.modelcontextprotocol.kotlin.sdk.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.ImageContent +import io.modelcontextprotocol.kotlin.sdk.PromptMessageContent +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.Tool +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +abstract class OldSchemaAbstractToolIntegrationTest : OldSchemaKotlinTestBase() { + private val testToolName = "echo" + private val testToolDescription = "A simple echo tool that returns the input text" + private val complexToolName = "calculator" + private val complexToolDescription = "A calculator tool that performs operations on numbers" + private val errorToolName = "error-tool" + private val errorToolDescription = "A tool that demonstrates error handling" + private val multiContentToolName = "multi-content" + private val multiContentToolDescription = "A tool that returns multiple content types" + + private val basicToolName = "basic-tool" + private val basicToolDescription = "A basic tool for testing" + + private val largeToolName = "large-tool" + private val largeToolDescription = "A tool that returns a large response" + private val largeToolContent = "X".repeat(100_000) // 100KB of data + + private val slowToolName = "slow-tool" + private val slowToolDescription = "A tool that takes time to respond" + + private val specialCharsToolName = "special-chars-tool" + private val specialCharsToolDescription = "A tool that handles special characters" + private val specialCharsContent = "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t" + + override fun configureServerCapabilities(): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities = + ServerCapabilities( + tools = ServerCapabilities.Tools( + listChanged = true, + ), + ) + + override fun configureServer() { + setupEchoTool() + setupCalculatorTool() + setupErrorHandlingTool() + setupMultiContentTool() + } + + private fun setupEchoTool() { + server.addTool( + name = testToolName, + description = testToolDescription, + inputSchema = Tool.Input( + properties = buildJsonObject { + put( + "text", + buildJsonObject { + put("type", "string") + put("description", "The text to echo back") + }, + ) + }, + required = listOf("text"), + ), + ) { request -> + val text = (request.params.arguments?.get("text") as? JsonPrimitive)?.content ?: "No text provided" + + CallToolResult( + content = listOf(TextContent(text = "Echo: $text")), + structuredContent = buildJsonObject { + put("result", text) + }, + ) + } + } + + private fun setupCalculatorTool() { + server.addTool( + name = basicToolName, + description = basicToolDescription, + inputSchema = Tool.Input( + properties = buildJsonObject { + put( + "text", + buildJsonObject { + put("type", "string") + put("description", "The text to echo back") + }, + ) + }, + required = listOf("text"), + ), + ) { request -> + val text = (request.params.arguments?.get("text") as? JsonPrimitive)?.content ?: "No text provided" + + CallToolResult( + content = listOf(TextContent(text = "Echo: $text")), + structuredContent = buildJsonObject { + put("result", text) + }, + ) + } + + server.addTool( + name = specialCharsToolName, + description = specialCharsToolDescription, + inputSchema = Tool.Input( + properties = buildJsonObject { + put( + "special", + buildJsonObject { + put("type", "string") + put("description", "Special characters to process") + }, + ) + }, + ), + ) { request -> + val special = (request.params.arguments?.get("special") as? JsonPrimitive)?.content ?: specialCharsContent + + CallToolResult( + content = listOf(TextContent(text = "Received special characters: $special")), + structuredContent = buildJsonObject { + put("special", special) + put("length", special.length) + }, + ) + } + + server.addTool( + name = slowToolName, + description = slowToolDescription, + inputSchema = Tool.Input( + properties = buildJsonObject { + put( + "delay", + buildJsonObject { + put("type", "integer") + put("description", "Delay in milliseconds") + }, + ) + }, + ), + ) { request -> + val delay = (request.params.arguments?.get("delay") as? JsonPrimitive)?.content?.toIntOrNull() ?: 1000 + + // simulate slow operation + runBlocking { + delay(delay.toLong()) + } + + CallToolResult( + content = listOf(TextContent(text = "Completed after ${delay}ms delay")), + structuredContent = buildJsonObject { + put("delay", delay) + }, + ) + } + + server.addTool( + name = largeToolName, + description = largeToolDescription, + inputSchema = Tool.Input( + properties = buildJsonObject { + put( + "size", + buildJsonObject { + put("type", "integer") + put("description", "Size multiplier") + }, + ) + }, + ), + ) { request -> + val size = (request.params.arguments?.get("size") as? JsonPrimitive)?.content?.toIntOrNull() ?: 1 + val content = largeToolContent.take(largeToolContent.length.coerceAtMost(size * 1000)) + + CallToolResult( + content = listOf(TextContent(text = content)), + structuredContent = buildJsonObject { + put("size", content.length) + }, + ) + } + + server.addTool( + name = complexToolName, + description = complexToolDescription, + inputSchema = Tool.Input( + properties = buildJsonObject { + put( + "operation", + buildJsonObject { + put("type", "string") + put("description", "The operation to perform (add, subtract, multiply, divide)") + put( + "enum", + buildJsonArray { + add("add") + add("subtract") + add("multiply") + add("divide") + }, + ) + }, + ) + put( + "a", + buildJsonObject { + put("type", "number") + put("description", "First operand") + }, + ) + put( + "b", + buildJsonObject { + put("type", "number") + put("description", "Second operand") + }, + ) + put( + "precision", + buildJsonObject { + put("type", "integer") + put("description", "Number of decimal places (optional)") + put("default", 2) + }, + ) + put( + "showSteps", + buildJsonObject { + put("type", "boolean") + put("description", "Whether to show calculation steps") + put("default", false) + }, + ) + put( + "tags", + buildJsonObject { + put("type", "array") + put("description", "Optional tags for the calculation") + put( + "items", + buildJsonObject { + put("type", "string") + }, + ) + }, + ) + }, + required = listOf("operation", "a", "b"), + ), + ) { request -> + val operation = (request.params.arguments?.get("operation") as? JsonPrimitive)?.content ?: "add" + val a = (request.params.arguments?.get("a") as? JsonPrimitive)?.content?.toDoubleOrNull() ?: 0.0 + val b = (request.params.arguments?.get("b") as? JsonPrimitive)?.content?.toDoubleOrNull() ?: 0.0 + val precision = (request.params.arguments?.get("precision") as? JsonPrimitive)?.content?.toIntOrNull() ?: 2 + val showSteps = + (request.params.arguments?.get("showSteps") as? JsonPrimitive)?.content?.toBoolean() ?: false + val tags = (request.params.arguments?.get("tags") as? JsonArray)?.mapNotNull { + (it as? JsonPrimitive)?.content + } ?: emptyList() + + val result = when (operation) { + "add" -> a + b + "subtract" -> a - b + "multiply" -> a * b + "divide" -> if (b != 0.0) a / b else Double.POSITIVE_INFINITY + else -> 0.0 + } + + val pattern = if (precision > 0) "0." + "0".repeat(precision) else "0" + val symbols = DecimalFormatSymbols(Locale.US).apply { decimalSeparator = '.' } + val df = DecimalFormat(pattern, symbols).apply { isGroupingUsed = false } + val formattedResult = df.format(result) + + val textContent = if (showSteps) { + "Operation: $operation\nA: $a\nB: $b\nResult: $formattedResult\nTags: ${ + tags.joinToString(", ") + }" + } else { + "Result: $formattedResult" + } + + CallToolResult( + content = listOf(TextContent(text = textContent)), + structuredContent = buildJsonObject { + put("operation", operation) + put("a", a) + put("b", b) + put("result", result) + put("formattedResult", formattedResult) + put("precision", precision) + put("tags", buildJsonArray { tags.forEach { add(it) } }) + }, + ) + } + } + + private fun setupErrorHandlingTool() { + server.addTool( + name = errorToolName, + description = errorToolDescription, + inputSchema = Tool.Input( + properties = buildJsonObject { + put( + "errorType", + buildJsonObject { + put("type", "string") + put("description", "Type of error to simulate (none, exception, error)") + put( + "enum", + buildJsonArray { + add("none") + add("exception") + add("error") + }, + ) + }, + ) + put( + "message", + buildJsonObject { + put("type", "string") + put("description", "Custom error message") + put("default", "An error occurred") + }, + ) + }, + required = listOf("errorType"), + ), + ) { request -> + val errorType = (request.params.arguments?.get("errorType") as? JsonPrimitive)?.content ?: "none" + val message = (request.params.arguments?.get("message") as? JsonPrimitive)?.content ?: "An error occurred" + + when (errorType) { + "exception" -> throw IllegalArgumentException(message) + + "error" -> CallToolResult( + content = listOf(TextContent(text = "Error: $message")), + structuredContent = buildJsonObject { + put("error", true) + put("message", message) + }, + ) + + else -> CallToolResult( + content = listOf(TextContent(text = "No error occurred")), + structuredContent = buildJsonObject { + put("error", false) + put("message", "Success") + }, + ) + } + } + } + + private fun setupMultiContentTool() { + server.addTool( + name = multiContentToolName, + description = multiContentToolDescription, + inputSchema = Tool.Input( + properties = buildJsonObject { + put( + "text", + buildJsonObject { + put("type", "string") + put("description", "Text to include in the response") + }, + ) + put( + "includeImage", + buildJsonObject { + put("type", "boolean") + put("description", "Whether to include an image in the response") + put("default", true) + }, + ) + }, + required = listOf("text"), + ), + ) { request -> + val text = (request.params.arguments?.get("text") as? JsonPrimitive)?.content ?: "Default text" + val includeImage = + (request.params.arguments?.get("includeImage") as? JsonPrimitive)?.content?.toBoolean() ?: true + + val content = mutableListOf( + TextContent(text = "Text content: $text"), + ) + + if (includeImage) { + content.add( + ImageContent( + data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + mimeType = "image/png", + ), + ) + } + + CallToolResult( + content = content, + structuredContent = buildJsonObject { + put("text", text) + put("includeImage", includeImage) + }, + ) + } + } + + @Test + fun testListTools(): Unit = runBlocking(Dispatchers.IO) { + val result = client.listTools() + + assertNotNull(result, "List tools result should not be null") + assertNull(actual = result.meta, "List tools result meta should be null") + assertTrue(result.tools.isNotEmpty(), "Tools list should not be empty") + + val testTool = result.tools.find { it.name == testToolName } + + assertNotNull(testTool, "Test tool should be in the list") + assertEquals( + testToolDescription, + testTool.description, + "Tool description should match", + ) + } + + @Test + fun testCallTool(): Unit = runBlocking(Dispatchers.IO) { + val testText = "Hello, world!" + val arguments = mapOf("text" to testText) + + val result = client.callTool(testToolName, arguments) + assertNull(actual = result.meta, "CallTool result meta should be null") + + val actualContent = result.structuredContent.toString() + val expectedContent = """ + {"result":"Hello, world!"} + """.trimIndent() + + actualContent shouldEqualJson expectedContent + } + + @Test + fun testComplexInputSchemaTool(): Unit = runBlocking(Dispatchers.IO) { + val toolsList = client.listTools() + assertNotNull(toolsList, "Tools list should not be null") + val calculatorTool = toolsList.tools.find { it.name == complexToolName } + assertNotNull(calculatorTool, "Calculator tool should be in the list") + + val arguments = mapOf( + "operation" to "multiply", + "a" to 5.5, + "b" to 2.0, + "precision" to 3, + "showSteps" to true, + "tags" to listOf("test", "calculator", "integration"), + ) + + val result = client.callTool( + complexToolName, + arguments, + ) + + val actualContent = result.structuredContent.toString() + val expectedContent = """ + { + "operation" : "multiply", + "a" : 5.5, + "b" : 2.0, + "result" : 11.0, + "formattedResult" : "11.000", + "precision" : 3, + "tags" : ["test", "calculator", "integration"] + } + """.trimIndent() + + actualContent shouldEqualJson expectedContent + } + + @Test + fun testToolErrorHandling(): Unit = runBlocking(Dispatchers.IO) { + val successArgs = mapOf("errorType" to "none") + val successResult = client.callTool(errorToolName, successArgs) + + val actualContent = successResult?.structuredContent.toString() + val expectedContent = """ + { + "error" : false, + "message" : "Success" + } + """.trimIndent() + + actualContent shouldEqualJson expectedContent + + val errorArgs = mapOf( + "errorType" to "error", + "message" to "Custom error message", + ) + val errorResult = client.callTool( + errorToolName, + errorArgs, + ) + + val actualError = errorResult.structuredContent.toString() + val expectedError = """ + { + "error" : true, + "message" : "Custom error message" + } + """.trimIndent() + + actualError shouldEqualJson expectedError + + val exceptionArgs = mapOf( + "errorType" to "exception", + "message" to "My exception message", + ) + + val exceptionResult = client.callTool( + errorToolName, + exceptionArgs, + ) + + assertTrue(exceptionResult.isError ?: false, "isError should be true for exception") + + val exceptionContent = exceptionResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(exceptionContent, "Error content should be present in the result") + + val exceptionText = exceptionContent.text ?: "" + assertTrue( + exceptionText.contains("Error executing tool") && exceptionText.contains("My exception message"), + "Error message should contain the exception details", + ) + } + + @Test + fun testMultiContentTool(): Unit = runBlocking(Dispatchers.IO) { + val testText = "Test multi-content" + val arguments = mapOf( + "text" to testText, + "includeImage" to true, + ) + + val result = client.callTool( + multiContentToolName, + arguments, + ) + + assertEquals( + 2, + result.content.size, + "Tool result should have 2 content items", + ) + + val textContent = result.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Result should contain TextContent") + assertNotNull(textContent.text, "Text content should not be null") + assertEquals( + "Text content: $testText", + textContent.text, + "Text content should match", + ) + + val imageContent = result.content.firstOrNull { it is ImageContent } as? ImageContent + assertNotNull(imageContent, "Result should contain ImageContent") + assertEquals("image/png", imageContent.mimeType, "Image MIME type should match") + assertTrue(imageContent.data.isNotEmpty(), "Image data should not be empty") + + val actualContent = result.structuredContent.toString() + val expectedContent = """ + { + "text" : "Test multi-content", + "includeImage" : true + } + """.trimIndent() + + actualContent shouldEqualJson expectedContent + + val textOnlyArgs = mapOf( + "text" to testText, + "includeImage" to false, + ) + + val textOnlyResult = client.callTool( + multiContentToolName, + textOnlyArgs, + ) + + assertEquals( + 1, + textOnlyResult.content.size, + "Text-only result should have 1 content item", + ) + } + + @Test + fun testComplexNestedSchema(): Unit = runBlocking(Dispatchers.IO) { + val userJson = buildJsonObject { + put("name", JsonPrimitive("John Galt")) + put("age", JsonPrimitive(30)) + put( + "address", + buildJsonObject { + put("street", JsonPrimitive("123 Main St")) + put("city", JsonPrimitive("New York")) + put("country", JsonPrimitive("USA")) + }, + ) + } + + val optionsJson = buildJsonArray { + add(JsonPrimitive("option1")) + add(JsonPrimitive("option2")) + add(JsonPrimitive("option3")) + } + + val arguments = buildJsonObject { + put("user", userJson) + put("options", optionsJson) + } + + val result = client.callTool( + CallToolRequest( + CallToolRequestParams( + name = complexToolName, + arguments = arguments, + ), + ), + ) + + val actualContent = result.structuredContent.toString() + val expectedContent = """ + { + "operation": "add", + "a": 0.0, + "b": 0.0, + "result": 0.0, + "formattedResult": "0.00", + "precision": 2, + "tags": [] + } + """.trimIndent() + + actualContent shouldEqualJson expectedContent + } + + @Test + fun testLargeResponse(): Unit = runBlocking(Dispatchers.IO) { + val size = 10 + val arguments = mapOf("size" to size) + + val result = client.callTool( + largeToolName, + arguments, + ) + + val content = result.content.firstOrNull() as TextContent + assertNotNull(content, "Tool result content should be TextContent") + + val actualContent = result.structuredContent.toString() + val expectedContent = """ + { + "size" : 10000 + } + """.trimIndent() + + actualContent shouldEqualJson expectedContent + } + + @Test + fun testSlowTool(): Unit = runBlocking(Dispatchers.IO) { + val delay = 500 + val arguments = mapOf("delay" to delay) + + val startTime = System.currentTimeMillis() + val result = client.callTool(slowToolName, arguments) + val endTime = System.currentTimeMillis() + + val content = result.content.firstOrNull() as? TextContent + assertNotNull(content, "Tool result content should be TextContent") + + assertTrue(endTime - startTime >= delay, "Tool should take at least the specified delay") + + val actualContent = result.structuredContent.toString() + val expectedContent = """ + { + "delay" : 500 + } + """.trimIndent() + + actualContent shouldEqualJson expectedContent + } + + @Test + fun testSpecialCharacters() { + runBlocking(Dispatchers.IO) { + val arguments = mapOf("special" to specialCharsContent) + + val result = client.callTool( + specialCharsToolName, + arguments, + ) + + val content = result.content.firstOrNull() as? TextContent + assertNotNull(content, "Tool result content should be TextContent") + val text = content.text ?: "" + + assertTrue(text.contains(specialCharsContent), "Result should contain the special characters") + + val actualContent = result.structuredContent.toString() + val expectedContent = """ + { + "special" : "!@#$%^&*()_+{}|:\"<>?~`-=[]\\;',./\n\t", + "length" : 34 + } + """.trimIndent() + + actualContent shouldEqualJson expectedContent + } + } + + @Test + fun testConcurrentToolCalls() = runTest { + val concurrentCount = 10 + val results = mutableListOf() + + runBlocking { + repeat(concurrentCount) { index -> + launch { + val toolName = when (index % 5) { + 0 -> basicToolName + 1 -> complexToolName + 2 -> largeToolName + 3 -> slowToolName + else -> specialCharsToolName + } + + val arguments = when (toolName) { + basicToolName -> mapOf("text" to "Concurrent call $index") + + complexToolName -> mapOf( + "user" to mapOf( + "name" to "User $index", + "age" to 20 + index, + "address" to mapOf( + "street" to "Street $index", + "city" to "City $index", + "country" to "Country $index", + ), + ), + ) + + largeToolName -> mapOf("size" to 1) + + slowToolName -> mapOf("delay" to 100) + + else -> mapOf("special" to "!@#$%^&*()") + } + + val result = client.callTool(toolName, arguments) + + synchronized(results) { + results.add(result) + } + } + } + } + + assertEquals(concurrentCount, results.size, "All concurrent operations should complete") + results.forEach { result -> + assertNotNull(result, "Result should not be null") + assertTrue(result.content.isNotEmpty(), "Result content should not be empty") + } + } + + @Test + fun testNonExistentTool() = runTest { + val nonExistentToolName = "non-existent-tool" + val arguments = mapOf("text" to "Test") + + val result = runBlocking { + client.callTool(nonExistentToolName, arguments) + } + + assertNotNull(result, "Tool call result should not be null") + val callResult = result + assertTrue(callResult.isError ?: false, "isError should be true for non-existent tool") + + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Error content should be present in the result") + + val errorText = textContent.text ?: "" + assertTrue( + errorText.contains("non-existent-tool") && errorText.contains("not found"), + "Error message should indicate the tool was not found", + ) + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/OldSchemaKotlinTestBase.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/OldSchemaKotlinTestBase.kt new file mode 100644 index 0000000..241172e --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/OldSchemaKotlinTestBase.kt @@ -0,0 +1,184 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.kotlin + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.sse.SSE +import io.ktor.server.application.install +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport +import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport +import io.modelcontextprotocol.kotlin.sdk.server.mcp +import io.modelcontextprotocol.kotlin.test.utils.Retry +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered +import org.awaitility.kotlin.await +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import java.io.PipedInputStream +import java.io.PipedOutputStream +import kotlin.time.Duration.Companion.seconds +import io.ktor.server.cio.CIO as ServerCIO +import io.ktor.server.sse.SSE as ServerSSE + +@Retry(times = 3) +abstract class OldSchemaKotlinTestBase { + + protected val host = "localhost" + protected var port: Int = 0 + + protected lateinit var server: Server + protected lateinit var client: Client + protected lateinit var serverEngine: EmbeddedServer<*, *> + + // Transport selection + protected enum class TransportKind { SSE, STDIO } + protected open val transportKind: TransportKind = TransportKind.STDIO + + // STDIO-specific fields + private var stdioServerTransport: StdioServerTransport? = null + private var stdioClientInput: Source? = null + private var stdioClientOutput: Sink? = null + + protected abstract fun configureServerCapabilities(): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities + protected abstract fun configureServer() + + @BeforeEach + fun setUp() { + setupServer() + if (transportKind == TransportKind.SSE) { + await + .ignoreExceptions() + .until { + port = runBlocking { serverEngine.engine.resolvedConnectors().first().port } + port != 0 + } + } + runBlocking { + setupClient() + } + } + + protected suspend fun setupClient() { + when (transportKind) { + TransportKind.SSE -> { + val transport = SseClientTransport( + HttpClient(CIO) { + install(SSE) + }, + "http://$host:$port", + ) + client = Client( + Implementation("test", "1.0"), + ) + client.connect(transport) + } + + TransportKind.STDIO -> { + val input = checkNotNull(stdioClientInput) { "STDIO client input not initialized" } + val output = checkNotNull(stdioClientOutput) { "STDIO client output not initialized" } + val transport = StdioClientTransport( + input = input, + output = output, + ) + client = Client( + Implementation("test", "1.0"), + ) + client.connect(transport) + } + } + } + + protected fun setupServer() { + val capabilities = configureServerCapabilities() + + server = Server( + Implementation(name = "test-server", version = "1.0"), + ServerOptions(capabilities = capabilities), + ) + + configureServer() + + if (transportKind == TransportKind.SSE) { + serverEngine = embeddedServer(ServerCIO, host = host, port = port) { + install(ServerSSE) + routing { + mcp { server } + } + }.start(wait = false) + } else { + // Create in-memory stdio pipes: client->server and server->client + val clientToServerOut = PipedOutputStream() + val clientToServerIn = PipedInputStream(clientToServerOut) + + val serverToClientOut = PipedOutputStream() + val serverToClientIn = PipedInputStream(serverToClientOut) + + // Server transport reads from client and writes to client + val serverTransport = StdioServerTransport( + inputStream = clientToServerIn.asSource().buffered(), + outputStream = serverToClientOut.asSink().buffered(), + ) + stdioServerTransport = serverTransport + + // Prepare client-side streams for later client initialization + stdioClientInput = serverToClientIn.asSource().buffered() + stdioClientOutput = clientToServerOut.asSink().buffered() + + // Start server transport by connecting the server + runBlocking { + server.createSession(serverTransport) + } + } + } + + @AfterEach + fun tearDown() { + // close client + if (::client.isInitialized) { + try { + runBlocking { + withTimeout(3.seconds) { + client.close() + } + } + } catch (e: Exception) { + println("Warning: Error during client close: ${e.message}") + } + } + + // stop server + if (transportKind == TransportKind.SSE) { + if (::serverEngine.isInitialized) { + try { + serverEngine.stop(500, 1000) + } catch (e: Exception) { + println("Warning: Error during server stop: ${e.message}") + } + } + } else { + stdioServerTransport?.let { + try { + runBlocking { it.close() } + } catch (e: Exception) { + println("Warning: Error during stdio server stop: ${e.message}") + } finally { + stdioServerTransport = null + stdioClientInput = null + stdioClientOutput = null + } + } + } + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/sse/OldSchemaPromptIntegrationTestSse.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/sse/OldSchemaPromptIntegrationTestSse.kt new file mode 100644 index 0000000..63d5865 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/sse/OldSchemaPromptIntegrationTestSse.kt @@ -0,0 +1,7 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.sse + +import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.OldSchemaAbstractPromptIntegrationTest + +class OldSchemaPromptIntegrationTestSse : OldSchemaAbstractPromptIntegrationTest() { + override val transportKind: TransportKind = TransportKind.SSE +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/sse/OldSchemaResourceIntegrationTestSse b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/sse/OldSchemaResourceIntegrationTestSse new file mode 100644 index 0000000..6837753 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/sse/OldSchemaResourceIntegrationTestSse @@ -0,0 +1,7 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.sse + +import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.OldSchemaAbstractResourceIntegrationTest + +class OldSchemaResourceIntegrationTestSse : OldSchemaAbstractResourceIntegrationTest() { + override val transportKind: TransportKind = TransportKind.SSE +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/sse/OldSchemaToolIntegrationTestSse.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/sse/OldSchemaToolIntegrationTestSse.kt new file mode 100644 index 0000000..bd39d77 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/sse/OldSchemaToolIntegrationTestSse.kt @@ -0,0 +1,7 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.sse + +import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.OldSchemaAbstractToolIntegrationTest + +class OldSchemaToolIntegrationTestSse : OldSchemaAbstractToolIntegrationTest() { + override val transportKind: TransportKind = TransportKind.SSE +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/stdio/OldSchemaPromptIntegrationTestStdio.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/stdio/OldSchemaPromptIntegrationTestStdio.kt new file mode 100644 index 0000000..68d03eb --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/stdio/OldSchemaPromptIntegrationTestStdio.kt @@ -0,0 +1,7 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.stdio + +import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.OldSchemaAbstractPromptIntegrationTest + +class OldSchemaPromptIntegrationTestStdio : OldSchemaAbstractPromptIntegrationTest() { + override val transportKind: TransportKind = TransportKind.STDIO +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/stdio/OldSchemaResourceIntegrationTestStdio.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/stdio/OldSchemaResourceIntegrationTestStdio.kt new file mode 100644 index 0000000..4b07760 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/stdio/OldSchemaResourceIntegrationTestStdio.kt @@ -0,0 +1,7 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.stdio + +import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.OldSchemaAbstractResourceIntegrationTest + +class OldSchemaResourceIntegrationTestStdio : OldSchemaAbstractResourceIntegrationTest() { + override val transportKind: TransportKind = TransportKind.STDIO +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/stdio/OldSchemaToolIntegrationTestStdio.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/stdio/OldSchemaToolIntegrationTestStdio.kt new file mode 100644 index 0000000..046eebf --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/stdio/OldSchemaToolIntegrationTestStdio.kt @@ -0,0 +1,7 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.stdio + +import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.OldSchemaAbstractToolIntegrationTest + +class OldSchemaToolIntegrationTestStdio : OldSchemaAbstractToolIntegrationTest() { + override val transportKind: TransportKind = TransportKind.STDIO +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/PromptIntegrationTestStreamableHttp.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/PromptIntegrationTestStreamableHttp.kt deleted file mode 100644 index 8f4e334..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/PromptIntegrationTestStreamableHttp.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.streamablehttp - -import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.AbstractPromptIntegrationTest - -class PromptIntegrationTestStreamableHttp : AbstractPromptIntegrationTest() { - override val transportKind: TransportKind = TransportKind.STREAMABLE_HTTP -} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/ResourceIntegrationTestStreamableHttp.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/ResourceIntegrationTestStreamableHttp.kt deleted file mode 100644 index d72bcfd..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/ResourceIntegrationTestStreamableHttp.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.streamablehttp - -import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.AbstractResourceIntegrationTest - -class ResourceIntegrationTestStreamableHttp : AbstractResourceIntegrationTest() { - override val transportKind: TransportKind = TransportKind.STREAMABLE_HTTP -} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/ToolIntegrationTestStreamableHttp.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/ToolIntegrationTestStreamableHttp.kt deleted file mode 100644 index 288ff07..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/streamablehttp/ToolIntegrationTestStreamableHttp.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.streamablehttp - -import io.modelcontextprotocol.kotlin.sdk.integration.kotlin.AbstractToolIntegrationTest - -class ToolIntegrationTestStreamableHttp : AbstractToolIntegrationTest() { - override val transportKind: TransportKind = TransportKind.STREAMABLE_HTTP -} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/websocket/OldSchemaWebSocketIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/websocket/OldSchemaWebSocketIntegrationTest.kt new file mode 100644 index 0000000..312e3be --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/kotlin/websocket/OldSchemaWebSocketIntegrationTest.kt @@ -0,0 +1,193 @@ +@file:Suppress("DEPRECATION") + +package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.websocket + +import io.ktor.client.HttpClient +import io.ktor.server.application.install +import io.ktor.server.cio.CIOApplicationEngine +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest +import io.modelcontextprotocol.kotlin.sdk.GetPromptResult +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.PromptArgument +import io.modelcontextprotocol.kotlin.sdk.PromptMessage +import io.modelcontextprotocol.kotlin.sdk.Role +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.mcpWebSocketTransport +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.mcpWebSocket +import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertTrue +import io.ktor.client.engine.cio.CIO as ClientCIO +import io.ktor.client.plugins.websocket.WebSockets as ClientWebSocket +import io.ktor.server.cio.CIO as ServerCIO +import io.ktor.server.websocket.WebSockets as ServerWebSockets + +class OldSchemaWebSocketIntegrationTest { + + @Test + fun `client should be able to connect to websocket server 2`() = runBlocking(Dispatchers.IO) { + var server: EmbeddedServer? = null + var client: Client? = null + + try { + server = initServer() + val port = server.engine.resolvedConnectors().first().port + client = initClient(serverPort = port) + } finally { + client?.close() + server?.stop(1000, 2000) + } + } + + /** + * Test Case #1: One opened connection, a client gets a prompt + * + * 1. Open WebSocket from Client A. + * 2. Send a POST request from Client A to POST /prompts/get. + * 3. Observe that Client A receives a response related to it. + */ + @Test + fun `single websocket connection`() = runBlocking(Dispatchers.IO) { + var server: EmbeddedServer? = null + var client: Client? = null + + try { + server = initServer() + val port = server.engine.resolvedConnectors().first().port + client = initClient("Client A", port) + + val promptA = getPrompt(client, "Client A") + assertTrue { "Client A" in promptA } + } finally { + client?.close() + server?.stop(1000, 2000) + } + } + + /** + * Test Case #1: Two open connections, each client gets a client-specific prompt + * + * 1. Open WebSocket connection #1 from Client A and note the sessionId= value. + * 2. Open WebSocket connection #2 from Client B and note the sessionId= value. + * 3. Send a POST request to POST /message with the corresponding sessionId#1. + * 4. Observe that Client B (connection #2) receives a response related to sessionId#1. + */ + @Test + fun `multiple websocket connections`() = runBlocking(Dispatchers.IO) { + var server: EmbeddedServer? = null + var clientA: Client? = null + var clientB: Client? = null + + try { + server = initServer() + val port = server.engine.resolvedConnectors().first().port + clientA = initClient("Client A", port) + clientB = initClient("Client B", port) + + // Step 3: Send a prompt request from Client A + val promptA = getPrompt(clientA, "Client A") + // Step 4: Send a prompt request from Client B + val promptB = getPrompt(clientB, "Client B") + + assertTrue { "Client A" in promptA } + assertTrue { "Client B" in promptB } + } finally { + clientA?.close() + clientB?.close() + server?.stop(1000, 2000) + } + } + + private suspend fun initClient(name: String = "", serverPort: Int): Client { + val client = Client( + Implementation(name = name, version = "1.0.0"), + ) + + val httpClient = HttpClient(ClientCIO) { + install(ClientWebSocket) + } + + // Create a transport wrapper that captures the session ID and received messages + val transport = httpClient.mcpWebSocketTransport { + url { + host = URL + port = serverPort + } + } + + client.connect(transport) + + return client + } + + private fun initServer(): EmbeddedServer { + val server = Server( + Implementation(name = "websocket-server", version = "1.0.0"), + ServerOptions( + capabilities = ServerCapabilities(prompts = ServerCapabilities.Prompts(listChanged = true)), + ), + ) + + server.addPrompt( + name = "prompt", + description = "Prompt description", + arguments = listOf( + PromptArgument( + name = "client", + description = "Client name who requested a prompt", + required = true, + ), + ), + ) { request -> + GetPromptResult( + description = "Prompt for ${request.params.name}", + messages = listOf( + PromptMessage( + role = Role.user, + content = TextContent("Prompt for client ${request.params.arguments?.get("client")}"), + ), + ), + ) + } + + val ktorServer = embeddedServer(ServerCIO, host = URL, port = PORT) { + install(ServerWebSockets) + routing { + mcpWebSocket(block = { server }) + } + } + + return ktorServer.start(wait = false) + } + + /** + * Retrieves a prompt result using the provided client and client name. + */ + private suspend fun getPrompt(client: Client, clientName: String): String { + val response = client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + "prompt", + arguments = mapOf("client" to clientName), + ), + ), + ) + + return (response.messages.first().content as? TextContent)?.text + ?: error("Failed to receive prompt for Client $clientName") + } + + companion object { + private const val PORT = 0 + private const val URL = "127.0.0.1" + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaAbstractSseIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaAbstractSseIntegrationTest.kt new file mode 100644 index 0000000..82d51b9 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaAbstractSseIntegrationTest.kt @@ -0,0 +1,105 @@ +@file:Suppress("DEPRECATION") + +package io.modelcontextprotocol.kotlin.sdk.integration.sse + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.sse.SSE +import io.ktor.server.application.install +import io.ktor.server.cio.CIOApplicationEngine +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import io.modelcontextprotocol.kotlin.sdk.GetPromptResult +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.PromptArgument +import io.modelcontextprotocol.kotlin.sdk.PromptMessage +import io.modelcontextprotocol.kotlin.sdk.Role +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.mcpSseTransport +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.mcp +import io.ktor.client.engine.cio.CIO as ClientCIO +import io.ktor.server.cio.CIO as ServerCIO +import io.ktor.server.sse.SSE as ServerSSE + +open class OldSchemaAbstractSseIntegrationTest { + + suspend fun initTestClient(serverPort: Int, name: String? = null): Client { + val client = Client( + Implementation(name = name ?: DEFAULT_CLIENT_NAME, version = VERSION), + ) + + val httpClient = HttpClient(ClientCIO) { + install(SSE) + } + + // Create a transport wrapper that captures the session ID and received messages + val transport = httpClient.mcpSseTransport { + url { + host = URL + port = serverPort + } + } + + client.connect(transport) + + return client + } + + suspend fun initTestServer( + name: String? = null, + ): EmbeddedServer { + val server = Server( + Implementation(name = name ?: DEFAULT_SERVER_NAME, version = VERSION), + ServerOptions( + capabilities = ServerCapabilities(prompts = ServerCapabilities.Prompts(listChanged = true)), + ), + ) { + addPrompt( + name = "prompt", + description = "Prompt description", + arguments = listOf( + PromptArgument( + name = "client", + description = "Client name who requested a prompt", + required = true, + ), + ), + ) { request -> + GetPromptResult( + description = "Prompt for ${request.params.name}", + messages = listOf( + PromptMessage( + role = Role.user, + content = TextContent("Prompt for client ${request.params.arguments?.get("client")}"), + ), + ), + ) + } + } + + val ktorServer = embeddedServer( + ServerCIO, + host = URL, + port = PORT, + ) { + install(ServerSSE) + routing { + mcp { server } + } + } + + return ktorServer.startSuspend(wait = false) + } + + companion object { + private const val DEFAULT_CLIENT_NAME = "sse-test-client" + private const val DEFAULT_SERVER_NAME = "sse-test-server" + private const val VERSION = "1.0.0" + private const val URL = "127.0.0.1" + private const val PORT = 0 + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaSseIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaSseIntegrationTest.kt new file mode 100644 index 0000000..97313bd --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/OldSchemaSseIntegrationTest.kt @@ -0,0 +1,109 @@ +@file:Suppress("DEPRECATION") + +package io.modelcontextprotocol.kotlin.sdk.integration.sse + +import io.ktor.server.cio.CIOApplicationEngine +import io.ktor.server.engine.EmbeddedServer +import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams +import io.modelcontextprotocol.kotlin.test.utils.actualPort +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertTrue + +class OldSchemaSseIntegrationTest : OldSchemaAbstractSseIntegrationTest() { + + @Test + fun `client should be able to connect to sse server`() = runBlocking(Dispatchers.IO) { + var server: EmbeddedServer? = null + var client: Client? = null + + try { + server = initTestServer() + val port = server.actualPort() + client = initTestClient(serverPort = port) + } finally { + client?.close() + server?.stopSuspend(1000, 2000) + } + } + + /** + * Test Case #1: One opened connection, a client gets a prompt + * + * 1. Open SSE from Client A. + * 2. Send a POST request from Client A to POST /prompts/get. + * 3. Observe that Client A receives a response related to it. + */ + @Test + fun `single sse connection`() = runBlocking(Dispatchers.IO) { + var server: EmbeddedServer? = null + var client: Client? = null + try { + server = initTestServer() + val port = server.actualPort() + client = initTestClient(port, "Client A") + + val promptA = getPrompt(client, "Client A") + assertTrue { "Client A" in promptA } + } finally { + client?.close() + server?.stopSuspend(1000, 2000) + } + } + + /** + * Test Case #1: Two open connections, each client gets a client-specific prompt + * + * 1. Open SSE connection #1 from Client A and note the sessionId= value. + * 2. Open SSE connection #2 from Client B and note the sessionId= value. + * 3. Send a POST request to POST /message with the corresponding sessionId#1. + * 4. Observe that Client B (connection #2) receives a response related to sessionId#1. + */ + @Test + fun `multiple sse connections`() = runBlocking(Dispatchers.IO) { + var server: EmbeddedServer? = null + var clientA: Client? = null + var clientB: Client? = null + + try { + server = initTestServer() + val port = server.engine.resolvedConnectors().first().port + + clientA = initTestClient(port, "Client A") + clientB = initTestClient(port, "Client B") + + // Step 3: Send a prompt request from Client A + val promptA = getPrompt(clientA, "Client A") + // Step 4: Send a prompt request from Client B + val promptB = getPrompt(clientB, "Client B") + + assertTrue { "Client A" in promptA } + assertTrue { "Client B" in promptB } + } finally { + clientA?.close() + clientB?.close() + server?.stopSuspend(1000, 2000) + } + } + + /** + * Retrieves a prompt result using the provided client and client name. + */ + private suspend fun getPrompt(client: Client, clientName: String): String { + val response = client.getPrompt( + GetPromptRequest( + GetPromptRequestParams( + "prompt", + arguments = mapOf("client" to clientName), + ), + ), + ) + + return (response.messages.first().content as? TextContent)?.text + ?: error("Failed to receive prompt for Client $clientName") + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/SseAuthenticationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/SseAuthenticationTest.kt deleted file mode 100644 index da76319..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/sse/SseAuthenticationTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.sse - -import io.ktor.client.HttpClient -import io.ktor.client.plugins.sse.SSE -import io.ktor.client.request.basicAuth -import io.ktor.server.application.ApplicationCall -import io.ktor.server.routing.Route -import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport -import io.modelcontextprotocol.kotlin.sdk.integration.AbstractAuthenticationTest -import io.modelcontextprotocol.kotlin.sdk.server.Server -import io.modelcontextprotocol.kotlin.sdk.server.mcp -import io.modelcontextprotocol.kotlin.sdk.shared.Transport -import kotlin.test.AfterTest -import io.ktor.client.engine.cio.CIO as ClientCIO - -class SseAuthenticationTest : AbstractAuthenticationTest() { - - private var httpClient: HttpClient? = null - - @AfterTest - fun closeHttpClient() { - httpClient?.close() - httpClient = null - } - - override fun Route.registerMcpServer(serverFactory: ApplicationCall.() -> Server) { - mcp { - serverFactory(call) - } - } - - override fun createClientTransport(baseUrl: String, user: String, pass: String): Transport { - val client = HttpClient(ClientCIO) { install(SSE) } - httpClient = client - return SseClientTransport( - client = client, - urlString = baseUrl, - requestBuilder = { basicAuth(user, pass) }, - ) - } -} 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 deleted file mode 100644 index 535393e..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/AbstractStreamableHttpIntegrationTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -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 -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument -import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage -import io.modelcontextprotocol.kotlin.sdk.types.Role -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.ktor.server.cio.CIO as ServerCIO - -open class AbstractStreamableHttpIntegrationTest { - - suspend fun initTestServer( - name: String? = null, - sseHeartbeatConfig: (Heartbeat.() -> Unit)? = null, - ): StreamableHttpTestServer { - val mcpServer = Server( - Implementation(name = name ?: DEFAULT_SERVER_NAME, version = VERSION), - ServerOptions( - capabilities = ServerCapabilities(prompts = ServerCapabilities.Prompts(listChanged = true)), - ), - ) { - addPrompt( - name = "prompt", - description = "Prompt description", - arguments = listOf( - PromptArgument( - name = "client", - description = "Client name who requested a prompt", - required = true, - ), - ), - ) { request -> - GetPromptResult( - messages = listOf( - PromptMessage( - role = Role.User, - content = TextContent("Prompt for client ${request.params.arguments?.get("client")}"), - ), - ), - description = "Prompt for ${request.params.name}", - ) - } - } - - val ktorServer = embeddedServer( - ServerCIO, - host = URL, - port = PORT, - ) { - mcpStreamableHttp(sseHeartbeatConfig = sseHeartbeatConfig) { mcpServer } - } - - return StreamableHttpTestServer( - mcpServer = mcpServer, - ktorServer = ktorServer.startSuspend(wait = false), - ) - } - - data class StreamableHttpTestServer( - val mcpServer: Server, - val ktorServer: EmbeddedServer, - ) - - companion object { - internal const val DEFAULT_SERVER_NAME = "streamable-http-test-server" - internal const val VERSION = "1.0.0" - internal const val URL = "127.0.0.1" - internal const val PORT = 0 - } -} 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/StreamableHttpIntegrationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpIntegrationTest.kt deleted file mode 100644 index b64df4a..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/streamablehttp/StreamableHttpIntegrationTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.streamablehttp - -import io.ktor.client.HttpClient -import io.ktor.client.plugins.sse.SSE -import io.modelcontextprotocol.kotlin.sdk.client.Client -import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.test.utils.actualPort -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlin.test.Test -import kotlin.test.assertTrue -import io.ktor.client.engine.cio.CIO as ClientCIO - -class StreamableHttpIntegrationTest : AbstractStreamableHttpIntegrationTest() { - - @Test - fun `client should receive responses via POST json and server notifications via GET sse`() = - runBlocking(Dispatchers.IO) { - var server: StreamableHttpTestServer? = null - var client: Client? = null - var httpClient: HttpClient? = null - - try { - server = initTestServer() - val port = server.ktorServer.actualPort() - - httpClient = HttpClient(ClientCIO) { install(SSE) } - val transport = StreamableHttpClientTransport( - client = httpClient, - url = "http://$URL:$port/mcp", - ).apply { - protocolVersion = LATEST_PROTOCOL_VERSION - } - - client = Client(Implementation("streamable-http-client", VERSION)) - client.connect(transport) - - val prompt = getPrompt(client, "Client A") - assertTrue { "Client A" in prompt } - } finally { - client?.close() - httpClient?.close() - server?.ktorServer?.stopSuspend(1000, 2000) - } - } - - private suspend fun getPrompt(client: Client, clientName: String): String { - val response = client.getPrompt( - GetPromptRequest( - GetPromptRequestParams( - name = "prompt", - arguments = mapOf("client" to clientName), - ), - ), - ) - - return (response.messages.first().content as? TextContent)?.text - ?: error("Failed to receive prompt for Client $clientName") - } -} 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/integration/typescript/AbstractKotlinClientTsServerTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerTest.kt index 9d27a1d..1ed3cf4 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerTest.kt @@ -15,7 +15,7 @@ abstract class AbstractKotlinClientTsServerTest : TsTestBase() { protected abstract suspend fun useClient(block: suspend (Client) -> T): T @Test - @Timeout(60, unit = TimeUnit.SECONDS) + @Timeout(20, unit = TimeUnit.SECONDS) fun connectsAndPings() = runBlocking(Dispatchers.IO) { useClient { client -> assertNotNull(client, "Client should be initialized") @@ -28,7 +28,7 @@ abstract class AbstractKotlinClientTsServerTest : TsTestBase() { } @Test - @Timeout(60, unit = TimeUnit.SECONDS) + @Timeout(20, unit = TimeUnit.SECONDS) fun listsTools() = runBlocking(Dispatchers.IO) { useClient { client -> val result = client.listTools() @@ -42,7 +42,7 @@ abstract class AbstractKotlinClientTsServerTest : TsTestBase() { } @Test - @Timeout(60, unit = TimeUnit.SECONDS) + @Timeout(20, unit = TimeUnit.SECONDS) fun callGreet() = runBlocking(Dispatchers.IO) { useClient { client -> val testName = "TestUser" @@ -57,7 +57,7 @@ abstract class AbstractKotlinClientTsServerTest : TsTestBase() { } @Test - @Timeout(60, unit = TimeUnit.SECONDS) + @Timeout(20, unit = TimeUnit.SECONDS) fun multipleClients() = runBlocking(Dispatchers.IO) { useClient { client1 -> useClient { client2 -> diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/OldSchemaAbstractKotlinClientTsServerTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/OldSchemaAbstractKotlinClientTsServerTest.kt new file mode 100644 index 0000000..5d0406c --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/OldSchemaAbstractKotlinClientTsServerTest.kt @@ -0,0 +1,77 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.typescript + +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.client.Client +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +abstract class OldSchemaAbstractKotlinClientTsServerTest : TsTestBase() { + protected abstract suspend fun useClient(block: suspend (Client) -> T): T + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun connectsAndPings() = runBlocking(Dispatchers.IO) { + useClient { client -> + assertNotNull(client, "Client should be initialized") + val ping = client.ping() + assertNotNull(ping, "Ping result should not be null") + val serverImpl = client.serverVersion + assertNotNull(serverImpl, "Server implementation should not be null") + println("Connected to TypeScript server: ${serverImpl.name} v${serverImpl.version}") + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun listsTools() = runBlocking(Dispatchers.IO) { + useClient { client -> + val result = client.listTools() + assertNotNull(result, "Tools list should not be null") + assertTrue(result.tools.isNotEmpty(), "Tools list should not be empty") + val toolNames = result.tools.map { it.name } + assertTrue("greet" in toolNames, "Greet tool should be available") + assertTrue("multi-greet" in toolNames, "Multi-greet tool should be available") + // Some tests also check collect-user-info; keep base minimal and non-breaking + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun callGreet() = runBlocking(Dispatchers.IO) { + useClient { client -> + val testName = "TestUser" + val arguments = mapOf("name" to testName) + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null") + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present in the result") + assertEquals("Hello, $testName!", textContent.text) + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun multipleClients() = runBlocking(Dispatchers.IO) { + useClient { client1 -> + useClient { client2 -> + val tools1 = client1.listTools() + val tools2 = client2.listTools() + assertTrue(tools1.tools.isNotEmpty(), "Tools list for first client should not be empty") + assertTrue(tools2.tools.isNotEmpty(), "Tools list for second client should not be empty") + val toolNames1 = tools1.tools.map { it.name } + val toolNames2 = tools2.tools.map { it.name } + assertTrue("greet" in toolNames1, "Greet tool should be available to first client") + assertTrue("multi-greet" in toolNames1, "Multi-greet tool should be available to first client") + assertTrue("greet" in toolNames2, "Greet tool should be available to second client") + assertTrue("multi-greet" in toolNames2, "Multi-greet tool should be available to second client") + } + } + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TsTestBase.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TsTestBase.kt index 29a6de7..49632c4 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TsTestBase.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TsTestBase.kt @@ -6,7 +6,7 @@ import io.ktor.client.plugins.sse.SSE import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport import io.modelcontextprotocol.kotlin.sdk.client.mcpStreamableHttp -import io.modelcontextprotocol.kotlin.sdk.integration.typescript.http.KotlinServerForTsClient +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse.KotlinServerForTsClient import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport import io.modelcontextprotocol.kotlin.sdk.types.Implementation @@ -26,7 +26,7 @@ import java.util.concurrent.TimeUnit import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -enum class TransportKind { HTTP, STDIO } +enum class TransportKind { SSE, STDIO } @Retry(times = 3) abstract class TsTestBase { @@ -43,10 +43,10 @@ abstract class TsTestBase { protected val typescriptDir = File(projectRoot, "src/jvmTest/typescript") @JvmStatic - private var sharedHttpServer: Process? = null + private var sharedSseServer: Process? = null @JvmStatic - private var sharedHttpPort: Int = 0 + private var sharedSsePort: Int = 0 init { installDependencies(typescriptDir) @@ -54,23 +54,23 @@ abstract class TsTestBase { @JvmStatic @Synchronized - protected fun getSharedHttpUrl(): String { - if (sharedHttpServer == null || !sharedHttpServer!!.isAlive) { - sharedHttpPort = io.modelcontextprotocol.kotlin.test.utils.findFreePort() + protected fun getSharedSseUrl(): String { + if (sharedSseServer == null || !sharedSseServer!!.isAlive) { + sharedSsePort = io.modelcontextprotocol.kotlin.test.utils.findFreePort() val server = TypeScriptServer(typescriptDir) - sharedHttpServer = server.startHttp(sharedHttpPort) - println("Shared TypeScript HTTP server started on port $sharedHttpPort") + sharedSseServer = server.startSse(sharedSsePort) + println("Shared TypeScript SSE server started on port $sharedSsePort") Runtime.getRuntime().addShutdownHook( Thread { - sharedHttpServer?.let { - println("Stopping shared TypeScript HTTP server") + sharedSseServer?.let { + println("Stopping shared TypeScript SSE server") io.modelcontextprotocol.kotlin.test.utils.stopProcess(it) } }, ) } - return "http://localhost:$sharedHttpPort/mcp" + return "http://localhost:$sharedSsePort/mcp" } @JvmStatic @@ -141,7 +141,7 @@ abstract class TsTestBase { println("$name stopped") } - // ===== HTTP client helpers ===== + // ===== SSE client helpers ===== protected suspend fun newClient(serverUrl: String): Client = HttpClient(CIO) { install(SSE) }.mcpStreamableHttp(serverUrl) @@ -213,19 +213,14 @@ abstract class TsTestBase { serverThread.isDaemon = true serverThread.start() - // Read ONLY stderr from client for human-readable output. - // Also use it as a signal that the client is done (some Node processes keep the event-loop alive). + // Read ONLY stderr from client for human-readable output val output = StringBuilder() - val doneLatch = java.util.concurrent.CountDownLatch(1) val errReader = Thread { try { process.errorStream.bufferedReader().useLines { lines -> lines.forEach { line -> println("[TS-CLIENT-STDIO][err] $line") output.append(line).append('\n') - if (line.contains("Disconnected from server")) { - doneLatch.countDown() - } } } } catch (e: Exception) { @@ -235,26 +230,11 @@ abstract class TsTestBase { errReader.isDaemon = true errReader.start() - // Wait for a "done" signal first, then give the process a short grace period to exit. - // This avoids hanging under the test's outer timeout (often ~30s). - val doneTimeoutSeconds = System.getenv("MCP_TS_CLIENT_DONE_TIMEOUT_SECONDS")?.toLongOrNull() ?: 25L - val sawDone = doneLatch.await(doneTimeoutSeconds, TimeUnit.SECONDS) - if (sawDone) { - // Encourage Node to exit if it is waiting on stdin. - try { - process.outputStream.close() - } catch (_: Exception) { - } - } - - // Primary completion condition: the process exits. - // (Some error-paths may not print the disconnect line, but should still exit.) - val clientTimeoutSeconds = System.getenv("MCP_TS_CLIENT_TIMEOUT_SECONDS")?.toLongOrNull() ?: 30L - val finished = process.waitFor(clientTimeoutSeconds, TimeUnit.SECONDS) + // Wait up to 25s for client to exit + val finished = process.waitFor(25, TimeUnit.SECONDS) if (!finished) { - println("Stdio client did not finish in time (${clientTimeoutSeconds}s); destroying") + println("Stdio client did not finish in time; destroying") process.destroyForcibly() - process.waitFor(2, TimeUnit.SECONDS) } try { diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptClient.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptClient.kt index 109eab4..7664abf 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptClient.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptClient.kt @@ -7,28 +7,28 @@ import java.io.File /** * Component for starting and managing TypeScript MCP clients for integration tests. * - * This class provides methods to start TypeScript clients using HTTP or STDIO transports. + * This class provides methods to start TypeScript clients using SSE or STDIO transports. * The clients are started using npx tsx to execute TypeScript files directly. */ class TypeScriptClient(private val typescriptDir: File) : AutoCloseable { private var process: Process? = null /** - * Starts a TypeScript MCP client using Streamable HTTP transport. + * Starts a TypeScript MCP client using SSE transport. * * @param arguments Arguments to pass to the client (typically server URL, tool name, and tool args) * @param log Whether to automatically log process output/error * @return The started Process */ - fun startHttp(arguments: List, log: Boolean = true): Process { - val scriptPath = File(typescriptDir, "client/http-client.ts").absolutePath + fun startSse(arguments: List, log: Boolean = true): Process { + val scriptPath = File(typescriptDir, "client/sse-client.ts").absolutePath val proc = TypeScriptRunner.run( typescriptDir = typescriptDir, scriptPath = scriptPath, arguments = arguments, env = emptyMap(), redirectErrorStream = false, - logPrefix = "TS-CLIENT-HTTP", + logPrefix = "TS-CLIENT-SSE", log = log, ) process = proc diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptServer.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptServer.kt index 411a212..d926590 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptServer.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/TypeScriptServer.kt @@ -12,23 +12,23 @@ import java.util.concurrent.TimeUnit * Factory class for starting and managing TypeScript MCP servers for integration tests. * * This class provides methods to: - * - Start TypeScript servers using HTTP or STDIO transports via npx tsx + * - Start TypeScript servers using SSE or STDIO transports via npx tsx * - Install npm dependencies in the TypeScript test directory * - Manage server lifecycle (start/stop) * - Handle port management and process cleanup * * The servers are started using npx tsx to execute TypeScript files directly: * - `npx tsx server/stdio-server.ts` for STDIO transport - * - `npx tsx server/http-server.ts` for HTTP transport + * - `npx tsx server/sse-server.ts` for SSE transport * * Usage: * ```kotlin * val typescriptDir = File("kotlin-sdk-test/src/jvmTest/typescript") * val server = TypeScriptServer(typescriptDir) * - * // Start HTTP server + * // Start SSE server * val port = TypeScriptServer.findFreePort() - * val process = server.startHttp(port) + * val process = server.startSse(port) * * // Stop server * server.stop() @@ -38,27 +38,27 @@ class TypeScriptServer(private val typescriptDir: File) { private var process: Process? = null /** - * Starts a TypeScript MCP server using Streamable HTTP transport. + * Starts a TypeScript MCP server using SSE transport. * - * @param port The port number to bind the HTTP server to + * @param port The port number to bind the SSE server to * @return The started Process * @throws IllegalStateException if the server fails to start within timeout */ - fun startHttp(port: Int): Process { + fun startSse(port: Int): Process { killProcessOnPort(port) - val serverPath = File(typescriptDir, "server/http-server.ts").absolutePath + val serverPath = File(typescriptDir, "server/sse-server.ts").absolutePath val proc = TypeScriptRunner.run( typescriptDir = typescriptDir, scriptPath = serverPath, env = mapOf("MCP_PORT" to port.toString()), redirectErrorStream = true, - logPrefix = "TS-SERVER-HTTP", + logPrefix = "TS-SERVER-SSE", ) process = proc - check(waitForPort(port = port, timeoutSeconds = 60)) { + check(waitForPort(port = port, timeoutSeconds = 10)) { proc.destroyForcibly() - "TypeScript HTTP server did not become ready on localhost:$port within timeout" + "TypeScript SSE server did not become ready on localhost:$port within timeout" } return proc } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerEdgeCasesTestHttp.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerEdgeCasesTestHttp.kt deleted file mode 100644 index 016aa1c..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerEdgeCasesTestHttp.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http - -import io.modelcontextprotocol.kotlin.sdk.client.Client -import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerEdgeCasesTest -import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind - -class KotlinClientTsServerEdgeCasesTestHttp : AbstractKotlinClientTsServerEdgeCasesTest() { - - override val transportKind = TransportKind.HTTP - - private val serverUrl: String by lazy { getSharedHttpUrl() } - - override suspend fun useClient(block: suspend (Client) -> T): T = withClient(serverUrl, block) -} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerEdgeCasesTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerEdgeCasesTestSse.kt similarity index 82% rename from integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerEdgeCasesTest.kt rename to integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerEdgeCasesTestSse.kt index d977bd0..356fd30 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/AbstractKotlinClientTsServerEdgeCasesTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerEdgeCasesTestSse.kt @@ -1,29 +1,53 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.typescript +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase import io.modelcontextprotocol.kotlin.sdk.types.TextContent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout import java.util.concurrent.TimeUnit import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds -abstract class AbstractKotlinClientTsServerEdgeCasesTest : TsTestBase() { +class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() { - protected abstract suspend fun useClient(block: suspend (Client) -> T): T + override val transportKind = TransportKind.SSE + + private val serverUrl: String by lazy { getSharedSseUrl() } + + private lateinit var client: Client + + @AfterEach + fun tearDown() { + if (::client.isInitialized) { + try { + runBlocking { + withTimeout(3.seconds) { + client.close() + } + } + } catch (e: Exception) { + println("Warning: Error during client close: ${e.message}") + } + } + } @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testNonExistentTool(): Unit = runBlocking(Dispatchers.IO) { - useClient { client -> + withClient(serverUrl) { client -> val nonExistentToolName = "non-existent-tool" val arguments = mapOf("name" to "TestUser") @@ -46,7 +70,7 @@ abstract class AbstractKotlinClientTsServerEdgeCasesTest : TsTestBase() { @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testSpecialCharactersInArguments(): Unit = runBlocking(Dispatchers.IO) { - useClient { client -> + withClient(serverUrl) { client -> val specialChars = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/" val arguments = mapOf("name" to specialChars) @@ -67,7 +91,7 @@ abstract class AbstractKotlinClientTsServerEdgeCasesTest : TsTestBase() { @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testLargePayload(): Unit = runBlocking(Dispatchers.IO) { - useClient { client -> + withClient(serverUrl) { client -> val largeName = "A".repeat(10 * 1024) val arguments = mapOf("name" to largeName) @@ -88,7 +112,7 @@ abstract class AbstractKotlinClientTsServerEdgeCasesTest : TsTestBase() { @Test @Timeout(60, unit = TimeUnit.SECONDS) fun testConcurrentRequests(): Unit = runBlocking(Dispatchers.IO) { - useClient { client -> + withClient(serverUrl) { client -> val concurrentCount = 5 val responses = coroutineScope { val results = (1..concurrentCount).map { i -> @@ -123,7 +147,7 @@ abstract class AbstractKotlinClientTsServerEdgeCasesTest : TsTestBase() { @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testInvalidArguments(): Unit = runBlocking(Dispatchers.IO) { - useClient { client -> + withClient(serverUrl) { client -> val invalidArguments = mapOf( "name" to JsonObject(mapOf("nested" to JsonPrimitive("value"))), ) @@ -147,7 +171,7 @@ abstract class AbstractKotlinClientTsServerEdgeCasesTest : TsTestBase() { @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testMultipleToolCalls(): Unit = runBlocking(Dispatchers.IO) { - useClient { client -> + withClient(serverUrl) { client -> repeat(10) { i -> val name = "SequentialClient$i" val arguments = mapOf("name" to name) @@ -155,7 +179,8 @@ abstract class AbstractKotlinClientTsServerEdgeCasesTest : TsTestBase() { val result = client.callTool("greet", arguments) assertNotNull(result, "Tool call result should not be null for call $i") - val textContent = result.content.firstOrNull { it is TextContent } as? TextContent + val callResult = result + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent assertNotNull(textContent, "Text content should be present for call $i") assertEquals( diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerTestHttp.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerTestSse.kt similarity index 75% rename from integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerTestHttp.kt rename to integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerTestSse.kt index 7d919af..2006285 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinClientTsServerTestHttp.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinClientTsServerTestSse.kt @@ -1,4 +1,4 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerTest @@ -6,10 +6,11 @@ import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind import kotlinx.coroutines.withTimeout import kotlin.time.Duration.Companion.seconds -class KotlinClientTsServerTestHttp : AbstractKotlinClientTsServerTest() { - override val transportKind = TransportKind.HTTP +class KotlinClientTsServerTestSse : AbstractKotlinClientTsServerTest() { + override val transportKind = TransportKind.SSE - private val serverUrl: String by lazy { getSharedHttpUrl() } + private val host = "localhost" + private val serverUrl: String by lazy { getSharedSseUrl() } override suspend fun useClient(block: suspend (Client) -> T): T = withClient(serverUrl) { client -> try { diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinServerForTsClientHttp.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinServerForTsClientSse.kt similarity index 92% rename from integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinServerForTsClientHttp.kt rename to integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinServerForTsClientSse.kt index ebd0499..d0056cb 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/KotlinServerForTsClientHttp.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/KotlinServerForTsClientSse.kt @@ -1,4 +1,4 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.http.ContentType @@ -42,9 +42,8 @@ import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -133,16 +132,14 @@ class KotlinServerForTsClient { call.response.header("mcp-session-id", newSessionId) - launch(Dispatchers.IO) { - try { + val serverThread = Thread { + runBlocking { mcpServer.createSession(transport) - } catch (e: Exception) { - logger.error(e) { "Failed to create MCP session" } - transport.ready.completeExceptionally(e) } } + serverThread.start() - transport.ready.await() + Thread.sleep(500) transport.handleRequest(call, jsonElement) } else { @@ -176,7 +173,9 @@ class KotlinServerForTsClient { logger.info { "Terminating session: $sessionId" } val transport = serverTransports[sessionId]!! serverTransports.remove(sessionId) - transport.close() + runBlocking { + transport.close() + } call.respond(HttpStatusCode.OK) } else { logger.warn { "Invalid session termination request: $sessionId" } @@ -314,10 +313,9 @@ class HttpServerTransport(private val sessionId: String) : AbstractTransport() { private val logger = KotlinLogging.logger {} private val pendingResponses = ConcurrentHashMap>() private val messageQueue = Channel(Channel.UNLIMITED) - internal val ready = CompletableDeferred() suspend fun stream(call: ApplicationCall) { - logger.debug { "Starting Event stream for session: $sessionId" } + logger.debug { "Starting SSE stream for session: $sessionId" } call.response.header("Cache-Control", "no-cache") call.response.header("Connection", "keep-alive") call.respondTextWriter(ContentType.Text.EventStream) { @@ -333,28 +331,29 @@ class HttpServerTransport(private val sessionId: String) : AbstractTransport() { flush() } } catch (e: Exception) { - logger.warn(e) { "Event stream terminated for session: $sessionId" } + logger.warn(e) { "SSE stream terminated for session: $sessionId" } } finally { - logger.debug { "Event stream closed for session: $sessionId" } + logger.debug { "SSE stream closed for session: $sessionId" } } } } suspend fun handleRequest(call: ApplicationCall, requestBody: JsonElement) { try { - logger.trace { "Handling request body: $requestBody" } + logger.info { "Handling request body: $requestBody" } val message = McpJson.decodeFromJsonElement(requestBody) - logger.debug { "Decoded message: $message" } + logger.info { "Decoded message: $message" } if (message is JSONRPCRequest) { val id = message.id.toString() - logger.debug { "Received request with ID: $id, method: ${message.method}" } + logger.info { "Received request with ID: $id, method: ${message.method}" } val responseDeferred = CompletableDeferred() pendingResponses[id] = responseDeferred + logger.info { "Created deferred response for ID: $id" } - logger.trace { "Invoking onMessage handler for ID: $id" } + logger.info { "Invoking onMessage handler" } _onMessage.invoke(message) - logger.trace { "onMessage handler completed for ID: $id" } + logger.info { "onMessage handler completed" } try { val response = withTimeoutOrNull(10000) { @@ -427,35 +426,36 @@ class HttpServerTransport(private val sessionId: String) : AbstractTransport() { override suspend fun start() { logger.debug { "Starting HTTP server transport for session: $sessionId" } - ready.complete(Unit) } override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { - logger.trace { "Sending message: $message" } + logger.info { "Sending message: $message" } if (message is JSONRPCResponse) { val id = message.id.toString() - logger.debug { "Completing response for request ID: $id" } + logger.info { "Sending response for request ID: $id" } val deferred = pendingResponses.remove(id) if (deferred != null) { + logger.info { "Found pending response for ID: $id, completing deferred" } deferred.complete(message) return } else { logger.warn { "No pending response found for ID: $id" } } } else if (message is JSONRPCRequest) { - logger.debug { "Sending request with ID: ${message.id}" } + logger.info { "Sending request with ID: ${message.id}" } } else if (message is JSONRPCNotification) { - logger.debug { "Sending notification: ${message.method}" } + logger.info { "Sending notification: ${message.method}" } } + logger.info { "Queueing message for next client request" } messageQueue.send(message) } override suspend fun close() { logger.debug { "Closing HTTP server transport for session: $sessionId" } messageQueue.close() - invokeOnCloseCallback() + _onClose.invoke() } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/OldSchemaKotlinClientTsServerEdgeCasesTestSse.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/OldSchemaKotlinClientTsServerEdgeCasesTestSse.kt new file mode 100644 index 0000000..f2f6009 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/OldSchemaKotlinClientTsServerEdgeCasesTestSse.kt @@ -0,0 +1,200 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse + +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class OldSchemaKotlinClientTsServerEdgeCasesTestSse : TsTestBase() { + + override val transportKind = TransportKind.SSE + + private val host = "localhost" + private val serverUrl: String by lazy { getSharedSseUrl() } + + private lateinit var client: Client + + @AfterEach + fun tearDown() { + if (::client.isInitialized) { + try { + runBlocking { + withTimeout(3.seconds) { + client.close() + } + } + } catch (e: Exception) { + println("Warning: Error during client close: ${e.message}") + } + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testNonExistentTool(): Unit = runBlocking(Dispatchers.IO) { + withClient(serverUrl) { client -> + val nonExistentToolName = "non-existent-tool" + val arguments = mapOf("name" to "TestUser") + + val result = client.callTool(nonExistentToolName, arguments) + assertNotNull(result, "Tool call result should not be null") + + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + assertTrue(callResult.isError ?: false, "isError should be true for non-existent tool") + + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Error content should be present in the result") + + val errorText = textContent.text ?: "" + assertTrue( + errorText.contains("non-existent-tool") && errorText.contains("not found"), + "Error message should indicate the tool was not found", + ) + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testSpecialCharactersInArguments(): Unit = runBlocking(Dispatchers.IO) { + withClient(serverUrl) { client -> + val specialChars = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/" + val arguments = mapOf("name" to specialChars) + + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null") + + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present in the result") + + val text = textContent.text ?: "" + assertTrue( + text.contains(specialChars), + "Tool response should contain the special characters", + ) + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testLargePayload(): Unit = runBlocking(Dispatchers.IO) { + withClient(serverUrl) { client -> + val largeName = "A".repeat(10 * 1024) + val arguments = mapOf("name" to largeName) + + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null") + + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present in the result") + + val text = textContent.text ?: "" + assertTrue( + text.contains("Hello,") && text.contains("A"), + "Tool response should contain the greeting with the large name", + ) + } + } + + @Test + @Timeout(60, unit = TimeUnit.SECONDS) + fun testConcurrentRequests(): Unit = runBlocking(Dispatchers.IO) { + withClient(serverUrl) { client -> + val concurrentCount = 5 + val responses = coroutineScope { + val results = (1..concurrentCount).map { i -> + async { + val name = "ConcurrentClient$i" + val arguments = mapOf("name" to name) + + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null for client $i") + + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present for client $i") + + textContent.text ?: "" + } + } + results.awaitAll() + } + + for (i in 1..concurrentCount) { + val expectedName = "ConcurrentClient$i" + val matchingResponses = responses.filter { it.contains("Hello, $expectedName!") } + assertEquals( + 1, + matchingResponses.size, + "Should have exactly one response for $expectedName", + ) + } + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testInvalidArguments(): Unit = runBlocking(Dispatchers.IO) { + withClient(serverUrl) { client -> + val invalidArguments = mapOf( + "name" to JsonObject(mapOf("nested" to JsonPrimitive("value"))), + ) + + val result = client.callTool("greet", invalidArguments) + assertNotNull(result, "Tool call result should not be null") + + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + assertTrue(callResult.isError ?: false, "isError should be true for invalid arguments") + + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Error content should be present in the result") + + val errorText = textContent.text ?: "" + assertTrue( + errorText.contains("Invalid arguments") && errorText.contains("greet"), + "Error message should indicate invalid arguments for tool greet", + ) + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testMultipleToolCalls(): Unit = runBlocking(Dispatchers.IO) { + withClient(serverUrl) { client -> + repeat(10) { i -> + val name = "SequentialClient$i" + val arguments = mapOf("name" to name) + + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null for call $i") + + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present for call $i") + + assertEquals( + "Hello, $name!", + textContent.text, + "Tool response should contain the greeting with the provided name", + ) + } + } + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/OldSchemaKotlinClientTsServerTestSse.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/OldSchemaKotlinClientTsServerTestSse.kt new file mode 100644 index 0000000..f9415e1 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/OldSchemaKotlinClientTsServerTestSse.kt @@ -0,0 +1,25 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse + +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.OldSchemaAbstractKotlinClientTsServerTest +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +class OldSchemaKotlinClientTsServerTestSse : OldSchemaAbstractKotlinClientTsServerTest() { + + override val transportKind = TransportKind.SSE + + private val host = "localhost" + private val serverUrl: String by lazy { getSharedSseUrl() } + + override suspend fun useClient(block: suspend (Client) -> T): T = withClient(serverUrl) { client -> + try { + withTimeout(20.seconds) { block(client) } + } finally { + try { + withTimeout(3.seconds) { client.close() } + } catch (_: Exception) {} + } + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/OldSchemaKotlinServerForTsClientSse.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/OldSchemaKotlinServerForTsClientSse.kt new file mode 100644 index 0000000..08dcad7 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/OldSchemaKotlinServerForTsClientSse.kt @@ -0,0 +1,466 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.cio.CIO +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.request.header +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.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import io.modelcontextprotocol.kotlin.sdk.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.ErrorCode +import io.modelcontextprotocol.kotlin.sdk.GetPromptResult +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.JSONRPCError +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.JSONRPCNotification +import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.PromptArgument +import io.modelcontextprotocol.kotlin.sdk.PromptMessage +import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult +import io.modelcontextprotocol.kotlin.sdk.RequestId +import io.modelcontextprotocol.kotlin.sdk.Role +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.TextResourceContents +import io.modelcontextprotocol.kotlin.sdk.Tool +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport +import io.modelcontextprotocol.kotlin.sdk.shared.McpJson +import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonPrimitive +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +private val logger = KotlinLogging.logger {} + +class OldSchemaKotlinServerForTsClient { + private val serverTransports = ConcurrentHashMap() + private val jsonFormat = Json { ignoreUnknownKeys = true } + private var server: EmbeddedServer<*, *>? = null + + fun start(port: Int = 3000) { + logger.info { "Starting HTTP server on port $port" } + + server = embeddedServer(CIO, port = port) { + routing { + get("/mcp") { + val sessionId = call.request.header("mcp-session-id") + if (sessionId == null) { + call.respond(HttpStatusCode.BadRequest, "Missing mcp-session-id header") + return@get + } + val transport = serverTransports[sessionId] + if (transport == null) { + call.respond(HttpStatusCode.BadRequest, "Invalid mcp-session-id") + return@get + } + transport.stream(call) + } + + post("/mcp") { + val sessionId = call.request.header("mcp-session-id") + val requestBody = call.receiveText() + + logger.debug { "Received request with sessionId: $sessionId" } + logger.trace { "Request body: $requestBody" } + + val jsonElement = try { + jsonFormat.parseToJsonElement(requestBody) + } catch (e: Exception) { + logger.error(e) { "Failed to parse request body as JSON" } + call.respond( + HttpStatusCode.BadRequest, + jsonFormat.encodeToString( + JsonObject.serializer(), + JsonObject( + mapOf( + "jsonrpc" to JsonPrimitive("2.0"), + "error" to JsonObject( + mapOf( + "code" to JsonPrimitive(-32700), + "message" to JsonPrimitive("Parse error: ${e.message}"), + ), + ), + "id" to JsonNull, + ), + ), + ), + ) + return@post + } + + if (sessionId != null && serverTransports.containsKey(sessionId)) { + logger.debug { "Using existing transport for session: $sessionId" } + val transport = serverTransports[sessionId]!! + transport.handleRequest(call, jsonElement) + } else { + if (isInitializeRequest(jsonElement)) { + val newSessionId = UUID.randomUUID().toString() + logger.info { "Creating new session with ID: $newSessionId" } + + val transport = OldSchemaHttpServerTransport(newSessionId) + + serverTransports[newSessionId] = transport + + val mcpServer = createMcpServer() + + call.response.header("mcp-session-id", newSessionId) + + val serverThread = Thread { + runBlocking { + mcpServer.createSession(transport) + } + } + serverThread.start() + + Thread.sleep(500) + + transport.handleRequest(call, jsonElement) + } else { + logger.warn { "Invalid request: no session ID or not an initialization request" } + call.respond( + HttpStatusCode.BadRequest, + jsonFormat.encodeToString( + JsonObject.serializer(), + JsonObject( + mapOf( + "jsonrpc" to JsonPrimitive("2.0"), + "error" to JsonObject( + mapOf( + "code" to JsonPrimitive(-32000), + "message" to + JsonPrimitive("Bad Request: No valid session ID provided"), + ), + ), + "id" to JsonNull, + ), + ), + ), + ) + } + } + } + + delete("/mcp") { + val sessionId = call.request.header("mcp-session-id") + if (sessionId != null && serverTransports.containsKey(sessionId)) { + logger.info { "Terminating session: $sessionId" } + val transport = serverTransports[sessionId]!! + serverTransports.remove(sessionId) + runBlocking { + transport.close() + } + call.respond(HttpStatusCode.OK) + } else { + logger.warn { "Invalid session termination request: $sessionId" } + call.respond(HttpStatusCode.BadRequest, "Invalid or missing session ID") + } + } + } + } + + server?.start(wait = false) + } + + fun stop() { + logger.info { "Stopping HTTP server" } + server?.stop(500, 1000) + server = null + } + + fun createMcpServer(): Server { + val server = Server( + Implementation( + name = "kotlin-http-server", + version = "1.0.0", + ), + ServerOptions( + capabilities = ServerCapabilities( + prompts = ServerCapabilities.Prompts(listChanged = true), + resources = ServerCapabilities.Resources(subscribe = true, listChanged = true), + tools = ServerCapabilities.Tools(listChanged = true), + ), + ), + ) + + server.addTool( + name = "greet", + description = "A simple greeting tool", + inputSchema = Tool.Input( + properties = buildJsonObject { + put( + "name", + buildJsonObject { + put("type", JsonPrimitive("string")) + put("description", JsonPrimitive("Name to greet")) + }, + ) + }, + required = listOf("name"), + ), + ) { request -> + val name = (request.params.arguments?.get("name") as? JsonPrimitive)?.content ?: "World" + CallToolResult( + content = listOf(TextContent("Hello, $name!")), + structuredContent = buildJsonObject { + put("greeting", JsonPrimitive("Hello, $name!")) + }, + ) + } + + server.addTool( + name = "multi-greet", + description = "A greeting tool that sends multiple notifications", + inputSchema = Tool.Input( + properties = buildJsonObject { + put( + "name", + buildJsonObject { + put("type", JsonPrimitive("string")) + put("description", JsonPrimitive("Name to greet")) + }, + ) + }, + required = listOf("name"), + ), + ) { request -> + val name = (request.params.arguments?.get("name") as? JsonPrimitive)?.content ?: "World" + + CallToolResult( + content = listOf(TextContent("Multiple greetings sent to $name!")), + structuredContent = buildJsonObject { + put("greeting", JsonPrimitive("Multiple greetings sent to $name!")) + put("notificationCount", JsonPrimitive(3)) + }, + ) + } + + server.addPrompt( + name = "greeting-template", + description = "A simple greeting prompt template", + arguments = listOf( + PromptArgument( + name = "name", + description = "Name to include in greeting", + required = true, + ), + ), + ) { request -> + GetPromptResult( + description = "Greeting for ${request.params.name}", + messages = listOf( + PromptMessage( + role = Role.user, + content = TextContent( + "Please greet ${request.params.arguments?.get("name") ?: "someone"} in a friendly manner.", + ), + ), + ), + ) + } + + server.addResource( + uri = "https://example.com/greetings/default", + name = "Default Greeting", + description = "A simple greeting resource", + mimeType = "text/plain", + ) { request -> + ReadResourceResult( + contents = listOf( + TextResourceContents("Hello, world!", request.params.uri, "text/plain"), + ), + ) + } + + return server + } + + private fun isInitializeRequest(json: JsonElement): Boolean { + if (json !is JsonObject) return false + + val method = json["method"]?.jsonPrimitive?.contentOrNull + return method == "initialize" + } +} + +class OldSchemaHttpServerTransport(private val sessionId: String) : AbstractTransport() { + private val logger = KotlinLogging.logger {} + private val pendingResponses = ConcurrentHashMap>() + private val messageQueue = Channel(Channel.UNLIMITED) + + suspend fun stream(call: ApplicationCall) { + logger.debug { "Starting SSE stream for session: $sessionId" } + call.response.header("Cache-Control", "no-cache") + call.response.header("Connection", "keep-alive") + call.respondTextWriter(ContentType.Text.EventStream) { + try { + while (true) { + val result = messageQueue.receiveCatching() + val msg = result.getOrNull() ?: break + val json = McpJson.encodeToString(msg) + write("event: message\n") + write("data: ") + write(json) + write("\n\n") + flush() + } + } catch (e: Exception) { + logger.warn(e) { "SSE stream terminated for session: $sessionId" } + } finally { + logger.debug { "SSE stream closed for session: $sessionId" } + } + } + } + + suspend fun handleRequest(call: ApplicationCall, requestBody: JsonElement) { + try { + logger.info { "Handling request body: $requestBody" } + val message = McpJson.decodeFromJsonElement(requestBody) + logger.info { "Decoded message: $message" } + + if (message is JSONRPCRequest) { + val id = message.id.toString() + logger.info { "Received request with ID: $id, method: ${message.method}" } + val responseDeferred = CompletableDeferred() + pendingResponses[id] = responseDeferred + logger.info { "Created deferred response for ID: $id" } + + logger.info { "Invoking onMessage handler" } + _onMessage.invoke(message) + logger.info { "onMessage handler completed" } + + try { + val response = withTimeoutOrNull(10000) { + responseDeferred.await() + } + + if (response != null) { + val jsonResponse = McpJson.encodeToString(response) + call.respondText(jsonResponse, ContentType.Application.Json) + } else { + logger.warn { "Timeout waiting for response to request ID: $id" } + call.respondText( + McpJson.encodeToString( + JSONRPCError( + id = message.id, + error = RPCError( + code = ErrorCode.Defined.RequestTimeout, + message = "Request timed out", + ), + ), + ), + ContentType.Application.Json, + ) + } + } catch (_: CancellationException) { + logger.warn { "Request cancelled for ID: $id" } + pendingResponses.remove(id) + if (!call.response.isCommitted) { + call.respondText( + McpJson.encodeToString( + JSONRPCError( + id = message.id, + error = RPCError( + code = ErrorCode.Defined.ConnectionClosed, + message = "Request cancelled", + ), + ), + ), + ContentType.Application.Json, + HttpStatusCode.ServiceUnavailable, + ) + } + } + } else { + call.respondText("", ContentType.Application.Json, HttpStatusCode.Accepted) + } + } catch (e: Exception) { + logger.error(e) { "Error handling request" } + if (!call.response.isCommitted) { + try { + val errorResponse = JSONRPCError( + id = RequestId.NumberId(0), + error = RPCError( + code = ErrorCode.Defined.InternalError, + message = "Internal server error: ${e.message}", + ), + ) + + call.respondText( + McpJson.encodeToString(errorResponse), + ContentType.Application.Json, + HttpStatusCode.InternalServerError, + ) + } catch (responseEx: Exception) { + logger.error(responseEx) { "Failed to send error response" } + } + } + } + } + + override suspend fun start() { + logger.debug { "Starting HTTP server transport for session: $sessionId" } + } + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + logger.info { "Sending message: $message" } + + if (message is JSONRPCResponse) { + val id = message.id.toString() + logger.info { "Sending response for request ID: $id" } + val deferred = pendingResponses.remove(id) + if (deferred != null) { + logger.info { "Found pending response for ID: $id, completing deferred" } + deferred.complete(message) + return + } else { + logger.warn { "No pending response found for ID: $id" } + } + } else if (message is JSONRPCRequest) { + logger.info { "Sending request with ID: ${message.id}" } + } else if (message is JSONRPCNotification) { + logger.info { "Sending notification: ${message.method}" } + } + + logger.info { "Queueing message for next client request" } + messageQueue.send(message) + } + + override suspend fun close() { + logger.debug { "Closing HTTP server transport for session: $sessionId" } + messageQueue.close() + _onClose.invoke() + } +} + +fun main() { + val server = OldSchemaKotlinServerForTsClient() + server.start() +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/TsClientKotlinServerTestHttp.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsClientKotlinServerTestSse.kt similarity index 85% rename from integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/TsClientKotlinServerTestHttp.kt rename to integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsClientKotlinServerTestSse.kt index eff91e5..dd43b70 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/TsClientKotlinServerTestHttp.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsClientKotlinServerTestSse.kt @@ -1,4 +1,4 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractTsClientKotlinServerTest import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind @@ -7,9 +7,9 @@ import io.modelcontextprotocol.kotlin.test.utils.killProcessOnPort import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach -class TsClientKotlinServerTestHttp : AbstractTsClientKotlinServerTest() { +class TsClientKotlinServerTestSse : AbstractTsClientKotlinServerTest() { - override val transportKind = TransportKind.HTTP + override val transportKind = TransportKind.SSE private var port: Int = 0 private lateinit var serverUrl: String @@ -36,11 +36,11 @@ class TsClientKotlinServerTestHttp : AbstractTsClientKotlinServerTest() { } override fun runClient(vararg args: String): String = tsClient.use { client -> - val process = client.startHttp(listOf(serverUrl) + args.toList(), log = true) + val process = client.startSse(listOf(serverUrl) + args.toList(), log = true) val output = StringBuilder() process.inputStream.bufferedReader().useLines { lines -> lines.forEach { line -> - println("[TS-CLIENT-HTTP] $line") + println("[TS-CLIENT-SSE] $line") output.append(line).append("\n") } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/TsEdgeCasesTestHttp.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsEdgeCasesTestSse.kt similarity index 90% rename from integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/TsEdgeCasesTestHttp.kt rename to integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsEdgeCasesTestSse.kt index 9326cc6..ee81ce2 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/http/TsEdgeCasesTestHttp.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/sse/TsEdgeCasesTestSse.kt @@ -1,4 +1,4 @@ -package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase @@ -21,9 +21,9 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.test.fail -class TsEdgeCasesTestHttp : TsTestBase() { +class TsEdgeCasesTestSse : TsTestBase() { - override val transportKind = TransportKind.HTTP + override val transportKind = TransportKind.SSE private var port: Int = 0 private lateinit var serverUrl: String @@ -55,7 +55,7 @@ class TsEdgeCasesTestHttp : TsTestBase() { @Test @Timeout(30, unit = TimeUnit.SECONDS) fun testInvalidURL() = runTest { - val nonExistentToolCommand = "npx tsx client/http-client.ts $serverUrl non-existent-tool" + val nonExistentToolCommand = "npx tsx client/sse-client.ts $serverUrl non-existent-tool" val nonExistentToolOutput = executeCommandAllowingFailure(nonExistentToolCommand, typescriptDir) assertTrue( @@ -63,7 +63,7 @@ class TsEdgeCasesTestHttp : TsTestBase() { "Client should handle non-existent tool gracefully", ) - val invalidUrlCommand = "npx tsx client/http-client.ts http://localhost:${port + 1000}/mcp greet TestUser" + val invalidUrlCommand = "npx tsx client/sse-client.ts http://localhost:${port + 1000}/mcp greet TestUser" val invalidUrlOutput = executeCommandAllowingFailure(invalidUrlCommand, typescriptDir) assertTrue( @@ -84,7 +84,7 @@ class TsEdgeCasesTestHttp : TsTestBase() { tempFile.deleteOnExit() val specialCharsContent = tempFile.readText() - val specialCharsCommand = "npx tsx client/http-client.ts $serverUrl greet \"$specialCharsContent\"" + val specialCharsCommand = "npx tsx client/sse-client.ts $serverUrl greet \"$specialCharsContent\"" val specialCharsOutput = executeCommand(specialCharsCommand, typescriptDir) assertTrue( @@ -109,7 +109,7 @@ class TsEdgeCasesTestHttp : TsTestBase() { tempFile.deleteOnExit() val largeNameContent = tempFile.readText() - val largePayloadCommand = "npx tsx client/http-client.ts $serverUrl greet \"$largeNameContent\"" + val largePayloadCommand = "npx tsx client/sse-client.ts $serverUrl greet \"$largeNameContent\"" val largePayloadOutput = executeCommand(largePayloadCommand, typescriptDir) tempFile.delete() @@ -157,11 +157,11 @@ class TsEdgeCasesTestHttp : TsTestBase() { } val commands = listOf( - "npx tsx client/http-client.ts $serverUrl greet \"Client1\"", - "npx tsx client/http-client.ts $serverUrl multi-greet \"Client2\"", - "npx tsx client/http-client.ts $serverUrl greet \"Client3\"", - "npx tsx client/http-client.ts $serverUrl", - "npx tsx client/http-client.ts $serverUrl multi-greet \"Client5\"", + "npx tsx client/sse-client.ts $serverUrl greet \"Client1\"", + "npx tsx client/sse-client.ts $serverUrl multi-greet \"Client2\"", + "npx tsx client/sse-client.ts $serverUrl greet \"Client3\"", + "npx tsx client/sse-client.ts $serverUrl", + "npx tsx client/sse-client.ts $serverUrl multi-greet \"Client5\"", ) coroutineScope { @@ -242,7 +242,7 @@ class TsEdgeCasesTestHttp : TsTestBase() { @Timeout(120, unit = TimeUnit.SECONDS) fun testRapidSequentialRequests() = runBlocking { val outputs = (1..10).map { i -> - val command = "npx tsx client/http-client.ts $serverUrl greet \"RapidClient$i\"" + val command = "npx tsx client/sse-client.ts $serverUrl greet \"RapidClient$i\"" val output = executeCommand(command, typescriptDir) assertTrue( diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/KotlinClientTsServerEdgeCasesTestStdio.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/KotlinClientTsServerEdgeCasesTestStdio.kt index b8c8e93..2bdd361 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/KotlinClientTsServerEdgeCasesTestStdio.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/KotlinClientTsServerEdgeCasesTestStdio.kt @@ -1,12 +1,173 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio import io.modelcontextprotocol.kotlin.sdk.client.Client -import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerEdgeCasesTest import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue -class KotlinClientTsServerEdgeCasesTestStdio : AbstractKotlinClientTsServerEdgeCasesTest() { +class KotlinClientTsServerEdgeCasesTestStdio : TsTestBase() { override val transportKind = TransportKind.STDIO - override suspend fun useClient(block: suspend (Client) -> T): T = withClientStdio { c, _ -> block(c) } + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testNonExistentToolOverStdio(): Unit = runBlocking(Dispatchers.IO) { + withClientStdio { client: Client, _ -> + val nonExistentToolName = "non-existent-tool" + val arguments = mapOf("name" to "TestUser") + + val result = client.callTool(nonExistentToolName, arguments) + assertNotNull(result, "Tool call result should not be null") + + assertTrue(result.isError ?: false, "isError should be true for non-existent tool") + + val textContent = result.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Error content should be present in the result") + + val errorText = textContent.text + assertTrue( + errorText.contains("non-existent-tool") && errorText.contains("not found"), + "Error message should indicate the tool was not found", + ) + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testSpecialCharactersInArgumentsOverStdio(): Unit = runBlocking(Dispatchers.IO) { + withClientStdio { client: Client, _ -> + val specialChars = "!@#$%^&*()_+{}[]|\\:;\"'<>.,?/" + val arguments = mapOf("name" to specialChars) + + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null") + + val textContent = result.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present in the result") + + val text = textContent.text + assertTrue( + text.contains(specialChars), + "Tool response should contain the special characters", + ) + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testLargePayloadOverStdio(): Unit = runBlocking(Dispatchers.IO) { + withClientStdio { client: Client, _ -> + val largeName = "A".repeat(10 * 1024) + val arguments = mapOf("name" to largeName) + + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null") + + val callResult = result + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present in the result") + + val text = textContent.text + assertTrue( + text.contains("Hello,") && text.contains("A"), + "Tool response should contain the greeting with the large name", + ) + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testConcurrentRequestsOverStdio(): Unit = runBlocking(Dispatchers.IO) { + withClientStdio { client: Client, _ -> + val concurrentCount = 5 + val responses = coroutineScope { + val results = (1..concurrentCount).map { i -> + async { + val name = "ConcurrentClient$i" + val arguments = mapOf("name" to name) + + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null for client $i") + + val textContent = result.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present for client $i") + + textContent.text + } + } + results.awaitAll() + } + + for (i in 1..concurrentCount) { + val expectedName = "ConcurrentClient$i" + val matchingResponses = responses.filter { it.contains("Hello, $expectedName!") } + assertEquals( + 1, + matchingResponses.size, + "Should have exactly one response for $expectedName", + ) + } + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testInvalidArgumentsOverStdio(): Unit = runBlocking(Dispatchers.IO) { + withClientStdio { client: Client, _ -> + val invalidArguments = mapOf( + "name" to JsonObject(mapOf("nested" to JsonPrimitive("value"))), + ) + + val result = client.callTool("greet", invalidArguments) + assertNotNull(result, "Tool call result should not be null") + + val callResult = result + assertTrue(callResult.isError ?: false, "isError should be true for invalid arguments") + + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Error content should be present in the result") + + val errorText = textContent.text + assertTrue( + errorText.contains("Invalid arguments") && errorText.contains("greet"), + "Error message should indicate invalid arguments for tool greet", + ) + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testMultipleToolCallsOverStdio(): Unit = runBlocking(Dispatchers.IO) { + withClientStdio { client: Client, _ -> + repeat(10) { i -> + val name = "SequentialClient$i" + val arguments = mapOf("name" to name) + + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null for call $i") + + val textContent = result.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present for call $i") + + assertEquals( + "Hello, $name!", + textContent.text, + "Tool response should contain the greeting with the provided name", + ) + } + } + } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/KotlinClientTsServerTestStdio.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/KotlinClientTsServerTestStdio.kt index 928d591..8273724 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/KotlinClientTsServerTestStdio.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/KotlinClientTsServerTestStdio.kt @@ -8,7 +8,16 @@ class KotlinClientTsServerTestStdio : AbstractKotlinClientTsServerTest() { override val transportKind = TransportKind.STDIO - override suspend fun useClient(block: suspend (Client) -> T): T = - // TsTestBase.withClientStdio already handles client.close() + process shutdown. - withClientStdio { client, _ -> block(client) } + override suspend fun useClient(block: suspend (Client) -> T): T = withClientStdio { client, proc -> + try { + block(client) + } finally { + try { + client.close() + } catch (_: Exception) {} + try { + stopProcess(proc, name = "TypeScript stdio server") + } catch (_: Exception) {} + } + } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/OldSchemaKotlinClientTsServerEdgeCasesTestStdio.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/OldSchemaKotlinClientTsServerEdgeCasesTestStdio.kt new file mode 100644 index 0000000..0ed8676 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/OldSchemaKotlinClientTsServerEdgeCasesTestStdio.kt @@ -0,0 +1,177 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio + +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class OldSchemaKotlinClientTsServerEdgeCasesTestStdio : TsTestBase() { + + override val transportKind = TransportKind.STDIO + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testNonExistentToolOverStdio(): Unit = runBlocking(Dispatchers.IO) { + withClientStdio { client: Client, _ -> + val nonExistentToolName = "non-existent-tool" + val arguments = mapOf("name" to "TestUser") + + val result = client.callTool(nonExistentToolName, arguments) + assertNotNull(result, "Tool call result should not be null") + + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + assertTrue(callResult.isError ?: false, "isError should be true for non-existent tool") + + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Error content should be present in the result") + + val errorText = textContent.text ?: "" + assertTrue( + errorText.contains("non-existent-tool") && errorText.contains("not found"), + "Error message should indicate the tool was not found", + ) + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testSpecialCharactersInArgumentsOverStdio(): Unit = runBlocking(Dispatchers.IO) { + withClientStdio { client: Client, _ -> + val specialChars = "!@#$%^&*()_+{}[]|\\:;\"'<>.,?/" + val arguments = mapOf("name" to specialChars) + + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null") + + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present in the result") + + val text = textContent.text ?: "" + assertTrue( + text.contains(specialChars), + "Tool response should contain the special characters", + ) + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testLargePayloadOverStdio(): Unit = runBlocking(Dispatchers.IO) { + withClientStdio { client: Client, _ -> + val largeName = "A".repeat(10 * 1024) + val arguments = mapOf("name" to largeName) + + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null") + + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present in the result") + + val text = textContent.text ?: "" + assertTrue( + text.contains("Hello,") && text.contains("A"), + "Tool response should contain the greeting with the large name", + ) + } + } + + @Test + @Timeout(60, unit = TimeUnit.SECONDS) + fun testConcurrentRequestsOverStdio(): Unit = runBlocking(Dispatchers.IO) { + withClientStdio { client: Client, _ -> + val concurrentCount = 5 + val responses = coroutineScope { + val results = (1..concurrentCount).map { i -> + async { + val name = "ConcurrentClient$i" + val arguments = mapOf("name" to name) + + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null for client $i") + + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present for client $i") + + textContent.text ?: "" + } + } + results.awaitAll() + } + + for (i in 1..concurrentCount) { + val expectedName = "ConcurrentClient$i" + val matchingResponses = responses.filter { it.contains("Hello, $expectedName!") } + assertEquals( + 1, + matchingResponses.size, + "Should have exactly one response for $expectedName", + ) + } + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testInvalidArgumentsOverStdio(): Unit = runBlocking(Dispatchers.IO) { + withClientStdio { client: Client, _ -> + val invalidArguments = mapOf( + "name" to JsonObject(mapOf("nested" to JsonPrimitive("value"))), + ) + + val result = client.callTool("greet", invalidArguments) + assertNotNull(result, "Tool call result should not be null") + + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + assertTrue(callResult.isError ?: false, "isError should be true for invalid arguments") + + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Error content should be present in the result") + + val errorText = textContent.text ?: "" + assertTrue( + errorText.contains("Invalid arguments") && errorText.contains("greet"), + "Error message should indicate invalid arguments for tool greet", + ) + } + } + + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + fun testMultipleToolCallsOverStdio(): Unit = runBlocking(Dispatchers.IO) { + withClientStdio { client: Client, _ -> + repeat(10) { i -> + val name = "SequentialClient$i" + val arguments = mapOf("name" to name) + + val result = client.callTool("greet", arguments) + assertNotNull(result, "Tool call result should not be null for call $i") + + val callResult = result as io.modelcontextprotocol.kotlin.sdk.types.CallToolResult + val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent + assertNotNull(textContent, "Text content should be present for call $i") + + assertEquals( + "Hello, $name!", + textContent.text, + "Tool response should contain the greeting with the provided name", + ) + } + } + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/OldSchemaKotlinClientTsServerTestStdio.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/OldSchemaKotlinClientTsServerTestStdio.kt new file mode 100644 index 0000000..e87a5c8 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript/stdio/OldSchemaKotlinClientTsServerTestStdio.kt @@ -0,0 +1,23 @@ +package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio + +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.OldSchemaAbstractKotlinClientTsServerTest +import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind + +class OldSchemaKotlinClientTsServerTestStdio : OldSchemaAbstractKotlinClientTsServerTest() { + + override val transportKind = TransportKind.STDIO + + override suspend fun useClient(block: suspend (Client) -> T): T = withClientStdio { client, proc -> + try { + block(client) + } finally { + try { + client.close() + } catch (_: Exception) {} + try { + stopProcess(proc, name = "TypeScript stdio server") + } catch (_: Exception) {} + } + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractServerFeaturesTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractServerFeaturesTest.kt index 1d28218..21b46d2 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractServerFeaturesTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractServerFeaturesTest.kt @@ -1,16 +1,9 @@ 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.CallToolResult -import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.BeforeEach @@ -20,31 +13,8 @@ abstract class AbstractServerFeaturesTest { protected lateinit var server: Server protected lateinit var client: Client - protected fun addTool(name: String, block: suspend ClientConnection.() -> Unit) { - server.addTool(name, "Test $name") { - block() - CallToolResult(listOf(TextContent("Success"))) - } - } - - protected fun addPrompt(name: String, block: suspend ClientConnection.() -> Unit) { - server.addPrompt(name, "Test $name") { - block() - GetPromptResult(messages = emptyList()) - } - } - - protected fun addResource(uri: String, block: suspend ClientConnection.() -> Unit) { - server.addResource(uri, uri, "Test resource $uri") { - block() - ReadResourceResult(contents = listOf(TextResourceContents(text = "content", uri = uri))) - } - } - abstract fun getServerCapabilities(): ServerCapabilities - protected open fun getClientCapabilities(): ClientCapabilities = ClientCapabilities() - protected open fun getServerInstructionsProvider(): (() -> String)? = null @BeforeEach @@ -63,9 +33,6 @@ abstract class AbstractServerFeaturesTest { client = Client( clientInfo = Implementation(name = "test client", version = "1.0"), - options = ClientOptions( - capabilities = getClientCapabilities(), - ), ) runBlocking { 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 deleted file mode 100644 index a1cf797..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionLoggingTest.kt +++ /dev/null @@ -1,182 +0,0 @@ -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 -import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest -import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel -import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification -import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams -import io.modelcontextprotocol.kotlin.sdk.types.Method -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.JsonObject -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() { - - override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = true), - logging = JsonObject(emptyMap()), - ) - - @Test - fun `notification should send logging message to client`(): Unit = runBlocking { - val notificationReceived = CompletableDeferred() - client.setNotificationHandler(Method.Defined.NotificationsMessage) { - notificationReceived.complete(it) - CompletableDeferred(Unit) - } - - val expectedLevel = LoggingLevel.Info - val expectedData = JsonPrimitive("test-data-sample") - - addTool("test-notification") { - notification( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = expectedLevel, - data = expectedData, - ), - ), - ) - } - - client.callTool(CallToolRequest(CallToolRequestParams("test-notification"))) - val received = notificationReceived.await() - received.params.level shouldBe expectedLevel - received.params.data shouldBe expectedData - } - - @ParameterizedTest - @EnumSource(LoggingLevel::class) - fun `notification should filter logging messages below level`(minLevel: LoggingLevel): Unit = runBlocking { - val receivedMessages = CopyOnWriteArrayList() - client.setNotificationHandler(Method.Defined.NotificationsMessage) { - receivedMessages.add(it) - CompletableDeferred(Unit) - } - - client.setLoggingLevel(minLevel) - - addTool("test-notification-filtered") { - LoggingLevel.entries.forEach { level -> - notification( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = level, - data = JsonPrimitive(level.name), - ), - ), - ) - } - } - - 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.map { it.params.level } shouldBe expectedLevels - } - - @ParameterizedTest - @EnumSource(LoggingLevel::class) - fun `sendLoggingMessage should send message at level`(expectedLevel: LoggingLevel): Unit = runBlocking { - val notificationReceived = CompletableDeferred() - client.setNotificationHandler(Method.Defined.NotificationsMessage) { - notificationReceived.complete(it) - CompletableDeferred(Unit) - } - - val expectedData = JsonObject(mapOf("key" to JsonPrimitive("value"))) - - addTool("test-logging") { - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = expectedLevel, - data = expectedData, - ), - ), - ) - } - - client.callTool(CallToolRequest(CallToolRequestParams("test-logging"))) - val received = notificationReceived.await() - received.params.level shouldBe expectedLevel - received.params.data shouldBe expectedData - } - - @ParameterizedTest - @EnumSource(LoggingLevel::class) - fun `sendLoggingMessage should filter messages below level`(minLevel: LoggingLevel): Unit = runBlocking { - val receivedMessages = CopyOnWriteArrayList() - client.setNotificationHandler(Method.Defined.NotificationsMessage) { - receivedMessages.add(it) - CompletableDeferred(Unit) - } - - client.setLoggingLevel(minLevel) - - addTool("test-logging-level") { - LoggingLevel.entries.forEach { level -> - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = level, - data = JsonPrimitive(level.name), - ), - ), - ) - } - } - - 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.map { it.params.level } shouldBe expectedLevels - } - - @Test - fun `sendLoggingMessage should send no messages when level is set to highest`(): Unit = runBlocking { - val receivedMessages = CopyOnWriteArrayList() - client.setNotificationHandler(Method.Defined.NotificationsMessage) { - receivedMessages.add(it) - CompletableDeferred(Unit) - } - - client.setLoggingLevel(LoggingLevel.Emergency) - - addTool("test-logging-highest") { - LoggingLevel.entries.dropLast(1).forEach { level -> - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = level, - data = JsonPrimitive(level.name), - ), - ), - ) - } - } - - 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() } - } -} 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 deleted file mode 100644 index 581e5ae..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnectionTest.kt +++ /dev/null @@ -1,178 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.assertions.assertSoftly -import io.kotest.assertions.withClue -import io.kotest.matchers.shouldBe -import io.kotest.matchers.string.shouldNotBeBlank -import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest -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 -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.LoggingMessageNotificationParams -import io.modelcontextprotocol.kotlin.sdk.types.Method -import io.modelcontextprotocol.kotlin.sdk.types.Notification -import io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification -import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest -import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.Request -import io.modelcontextprotocol.kotlin.sdk.types.RequestResult -import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification -import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification -import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotificationParams -import io.modelcontextprotocol.kotlin.sdk.types.Root -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.JsonPrimitive -import org.junit.jupiter.api.Test - -class ClientConnectionTest : AbstractServerFeaturesTest() { - - private inline fun onClientNotification(method: Method): CompletableDeferred { - val deferred = CompletableDeferred() - client.setNotificationHandler(method) { - deferred.complete(it) - CompletableDeferred(Unit) - } - return deferred - } - - private inline fun onClientRequest( - method: Method, - crossinline response: (Req) -> Res, - ) { - client.setRequestHandler(method) { req, _ -> response(req) } - } - - override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = true), - prompts = ServerCapabilities.Prompts(listChanged = true), - resources = ServerCapabilities.Resources(listChanged = true), - logging = ServerCapabilities.Logging, - ) - - override fun getClientCapabilities(): ClientCapabilities = ClientCapabilities( - roots = ClientCapabilities.Roots(listChanged = true), - elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject), - ) - - private val sampleRoots = listOf(Root("file:///project", "Project Root")) - - /** Holds all deferreds that capture what a server-side handler emits during [callAllMethods]. */ - private inner class Captures { - val pingComplete = CompletableDeferred() - val rootsResult = CompletableDeferred() - val sessionId = CompletableDeferred() - val toolListChanged = - onClientNotification(Method.Defined.NotificationsToolsListChanged) - val resourceListChanged = - onClientNotification(Method.Defined.NotificationsResourcesListChanged) - val promptListChanged = - onClientNotification(Method.Defined.NotificationsPromptsListChanged) - val loggingMessage = onClientNotification(Method.Defined.NotificationsMessage) - val resourceUpdated = - onClientNotification(Method.Defined.NotificationsResourcesUpdated) - val elicitationComplete = - onClientNotification(Method.Defined.NotificationsElicitationComplete) - - init { - onClientRequest(Method.Defined.RootsList) { - ListRootsResult(sampleRoots) - } - } - - suspend fun assertAll() { - val roots = rootsResult.await() - val log = loggingMessage.await() - val resourceUri = resourceUpdated.await().params.uri - val capturedSessionId = sessionId.await() - assertSoftly { - withClue("ping() was not called inside the handler") { - pingComplete.isCompleted shouldBe true - } - roots.roots shouldBe sampleRoots - withClue("sendToolListChanged() did not deliver a notification to the client") { - toolListChanged.isCompleted shouldBe true - } - withClue("sendResourceListChanged() did not deliver a notification to the client") { - resourceListChanged.isCompleted shouldBe true - } - withClue("sendPromptListChanged() did not deliver a notification to the client") { - promptListChanged.isCompleted shouldBe true - } - withClue("sendLoggingMessage() delivered unexpected payload") { - log.params.data shouldBe JsonPrimitive("log") - } - withClue("sendResourceUpdated() delivered wrong URI") { - resourceUri shouldBe "test://res" - } - withClue("sendElicitationComplete() did not deliver a notification to the client") { - elicitationComplete.isCompleted shouldBe true - } - withClue("sendElicitationComplete() delivered wrong elicitationId") { - elicitationComplete.await().params.elicitationId shouldBe "elicit-123" - } - capturedSessionId.shouldNotBeBlank() - } - } - } - - /** Exercises every [ClientConnection] method, recording results into [cap]. */ - private suspend fun ClientConnection.callAllMethods(cap: Captures) { - ping() - cap.pingComplete.complete(Unit) - cap.rootsResult.complete(listRoots()) - sendToolListChanged() - sendResourceListChanged() - sendPromptListChanged() - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams(level = LoggingLevel.Info, data = JsonPrimitive("log")), - ), - ) - sendResourceUpdated(ResourceUpdatedNotification(ResourceUpdatedNotificationParams("test://res"))) - sendElicitationComplete( - ElicitationCompleteNotification(ElicitationCompleteNotificationParams(elicitationId = "elicit-123")), - ) - cap.sessionId.complete(sessionId) - } - - @Test - fun `all ClientConnection methods are callable from tool handler`() = runTest { - val cap = Captures() - addTool("test") { callAllMethods(cap) } - - client.callTool(CallToolRequest(CallToolRequestParams("test"))) - - cap.assertAll() - } - - @Test - fun `all ClientConnection methods are callable from prompt handler`() = runTest { - val cap = Captures() - addPrompt("test") { callAllMethods(cap) } - - client.getPrompt(GetPromptRequest(GetPromptRequestParams("test"))) - - cap.assertAll() - } - - @Test - fun `all ClientConnection methods are callable from resource handler`() = runTest { - val cap = Captures() - addResource("test://resource") { callAllMethods(cap) } - - client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://resource"))) - - cap.assertAll() - } -} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaAbstractServerFeaturesTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaAbstractServerFeaturesTest.kt new file mode 100644 index 0000000..d74c0b0 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaAbstractServerFeaturesTest.kt @@ -0,0 +1,43 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.BeforeEach + +abstract class OldSchemaAbstractServerFeaturesTest { + + protected lateinit var server: Server + protected lateinit var client: Client + + abstract fun getServerCapabilities(): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities + + protected open fun getServerInstructionsProvider(): (() -> String)? = null + + @BeforeEach + fun setUp() { + val serverOptions = ServerOptions( + capabilities = getServerCapabilities(), + ) + + server = Server( + serverInfo = Implementation(name = "test server", version = "1.0"), + options = serverOptions, + instructionsProvider = getServerInstructionsProvider(), + ) + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + + client = Client( + clientInfo = Implementation(name = "test client", version = "1.0"), + ) + + runBlocking { + // Connect client and server + launch { client.connect(clientTransport) } + launch { server.createSession(serverTransport) } + } + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaServerInstructionsTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaServerInstructionsTest.kt new file mode 100644 index 0000000..26bb7bf --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaServerInstructionsTest.kt @@ -0,0 +1,68 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull +import kotlin.test.assertEquals + +class OldSchemaServerInstructionsTest { + + @Test + fun `Server constructor should accept instructions provider parameter`() = runTest { + val serverInfo = Implementation(name = "test server", version = "1.0") + val serverOptions = ServerOptions(capabilities = ServerCapabilities()) + val instructions = "This is a test server. Use it for testing purposes only." + + val server = Server(serverInfo, serverOptions, { instructions }) + + // The instructions should be stored internally and used in handleInitialize + // We can't directly access the private field, but we can test it through initialization + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + val client = Client(clientInfo = Implementation(name = "test client", version = "1.0")) + + server.createSession(serverTransport) + client.connect(clientTransport) + + assertEquals(instructions, client.serverInstructions) + } + + @Test + fun `Server constructor should accept instructions parameter`() = runTest { + val serverInfo = Implementation(name = "test server", version = "1.0") + val serverOptions = ServerOptions(capabilities = ServerCapabilities()) + val instructions = "This is a test server. Use it for testing purposes only." + + val server = Server(serverInfo, serverOptions, instructions) + + // The instructions should be stored internally and used in handleInitialize + // We can't directly access the private field, but we can test it through initialization + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + val client = Client(clientInfo = Implementation(name = "test client", version = "1.0")) + + server.createSession(serverTransport) + client.connect(clientTransport) + + assertEquals(instructions, client.serverInstructions) + } + + @Test + fun `Server constructor should work without instructions parameter`() = runTest { + val serverInfo = Implementation(name = "test server", version = "1.0") + val serverOptions = ServerOptions(capabilities = ServerCapabilities()) + + // Test that server works when instructions parameter is omitted (defaults to null) + val server = Server(serverInfo, serverOptions) + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + val client = Client(clientInfo = Implementation(name = "test client", version = "1.0")) + + server.createSession(serverTransport) + client.connect(clientTransport) + + assertNull(client.serverInstructions) + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaServerPromptsTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaServerPromptsTest.kt new file mode 100644 index 0000000..1721bec --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaServerPromptsTest.kt @@ -0,0 +1,213 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContain +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.throwable.shouldHaveMessage +import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject +import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest +import io.modelcontextprotocol.kotlin.sdk.GetPromptResult +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.Method +import io.modelcontextprotocol.kotlin.sdk.Prompt +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OldSchemaServerPromptsTest : OldSchemaAbstractServerFeaturesTest() { + + override fun getServerCapabilities(): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities = + ServerCapabilities( + prompts = ServerCapabilities.Prompts(false), + ) + + @Test + fun `Should list no prompts by default`() = runTest { + client.listPrompts() shouldNotBeNull { + prompts.shouldBeEmpty() + } + } + + @Test + fun `Should add a prompt`() = runTest { + // Add a prompt + val testPrompt = Prompt( + name = "test-prompt-with-custom-handler", + description = "Test Prompt", + arguments = null, + ) + val expectedPromptResult = GetPromptResult( + description = "Test prompt description", + messages = listOf(), + ) + + server.addPrompt(testPrompt) { + expectedPromptResult + } + + client.getPrompt( + GetPromptRequest( + name = "test-prompt-with-custom-handler", + arguments = null, + ), + ) shouldBe expectedPromptResult + + client.listPrompts() shouldNotBeNull { + prompts shouldContainExactly listOf(testPrompt) + nextCursor shouldBe null + _meta shouldBe EmptyJsonObject + } + } + + @Test + fun `Should remove a prompt`() = runTest { + // given + val testPrompt = Prompt( + name = "test-prompt-to-remove", + description = "Test Prompt", + arguments = null, + ) + server.addPrompt(testPrompt) { + GetPromptResult( + description = "Test prompt description", + messages = listOf(), + ) + } + + client.listPrompts() shouldNotBeNull { + prompts shouldContain testPrompt + } + + // when + val result = server.removePrompt(testPrompt.name) + + // then + assertTrue(result, "Prompt should be removed successfully") + val mcpException = shouldThrow { + client.getPrompt( + GetPromptRequest( + name = testPrompt.name, + arguments = null, + ), + ) + } + mcpException shouldHaveMessage "MCP error -32603: Prompt not found: ${testPrompt.name}" + + client.listPrompts() shouldNotBeNull { + prompts.firstOrNull { it.name == testPrompt.name } shouldBe null + } + } + + @Test + fun `Should remove multiple prompts and send notification`() = runTest { + // Add prompts + val testPrompt1 = Prompt("test-prompt-1", "Test Prompt 1", null) + val testPrompt2 = Prompt("test-prompt-2", "Test Prompt 2", null) + server.addPrompt(testPrompt1) { + GetPromptResult( + description = "Test prompt description 1", + messages = listOf(), + ) + } + server.addPrompt(testPrompt2) { + GetPromptResult( + description = "Test prompt description 2", + messages = listOf(), + ) + } + + client.listPrompts() shouldNotBeNull { + prompts shouldHaveSize 2 + } + // Remove the prompts + val result = server.removePrompts(listOf(testPrompt1.name, testPrompt2.name)) + + // Verify the prompts were removed + assertEquals(2, result, "Both prompts should be removed") + client.listPrompts() shouldNotBeNull { + prompts.shouldBeEmpty() + } + } + + @Test + fun `removePrompt should return false when prompt does not exist`() = runTest { + // Track notifications + var promptListChangedNotificationReceived = false + client.setNotificationHandler( + Method.Defined.NotificationsPromptsListChanged, + ) { + promptListChangedNotificationReceived = true + CompletableDeferred(Unit) + } + + // Try to remove a non-existent prompt + val result = server.removePrompt("non-existent-prompt") + + // Verify the result + assertFalse(result, "Removing non-existent prompt should return false") + assertFalse(promptListChangedNotificationReceived, "No notification should be sent when prompt doesn't exist") + } + + @Nested + inner class NoPromptsCapabilitiesTests { + // Create server without prompts capability + val serverWithoutPrompts = Server( + Implementation(name = "test server", version = "1.0"), + ServerOptions( + capabilities = ServerCapabilities(), + ), + ) + + @Test + fun `RemovePrompt should throw when prompts capability is not supported`() = runTest { + // Verify that removing a prompt throws an exception + val exception = assertThrows { + serverWithoutPrompts.removePrompt("test-prompt") + } + assertEquals("Server does not support prompts capability.", exception.message) + } + + @Test + fun `Remove Prompts should throw when prompts capability is not supported`() = runTest { + // Verify that removing a prompt throws an exception + val exception = assertThrows { + serverWithoutPrompts.removePrompts(emptyList()) + } + assertEquals("Server does not support prompts capability.", exception.message) + } + + @Test + fun `Add Prompt should throw when prompts capability is not supported`() = runTest { + // Verify that removing a prompt throws an exception + val exception = assertThrows { + serverWithoutPrompts.addPrompt(name = "test-prompt") { + GetPromptResult( + description = "Test prompt description", + messages = listOf(), + ) + } + } + assertEquals("Server does not support prompts capability.", exception.message) + } + + @Test + fun `Add Prompts should throw when prompts capability is not supported`() = runTest { + // Verify that removing a prompt throws an exception + val exception = assertThrows { + serverWithoutPrompts.addPrompts(emptyList()) + } + assertEquals("Server does not support prompts capability.", exception.message) + } + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaServerResourcesTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaServerResourcesTest.kt new file mode 100644 index 0000000..90cb9a0 --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaServerResourcesTest.kt @@ -0,0 +1,135 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.Method +import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.TextResourceContents +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OldSchemaServerResourcesTest : OldSchemaAbstractServerFeaturesTest() { + + override fun getServerCapabilities(): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities = + ServerCapabilities( + resources = ServerCapabilities.Resources(null, null), + ) + + @Test + fun `removeResource should remove a resource and send notification`() = runTest { + // Add a resource + val testResourceUri = "test://resource" + server.addResource( + uri = testResourceUri, + name = "Test Resource", + description = "A test resource", + mimeType = "text/plain", + ) { + ReadResourceResult( + contents = listOf( + TextResourceContents( + text = "Test resource content", + uri = testResourceUri, + mimeType = "text/plain", + ), + ), + ) + } + + // Remove the resource + val result = server.removeResource(testResourceUri) + + // Verify the resource was removed + assertTrue(result, "Resource should be removed successfully") + } + + @Test + fun `removeResources should remove multiple resources and send notification`() = runTest { + // Add resources + val testResourceUri1 = "test://resource1" + val testResourceUri2 = "test://resource2" + server.addResource( + uri = testResourceUri1, + name = "Test Resource 1", + description = "A test resource 1", + mimeType = "text/plain", + ) { + ReadResourceResult( + contents = listOf( + TextResourceContents( + text = "Test resource content 1", + uri = testResourceUri1, + mimeType = "text/plain", + ), + ), + ) + } + server.addResource( + uri = testResourceUri2, + name = "Test Resource 2", + description = "A test resource 2", + mimeType = "text/plain", + ) { + ReadResourceResult( + contents = listOf( + TextResourceContents( + text = "Test resource content 2", + uri = testResourceUri2, + mimeType = "text/plain", + ), + ), + ) + } + + // Remove the resources + val result = server.removeResources(listOf(testResourceUri1, testResourceUri2)) + + // Verify the resources were removed + assertEquals(2, result, "Both resources should be removed") + } + + @Test + fun `removeResource should return false when resource does not exist`() = runTest { + // Track notifications + var resourceListChangedNotificationReceived = false + client.setNotificationHandler( + Method.Defined.NotificationsResourcesListChanged, + ) { + resourceListChangedNotificationReceived = true + CompletableDeferred(Unit) + } + + // Try to remove a non-existent resource + val result = server.removeResource("non-existent-resource") + + // Verify the result + assertFalse(result, "Removing non-existent resource should return false") + assertFalse( + resourceListChangedNotificationReceived, + "No notification should be sent when resource doesn't exist", + ) + } + + @Test + fun `removeResource should throw when resources capability is not supported`() = runTest { + // Create server without resources capability + val serverOptions = ServerOptions( + capabilities = ServerCapabilities(), + ) + val server = Server( + Implementation(name = "test server", version = "1.0"), + serverOptions, + ) + + // Verify that removing a resource throws an exception + val exception = assertThrows { + server.removeResource("test://resource") + } + assertEquals("Server does not support resources capability.", exception.message) + } +} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaServerToolsTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaServerToolsTest.kt new file mode 100644 index 0000000..b956e2c --- /dev/null +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaServerToolsTest.kt @@ -0,0 +1,91 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.Method +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.TextContent +import io.modelcontextprotocol.kotlin.sdk.Tool +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OldSchemaServerToolsTest : OldSchemaAbstractServerFeaturesTest() { + + override fun getServerCapabilities(): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities = + ServerCapabilities( + tools = ServerCapabilities.Tools(null), + ) + + @Test + fun `removeTool should remove a tool`() = runTest { + // Add a tool + server.addTool("test-tool", "Test Tool", Tool.Input()) { + CallToolResult(listOf(TextContent("Test result"))) + } + + // Remove the tool + val result = server.removeTool("test-tool") + + // Verify the tool was removed + assertTrue(result, "Tool should be removed successfully") + } + + @Test + fun `removeTool should return false when tool does not exist`() = runTest { + // Track notifications + var toolListChangedNotificationReceived = false + client.setNotificationHandler( + Method.Defined.NotificationsToolsListChanged, + ) { + toolListChangedNotificationReceived = true + CompletableDeferred(Unit) + } + + // Try to remove a non-existent tool + val result = server.removeTool("non-existent-tool") + + // Verify the result + assertFalse(result, "Removing non-existent tool should return false") + assertFalse(toolListChangedNotificationReceived, "No notification should be sent when tool doesn't exist") + } + + @Test + fun `removeTool should throw when tools capability is not supported`() = runTest { + // Create server without tools capability + val serverOptions = ServerOptions( + capabilities = ServerCapabilities(), + ) + val server = Server( + Implementation(name = "test server", version = "1.0"), + serverOptions, + ) + + // Verify that removing a tool throws an exception + val exception = assertThrows { + server.removeTool("test-tool") + } + assertEquals("Server does not support tools capability.", exception.message) + } + + @Test + fun `removeTools should remove multiple tools`() = runTest { + // Add tools + server.addTool("test-tool-1", "Test Tool 1") { + CallToolResult(listOf(TextContent("Test result 1"))) + } + server.addTool("test-tool-2", "Test Tool 2") { + CallToolResult(listOf(TextContent("Test result 2"))) + } + + // Remove the tools + val result = server.removeTools(listOf("test-tool-1", "test-tool-2")) + + // Verify the tools were removed + assertEquals(2, result, "Both tools should be removed") + } +} 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 deleted file mode 100644 index 52f6908..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerBulkFeaturesTest.kt +++ /dev/null @@ -1,257 +0,0 @@ -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 -import io.kotest.matchers.shouldBe -import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult -import io.modelcontextprotocol.kotlin.sdk.types.Prompt -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.Tool -import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Test - -class ServerBulkFeaturesTest : AbstractServerFeaturesTest() { - - override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = null), - prompts = ServerCapabilities.Prompts(listChanged = null), - resources = ServerCapabilities.Resources(listChanged = null, subscribe = null), - ) - - // ── addTools ─────────────────────────────────────────────────────────────── - - @Test - fun `addTools should register all provided tools`() = runTest { - server.addTools( - listOf( - RegisteredTool(Tool("bulk-a", ToolSchema(), "Tool A")) { CallToolResult(emptyList()) }, - RegisteredTool(Tool("bulk-b", ToolSchema(), "Tool B")) { CallToolResult(emptyList()) }, - RegisteredTool(Tool("bulk-c", ToolSchema(), "Tool C")) { CallToolResult(emptyList()) }, - ), - ) - - val tools = client.listTools().tools - - tools shouldHaveSize 3 - tools.map { it.name } shouldContainExactlyInAnyOrder listOf("bulk-a", "bulk-b", "bulk-c") - } - - @Test - fun `addTools should append to existing tools`() = runTest { - server.addTool("existing", "Existing") { CallToolResult(emptyList()) } - server.addTools( - listOf( - RegisteredTool(Tool("new-a", ToolSchema())) { CallToolResult(emptyList()) }, - RegisteredTool(Tool("new-b", ToolSchema())) { CallToolResult(emptyList()) }, - ), - ) - - val tools = client.listTools().tools - - tools shouldHaveSize 3 - 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 - fun `addPrompts should register all provided prompts`() = runTest { - server.addPrompts( - listOf( - RegisteredPrompt(Prompt("bulk-p1", "Prompt 1")) { GetPromptResult(messages = emptyList()) }, - RegisteredPrompt(Prompt("bulk-p2", "Prompt 2")) { GetPromptResult(messages = emptyList()) }, - ), - ) - - val prompts = client.listPrompts().prompts - - prompts shouldHaveSize 2 - prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("bulk-p1", "bulk-p2") - } - - @Test - fun `addPrompts should append to existing prompts`() = runTest { - server.addPrompt("existing-prompt", "Existing") { GetPromptResult(messages = emptyList()) } - server.addPrompts( - listOf( - RegisteredPrompt(Prompt("new-p")) { GetPromptResult(messages = emptyList()) }, - ), - ) - - val prompts = client.listPrompts().prompts - - prompts shouldHaveSize 2 - 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 - fun `addResources should register all provided resources`() = runTest { - server.addResources( - listOf( - RegisteredResource(Resource("test://bulk-r1", "Resource 1")) { ReadResourceResult(emptyList()) }, - RegisteredResource(Resource("test://bulk-r2", "Resource 2")) { ReadResourceResult(emptyList()) }, - ), - ) - - val resources = client.listResources().resources - - resources shouldHaveSize 2 - resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://bulk-r1", "test://bulk-r2") - } - - @Test - fun `addResources should append to existing resources`() = runTest { - server.addResource("test://existing-res", "Existing", "Existing resource") { - ReadResourceResult(emptyList()) - } - server.addResources( - listOf( - RegisteredResource(Resource("test://new-res", "New")) { ReadResourceResult(emptyList()) }, - ), - ) - - val resources = client.listResources().resources - - resources shouldHaveSize 2 - 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 - fun `removeTools should return count of actually removed tools`() = runTest { - server.addTool("present", "Present") { CallToolResult(emptyList()) } - client.listTools().tools shouldHaveSize 1 - - val count = server.removeTools(listOf("present", "absent-1", "absent-2")) - - count shouldBe 1 - client.listTools().tools.shouldBeEmpty() - } - - @Test - fun `removeTools on empty list should return zero`() = runTest { - val count = server.removeTools(emptyList()) - - count shouldBe 0 - } - - @Test - fun `removePrompts should return count of actually removed prompts`() = runTest { - server.addPrompt("present-p", "Present") { GetPromptResult(messages = emptyList()) } - client.listPrompts().prompts shouldHaveSize 1 - - val count = server.removePrompts(listOf("present-p", "absent-p")) - - count shouldBe 1 - client.listPrompts().prompts.shouldBeEmpty() - } - - @Test - fun `removePrompts on empty list should return zero`() = runTest { - val count = server.removePrompts(emptyList()) - - count shouldBe 0 - } - - @Test - fun `removeResources should return count of actually removed resources`() = runTest { - server.addResource("test://present-r", "Present", "Present resource") { - ReadResourceResult(emptyList()) - } - client.listResources().resources shouldHaveSize 1 - - val count = server.removeResources(listOf("test://present-r", "test://absent-r")) - - count shouldBe 1 - client.listResources().resources.shouldBeEmpty() - } - - @Test - fun `removeResources on empty list should return zero`() = runTest { - val count = server.removeResources(emptyList()) - - count shouldBe 0 - } -} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerFeaturesInvocationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerFeaturesInvocationTest.kt deleted file mode 100644 index 52db367..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerFeaturesInvocationTest.kt +++ /dev/null @@ -1,222 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.assertions.assertSoftly -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNotBe -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.GetPromptRequest -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult -import io.modelcontextprotocol.kotlin.sdk.types.McpException -import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage -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.Role -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonPrimitive -import org.junit.jupiter.api.Test -import kotlin.test.assertFailsWith - -class ServerFeaturesInvocationTest : AbstractServerFeaturesTest() { - - override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = null), - prompts = ServerCapabilities.Prompts(listChanged = null), - resources = ServerCapabilities.Resources(listChanged = null, subscribe = null), - ) - - // ── Tool invocation ──────────────────────────────────────────────────────── - - @Test - fun `callTool should return tool result content`() = runTest { - server.addTool("echo", "Echo tool") { - CallToolResult(listOf(TextContent("echo response"))) - } - - val result = client.callTool(CallToolRequest(CallToolRequestParams("echo"))) - - assertSoftly(result) { - isError shouldNotBe true - (content.single() as TextContent).text shouldBe "echo response" - } - } - - @Test - fun `callTool should pass arguments to handler`() = runTest { - server.addTool("greet", "Greeting tool") { request -> - val name = request.params.arguments?.get("name")?.jsonPrimitive?.content ?: "stranger" - CallToolResult(listOf(TextContent("Hello, $name!"))) - } - - val result = client.callTool( - CallToolRequest( - CallToolRequestParams( - name = "greet", - arguments = JsonObject(mapOf("name" to JsonPrimitive("World"))), - ), - ), - ) - - (result.content.single() as TextContent).text shouldBe "Hello, World!" - } - - @Test - fun `callTool should return error result when tool not found`() = runTest { - val result = client.callTool(CallToolRequest(CallToolRequestParams("nonexistent"))) - - assertSoftly(result) { - isError shouldBe true - (content.single() as TextContent).text shouldBe "Tool nonexistent not found" - } - } - - @Test - fun `callTool should return error result when handler throws`() = runTest { - server.addTool("failing", "Failing tool") { - throw IllegalStateException("handler failure") - } - - val result = client.callTool(CallToolRequest(CallToolRequestParams("failing"))) - - assertSoftly(result) { - isError shouldBe true - (content.single() as TextContent).text shouldBe "Error executing tool failing: handler failure" - } - } - - @Test - fun `callTool should pass empty arguments map to handler`() = runTest { - server.addTool("greet", "Greeting tool") { request -> - val name = request.params.arguments?.get("name")?.jsonPrimitive?.content ?: "stranger" - CallToolResult(listOf(TextContent("Hello, $name!"))) - } - - val result = client.callTool( - CallToolRequest( - CallToolRequestParams( - name = "greet", - arguments = JsonObject(emptyMap()), - ), - ), - ) - - (result.content.single() as TextContent).text shouldBe "Hello, stranger!" - } - - // ── Prompt invocation ────────────────────────────────────────────────────── - - @Test - fun `getPrompt should return prompt description and messages`() = runTest { - server.addPrompt("my-prompt", "My prompt") { - GetPromptResult( - description = "Prompt result description", - messages = listOf( - PromptMessage(role = Role.User, content = TextContent("User message")), - ), - ) - } - - val result = client.getPrompt(GetPromptRequest(GetPromptRequestParams("my-prompt"))) - - assertSoftly(result) { - description shouldBe "Prompt result description" - messages shouldHaveSize 1 - (messages.single().content as TextContent).text shouldBe "User message" - } - } - - @Test - fun `getPrompt should pass arguments to handler`() = runTest { - server.addPrompt("templated", "Templated prompt") { request -> - val topic = request.params.arguments?.get("topic") ?: "unknown" - GetPromptResult( - messages = listOf( - PromptMessage(role = Role.User, content = TextContent("Tell me about $topic")), - ), - ) - } - - val result = client.getPrompt( - GetPromptRequest( - GetPromptRequestParams( - name = "templated", - arguments = mapOf("topic" to "Kotlin coroutines"), - ), - ), - ) - - (result.messages.single().content as TextContent).text shouldBe "Tell me about Kotlin coroutines" - } - - @Test - fun `getPrompt should use default when arguments are absent`() = runTest { - server.addPrompt("templated", "Templated prompt") { request -> - val topic = request.params.arguments?.get("topic") ?: "unknown" - GetPromptResult( - messages = listOf( - PromptMessage(role = Role.User, content = TextContent("Tell me about $topic")), - ), - ) - } - - val result = client.getPrompt(GetPromptRequest(GetPromptRequestParams("templated"))) - - (result.messages.single().content as TextContent).text shouldBe "Tell me about unknown" - } - - @Test - fun `getPrompt should throw when prompt not found`() = runTest { - assertFailsWith { - client.getPrompt(GetPromptRequest(GetPromptRequestParams("nonexistent"))) - } - } - - // ── Resource invocation ──────────────────────────────────────────────────── - - @Test - fun `readResource should return resource content`() = runTest { - val uri = "test://my-resource" - server.addResource(uri, "My Resource", "Test resource") { - ReadResourceResult( - contents = listOf(TextResourceContents(text = "resource content", uri = uri)), - ) - } - - val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri))) - - (result.contents.single() as TextResourceContents).text shouldBe "resource content" - } - - @Test - fun `readResource should pass request URI to handler`() = runTest { - val uri = "test://uri-check" - val receivedUri = CompletableDeferred() - server.addResource(uri, "URI Check", "Test") { request -> - receivedUri.complete(request.params.uri) - ReadResourceResult( - contents = listOf(TextResourceContents(text = "ok", uri = uri)), - ) - } - - client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri))) - - receivedUri.await() shouldBe uri - } - - @Test - fun `readResource should throw when resource not found`() = runTest { - assertFailsWith { - client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://nonexistent"))) - } - } -} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerFeaturesListTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerFeaturesListTest.kt deleted file mode 100644 index e6aca7f..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerFeaturesListTest.kt +++ /dev/null @@ -1,221 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.assertions.assertSoftly -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult -import io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesRequest -import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument -import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult -import io.modelcontextprotocol.kotlin.sdk.types.Resource -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Test - -class ServerFeaturesListTest : AbstractServerFeaturesTest() { - - override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = null), - prompts = ServerCapabilities.Prompts(listChanged = null), - resources = ServerCapabilities.Resources(listChanged = null, subscribe = null), - ) - - // ── listTools ────────────────────────────────────────────────────────────── - - @Test - fun `listTools should return empty list when no tools registered`() = runTest { - client.listTools().tools.shouldBeEmpty() - } - - @Test - fun `listTools should return all registered tools`() = runTest { - server.addTool("alpha", "First tool") { CallToolResult(emptyList()) } - server.addTool("beta", "Second tool") { CallToolResult(emptyList()) } - - val tools = client.listTools().tools - - tools shouldHaveSize 2 - tools.map { it.name } shouldContainExactlyInAnyOrder listOf("alpha", "beta") - } - - @Test - fun `listTools should expose tool name and description`() = runTest { - server.addTool("my-tool", "Detailed description") { CallToolResult(emptyList()) } - - val tool = client.listTools().tools.single() - - assertSoftly(tool) { - name shouldBe "my-tool" - description shouldBe "Detailed description" - } - } - - @Test - fun `listTools should reflect tool removal`() = runTest { - server.addTool("keep", "Kept") { CallToolResult(emptyList()) } - server.addTool("drop", "Dropped") { CallToolResult(emptyList()) } - client.listTools().tools shouldHaveSize 2 - - server.removeTool("drop") shouldBe true - - val tools = client.listTools().tools - - tools shouldHaveSize 1 - tools.single().name shouldBe "keep" - } - - @Test - fun `removeTool should return false when tool not found`() = runTest { - server.removeTool("ghost") shouldBe false - } - - // ── listPrompts ──────────────────────────────────────────────────────────── - - @Test - fun `listPrompts should return empty list when no prompts registered`() = runTest { - client.listPrompts().prompts.shouldBeEmpty() - } - - @Test - fun `listPrompts should return all registered prompts`() = runTest { - server.addPrompt("prompt-a", "First prompt") { GetPromptResult(messages = emptyList()) } - server.addPrompt("prompt-b", "Second prompt") { GetPromptResult(messages = emptyList()) } - - val prompts = client.listPrompts().prompts - - prompts shouldHaveSize 2 - prompts.map { it.name } shouldContainExactlyInAnyOrder listOf("prompt-a", "prompt-b") - } - - @Test - fun `listPrompts should expose prompt description`() = runTest { - server.addPrompt("my-prompt", "Helpful context prompt") { GetPromptResult(messages = emptyList()) } - - val prompt = client.listPrompts().prompts.single() - - assertSoftly(prompt) { - name shouldBe "my-prompt" - description shouldBe "Helpful context prompt" - } - } - - @Test - fun `listPrompts should include prompt arguments`() = runTest { - val args = listOf( - PromptArgument(name = "topic", description = "The topic to discuss", required = true), - PromptArgument(name = "tone", description = "Tone of voice", required = false), - ) - server.addPrompt("templated", "A templated prompt", args) { GetPromptResult(messages = emptyList()) } - - val prompt = client.listPrompts().prompts.single() - - val promptArgs = prompt.arguments.shouldNotBeNull() - promptArgs shouldHaveSize 2 - promptArgs.map { it.name } shouldContainExactlyInAnyOrder listOf("topic", "tone") - promptArgs.first { it.name == "topic" }.required shouldBe true - promptArgs.first { it.name == "tone" }.required shouldBe false - } - - @Test - fun `listPrompts should expose null description`() = runTest { - server.addPrompt("no-desc", null) { GetPromptResult(messages = emptyList()) } - - client.listPrompts().prompts.single().description shouldBe null - } - - @Test - fun `listPrompts should reflect prompt removal`() = runTest { - server.addPrompt("keep", "Kept") { GetPromptResult(messages = emptyList()) } - server.addPrompt("drop", "Dropped") { GetPromptResult(messages = emptyList()) } - client.listPrompts().prompts shouldHaveSize 2 - - server.removePrompt("drop") shouldBe true - - val prompts = client.listPrompts().prompts - - prompts shouldHaveSize 1 - prompts.single().name shouldBe "keep" - } - - @Test - fun `removePrompt should return false when prompt not found`() = runTest { - server.removePrompt("ghost") shouldBe false - } - - // ── listResources ────────────────────────────────────────────────────────── - - @Test - fun `listResources should return empty list when no resources registered`() = runTest { - client.listResources().resources.shouldBeEmpty() - } - - @Test - fun `listResources should return all registered resources`() = runTest { - server.addResource("test://res-1", "Resource 1", "First") { ReadResourceResult(emptyList()) } - server.addResource("test://res-2", "Resource 2", "Second") { ReadResourceResult(emptyList()) } - - val resources = client.listResources().resources - - resources shouldHaveSize 2 - resources.map { it.uri } shouldContainExactlyInAnyOrder listOf("test://res-1", "test://res-2") - } - - @Test - fun `listResources should expose resource metadata`() = runTest { - server.addResource("test://my-res", "My Resource", "A detailed resource", "application/json") { - ReadResourceResult(emptyList()) - } - - val resource = client.listResources().resources.single() - - assertSoftly(resource) { - uri shouldBe "test://my-res" - name shouldBe "My Resource" - description shouldBe "A detailed resource" - mimeType shouldBe "application/json" - } - } - - @Test - fun `listResources should expose resource without mimeType`() = runTest { - server.addResources( - listOf( - RegisteredResource(Resource("test://no-mime", "No Mime")) { - ReadResourceResult(emptyList()) - }, - ), - ) - - client.listResources().resources.single().mimeType shouldBe null - } - - @Test - fun `listResources should reflect resource removal`() = runTest { - server.addResource("test://keep", "Keep", "Kept") { ReadResourceResult(emptyList()) } - server.addResource("test://drop", "Drop", "Dropped") { ReadResourceResult(emptyList()) } - client.listResources().resources shouldHaveSize 2 - - server.removeResource("test://drop") shouldBe true - - val resources = client.listResources().resources - - resources shouldHaveSize 1 - resources.single().uri shouldBe "test://keep" - } - - @Test - fun `removeResource should return false when resource not found`() = runTest { - server.removeResource("test://ghost") shouldBe false - } - - // ── listResourceTemplates ────────────────────────────────────────────────── - - @Test - fun `listResourceTemplates should return empty list`() = runTest { - client.listResourceTemplates(ListResourceTemplatesRequest()).resourceTemplates.shouldBeEmpty() - } -} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerHandlerNotificationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerHandlerNotificationTest.kt deleted file mode 100644 index 655d1f1..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerHandlerNotificationTest.kt +++ /dev/null @@ -1,124 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.matchers.shouldBe -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.GetPromptRequest -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult -import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel -import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification -import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams -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.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import org.junit.jupiter.api.Test - -class ServerHandlerNotificationTest : AbstractServerFeaturesTest() { - - override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(true), - prompts = ServerCapabilities.Prompts(true), - resources = ServerCapabilities.Resources(true, null), - logging = JsonObject(emptyMap()), - ) - - @Test - fun `tool handler can send notification to client`() = runTest { - val notificationReceived = CompletableDeferred() - client.setNotificationHandler(Method.Defined.NotificationsMessage) { notification -> - notificationReceived.complete(notification) - CompletableDeferred(Unit) - } - - val notificationText = "Notification from tool" - server.addTool("notify-tool", "A tool that notifies") { - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = LoggingLevel.Info, - data = JsonPrimitive(notificationText), - ), - ), - ) - CallToolResult(listOf(TextContent("Tool executed"))) - } - - val result = client.callTool(CallToolRequest(CallToolRequestParams(name = "notify-tool"))) - - (result.content[0] as TextContent).text shouldBe "Tool executed" - val received = notificationReceived.await() - received.params.level shouldBe LoggingLevel.Info - received.params.data shouldBe JsonPrimitive(notificationText) - } - - @Test - fun `prompt handler can send notification to client`() = runTest { - val notificationReceived = CompletableDeferred() - client.setNotificationHandler(Method.Defined.NotificationsMessage) { notification -> - notificationReceived.complete(notification) - CompletableDeferred(Unit) - } - - val notificationText = "Notification from prompt" - server.addPrompt("notify-prompt", "A prompt that notifies") { - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = LoggingLevel.Info, - data = JsonPrimitive(notificationText), - ), - ), - ) - GetPromptResult(messages = listOf(), description = "Prompt executed") - } - - val result = client.getPrompt(GetPromptRequest(GetPromptRequestParams(name = "notify-prompt"))) - - result.description shouldBe "Prompt executed" - val received = notificationReceived.await() - received.params.level shouldBe LoggingLevel.Info - received.params.data shouldBe JsonPrimitive(notificationText) - } - - @Test - fun `resource handler can send notification to client`() = runTest { - val notificationReceived = CompletableDeferred() - client.setNotificationHandler(Method.Defined.NotificationsMessage) { notification -> - notificationReceived.complete(notification) - CompletableDeferred(Unit) - } - - val resourceUri = "test://notify-resource" - val notificationText = "Notification from resource" - server.addResource(resourceUri, "Notify Resource", "A resource that notifies") { - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = LoggingLevel.Info, - data = JsonPrimitive(notificationText), - ), - ), - ) - ReadResourceResult( - contents = listOf(TextResourceContents(uri = resourceUri, text = "Resource read")), - ) - } - - val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams(uri = resourceUri))) - - (result.contents[0] as TextResourceContents).text shouldBe "Resource read" - val received = notificationReceived.await() - received.params.level shouldBe LoggingLevel.Info - received.params.data shouldBe JsonPrimitive(notificationText) - } -} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerInstructionsTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerInstructionsTest.kt index c289b53..53d0fd1 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerInstructionsTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerInstructionsTest.kt @@ -1,12 +1,12 @@ package io.modelcontextprotocol.kotlin.sdk.server import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions import io.modelcontextprotocol.kotlin.sdk.client.mcpClient import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertNull diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsNotificationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsNotificationTest.kt index 08687d9..3674401 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsNotificationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerPromptsNotificationTest.kt @@ -24,10 +24,8 @@ class ServerPromptsNotificationTest : AbstractServerFeaturesTest() { fun `addPrompt should send notification`() = runTest { // Configure notification handler val notifications = mutableListOf() - client.setNotificationHandler( - Method.Defined.NotificationsPromptsListChanged, - ) { notification -> - notifications.add(notification) + client.setNotificationHandler(Method.Defined.NotificationsPromptsListChanged) { + notifications.add(it) CompletableDeferred(Unit) } @@ -98,10 +96,8 @@ class ServerPromptsNotificationTest : AbstractServerFeaturesTest() { fun `notification should not be send when removed prompt does not exists`() = runTest { // Track notifications val notifications = mutableListOf() - client.setNotificationHandler( - Method.Defined.NotificationsPromptsListChanged, - ) { notification -> - notifications.add(notification) + client.setNotificationHandler(Method.Defined.NotificationsPromptsListChanged) { + notifications.add(it) CompletableDeferred(Unit) } 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..5f49d04 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 @@ -37,7 +34,7 @@ class ServerPromptsTest : AbstractServerFeaturesTest() { server.addPrompt(testPrompt) { GetPromptResult( description = "Test prompt description", - messages = emptyList(), + messages = listOf(), ) } @@ -73,13 +70,13 @@ class ServerPromptsTest : AbstractServerFeaturesTest() { server.addPrompt(testPrompt1) { GetPromptResult( description = "Test prompt description 1", - messages = emptyList(), + messages = listOf(), ) } server.addPrompt(testPrompt2) { GetPromptResult( description = "Test prompt description 2", - messages = emptyList(), + messages = listOf(), ) } @@ -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 deleted file mode 100644 index 8405469..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourceTemplateTest.kt +++ /dev/null @@ -1,241 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.matchers.collections.shouldBeEmpty -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 -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.ResourceTemplate -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows - -class ServerResourceTemplateTest : AbstractServerFeaturesTest() { - - override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities( - resources = ServerCapabilities.Resources(listChanged = null, subscribe = null), - ) - - @Test - fun `listResourceTemplates should return registered templates`() = runTest { - server.addResourceTemplate("test://data/{id}", "Test Data", mimeType = "text/plain") { _, _ -> - ReadResourceResult(listOf(TextResourceContents("content", "test://data/1"))) - } - - val result = client.listResourceTemplates(ListResourceTemplatesRequest()) - - result.resourceTemplates shouldHaveSize 1 - result.resourceTemplates[0] shouldNotBeNull { - uriTemplate shouldBe "test://data/{id}" - name shouldBe "Test Data" - mimeType shouldBe "text/plain" - } - } - - @Test - fun `listResourceTemplates should return empty list when none registered`() = runTest { - val result = client.listResourceTemplates(ListResourceTemplatesRequest()) - - result.resourceTemplates.shouldBeEmpty() - } - - @Test - fun `readResource should match URI against template and invoke handler`() = runTest { - server.addResourceTemplate("test://items/{itemId}", "Item", mimeType = "text/plain") { request, variables -> - val itemId = variables["itemId"] ?: "unknown" - ReadResourceResult( - listOf(TextResourceContents(text = "item=$itemId", uri = request.uri, mimeType = "text/plain")), - ) - } - - val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://items/42"))) - - result.contents shouldBe - listOf(TextResourceContents(uri = "test://items/42", mimeType = "text/plain", text = "item=42")) - } - - @Test - fun `readResource should extract multiple URI template variables`() = runTest { - val capturedVars = CompletableDeferred>() - server.addResourceTemplate( - uriTemplate = "test://users/{userId}/posts/{postId}", - name = "User Post", - mimeType = "text/plain", - ) { _, variables -> - capturedVars.complete(variables) - ReadResourceResult(listOf(TextResourceContents("ok", "test://users/alice/posts/99"))) - } - - client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://users/alice/posts/99"))) - - val vars = capturedVars.await() - vars shouldContainKey "userId" - vars shouldContainKey "postId" - vars["userId"] shouldBe "alice" - vars["postId"] shouldBe "99" - } - - @Test - fun `readResource should prefer exact resource match over template`() = runTest { - var exactHandlerCalled = false - server.addResource("test://items/special", "Special Item", "An exact resource") { - exactHandlerCalled = true - ReadResourceResult(listOf(TextResourceContents("exact", "test://items/special"))) - } - server.addResourceTemplate("test://items/{itemId}", "Item Template") { _, _ -> - ReadResourceResult(listOf(TextResourceContents("template", "test://items/special"))) - } - - val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://items/special"))) - - exactHandlerCalled shouldBe true - (result.contents[0] as TextResourceContents).text shouldBe "exact" - } - - @Test - fun `readResource should select most specific template when multiple match`() = runTest { - // "test://users/profile" has more literal chars than "test://users/{id}" — should win - server.addResourceTemplate("test://users/{id}", "Generic User") { _, variables -> - ReadResourceResult(listOf(TextResourceContents("generic:${variables["id"]}", "test://users/profile"))) - } - server.addResourceTemplate("test://users/profile", "Profile") { _, _ -> - ReadResourceResult(listOf(TextResourceContents("profile-page", "test://users/profile"))) - } - - val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://users/profile"))) - - (result.contents[0] as TextResourceContents).text shouldBe "profile-page" - } - - @Test - fun `readResource should return RESOURCE_NOT_FOUND error when no match`() = runTest { - val exception = assertThrows { - client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://nonexistent/uri"))) - } - - exception.code shouldBe RPCError.ErrorCode.RESOURCE_NOT_FOUND - } - - @Test - fun `resourceTemplates property should reflect registered templates`() { - server.addResourceTemplate(ResourceTemplate("test://a/{x}", "A")) { _, _ -> - ReadResourceResult(emptyList()) - } - server.addResourceTemplate(ResourceTemplate("test://b/{y}", "B")) { _, _ -> - ReadResourceResult(emptyList()) - } - - val templates = server.resourceTemplates - - templates shouldBe listOf( - ResourceTemplate("test://a/{x}", "A"), - ResourceTemplate("test://b/{y}", "B"), - ) - } - - @Test - fun `removeResourceTemplate should remove a registered template`() { - server.addResourceTemplate("test://items/{id}", "Item") { _, _ -> - ReadResourceResult(emptyList()) - } - - val removed = server.removeResourceTemplate("test://items/{id}") - - removed shouldBe true - server.resourceTemplates.size shouldBe 0 - } - - @Test - fun `removeResourceTemplate should return false when template does not exist`() { - val removed = server.removeResourceTemplate("test://nonexistent/{id}") - - 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( - serverInfo = Implementation("test", "1.0"), - options = ServerOptions(capabilities = ServerCapabilities()), - ) - - assertThrows { - noResourcesServer.addResourceTemplate("test://{id}", "Test") { _, _ -> - ReadResourceResult(emptyList()) - } - } - } - - @Test - fun `addResourceTemplate with ResourceTemplate object should register correctly`() = runTest { - val template = ResourceTemplate( - uriTemplate = "test://docs/{section}", - name = "Documentation", - description = "API docs", - mimeType = "text/html", - ) - server.addResourceTemplate(template) { request, variables -> - val section = variables["section"] ?: "index" - ReadResourceResult( - listOf(TextResourceContents("docs for $section", request.uri, mimeType = "text/html")), - ) - } - - val result = client.readResource(ReadResourceRequest(ReadResourceRequestParams("test://docs/api"))) - - result.contents shouldHaveSize 1 - (result.contents[0] as TextResourceContents).text shouldBe "docs for api" - } - - @Test - fun `listResourceTemplates should include description from template`() = runTest { - server.addResourceTemplate( - uriTemplate = "test://data/{id}", - name = "Data", - description = "Parameterized data resource", - mimeType = "application/json", - ) { _, _ -> - ReadResourceResult(emptyList()) - } - - val result = client.listResourceTemplates(ListResourceTemplatesRequest()) - - result.resourceTemplates shouldBe listOf( - ResourceTemplate( - name = "Data", - uriTemplate = "test://data/{id}", - description = "Parameterized data resource", - mimeType = "application/json", - ), - ) - } -} diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationSubscribeTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationSubscribeTest.kt index 06fa962..1e102a7 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationSubscribeTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationSubscribeTest.kt @@ -1,10 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.server -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.assertions.withClue -import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe import io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsResourcesUpdated import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification @@ -13,11 +9,13 @@ import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.awaitility.kotlin.await import org.awaitility.kotlin.untilAsserted import org.junit.jupiter.api.Test import java.util.concurrent.ConcurrentLinkedQueue +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds import kotlin.uuid.ExperimentalUuidApi @@ -50,12 +48,12 @@ class ServerResourcesNotificationSubscribeTest : AbstractServerFeaturesTest() { name = "Test Resource 1", description = "A test resource 1", mimeType = "text/plain", - ) { request -> + ) { ReadResourceResult( contents = listOf( TextResourceContents( text = "Test resource content 1", - uri = request.uri, + uri = it.uri, mimeType = "text/plain", ), ), @@ -67,12 +65,12 @@ class ServerResourcesNotificationSubscribeTest : AbstractServerFeaturesTest() { name = "Test Resource 2", description = "A test resource 2", mimeType = "text/plain", - ) { request -> + ) { ReadResourceResult( contents = listOf( TextResourceContents( text = "Test resource content 2", - uri = request.uri, + uri = it.uri, mimeType = "text/plain", ), ), @@ -117,26 +115,24 @@ class ServerResourcesNotificationSubscribeTest : AbstractServerFeaturesTest() { } @Test - fun `notification should not be send when removed resource does not exists`(): Unit = runBlocking { + fun `notification should not be send when removed resource does not exists`() = runBlocking { println("Thread ${Thread.currentThread().name} test 1") // Track notifications val notifications = ConcurrentLinkedQueue() - client.setNotificationHandler(NotificationsResourcesUpdated) { notification -> - notifications.add(notification) + client.setNotificationHandler(NotificationsResourcesUpdated) { + notifications.add(it) CompletableDeferred(Unit) } // Try to remove a non-existent resource val result = server.removeResource("non-existent-resource") - withClue("Removing non-existent resource should return false") { - result shouldBe false - } + assertFalse(result, "Removing non-existent resource should return false") - // Verify no notification is sent (use eventually for cross-platform reliability) - withClue("No notification should be sent when resource doesn't exist") { - eventually(2.seconds) { - notifications.shouldBeEmpty() - } - } + // Verify the result + delay(1.seconds) + assertTrue( + notifications.isEmpty(), + "No notification should be sent when resource doesn't exist", + ) } } diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationTest.kt index f007723..6337479 100644 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationTest.kt +++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationTest.kt @@ -26,8 +26,8 @@ class ServerResourcesNotificationTest : AbstractServerFeaturesTest() { val notifications = mutableListOf() client.setNotificationHandler( Method.Defined.NotificationsResourcesListChanged, - ) { notification -> - notifications.add(notification) + ) { + notifications.add(it) CompletableDeferred(Unit) } 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 deleted file mode 100644 index 38b169a..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt +++ /dev/null @@ -1,194 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport -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.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 -import kotlinx.coroutines.Dispatchers -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 - -class ServerSessionInitializeTest { - - private fun createSession(): ServerSession = ServerSession( - serverInfo = Implementation(name = "test-server", version = "1.0"), - options = ServerOptions(capabilities = ServerCapabilities()), - instructions = null, - ) - - private fun createInitializeRequest(clientName: String = "test-client"): InitializeRequest = InitializeRequest( - InitializeRequestParams( - protocolVersion = LATEST_PROTOCOL_VERSION, - capabilities = ClientCapabilities(), - clientInfo = Implementation(name = clientName, version = "1.0"), - ), - ) - - 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() - val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() - - assertNull(session.clientCapabilities) - assertNull(session.clientVersion) - - val responseDone = CompletableDeferred() - clientTransport.onMessage { message -> - if (message is JSONRPCResponse) { - responseDone.complete(message) - } - } - - session.connect(serverTransport) - clientTransport.send(createInitializeRequest().toJSON()) - - val response = responseDone.await() - assertNotNull(response.result) - assertNotNull(session.clientCapabilities) - 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() - val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() - - val responses = CopyOnWriteArrayList() - val secondResponseDone = CompletableDeferred() - - clientTransport.onMessage { message -> - when (message) { - is JSONRPCResponse, is JSONRPCError -> { - responses.add(message) - if (responses.size == 2) secondResponseDone.complete(Unit) - } - - else -> {} - } - } - - session.connect(serverTransport) - - // First initialize should succeed - clientTransport.send(createInitializeRequest(clientName = "first-client").toJSON()) - - // Second initialize should be rejected - clientTransport.send(createInitializeRequest(clientName = "second-client").toJSON()) - - secondResponseDone.await() - - assertEquals(2, responses.size) - assertTrue(responses[0] is JSONRPCResponse, "First response should be success") - assertTrue(responses[1] is JSONRPCError, "Second response should be error") - assertEquals(RPCError.ErrorCode.INVALID_REQUEST, (responses[1] as JSONRPCError).error.code) - - // Capabilities still reflect the first client, not overwritten - assertEquals("first-client", session.clientVersion?.name) - } - - @Test - fun `should reject concurrent initialize requests - only first succeeds`() = runTest { - val session = createSession() - val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() - - val n = 10 - val allResponsesDone = CompletableDeferred() - val successes = CopyOnWriteArrayList() - val errors = CopyOnWriteArrayList() - - clientTransport.onMessage { message -> - when (message) { - is JSONRPCResponse -> successes.add(message) - is JSONRPCError -> errors.add(message) - else -> {} - } - if (successes.size + errors.size == n) { - allResponsesDone.complete(Unit) - } - } - - session.connect(serverTransport) - - // Use Dispatchers.Default for true parallelism on JVM - withContext(Dispatchers.Default) { - val barrier = CompletableDeferred() - val jobs = (1..n).map { i -> - launch { - barrier.await() - clientTransport.send( - createInitializeRequest(clientName = "client-$i").toJSON(), - ) - } - } - barrier.complete(Unit) - jobs.joinAll() - } - - allResponsesDone.await() - - assertEquals(1, successes.size, "Exactly one initialize should succeed") - assertEquals(n - 1, errors.size, "All other initializes should be rejected") - errors.forEach { error -> - assertEquals(RPCError.ErrorCode.INVALID_REQUEST, error.error.code) - } - } -} 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..4cda75f 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 @@ -52,10 +51,8 @@ class ServerToolsNotificationTest : AbstractServerFeaturesTest() { fun `removeTools should remove multiple tools and send two notifications`() = runTest { // Configure notification handler val notifications = mutableListOf() - client.setNotificationHandler( - Method.Defined.NotificationsToolsListChanged, - ) { notification -> - notifications.add(notification) + client.setNotificationHandler(Method.Defined.NotificationsToolsListChanged) { + notifications.add(it) CompletableDeferred(Unit) } @@ -82,35 +79,12 @@ 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 val notifications = mutableListOf() - client.setNotificationHandler( - Method.Defined.NotificationsToolsListChanged, - ) { notification -> - notifications.add(notification) + client.setNotificationHandler(Method.Defined.NotificationsToolsListChanged) { + notifications.add(it) CompletableDeferred(Unit) } 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 deleted file mode 100644 index 4d798ce..0000000 --- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt +++ /dev/null @@ -1,75 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.testing - -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.nulls.shouldNotBeNull -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.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest -import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult -import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION -import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesRequest -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.runBlocking -import kotlin.test.Test - -@OptIn(ExperimentalMcpApi::class) -class ChannelTransportTest { - - @Test - fun `should connect and list resources`(): Unit = runBlocking { - val serverOptions = ServerOptions( - capabilities = ServerCapabilities( - resources = ServerCapabilities.Resources(), - ), - ) - val server = Server( - Implementation(name = "test server", version = "1.0"), - serverOptions, - ) - - val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() - - val client = Client( - clientInfo = Implementation(name = "test client", version = "1.0"), - ) - - val serverSession = server.createSession(serverTransport) - client.connect(clientTransport) - - serverSession.setRequestHandler(Method.Defined.Initialize) { _, _ -> - InitializeResult( - protocolVersion = LATEST_PROTOCOL_VERSION, - capabilities = ServerCapabilities( - resources = ServerCapabilities.Resources(null, null), - tools = ServerCapabilities.Tools(null), - ), - serverInfo = Implementation(name = "test", version = "1.0"), - ) - } - - serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> - ListResourcesResult( - resources = listOf( - Resource( - uri = "/foo/bar", - name = "foo-bar-resource", - ), - ), - ) - } - - // These should not throw - client.listResources() shouldNotBeNull { - this.resources shouldHaveSize 1 - } - - client.close() - server.close() - } -} diff --git a/integration-test/src/jvmTest/typescript/client/http-client.ts b/integration-test/src/jvmTest/typescript/client/sse-client.ts similarity index 67% rename from integration-test/src/jvmTest/typescript/client/http-client.ts rename to integration-test/src/jvmTest/typescript/client/sse-client.ts index 5956a37..0acc256 100644 --- a/integration-test/src/jvmTest/typescript/client/http-client.ts +++ b/integration-test/src/jvmTest/typescript/client/sse-client.ts @@ -2,14 +2,14 @@ import {Client} from '@modelcontextprotocol/sdk/client/index'; import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp'; const args = process.argv.slice(2); -const serverUrl = args[0] || 'http://localhost:3000/mcp'; +const serverUrl = args[0] || 'http://localhost:3001/mcp'; const toolName = args[1]; const toolArgs = args.slice(2); -const PROTOCOL_VERSION = "2025-11-25"; +const PROTOCOL_VERSION = "2024-11-05"; async function main() { if (!toolName) { - console.log('Usage: npx tsx client/http-client.ts [server-url] [tool-args...]'); + console.log('Usage: npx tsx client/sse-client.ts [server-url] [tool-args...]'); console.log('Using default server URL:', serverUrl); console.log('Available utils will be listed after connection'); } @@ -22,10 +22,6 @@ async function main() { const client = new Client({ name: 'test-client', version: '1.0.0' - }, { - fallbackNotificationHandler: async (notification) => { - console.log('Notification:', notification.method, JSON.stringify(notification)); - } }); const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); @@ -34,20 +30,37 @@ async function main() { await client.connect(transport, {protocolVersion: PROTOCOL_VERSION}); console.log('Connected to server'); + try { + if (typeof (client as any).on === 'function') { + (client as any).on('notification', (n: any) => { + try { + const method = (n && (n.method || (n.params && n.params.method))) || 'unknown'; + console.log('Notification:', method, JSON.stringify(n)); + } catch { + console.log('Notification: '); + } + }); + } + } catch { + } + const toolsResult = await client.listTools(); const tools = toolsResult.tools; - console.log('Available utils:', tools.map((t) => t.name).join(', ')); + console.log('Available utils:', tools.map((t: { name: any; }) => t.name).join(', ')); if (!toolName) { + await client.close(); return; } - const tool = tools.find((t) => t.name === toolName); + const tool = tools.find((t: { name: string; }) => t.name === toolName); if (!tool) { - throw new Error(`Tool "${toolName}" not found`); + console.error(`Tool "${toolName}" not found`); + // @ts-ignore + process.exit(1); } - const toolArguments: Record = {}; + const toolArguments = {}; if (toolName === "greet" && toolArgs.length > 0) { toolArguments["name"] = toolArgs[0]; @@ -80,7 +93,8 @@ async function main() { } catch (error) { console.error('Error:', error); - process.exitCode = 1; + // @ts-ignore + process.exit(1); } finally { await client.close(); console.log('Disconnected from server'); @@ -89,5 +103,6 @@ async function main() { main().catch(error => { console.error('Unhandled error:', error); + // @ts-ignore process.exit(1); }); diff --git a/integration-test/src/jvmTest/typescript/client/stdio-client.ts b/integration-test/src/jvmTest/typescript/client/stdio-client.ts index a9c765c..c6eaca6 100644 --- a/integration-test/src/jvmTest/typescript/client/stdio-client.ts +++ b/integration-test/src/jvmTest/typescript/client/stdio-client.ts @@ -3,7 +3,7 @@ import {Client} from '@modelcontextprotocol/sdk/client/index'; const args = process.argv.slice(2); const toolName = args[0]; const toolArgs = args.slice(1); -const PROTOCOL_VERSION = "2025-11-25"; +const PROTOCOL_VERSION = "2024-11-05"; async function main() { if (!toolName) { @@ -65,10 +65,6 @@ async function main() { const client = new Client({ name: 'test-client', version: '1.0.0' - }, { - fallbackNotificationHandler: async (notification) => { - console.error('Notification:', notification.method, JSON.stringify(notification)); - } }); const transport = new MinimalStdioClientTransport(); @@ -77,6 +73,21 @@ async function main() { await client.connect(transport, {protocolVersion: PROTOCOL_VERSION}); console.error('Connected to server over stdio'); + try { + if (typeof (client as any).on === 'function') { + (client as any).on('notification', (n: any) => { + try { + const method = (n && (n.method || (n.params && n.params.method))) || 'unknown'; + console.error('Notification:', method, JSON.stringify(n)); + } catch { + console.error('Notification: '); + } + }); + } + } catch { + // ignore + } + const toolsResult = await client.listTools(); const tools = toolsResult.tools; console.error('Available utils:', tools.map((t: { name: any; }) => t.name).join(', ')); @@ -88,9 +99,8 @@ async function main() { const tool = tools.find((t: { name: string; }) => t.name === toolName); if (!tool) { - // Don't call process.exit() here: it prevents finally{} from running, - // which breaks tests that expect a clean disconnect signal. - throw new Error(`Tool "${toolName}" not found`); + console.error(`Tool "${toolName}" not found`); + process.exit(1); } const toolArguments: any = {}; @@ -126,8 +136,7 @@ async function main() { } catch (error) { console.error('Error:', error); - // Don't hard-exit; allow finally{} to run so the client always closes cleanly. - process.exitCode = 1; + process.exit(1); } finally { await client.close(); console.error('Disconnected from server'); diff --git a/integration-test/src/jvmTest/typescript/package-lock.json b/integration-test/src/jvmTest/typescript/package-lock.json index ffb53fc..496bfa0 100644 --- a/integration-test/src/jvmTest/typescript/package-lock.json +++ b/integration-test/src/jvmTest/typescript/package-lock.json @@ -8,23 +8,27 @@ "name": "mcp-typescript-test-sdk", "version": "1.0.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.25.2", + "body-parser": "^2.2.2", "express": "^5.2.1", - "zod": "^4.6.2" + "uuid": "^13.0.0", + "zod": "^4.3.5" }, "devDependencies": { + "@eslint/js": "^9.39.2", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^26.5.1", - "cors": "^2.8.6", - "tsx": "^4.23.13", - "typescript": "^7.0.2" + "@types/node": "^24.10.6", + "@types/uuid": "^10.0.0", + "cors": "^2.8.5", + "tsx": "^4.21.0", + "typescript": "^5.9.3" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.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 +43,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 +60,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 +77,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 +94,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 +111,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 +128,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 +145,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 +162,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 +179,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 +196,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 +213,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 +230,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 +247,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 +264,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 +281,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 +298,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 +315,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 +332,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 +349,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 +366,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 +383,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 +400,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 +417,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 +434,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 +451,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" ], @@ -463,25 +467,38 @@ "node": ">=18" } }, - "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==", + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=20" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.8", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.8.tgz", + "integrity": "sha512-0/g2lIOPzX8f3vzW1ggQgvG5mjtFBDBHFAzI5SFAi2DzSqS9luJwqg9T6O/gKYLi+inS7eNxBeIFkkghIPvrMA==", + "license": "MIT", + "engines": { + "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.25.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", + "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", + "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -489,15 +506,14 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" + "zod-to-json-schema": "^3.25.0" }, "engines": { "node": ">=18" @@ -579,13 +595,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": "24.10.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.6.tgz", + "integrity": "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.9.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/qs": { @@ -623,345 +639,12 @@ "@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" - ], + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", "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" - } + "license": "MIT" }, "node_modules/accepts": { "version": "2.0.0", @@ -977,9 +660,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1010,20 +693,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 +716,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", @@ -1125,9 +795,9 @@ } }, "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -1135,10 +805,6 @@ }, "engines": { "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -1229,9 +895,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 +907,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 +920,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,14 +1028,10 @@ } }, "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": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, "engines": { "node": ">= 16" }, @@ -1387,9 +1049,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", @@ -1446,6 +1108,7 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -1501,6 +1164,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 +1202,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,10 +1214,11 @@ } }, "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.11.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz", + "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -1588,15 +1265,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "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==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1770,9 +1438,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", - "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "license": "MIT", "funding": { "type": "opencollective", @@ -1802,13 +1470,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.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "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 +1517,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 +1622,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 +1641,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 +1712,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 +1732,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": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "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.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, @@ -2135,6 +1775,19 @@ "node": ">= 0.8" } }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2166,9 +1819,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.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "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..24a1ac9 100644 --- a/integration-test/src/jvmTest/typescript/package.json +++ b/integration-test/src/jvmTest/typescript/package.json @@ -4,21 +4,25 @@ "private": true, "scripts": { "client:stdio": "tsx client/stdio-client.ts", - "client:http": "tsx client/http-client.ts http://localhost:3000/mcp greet Hello", + "client:sse": "tsx client/sse-client.ts http://localhost:3000/mcp greet Hello", "server:stdio": "tsx server/stdio-server.ts", - "server:http": "tsx server/http-server.ts" + "server:sse": "tsx server/sse-server.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.25.2", + "body-parser": "^2.2.2", "express": "^5.2.1", - "zod": "^4.6.2" + "uuid": "^13.0.0", + "zod": "^4.3.5" }, "devDependencies": { + "@eslint/js": "^9.39.2", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^26.5.1", - "cors": "^2.8.6", - "tsx": "^4.23.13", - "typescript": "^7.0.2" + "@types/node": "^24.10.6", + "@types/uuid": "^10.0.0", + "cors": "^2.8.5", + "tsx": "^4.21.0", + "typescript": "^5.9.3" } } diff --git a/integration-test/src/jvmTest/typescript/server/http-server.ts b/integration-test/src/jvmTest/typescript/server/sse-server.ts similarity index 80% rename from integration-test/src/jvmTest/typescript/server/http-server.ts rename to integration-test/src/jvmTest/typescript/server/sse-server.ts index c42e115..48ea453 100644 --- a/integration-test/src/jvmTest/typescript/server/http-server.ts +++ b/integration-test/src/jvmTest/typescript/server/sse-server.ts @@ -1,24 +1,30 @@ +// @ts-ignore import express, {Request, Response} from 'express'; import {randomUUID} from 'node:crypto'; +// @ts-ignore import cors from 'cors'; import {McpServer} from '@modelcontextprotocol/sdk/server/mcp'; -import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp'; +import {StreamableHTTPServerTransport,} from '@modelcontextprotocol/sdk/server/streamableHttp'; import {registerTestUtils} from './server-common'; import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router'; import {requireBearerAuth} from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth'; -import {isInitializeRequest} from '@modelcontextprotocol/sdk/types'; +import {isInitializeRequest,} from '@modelcontextprotocol/sdk/types'; import {InMemoryEventStore} from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore'; import {setupAuthServer} from '@modelcontextprotocol/sdk/examples/server/demoInMemoryOAuthProvider'; import {checkResourceAllowed} from '@modelcontextprotocol/sdk/shared/auth-utils'; import {OAuthMetadata} from "@modelcontextprotocol/sdk/shared/auth"; +// import {type OAuthMetadata} from '@modelcontextprotocol/sdk/auth.js'; + async function main() { + // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); const strictOAuth = process.argv.includes('--oauth-strict'); +// Create an MCP server with implementation details const getServer = () => { const server = new McpServer({ name: 'simple-streamable-http-server', @@ -36,15 +42,16 @@ async function main() { const app = express(); app.use(express.json()); - // Allow CORS all domains, expose the Mcp-Session-Id header +// Allow CORS all domains, expose the Mcp-Session-Id header app.use(cors({ - origin: '*', + origin: '*', // Allow all origins exposedHeaders: ["Mcp-Session-Id"] })); - // Set up OAuth if enabled +// Set up OAuth if enabled let authMiddleware = null; if (useOAuth) { + // Create auth middleware for MCP endpoints const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); @@ -72,6 +79,7 @@ async function main() { }).toString() }); + if (!response.ok) { throw new Error(`Invalid or expired token: ${await response.text()}`); } @@ -87,6 +95,7 @@ async function main() { } } + // Convert the response to AuthInfo format return { token, clientId: data.client_id, @@ -95,7 +104,7 @@ async function main() { }; } } - + // Add metadata routes to the main MCP server app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl, @@ -110,10 +119,10 @@ async function main() { }); } - // Map to store transports by session ID +// Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; - // MCP POST endpoint with optional auth +// MCP POST endpoint with optional auth const mcpPostHandler = async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (sessionId) { @@ -128,18 +137,23 @@ async function main() { try { let transport: StreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { + // Reuse existing transport transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request const eventStore = new InMemoryEventStore(); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), - eventStore, + eventStore, // Enable resumability onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored console.log(`Session initialized with ID: ${sessionId}`); transports[sessionId] = transport; } }); + // Set up onclose handler to clean up transport when closed transport.onclose = () => { const sid = transport.sessionId; if (sid && transports[sid]) { @@ -148,12 +162,15 @@ async function main() { } }; + // Connect the transport to the MCP server BEFORE handling the request + // so responses can flow back through the same transport const server = getServer(); await server.connect(transport); await transport.handleRequest(req, res, req.body); - return; + return; // Already handled } else { + // Invalid request - no session ID or not initialization request res.status(400).json({ jsonrpc: '2.0', error: { @@ -165,6 +182,8 @@ async function main() { return; } + // Handle the request with existing transport - no need to reconnect + // The existing transport is already connected to the server await transport.handleRequest(req, res, req.body); } catch (error) { console.error('Error handling MCP request:', error); @@ -181,13 +200,14 @@ async function main() { } }; +// Set up routes with conditional auth middleware if (useOAuth && authMiddleware) { app.post('/mcp', authMiddleware, mcpPostHandler); } else { app.post('/mcp', mcpPostHandler); } - // Handle GET requests for event streams +// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) const mcpGetHandler = async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { @@ -196,27 +216,29 @@ async function main() { } if (useOAuth && req.auth) { - console.log('Authenticated event stream connection from user:', req.auth); + console.log('Authenticated SSE connection from user:', req.auth); } + // Check for Last-Event-ID header for resumability const lastEventId = req.headers['last-event-id'] as string | undefined; if (lastEventId) { console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); } else { - console.log(`Establishing new event stream for session ${sessionId}`); + console.log(`Establishing new SSE stream for session ${sessionId}`); } const transport = transports[sessionId]; await transport.handleRequest(req, res); }; +// Set up GET route with conditional auth middleware if (useOAuth && authMiddleware) { app.get('/mcp', authMiddleware, mcpGetHandler); } else { app.get('/mcp', mcpGetHandler); } - // Handle DELETE requests for session termination (according to MCP spec) +// Handle DELETE requests for session termination (according to MCP spec) const mcpDeleteHandler = async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { @@ -237,6 +259,7 @@ async function main() { } }; +// Set up DELETE route with conditional auth middleware if (useOAuth && authMiddleware) { app.delete('/mcp', authMiddleware, mcpDeleteHandler); } else { @@ -251,10 +274,11 @@ async function main() { console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); }); - // Handle server shutdown +// Handle server shutdown process.on('SIGINT', async () => { console.log('Shutting down server...'); + // Close all active transports to properly clean up resources for (const sessionId in transports) { try { console.log(`Closing transport for session ${sessionId}`); @@ -267,6 +291,7 @@ async function main() { console.log('Server shutdown complete'); process.exit(0); }); + } main().catch((err) => { diff --git a/integration-test/src/jvmTest/typescript/tsconfig.json b/integration-test/src/jvmTest/typescript/tsconfig.json deleted file mode 100644 index 58e3c67..0000000 --- a/integration-test/src/jvmTest/typescript/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "strict": true, - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["**/*.ts"] -} diff --git a/knit.properties b/knit.properties deleted file mode 100644 index 1c74097..0000000 --- a/knit.properties +++ /dev/null @@ -1,3 +0,0 @@ -# Knit configuration -knit.dir=docs/src/main/kotlin/ -knit.package=io.modelcontextprotocol.kotlin.sdk.examples diff --git a/kotlin-sdk-client/Module.md b/kotlin-sdk-client/Module.md index 4e6fd63..7154911 100644 --- a/kotlin-sdk-client/Module.md +++ b/kotlin-sdk-client/Module.md @@ -1,78 +1,10 @@ # Module kotlin-sdk-client -`kotlin-sdk-client` is the multiplatform MCP client for Kotlin. It handles the MCP handshake, enforces capabilities, and -gives you typed APIs to call tools, prompts, resources, completions, and logging endpoints on MCP servers. It reuses -protocol types from `kotlin-sdk-core` and ships with transports for CLI and Ktor-based networking. +The Model Context Protocol (MCP) Kotlin SDK client module provides a multiplatform implementation for connecting to and +interacting with MCP servers. -## What the module provides +## Features -- **Client runtime**: The `Client` class (or `mcpClient` helper) wraps initialization, capability checks, and - request/notification plumbing. After connecting, it exposes `serverCapabilities`, `serverVersion`, and - `serverInstructions`. -- **Typed operations**: Convenience functions like `listTools`, `callTool`, `listResources`, `readResource`, - `listPrompts`, `complete`, `setLoggingLevel`, and subscription helpers for resources. -- **Capability enforcement**: Methods fail fast if the server does not advertise the needed capability; optional - strictness mirrors the protocol options used by the server SDK. -- **Transports**: Ready-to-use implementations for STDIO (CLI interoperability), Ktor SSE (`SseClientTransport` with - POST back-channel), Ktor WebSocket, and Streamable HTTP for environments that prefer request/response streaming. -- **Ktor client integration**: Extension helpers wire MCP over Ktor client engines with minimal setup for SSE, - Streamable HTTP, or WebSocket. - -## Typical client setup - -1. Declare client capabilities with `ClientOptions` (e.g., sampling, roots, elicitation, experimental). -2. Create a transport suitable for your environment. -3. Connect using `mcpClient` or instantiate `Client` and call `connect(transport)`. -4. Use typed APIs to interact with the server; results and errors use the shared MCP types. - -### Minimal STDIO client - -```kotlin -val client = mcpClient( - clientInfo = Implementation(name = "demo-client", version = "1.0.0"), - clientOptions = ClientOptions( - capabilities = ClientCapabilities( - tools = ClientCapabilities.Tools(), - resources = ClientCapabilities.Resources(subscribe = true), - ) - ), - transport = StdioClientTransport( - System.`in`.asSource().buffered(), - System.out.asSink().buffered() - ) -) - -val tools = client.listTools() -val result = client.callTool("hello", mapOf("name" to "Kotlin")) -println(result.content) -``` - -### Ktor-based transports - -- **SSE** (streaming GET + POST back-channel): create `SseClientTransport(httpClient, url)` (or - `HttpClient.mcpSseTransport(url)`); sessions are keyed by `sessionId` managed by the SDK. -- **WebSocket**: use `WebSocketClientTransport(httpClient, url)` (or `HttpClient.mcpWebSocketTransport(url)`) to get - bidirectional messaging in one connection. -- **Streamable HTTP**: `StreamableHttpClientTransport(httpClient, url)` enables MCP over streaming HTTP - (or `HttpClient.mcpStreamableHttpTransport(url)`). - -## Feature usage highlights - -- **Tools**: `callTool` accepts raw maps or `CallToolRequest`; metadata keys are validated per MCP rules. -- **Prompts & completion**: `getPrompt`, `listPrompts`, and `complete` wrap common prompt flows. -- **Resources**: `listResources`, `listResourceTemplates`, `readResource`, and `subscribeResource` cover discovery, - templating, and updates (when the server allows subscriptions). -- **Roots**: If enabled, register local roots with `addRoot`/`addRoots`; the client responds to `RootsList` requests - from the server. -- **Notifications**: Built-in handlers cover initialization, cancellation, progress, and roots list changes; you can - register more handlers via the underlying `Protocol` API. - -## Diagnostics and lifecycle - -- Capability assertions prevent calling endpoints the server does not expose; errors surface with clear messages. -- Logging uses `KotlinLogging`; enable DEBUG to inspect transport and handshake behavior. -- `close()` tears down the transport and cancels in-flight requests. - -Use this module whenever you need an MCP-aware Kotlin client—whether embedding in a CLI over STDIO or talking to remote -servers via Ktor SSE/WebSocket/streamable HTTP transports. Public APIs are explicit so downstream users can rely on -stable, well-typed surface areas across platforms. +- Cross-platform support for JVM, WASM, JS, and Native +- Built-in support for multiple transport protocols (stdio, SSE, WebSocket) +- Type-safe API for resource management and server interactions diff --git a/kotlin-sdk-client/api/kotlin-sdk-client.api b/kotlin-sdk-client/api/kotlin-sdk-client.api index d856f5b..0609482 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; } @@ -64,30 +62,24 @@ public final class io/modelcontextprotocol/kotlin/sdk/client/KtorClientKt { public static synthetic fun mcpSseTransport-5_5nbZA$default (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/client/SseClientTransport; } -public final class io/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions { - public synthetic fun (JJDIILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (JJDILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun equals (Ljava/lang/Object;)Z - public final fun getInitialReconnectionDelay-UwyO8pc ()J - public final fun getMaxReconnectionDelay-UwyO8pc ()J - public final fun getMaxRetries ()I - public final fun getReconnectionDelayMultiplier ()D - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport { +public final class io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport { 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 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/client/StdioClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport { +public final class io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport { public static final field BUFFER_SIZE J public fun (Lkotlinx/io/Source;Lkotlinx/io/Sink;)V public fun (Lkotlinx/io/Source;Lkotlinx/io/Sink;Lkotlinx/io/Source;)V public fun (Lkotlinx/io/Source;Lkotlinx/io/Sink;Lkotlinx/io/Source;Lkotlinx/coroutines/channels/Channel;)V public fun (Lkotlinx/io/Source;Lkotlinx/io/Sink;Lkotlinx/io/Source;Lkotlinx/coroutines/channels/Channel;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Lkotlinx/io/Source;Lkotlinx/io/Sink;Lkotlinx/io/Source;Lkotlinx/coroutines/channels/Channel;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/client/StdioClientTransport$StderrSeverity : java/lang/Enum { @@ -101,16 +93,17 @@ public final class io/modelcontextprotocol/kotlin/sdk/client/StdioClientTranspor public static fun values ()[Lio/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport$StderrSeverity; } -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 final class io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport { 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 fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getProtocolVersion ()Ljava/lang/String; public final fun getSessionId ()Ljava/lang/String; + public fun send (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lio/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun send (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun send$default (Lio/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport;Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun setProtocolVersion (Ljava/lang/String;)V + public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun terminateSession (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } @@ -122,12 +115,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpError } public final class io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpMcpKtorClientExtensionsKt { - public static final fun mcpStreamableHttp (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun mcpStreamableHttp$default (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static final fun mcpStreamableHttp-BZiP2OM (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun mcpStreamableHttp-BZiP2OM$default (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static final fun mcpStreamableHttpTransport (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;Lkotlin/jvm/functions/Function1;)Lio/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport; - public static synthetic fun mcpStreamableHttpTransport$default (Lio/ktor/client/HttpClient;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport; public static final fun mcpStreamableHttpTransport-5_5nbZA (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;)Lio/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport; public static synthetic fun mcpStreamableHttpTransport-5_5nbZA$default (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport; } diff --git a/kotlin-sdk-client/build.gradle.kts b/kotlin-sdk-client/build.gradle.kts index fbb1300..36a3ed8 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 { @@ -40,7 +41,6 @@ kotlin { commonTest { dependencies { implementation(kotlin("test")) - implementation(project(":kotlin-sdk-testing")) implementation(libs.kotest.assertions.core) implementation(libs.kotlinx.coroutines.test) implementation(libs.ktor.client.logging) @@ -55,6 +55,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-main.xml b/kotlin-sdk-client/detekt-baseline-main.xml deleted file mode 100644 index c34e75c..0000000 --- a/kotlin-sdk-client/detekt-baseline-main.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - LoopWithTooManyJumpStatements:StreamableHttpClientTransport.kt:StreamableHttpClientTransport$while - - 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..d861bd0 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,22 +4,16 @@ 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 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.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 @@ -28,9 +22,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification -import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION -import io.modelcontextprotocol.kotlin.sdk.types.LegacyTitledEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.ListPromptsRequest import io.modelcontextprotocol.kotlin.sdk.types.ListPromptsResult import io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesRequest @@ -45,26 +37,17 @@ import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel 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.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 import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.SetLevelRequest import io.modelcontextprotocol.kotlin.sdk.types.SetLevelRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.StringSchema import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest -import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema -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 +55,8 @@ 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 +65,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, @@ -184,12 +156,11 @@ 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 } - check(hasCapability) { - "Server does not support $capability (required for $method)" + if (!hasCapability) { + throw IllegalStateException("Server does not support $capability (required for $method)") } } @@ -213,7 +184,7 @@ public open class Client(private val clientInfo: Implementation, options: Client val result = request(message) if (!SUPPORTED_PROTOCOL_VERSIONS.contains(result.protocolVersion)) { - error( + throw IllegalStateException( "Server's protocol version is not supported: ${result.protocolVersion}", ) } @@ -223,7 +194,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() @@ -243,22 +213,17 @@ public open class Client(private val clientInfo: Implementation, options: Client override fun assertCapabilityForMethod(method: Method) { when (method) { Method.Defined.LoggingSetLevel -> { - checkNotNull(serverCapabilities?.logging) { - "Server does not support logging (required for $method)" + if (serverCapabilities?.logging == null) { + throw IllegalStateException("Server does not support logging (required for $method)") } } 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)" + if (serverCapabilities?.prompts == null) { + throw IllegalStateException("Server does not support prompts (required for $method)") } } @@ -271,25 +236,19 @@ 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)", + ) } } Method.Defined.ToolsCall, Method.Defined.ToolsList -> { - checkNotNull(serverCapabilities?.tools) { - "Server does not support tools (required for $method)" + if (serverCapabilities?.tools == null) { + throw IllegalStateException("Server does not support tools (required for $method)") } } - 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,35 +259,13 @@ 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 -> { - check(capabilities.roots?.listChanged == true) { - "Client does not support roots list changed notifications (required for $method)" - } - } - - Method.Defined.NotificationsTasksStatus -> { - checkNotNull(capabilities.tasks) { - "Client does not support tasks (required for $method)" + if (capabilities.roots?.listChanged != true) { + throw IllegalStateException( + "Client does not support roots list changed notifications (required for $method)", + ) } } @@ -348,20 +285,26 @@ public open class Client(private val clientInfo: Implementation, options: Client override fun assertRequestHandlerCapability(method: Method) { when (method) { Method.Defined.SamplingCreateMessage -> { - checkNotNull(capabilities.sampling) { - "Client does not support sampling capability (required for $method)" + if (capabilities.sampling == null) { + throw IllegalStateException( + "Client does not support sampling capability (required for $method)", + ) } } Method.Defined.RootsList -> { - checkNotNull(capabilities.roots) { - "Client does not support roots capability (required for $method)" + if (capabilities.roots == null) { + throw IllegalStateException( + "Client does not support roots capability (required for $method)", + ) } } Method.Defined.ElicitationCreate -> { - checkNotNull(capabilities.elicitation) { - "Client does not support elicitation capability (required for $method)" + if (capabilities.elicitation == null) { + throw IllegalStateException( + "Client does not support elicitation capability (required for $method)", + ) } } @@ -373,26 +316,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 +330,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) @@ -571,9 +494,9 @@ public open class Client(private val clientInfo: Implementation, options: Client * @throws IllegalStateException If the client does not support roots. */ public fun addRoot(uri: String, name: String) { - checkNotNull(capabilities.roots) { + if (capabilities.roots == null) { logger.error { "Failed to add root '$name': Client does not support roots capability" } - "Client does not support roots capability." + throw IllegalStateException("Client does not support roots capability.") } logger.info { "Adding root: $name ($uri)" } roots.update { current -> current.put(uri, Root(uri, name)) } @@ -586,9 +509,9 @@ public open class Client(private val clientInfo: Implementation, options: Client * @throws IllegalStateException If the client does not support roots. */ public fun addRoots(rootsToAdd: List) { - checkNotNull(capabilities.roots) { + if (capabilities.roots == null) { logger.error { "Failed to add roots: Client does not support roots capability" } - "Client does not support roots capability." + throw IllegalStateException("Client does not support roots capability.") } logger.info { "Adding ${rootsToAdd.size} roots" } roots.update { current -> current.putAll(rootsToAdd.associateBy { it.uri }) } @@ -602,8 +525,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) } @@ -626,9 +550,9 @@ public open class Client(private val clientInfo: Implementation, options: Client * @throws IllegalStateException If the client does not support roots. */ public fun removeRoots(uris: List): Int { - checkNotNull(capabilities.roots) { + if (capabilities.roots == null) { logger.error { "Failed to remove roots: Client does not support roots capability" } - "Client does not support roots capability." + throw IllegalStateException("Client does not support roots capability.") } logger.info { "Removing ${uris.size} roots" } @@ -659,113 +583,24 @@ 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 - * [ElicitResult.content] are automatically populated with default values defined in the - * elicitation schema. URL-mode responses carry no content. - * * @param handler The elicitation handler. * @throws IllegalStateException if the client does not support elicitation. */ public fun setElicitationHandler(handler: (ElicitRequest) -> ElicitResult) { - checkNotNull(capabilities.elicitation) { + if (capabilities.elicitation == null) { logger.error { "Failed to set elicitation handler: Client does not support elicitation" } - "Client does not support elicitation." + throw IllegalStateException("Client does not support elicitation.") } logger.info { "Setting the elicitation handler" } setRequestHandler(Method.Defined.ElicitationCreate) { request, _ -> - val result = handler(request) - applyElicitationDefaults(request, result) - } - } - - /** - * 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) + handler(request) } } // --- Internal Handlers --- - private fun applyElicitationDefaults(request: ElicitRequest, result: ElicitResult): ElicitResult { - if (result.action != ElicitResult.Action.Accept) return result - val formParams = request.params as? ElicitRequestFormParams ?: return result - val content = result.content ?: return result - - val merged = buildMap { - putAll(content) - for ((key, schemaDef) in formParams.requestedSchema.properties) { - if (key !in content) { - schemaDef.defaultJsonValue()?.let { put(key, it) } - } - } - } - - return if (merged.size == content.size) { - result - } else { - result.copy(content = JsonObject(merged)) - } - } - - @Suppress("DEPRECATION", "CyclomaticComplexMethod") - private fun PrimitiveSchemaDefinition.defaultJsonValue(): JsonElement? = when (this) { - is StringSchema -> default?.let { JsonPrimitive(it) } - - is IntegerSchema -> default?.let { JsonPrimitive(it) } - - is DoubleSchema -> default?.let { JsonPrimitive(it) } - - is BooleanSchema -> default?.let { JsonPrimitive(it) } - - is UntitledSingleSelectEnumSchema -> default?.let { JsonPrimitive(it) } - - is TitledSingleSelectEnumSchema -> default?.let { JsonPrimitive(it) } - - is LegacyTitledEnumSchema -> default?.let { JsonPrimitive(it) } - - is UntitledMultiSelectEnumSchema -> default?.let { list -> - buildJsonArray { list.forEach { add(JsonPrimitive(it)) } } - } - - is TitledMultiSelectEnumSchema -> default?.let { list -> - buildJsonArray { list.forEach { add(JsonPrimitive(it)) } } - } - } - - private fun handleListRoots(): ListRootsResult { + private suspend fun handleListRoots(): ListRootsResult { val rootList = roots.value.values.toList() return ListRootsResult(rootList) } @@ -799,8 +634,7 @@ public open class Client(private val clientInfo: Implementation, options: Client val labels = it.split('.') require(labels.all { label -> label.matches(labelPattern) }) { - "Invalid _meta key '$key': prefix labels must start with a letter, end with letter/digit, " + - "and contain only letters, digits, or hyphens" + "Invalid _meta key '$key': prefix labels must start with a letter, end with letter/digit, and contain only letters, digits, or hyphens" } require( @@ -815,8 +649,7 @@ public open class Client(private val clientInfo: Implementation, options: Client // Validate name (empty allowed) require(name.isEmpty() || name.matches(namePattern)) { - "Invalid _meta key '$key': name must start and end with alphanumeric characters, " + - "and contain only alphanumerics, hyphens, underscores, or dots" + "Invalid _meta key '$key': name must start and end with alphanumeric characters, and contain only alphanumerics, hyphens, underscores, or dots" } } } diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/KtorClient.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/KtorClient.kt index 0c69504..fe92e15 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/KtorClient.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/KtorClient.kt @@ -13,7 +13,7 @@ import kotlin.time.Duration * @param urlString Optional URL of the MCP server. * @param reconnectionTime Optional duration to wait before attempting to reconnect. * @param requestBuilder Optional lambda to configure the HTTP request. - * @return A [SseClientTransport] configured for MCP communication. + * @return A [SSEClientTransport] configured for MCP communication. */ public fun HttpClient.mcpSseTransport( urlString: String? = null, diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions.kt deleted file mode 100644 index 95c5bde..0000000 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ReconnectionOptions.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.client - -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -/** - * Options for controlling SSE reconnection behavior. - * - * @property initialReconnectionDelay The initial delay before the first reconnection attempt. - * @property maxReconnectionDelay The maximum delay between reconnection attempts. - * @property reconnectionDelayMultiplier The factor by which the delay grows on each attempt. - * @property maxRetries The maximum number of reconnection attempts per disconnect. - */ -public class ReconnectionOptions( - public val initialReconnectionDelay: Duration = 1.seconds, - public val maxReconnectionDelay: Duration = 30.seconds, - public val reconnectionDelayMultiplier: Double = 1.5, - public val maxRetries: Int = 2, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as ReconnectionOptions - - if (reconnectionDelayMultiplier != other.reconnectionDelayMultiplier) return false - if (maxRetries != other.maxRetries) return false - if (initialReconnectionDelay != other.initialReconnectionDelay) return false - if (maxReconnectionDelay != other.maxReconnectionDelay) return false - - return true - } - - override fun hashCode(): Int { - var result = reconnectionDelayMultiplier.hashCode() - result = 31 * result + maxRetries - result = 31 * result + initialReconnectionDelay.hashCode() - result = 31 * result + maxReconnectionDelay.hashCode() - return result - } - - override fun toString(): String = - "ReconnectionOptions(initialReconnectionDelay=$initialReconnectionDelay, maxReconnectionDelay=$maxReconnectionDelay, reconnectionDelayMultiplier=$reconnectionDelayMultiplier, maxRetries=$maxRetries)" -} 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 similarity index 56% rename from kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport.kt rename to kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/SSEClientTransport.kt index 0295eb9..0f1353b 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 @@ -1,6 +1,5 @@ package io.modelcontextprotocol.kotlin.sdk.client -import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.HttpClient import io.ktor.client.plugins.sse.ClientSSESession @@ -11,10 +10,11 @@ import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpHeaders +import io.ktor.http.Url import io.ktor.http.append import io.ktor.http.isSuccess import io.ktor.http.protocolWithAuthority -import io.modelcontextprotocol.kotlin.sdk.shared.AbstractClientTransport +import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.McpJson @@ -24,16 +24,19 @@ 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.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.time.Duration +@Deprecated("Use SseClientTransport instead", ReplaceWith("SseClientTransport"), DeprecationLevel.ERROR) +public typealias SSEClientTransport = SseClientTransport + /** * Client transport for SSE: this will connect to a server using Server-Sent Events for receiving * messages and make separate POST requests for sending messages. @@ -44,68 +47,85 @@ public class SseClientTransport( private val urlString: String?, private val reconnectionTime: Duration? = null, private val requestBuilder: HttpRequestBuilder.() -> Unit = {}, -) : AbstractClientTransport() { - - override val logger: KLogger = KotlinLogging.logger {} +) : AbstractTransport() { + private val logger = KotlinLogging.logger {} + private val initialized: AtomicBoolean = AtomicBoolean(false) private val endpoint = CompletableDeferred() private lateinit var session: ClientSSESession private lateinit var scope: CoroutineScope private var job: Job? = null - private val origin: String by lazy { - session.call.request.url.protocolWithAuthority - } - private val baseUrl: String by lazy { session.call.request.url.let { url -> val path = url.encodedPath when { - path.isEmpty() -> origin - path.endsWith("/") -> origin + path.removeSuffix("/") - else -> origin + path.take(path.lastIndexOf("/")) + path.isEmpty() -> url.protocolWithAuthority + path.endsWith("/") -> url.protocolWithAuthority + path.removeSuffix("/") + else -> url.protocolWithAuthority + path.take(path.lastIndexOf("/")) } } } - override suspend fun initialize() { - session = urlString?.let { - client.sseSession( - urlString = it, + override suspend fun start() { + check(initialized.compareAndSet(expectedValue = false, newValue = true)) { + "SSEClientTransport already started! If using Client class, note that connect() calls start() automatically." + } + + try { + session = urlString?.let { + client.sseSession( + urlString = it, + reconnectionTime = reconnectionTime, + block = requestBuilder, + ) + } ?: client.sseSession( reconnectionTime = reconnectionTime, block = requestBuilder, ) - } ?: client.sseSession( - reconnectionTime = reconnectionTime, - block = requestBuilder, - ) - scope = CoroutineScope(session.coroutineContext + SupervisorJob()) + scope = CoroutineScope(session.coroutineContext + SupervisorJob()) - job = scope.launch(CoroutineName("SseMcpClientTransport.connect#${hashCode()}")) { - collectMessages() + job = scope.launch(CoroutineName("SseMcpClientTransport.connect#${hashCode()}")) { + collectMessages() + } + + endpoint.await() + } catch (e: Exception) { + closeResources() + initialized.store(false) + throw e } - - endpoint.await() } @OptIn(ExperimentalCoroutinesApi::class) - override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) { + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + check(initialized.load()) { "SseClientTransport is not initialized!" } check(job?.isActive == true) { "SseClientTransport is closed!" } check(endpoint.isCompleted) { "Not connected!" } - val response = client.post(endpoint.getCompleted()) { - requestBuilder() - headers.append(HttpHeaders.ContentType, ContentType.Application.Json) - setBody(McpJson.encodeToString(message)) - } + try { + val response = client.post(endpoint.getCompleted()) { + requestBuilder() + headers.append(HttpHeaders.ContentType, ContentType.Application.Json) + setBody(McpJson.encodeToString(message)) + } - if (!response.status.isSuccess()) { - val text = response.bodyAsText() - error("Error POSTing to endpoint (HTTP ${response.status}): $text") - } + if (!response.status.isSuccess()) { + val text = response.bodyAsText() + error("Error POSTing to endpoint (HTTP ${response.status}): $text") + } - logger.debug { "Client successfully sent message via SSE $endpoint" } + logger.debug { "Client successfully sent message via SSE $endpoint" } + } catch (e: Throwable) { + _onError(e) + throw e + } + } + + override suspend fun close() { + check(initialized.load()) { "SseClientTransport is not initialized!" } + closeResources() } private suspend fun CoroutineScope.collectMessages() { @@ -136,25 +156,14 @@ public class SseClientTransport( throw e } finally { closeResources() - invokeOnCloseCallback() } } - /** - * Resolves and completes [endpoint] based on [eventData]. - * Uses full URLs as-is, treats absolute paths as origin-relative, - * and relative paths as relative to [baseUrl]. - */ private fun handleEndpoint(eventData: String) { try { - val endpointUrl = if (eventData.startsWith("http://") || eventData.startsWith("https://")) { - eventData - } else if (eventData.startsWith("/")) { - origin + eventData - } else { - "$baseUrl/$eventData" - } - endpoint.complete(endpointUrl) + val path = if (eventData.startsWith("/")) eventData.substring(1) else eventData + val endpointUrl = Url("$baseUrl/$path") + endpoint.complete(endpointUrl.toString()) logger.debug { "Client connected to endpoint: $endpointUrl" } } catch (e: Throwable) { _onError(e) @@ -172,16 +181,18 @@ 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) - } + private suspend fun closeResources() { + if (!initialized.compareAndSet(expectedValue = true, newValue = false)) return + + job?.cancelAndJoin() + try { + if (::session.isInitialized) session.cancel() + if (::scope.isInitialized) scope.cancel() + endpoint.cancel() + } catch (e: Throwable) { + _onError(e) } + + _onClose() } } 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/StdioClientTransport.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport.kt index 5e990ee..0b774a6 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 @@ -1,6 +1,5 @@ package io.modelcontextprotocol.kotlin.sdk.client -import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport.StderrSeverity.DEBUG import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport.StderrSeverity.FATAL @@ -8,7 +7,7 @@ import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport.StderrSeve import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport.StderrSeverity.INFO import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport.StderrSeverity.WARNING import io.modelcontextprotocol.kotlin.sdk.internal.IODispatcher -import io.modelcontextprotocol.kotlin.sdk.shared.AbstractClientTransport +import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage @@ -21,7 +20,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 +30,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 @@ -42,6 +39,7 @@ import kotlinx.io.buffered import kotlinx.io.readByteArray import kotlinx.io.writeString import kotlinx.serialization.SerializationException +import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.coroutines.CoroutineContext import kotlin.jvm.JvmOverloads @@ -90,9 +88,7 @@ public class StdioClientTransport @JvmOverloads public constructor( private val error: Source? = null, private val sendChannel: Channel = Channel(Channel.BUFFERED), private val classifyStderr: (String) -> StderrSeverity = { DEBUG }, -) : AbstractClientTransport() { - - override val logger: KLogger = KotlinLogging.logger {} +) : AbstractTransport() { private companion object { /** @@ -100,6 +96,8 @@ public class StdioClientTransport @JvmOverloads public constructor( * 8KB is optimal for most systems (matches default page size). */ const val BUFFER_SIZE = 8 * 1024L + + private val logger = KotlinLogging.logger {} } /** @@ -115,8 +113,13 @@ public class StdioClientTransport @JvmOverloads public constructor( private val ioCoroutineContext: CoroutineContext = IODispatcher private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val initialized = AtomicBoolean(false) + private val onCloseCalled = AtomicBoolean(false) - override suspend fun initialize() { + override suspend fun start() { + if (!initialized.compareAndSet(expectedValue = false, newValue = true)) { + error("StdioClientTransport already started!") + } logger.debug { "Starting StdioClientTransport..." } // Producers run on IODispatcher for I/O @@ -221,12 +224,25 @@ public class StdioClientTransport @JvmOverloads public constructor( // Wait for write job to complete before closing, matching old implementation writeJob?.cancelAndJoin() logger.debug { "Transport coroutine completed, calling onClose" } - invokeOnCloseCallback() + callOnCloseOnce() } } } - override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) { + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + if (!initialized.load()) { + throw McpException( + code = CONNECTION_CLOSED, + message = "Transport is not started", + ) + } + if (onCloseCalled.load()) { + throw McpException( + code = CONNECTION_CLOSED, + message = "Transport is closed", + ) + } + @Suppress("TooGenericExceptionCaught", "SwallowedException") try { sendChannel.send(message) } catch (e: CancellationException) { @@ -238,23 +254,30 @@ public class StdioClientTransport @JvmOverloads public constructor( message = "Transport is closed", cause = e, ) + } catch (e: McpException) { + logger.debug(e) { "Error while sending message: ${e.message}" } + throw e + } catch (e: Throwable) { + logger.error(e) { "Error while sending message: ${e.message}" } + throw McpException( + code = INTERNAL_ERROR, + message = "Error while sending message: ${e.message}", + cause = e, + ) } } - override suspend fun closeResources() { - withContext(NonCancellable) { - scope.stopProcessing("Closed") - closeReadSources() - scope.coroutineContext[Job]?.join() // Wait for all coroutines to complete + override suspend fun close() { + if (!initialized.compareAndSet(expectedValue = true, newValue = false)) { + return // Already closed } + scope.stopProcessing("Closed") + scope.coroutineContext[Job]?.join() // Wait for all coroutines to complete } - private fun closeReadSources() { - runCatching { input.close() } - .onFailure { logger.debug(it) { "Error closing stdin source" } } - error?.let { source -> - runCatching { source.close() } - .onFailure { logger.debug(it) { "Error closing stderr source" } } + private fun callOnCloseOnce() { + if (onCloseCalled.compareAndSet(expectedValue = false, newValue = true)) { + runCatching { _onClose() } } } @@ -275,10 +298,9 @@ public class StdioClientTransport @JvmOverloads public constructor( } private suspend fun handleJSONRPCMessage(msg: JSONRPCMessage) { + @Suppress("TooGenericExceptionCaught") try { _onMessage.invoke(msg) - } catch (e: CancellationException) { - throw e } catch (e: Throwable) { logger.error(e) { "Error processing message." } runCatching { _onError.invoke(e) } @@ -287,7 +309,7 @@ public class StdioClientTransport @JvmOverloads public constructor( private fun CoroutineScope.stopProcessing(reason: String, cause: Throwable? = null) { sendChannel.close() // Stop accepting new messages - invokeOnCloseCallback() + callOnCloseOnce() cancel(reason, cause) // cancel current coroutine context } @@ -299,7 +321,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..c8f250a 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 @@ -1,6 +1,5 @@ package io.modelcontextprotocol.kotlin.sdk.client -import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.HttpClient import io.ktor.client.plugins.sse.ClientSSESession @@ -21,16 +20,16 @@ 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.AbstractTransport import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions 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.McpException import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED import io.modelcontextprotocol.kotlin.sdk.types.RequestId import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName @@ -40,133 +39,87 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel 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.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -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 + * Error class for Streamable HTTP transport errors. */ public class StreamableHttpError(public val code: Int? = null, message: String? = null) : Exception("Streamable HTTP error: $message") -private sealed interface ConnectResult { - data class Success(val session: ClientSSESession) : ConnectResult - data object NonRetryable : ConnectResult - data object Failed : ConnectResult -} - /** - * Client transport implementing the MCP Streamable HTTP transport specification. - * - * 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 + * 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. */ +@OptIn(ExperimentalAtomicApi::class) 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 reconnectionTime: Duration? = null, private val requestBuilder: HttpRequestBuilder.() -> Unit = {}, -) : AbstractClientTransport() { +) : AbstractTransport() { - init { - require(maxInlineSseEventSize > 0) { "maxInlineSseEventSize must be greater than 0" } + private companion object { + private val logger = KotlinLogging.logger {} } - @Deprecated( - "Use constructor with ReconnectionOptions", - replaceWith = ReplaceWith( - "StreamableHttpClientTransport(client, url, " + - "ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), requestBuilder)", - "kotlin.time.Duration.Companion.seconds", - "io.modelcontextprotocol.kotlin.sdk.client.ReconnectionOptions", - ), - ) - public constructor( - client: HttpClient, - url: String, - reconnectionTime: Duration?, - requestBuilder: HttpRequestBuilder.() -> Unit = {}, - ) : this( - client, - url, - ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), - requestBuilder = requestBuilder, - ) - - override val logger: KLogger = KotlinLogging.logger {} - - /** Session identifier assigned by the server after initialization, or `null` before connection. */ public var sessionId: String? = null private set - - /** MCP protocol version negotiated with the server, or `null` before connection. */ public var protocolVersion: String? = null + private val initialized: AtomicBoolean = AtomicBoolean(false) + + private var sseSession: ClientSSESession? = null private var sseJob: Job? = null private val scope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.Default) } - /** Result of an SSE stream collection. Reconnect when [hasPrimingEvent] is true and [receivedResponse] is false. */ - private data class SseStreamResult( - val hasPrimingEvent: Boolean, - val receivedResponse: Boolean, - val lastEventId: String? = null, - val serverRetryDelay: Duration? = null, - ) + private var lastEventId: String? = null - override suspend fun initialize() { - logger.debug { "Client transport is starting..." } + override suspend fun start() { + if (!initialized.compareAndSet(expectedValue = false, newValue = true)) { + error("StreamableHttpClientTransport already started!") + } + logger.debug { "Client transport starting..." } } /** * Sends a single message with optional resumption support */ - override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) { + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + send(message, options?.resumptionToken, options?.onResumptionToken) + } + + /** + * Sends one or more messages with optional resumption support. + * This is the main send method that matches the TypeScript implementation. + */ + public suspend fun send( + message: JSONRPCMessage, + resumptionToken: String?, + onResumptionToken: ((String) -> Unit)? = null, + ) { + if (!initialized.load()) { + throw McpException( + code = CONNECTION_CLOSED, + message = "Transport is not started", + ) + } logger.debug { "Client sending message via POST to $url: ${McpJson.encodeToString(message)}" } // If we have a resumption token, reconnect the SSE stream with it - options?.resumptionToken?.let { token -> + resumptionToken?.let { token -> startSseSession( resumptionToken = token, - onResumptionToken = options.onResumptionToken, + onResumptionToken = onResumptionToken, replayMessageId = if (message is JSONRPCRequest) message.id else null, ) return @@ -175,7 +128,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) @@ -186,7 +138,7 @@ public class StreamableHttpClientTransport( if (response.status == HttpStatusCode.Accepted) { if (message is JSONRPCNotification && message.method == "notifications/initialized") { - startSseSession(onResumptionToken = options?.onResumptionToken) + startSseSession(onResumptionToken = onResumptionToken) } return } @@ -207,51 +159,41 @@ public class StreamableHttpClientTransport( } } - ContentType.Text.EventStream -> { - val replayMessageId = if (message is JSONRPCRequest) message.id else null - val result = handleInlineSse(response, replayMessageId, options?.onResumptionToken) - if (result.hasPrimingEvent && !result.receivedResponse) { - startSseSession( - resumptionToken = result.lastEventId, - replayMessageId = replayMessageId, - onResumptionToken = options?.onResumptionToken, - initialServerRetryDelay = result.serverRetryDelay, - ) - } - } + ContentType.Text.EventStream -> handleInlineSse( + response, + onResumptionToken = onResumptionToken, + replayMessageId = if (message is JSONRPCRequest) message.id else null, + ) else -> { val body = response.bodyAsText() if (response.contentType() == null && body.isBlank()) return val ct = response.contentType()?.toString() ?: "" - val error = StreamableHttpError(-1, "Unexpected content type: $ct") + val error = StreamableHttpError(-1, "Unexpected content type: $$ct") _onError(error) throw error } } } - /** - * Sends one or more messages with optional resumption support. - * This is the main send method that matches the TypeScript implementation. - */ - public suspend fun send( - message: JSONRPCMessage, - resumptionToken: String?, - onResumptionToken: ((String) -> Unit)? = null, - ): Unit = send( - message = message, - options = TransportSendOptions( - resumptionToken = resumptionToken, - onResumptionToken = onResumptionToken, - ), - ) - - override suspend fun closeResources() { + override suspend fun close() { + if (!initialized.load()) return // Already closed or never started logger.debug { "Client transport closing." } - sseJob?.cancelAndJoin() - scope.cancel() + + try { + // Try to terminate session if we have one + terminateSession() + + sseSession?.cancel() + sseJob?.cancelAndJoin() + scope.cancel() + } catch (_: Exception) { + // Ignore errors during cleanup + } finally { + initialized.store(false) + _onClose() + } } /** @@ -277,118 +219,55 @@ public class StreamableHttpClientTransport( } sessionId = null + lastEventId = null logger.debug { "Session terminated successfully" } } - private fun startSseSession( + private suspend fun startSseSession( resumptionToken: String? = null, replayMessageId: RequestId? = null, onResumptionToken: ((String) -> Unit)? = null, - initialServerRetryDelay: Duration? = null, ) { - // Cancel-and-replace: cancel() signals the previous job, join() inside - // the new coroutine ensures it completes before we start collecting. - // This is intentionally non-suspend to avoid blocking performSend. - val previousJob = sseJob - previousJob?.cancel() - sseJob = scope.launch(CoroutineName("StreamableHttpTransport.collect#${hashCode()}")) { - previousJob?.join() - var lastEventId = resumptionToken - var serverRetryDelay = initialServerRetryDelay - var attempt = 0 - var needsDelay = initialServerRetryDelay != null + sseSession?.cancel() + sseJob?.cancelAndJoin() - while (isActive) { - // Delay before (re)connection: skip only for first fresh SSE connection - if (needsDelay) { - delay(getNextReconnectionDelay(attempt, serverRetryDelay)) - } - needsDelay = true - - // Connect - val session = when (val cr = connectSse(lastEventId)) { - is ConnectResult.Success -> { - attempt = 0 - cr.session - } - - ConnectResult.NonRetryable -> return@launch - - ConnectResult.Failed -> { - // Give up after maxRetries consecutive failed connection attempts - if (++attempt >= reconnectionOptions.maxRetries) { - _onError(StreamableHttpError(null, "Maximum reconnection attempts exceeded")) - return@launch - } - continue - } - } - - // Collect - val result = collectSse(session, replayMessageId, onResumptionToken) - lastEventId = result.lastEventId ?: lastEventId - serverRetryDelay = result.serverRetryDelay ?: serverRetryDelay - if (result.receivedResponse) break - } - } - } - - private suspend fun connectSse(lastEventId: String?): ConnectResult { logger.debug { "Client attempting to start SSE session at url: $url" } - return try { - val session = client.sseSession(urlString = url, showRetryEvents = true) { + try { + sseSession = client.sseSession( + urlString = url, + reconnectionTime = reconnectionTime, + ) { method = HttpMethod.Get applyCommonHeaders(this) + // sseSession will add ContentType.Text.EventStream automatically accept(ContentType.Application.Json) - lastEventId?.let { headers.append(MCP_RESUMPTION_TOKEN_HEADER, it) } + (resumptionToken ?: lastEventId)?.let { headers.append(MCP_RESUMPTION_TOKEN_HEADER, it) } requestBuilder() } logger.debug { "Client SSE session started successfully." } - ConnectResult.Success(session) - } catch (e: CancellationException) { - throw e } catch (e: SSEClientException) { - if (isNonRetryableSseError(e)) { - ConnectResult.NonRetryable - } else { - logger.debug { "SSE connection failed: ${e.message}" } - ConnectResult.Failed - } - } catch (e: Exception) { - logger.debug { "SSE connection failed: ${e.message}" } - ConnectResult.Failed - } - } + val responseStatus = e.response?.status + val responseContentType = e.response?.contentType() - private fun getNextReconnectionDelay(attempt: Int, serverRetryDelay: Duration?): Duration { - // Per SSE specification, the server-sent `retry` field sets the reconnection time - // for all subsequent attempts, taking priority over exponential backoff. - serverRetryDelay?.let { return it } - val delay = reconnectionOptions.initialReconnectionDelay * - reconnectionOptions.reconnectionDelayMultiplier.pow(attempt) - return delay.coerceAtMost(reconnectionOptions.maxReconnectionDelay) - } - - /** - * Checks if an SSE session error is non-retryable (404, 405, JSON-only). - * Returns `true` if non-retryable (should stop trying), `false` otherwise. - */ - private fun isNonRetryableSseError(e: SSEClientException): Boolean { - val responseStatus = e.response?.status - val responseContentType = e.response?.contentType() - - return when { - responseStatus == HttpStatusCode.NotFound || responseStatus == HttpStatusCode.MethodNotAllowed -> { - logger.info { "Server returned ${responseStatus.value} for GET/SSE, stream disabled." } - true + // 405 means server doesn't support SSE at GET endpoint - this is expected and valid + if (responseStatus == HttpStatusCode.MethodNotAllowed) { + logger.info { "Server returned 405 for GET/SSE, stream disabled." } + return } - responseContentType?.match(ContentType.Application.Json) == true -> { + // If server returns application/json, it means it doesn't support SSE for this session + // This is valid per spec - server can choose to only use JSON responses + if (responseContentType?.match(ContentType.Application.Json) == true) { logger.info { "Server returned application/json for GET/SSE, using JSON-only mode." } - true + return } - else -> false + _onError(e) + throw e + } + + sseJob = scope.launch(CoroutineName("StreamableHttpTransport.collect#${hashCode()}")) { + sseSession?.let { collectSse(it, replayMessageId, onResumptionToken) } } } @@ -399,51 +278,15 @@ public class StreamableHttpClientTransport( } } - private fun applyStandardPostHeaders(builder: HttpRequestBuilder, message: JSONRPCMessage) { - val (method, params) = when (message) { - is JSONRPCRequest -> message.method to message.params - is JSONRPCNotification -> message.method to message.params - else -> return - } - - builder.headers { - append(MCP_METHOD_HEADER, method) - - val paramsObject = params as? JsonObject ?: return@headers - val mcpName = paramsObject.stringValue("name") ?: paramsObject.stringValue("uri") - mcpName?.let { append(MCP_NAME_HEADER, it.encodeMcpHeaderValue()) } - } - } - - private fun JsonObject.stringValue(key: String): String? = - (get(key) as? JsonPrimitive)?.takeIf { it.isString }?.content - - @OptIn(ExperimentalEncodingApi::class) - private fun String.encodeMcpHeaderValue(): String { - val containsUnsafeCharacters = any { it != '\t' && it.code !in 0x20..0x7e } - val hasEdgeWhitespace = firstOrNull()?.isWhitespace() == true || lastOrNull()?.isWhitespace() == true - val matchesBase64Sentinel = startsWith(MCP_BASE64_PREFIX) && endsWith(MCP_BASE64_SUFFIX) - - if (!containsUnsafeCharacters && !hasEdgeWhitespace && !matchesBase64Sentinel) return this - - return "$MCP_BASE64_PREFIX${Base64.Default.encode(encodeToByteArray())}$MCP_BASE64_SUFFIX" - } - private suspend fun collectSse( session: ClientSSESession, replayMessageId: RequestId?, onResumptionToken: ((String) -> Unit)?, - ): SseStreamResult { - var hasPrimingEvent = false - var receivedResponse = false - var localLastEventId: String? = null - var localServerRetryDelay: Duration? = null + ) { try { session.incoming.collect { event -> - event.retry?.let { localServerRetryDelay = it.milliseconds } event.id?.let { - localLastEventId = it - hasPrimingEvent = true + lastEventId = it onResumptionToken?.invoke(it) } logger.trace { "Client received SSE event: event=${event.event}, data=${event.data}, id=${event.id}" } @@ -452,7 +295,6 @@ public class StreamableHttpClientTransport( event.data?.takeIf { it.isNotEmpty() }?.let { json -> runCatching { McpJson.decodeFromString(json) } .onSuccess { msg -> - if (msg is JSONRPCResponse) receivedResponse = true if (replayMessageId != null && msg is JSONRPCResponse) { _onMessage(msg.copy(id = replayMessageId)) } else { @@ -470,29 +312,23 @@ public class StreamableHttpClientTransport( } catch (t: Throwable) { _onError(t) } - return SseStreamResult(hasPrimingEvent, receivedResponse, localLastEventId, localServerRetryDelay) } private suspend fun handleInlineSse( response: HttpResponse, replayMessageId: RequestId?, onResumptionToken: ((String) -> Unit)?, - ): SseStreamResult { + ) { logger.trace { "Handling inline SSE from POST response" } val channel = response.bodyAsChannel() - var hasPrimingEvent = false - var receivedResponse = false - var localLastEventId: String? = null - var localServerRetryDelay: Duration? = null val sb = StringBuilder() var id: String? = null var eventName: String? = null suspend fun dispatch(id: String?, eventName: String?, data: String) { id?.let { - localLastEventId = it - hasPrimingEvent = true + lastEventId = it onResumptionToken?.invoke(it) } if (data.isBlank()) { @@ -501,7 +337,6 @@ public class StreamableHttpClientTransport( if (eventName == null || eventName == "message") { runCatching { McpJson.decodeFromString(data) } .onSuccess { msg -> - if (msg is JSONRPCResponse) receivedResponse = true if (replayMessageId != null && msg is JSONRPCResponse) { _onMessage(msg.copy(id = replayMessageId)) } else { @@ -513,21 +348,10 @@ public class StreamableHttpClientTransport( throw it } } - if (eventName == "error") { - _onError(StreamableHttpError(null, data)) - return - } } 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 @@ -538,22 +362,9 @@ public class StreamableHttpClientTransport( } when { line.startsWith("id:") -> id = line.substringAfter("id:").trim() - 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("retry:") -> line.substringAfter("retry:").trim().toLongOrNull()?.let { - localServerRetryDelay = it.milliseconds - } + line.startsWith("data:") -> sb.append(line.substringAfter("data:").trim()) } } - return SseStreamResult(hasPrimingEvent, receivedResponse, localLastEventId, localServerRetryDelay) } } diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpMcpKtorClientExtensions.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpMcpKtorClientExtensions.kt index a618f18..b64a220 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpMcpKtorClientExtensions.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpMcpKtorClientExtensions.kt @@ -6,67 +6,21 @@ import io.modelcontextprotocol.kotlin.sdk.LIB_VERSION import io.modelcontextprotocol.kotlin.sdk.shared.IMPLEMENTATION_NAME import io.modelcontextprotocol.kotlin.sdk.types.Implementation import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds /** * Returns a new Streamable HTTP transport for the Model Context Protocol using the provided HttpClient. * * @param url URL of the MCP server. - * @param reconnectionOptions Options for controlling SSE reconnection behavior. + * @param reconnectionTime Optional duration to wait before attempting to reconnect. * @param requestBuilder Optional lambda to configure the HTTP request. * @return A [StreamableHttpClientTransport] configured for MCP communication. */ public fun HttpClient.mcpStreamableHttpTransport( url: String, - reconnectionOptions: ReconnectionOptions = ReconnectionOptions(), + reconnectionTime: Duration? = null, requestBuilder: HttpRequestBuilder.() -> Unit = {}, ): StreamableHttpClientTransport = - StreamableHttpClientTransport(this, url, reconnectionOptions, requestBuilder = requestBuilder) - -/** - * Returns a new Streamable HTTP transport for the Model Context Protocol using the provided HttpClient. - * - * @param url URL of the MCP server. - * @param reconnectionTime Optional duration to wait before attempting to reconnect. - * @param requestBuilder Optional lambda to configure the HTTP request. - * @return A [StreamableHttpClientTransport] configured for MCP communication. - */ -@Deprecated( - "Use overload with ReconnectionOptions", - replaceWith = ReplaceWith( - "mcpStreamableHttpTransport(url, " + - "ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), requestBuilder)", - ), -) -public fun HttpClient.mcpStreamableHttpTransport( - url: String, - reconnectionTime: Duration?, - requestBuilder: HttpRequestBuilder.() -> Unit = {}, -): StreamableHttpClientTransport = StreamableHttpClientTransport( - this, - url, - ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), - requestBuilder = requestBuilder, -) - -/** - * Creates and connects an MCP client over Streamable HTTP using the provided HttpClient. - * - * @param url URL of the MCP server. - * @param reconnectionOptions Options for controlling SSE reconnection behavior. - * @param requestBuilder Optional lambda to configure the HTTP request. - * @return A connected [Client] ready for MCP communication. - */ -public suspend fun HttpClient.mcpStreamableHttp( - url: String, - reconnectionOptions: ReconnectionOptions = ReconnectionOptions(), - requestBuilder: HttpRequestBuilder.() -> Unit = {}, -): Client { - val transport = mcpStreamableHttpTransport(url, reconnectionOptions, requestBuilder) - val client = Client(Implementation(name = IMPLEMENTATION_NAME, version = LIB_VERSION)) - client.connect(transport) - return client -} + StreamableHttpClientTransport(this, url, reconnectionTime, requestBuilder = requestBuilder) /** * Creates and connects an MCP client over Streamable HTTP using the provided HttpClient. @@ -76,23 +30,12 @@ public suspend fun HttpClient.mcpStreamableHttp( * @param requestBuilder Optional lambda to configure the HTTP request. * @return A connected [Client] ready for MCP communication. */ -@Deprecated( - "Use overload with ReconnectionOptions", - replaceWith = ReplaceWith( - "mcpStreamableHttp(url, " + - "ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), requestBuilder)", - ), -) public suspend fun HttpClient.mcpStreamableHttp( url: String, - reconnectionTime: Duration?, + reconnectionTime: Duration? = null, requestBuilder: HttpRequestBuilder.() -> Unit = {}, ): Client { - val transport = mcpStreamableHttpTransport( - url, - ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), - requestBuilder, - ) + val transport = mcpStreamableHttpTransport(url, reconnectionTime, requestBuilder) val client = Client(Implementation(name = IMPLEMENTATION_NAME, version = LIB_VERSION)) client.connect(transport) return client diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketClientTransport.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketClientTransport.kt index c02d68e..ec3f947 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketClientTransport.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/WebSocketClientTransport.kt @@ -26,8 +26,8 @@ public class WebSocketClientTransport( override suspend fun initializeSession() { logger.debug { "Websocket session initialization started..." } - session = urlString?.let { url -> - client.webSocketSession(url) { + session = urlString?.let { + client.webSocketSession(it) { requestBuilder() header(HttpHeaders.SecWebSocketProtocol, MCP_SUBPROTOCOL) diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractClientTransportLifecycleTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractClientTransportLifecycleTest.kt index 28a1337..02b6d25 100644 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractClientTransportLifecycleTest.kt +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractClientTransportLifecycleTest.kt @@ -1,10 +1,9 @@ package io.modelcontextprotocol.kotlin.sdk.client -import io.kotest.assertions.nondeterministic.eventually import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain -import io.modelcontextprotocol.kotlin.sdk.shared.AbstractClientTransport +import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.PingRequest import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED @@ -14,9 +13,8 @@ import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -abstract class AbstractClientTransportLifecycleTest { +abstract class AbstractClientTransportLifecycleTest { protected lateinit var transport: T @@ -32,7 +30,7 @@ abstract class AbstractClientTransportLifecycleTest val exception = shouldThrow { transport.start() } - exception.message shouldContain "Can't change state: expected transport state" + exception.message shouldContain "already started" } @Test @@ -53,8 +51,8 @@ abstract class AbstractClientTransportLifecycleTest val exception = shouldThrow { transport.send(PingRequest().toJSON()) } + exception.message shouldContain "not started" exception.code shouldBe CONNECTION_CLOSED - exception.message shouldContain "Transport is not ready" } @Test @@ -62,15 +60,11 @@ abstract class AbstractClientTransportLifecycleTest val transport = createTransport() transport.start() - // Give transport time to initialize delay(50.milliseconds) transport.close() - // Verify that send throws after close using eventually for reliability - eventually(2.seconds) { - shouldThrow { - transport.send(PingRequest().toJSON()) - } + shouldThrow { + transport.send(PingRequest().toJSON()) } } @@ -82,17 +76,13 @@ abstract class AbstractClientTransportLifecycleTest transport.onClose { closeCallCount++ } transport.start() - // Give transport time to initialize delay(50.milliseconds) // Multiple close attempts transport.close() transport.close() - // Verify onClose was called exactly once using eventually for cross-platform reliability - eventually(2.seconds) { - closeCallCount shouldBe 1 - } + closeCallCount shouldBe 1 } protected abstract fun createTransport(): T 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/OldSchemaClientMetaParameterTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaClientMetaParameterTest.kt new file mode 100644 index 0000000..b541176 --- /dev/null +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaClientMetaParameterTest.kt @@ -0,0 +1,275 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Comprehensive test suite for MCP Client meta parameter functionality + * + * Tests cover: + * - Meta key validation according to MCP specification + * - JSON type conversion for various data types + * - Error handling for invalid meta keys + * - Integration with callTool method + */ +class OldSchemaClientMetaParameterTest { + + private lateinit var client: Client + private lateinit var mockTransport: OldSchemaMockTransport + private val clientInfo = Implementation("test-client", "1.0.0") + + @BeforeTest + fun setup() = runTest { + mockTransport = OldSchemaMockTransport() + client = Client(clientInfo = clientInfo) + mockTransport.setupInitializationResponse() + client.connect(mockTransport) + } + + @Test + fun `should accept valid meta keys without throwing exception`() = runTest { + val validMeta = buildMap { + put("simple-key", "value1") + put("api.example.com/version", "1.0") + put("com.company.app/setting", "enabled") + put("retry_count", 3) + put("user.preference", true) + put("valid123", "alphanumeric") + put("multi.dot.name", "multiple-dots") + put("under_score", "underscore") + put("hyphen-dash", "hyphen") + put("org.apache.kafka/consumer-config", "complex-valid-prefix") + } + + val result = runCatching { + client.callTool("test-tool", mapOf("arg" to "value"), validMeta) + } + + assertTrue(result.isSuccess, "Valid meta keys should not cause exceptions") + mockTransport.lastJsonRpcRequest()?.let { request -> + val params = request.params as JsonObject + assertTrue(params.containsKey("_meta"), "Request should contain _meta field") + val metaField = params["_meta"] as JsonObject + + // Verify all meta keys are present + assertEquals(validMeta.size, metaField.size, "All meta keys should be included") + + // Verify specific key-value pairs + assertEquals("value1", metaField["simple-key"]?.jsonPrimitive?.content) + assertEquals("1.0", metaField["api.example.com/version"]?.jsonPrimitive?.content) + assertEquals("enabled", metaField["com.company.app/setting"]?.jsonPrimitive?.content) + assertEquals(3, metaField["retry_count"]?.jsonPrimitive?.int) + assertEquals(true, metaField["user.preference"]?.jsonPrimitive?.boolean) + assertEquals("alphanumeric", metaField["valid123"]?.jsonPrimitive?.content) + assertEquals("multiple-dots", metaField["multi.dot.name"]?.jsonPrimitive?.content) + assertEquals("underscore", metaField["under_score"]?.jsonPrimitive?.content) + assertEquals("hyphen", metaField["hyphen-dash"]?.jsonPrimitive?.content) + assertEquals("complex-valid-prefix", metaField["org.apache.kafka/consumer-config"]?.jsonPrimitive?.content) + } + } + + @Test + fun `should accept edge case valid prefixes and names`() = runTest { + val edgeCaseValidMeta = buildMap { + put("a/", "single-char-prefix-empty-name") // empty name is allowed + put("a1-b2/test", "alphanumeric-hyphen-prefix") + put("long.domain.name.here/config", "long-prefix") + put("x/a", "minimal-valid-key") + put("test123", "alphanumeric-name-only") + } + + val result = runCatching { + client.callTool("test-tool", emptyMap(), edgeCaseValidMeta) + } + + assertTrue(result.isSuccess, "Edge case valid meta keys should be accepted") + } + + @Test + fun `should reject mcp reserved prefix`() = runTest { + val invalidMeta = mapOf("mcp/internal" to "value") + + val exception = assertFailsWith { + client.callTool("test-tool", emptyMap(), invalidMeta) + } + + assertContains( + charSequence = exception.message ?: "", + other = "Invalid _meta key", + ) + } + + @Test + fun `should reject modelcontextprotocol reserved prefix`() = runTest { + val invalidMeta = mapOf("modelcontextprotocol/config" to "value") + + val exception = assertFailsWith { + client.callTool("test-tool", emptyMap(), invalidMeta) + } + + assertContains( + charSequence = exception.message ?: "", + other = "Invalid _meta key", + ) + } + + @Test + fun `should reject nested reserved prefixes`() = runTest { + val invalidKeys = listOf( + "api.mcp.io/setting", + "com.modelcontextprotocol.test/value", + "example.mcp/data", + "subdomain.mcp.com/config", + "app.modelcontextprotocol.dev/setting", + "test.mcp/value", + "service.modelcontextprotocol/data", + ) + + invalidKeys.forEach { key -> + val exception = assertFailsWith( + message = "Should reject nested reserved key: $key", + ) { + client.callTool("test-tool", emptyMap(), mapOf(key to "value")) + } + assertContains( + charSequence = exception.message ?: "", + other = "Invalid _meta key", + ) + } + } + + @Test + fun `should reject case-insensitive reserved prefixes`() = runTest { + val invalidKeys = listOf( + "MCP/internal", + "Mcp/config", + "mCp/setting", + "MODELCONTEXTPROTOCOL/data", + "ModelContextProtocol/value", + "modelContextProtocol/test", + ) + + invalidKeys.forEach { key -> + val exception = assertFailsWith( + message = "Should reject case-insensitive reserved key: $key", + ) { + client.callTool("test-tool", emptyMap(), mapOf(key to "value")) + } + assertContains( + charSequence = exception.message ?: "", + other = "Invalid _meta key", + ) + } + } + + @Test + fun `should reject invalid key formats`() = runTest { + val invalidKeys = listOf( + "", // empty key - not allowed at key level + "/invalid", // starts with slash + "-invalid", // starts with hyphen + ".invalid", // starts with dot + "in valid", // contains space + "api../test", // consecutive dots + "api./test", // label ends with dot + ) + + invalidKeys.forEach { key -> + assertFailsWith( + message = "Should reject invalid key format: '$key'", + ) { + client.callTool("test-tool", emptyMap(), mapOf(key to "value")) + } + } + } + + @Test + fun `should convert various data types to JSON correctly`() = runTest { + val complexMeta = createComplexMetaData() + + val result = runCatching { + client.callTool( + "test-tool", + emptyMap(), + complexMeta, + ) + } + + assertTrue(result.isSuccess, "Complex data type conversion should not throw exceptions") + + mockTransport.lastJsonRpcRequest()?.let { request -> + assertEquals("tools/call", request.method) + val params = request.params as JsonObject + assertTrue(params.containsKey("_meta"), "Request should contain _meta field") + } + } + + @Test + fun `should handle nested map structures correctly`() = runTest { + val nestedMeta = buildNestedConfiguration() + + val result = runCatching { + client.callTool("test-tool", emptyMap(), nestedMeta) + } + + assertTrue(result.isSuccess) + + mockTransport.lastJsonRpcRequest()?.let { request -> + val params = request.params as JsonObject + val metaField = params["_meta"] as JsonObject + assertTrue(metaField.containsKey("config")) + } + } + + @Test + fun `should include empty meta object when meta parameter not provided`() = runTest { + client.callTool("test-tool", mapOf("arg" to "value")) + + mockTransport.lastJsonRpcRequest()?.let { request -> + val params = request.params as JsonObject + val metaField = params["_meta"] as JsonObject + assertTrue(metaField.isEmpty(), "Meta field should be empty when not provided") + } + } + + private fun createComplexMetaData(): Map = buildMap { + put("string", "text") + put("number", 42) + put("boolean", true) + put("null_value", null) + put("list", listOf(1, 2, 3)) + put("map", mapOf("nested" to "value")) + put("enum", "STRING") + put("int_array", intArrayOf(1, 2, 3)) + } + + private fun buildNestedConfiguration(): Map = buildMap { + put( + "config", + buildMap { + put( + "database", + buildMap { + put("host", "localhost") + put("port", 5432) + }, + ) + put("features", listOf("feature1", "feature2")) + }, + ) + } +} + +suspend fun OldSchemaMockTransport.lastJsonRpcRequest(): JSONRPCRequest? = + getSentMessages().lastOrNull() as? JSONRPCRequest diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaMockTransport.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaMockTransport.kt new file mode 100644 index 0000000..1a0db70 --- /dev/null +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaMockTransport.kt @@ -0,0 +1,95 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.modelcontextprotocol.kotlin.sdk.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.InitializeResult +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.shared.Transport +import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class OldSchemaMockTransport : Transport { + private val _sentMessages = mutableListOf() + private val _receivedMessages = mutableListOf() + private val mutex = Mutex() + + suspend fun getSentMessages() = mutex.withLock { _sentMessages.toList() } + suspend fun getReceivedMessages() = mutex.withLock { _receivedMessages.toList() } + + private var onMessageBlock: (suspend (JSONRPCMessage) -> Unit)? = null + private var onCloseBlock: (() -> Unit)? = null + private var onErrorBlock: ((Throwable) -> Unit)? = null + + override suspend fun start() = Unit + + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + mutex.withLock { + _sentMessages += message + } + + // Auto-respond to initialization and tool calls + when (message) { + is JSONRPCRequest -> { + when (message.method) { + "initialize" -> { + val initResponse = JSONRPCResponse( + id = message.id, + result = InitializeResult( + protocolVersion = "2024-11-05", + capabilities = ServerCapabilities( + tools = ServerCapabilities.Tools(listChanged = null), + ), + serverInfo = Implementation("mock-server", "1.0.0"), + ), + ) + onMessageBlock?.invoke(initResponse) + } + + "tools/call" -> { + val toolResponse = JSONRPCResponse( + id = message.id, + result = CallToolResult( + content = listOf(), + isError = false, + ), + ) + onMessageBlock?.invoke(toolResponse) + } + } + } + + else -> { + // Handle other message types if needed + } + } + } + + override suspend fun close() { + onCloseBlock?.invoke() + } + + override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) { + onMessageBlock = { message -> + mutex.withLock { + _receivedMessages += message + } + block(message) + } + } + + override fun onClose(block: () -> Unit) { + onCloseBlock = block + } + + override fun onError(block: (Throwable) -> Unit) { + onErrorBlock = block + } + + fun setupInitializationResponse() { + // This method helps set up the mock for proper initialization + } +} diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaStreamableHttpClientTransportTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaStreamableHttpClientTransportTest.kt new file mode 100644 index 0000000..c5be21e --- /dev/null +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldSchemaStreamableHttpClientTransportTest.kt @@ -0,0 +1,422 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandler +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.sse.SSE +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import io.ktor.utils.io.ByteReadChannel +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.JSONRPCNotification +import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest +import io.modelcontextprotocol.kotlin.sdk.RequestId +import io.modelcontextprotocol.kotlin.sdk.shared.McpJson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class OldSchemaStreamableHttpClientTransportTest { + + private fun createTransport(handler: MockRequestHandler): StreamableHttpClientTransport { + val mockEngine = MockEngine(handler) + val httpClient = HttpClient(mockEngine) { + install(SSE) { + reconnectionTime = 1.seconds + } + } + + return StreamableHttpClientTransport(httpClient, url = "http://localhost:8080/mcp") + } + + @Test + fun testSendJsonRpcMessage() = runTest { + val message = JSONRPCRequest( + id = RequestId.StringId("test-id"), + method = "test", + params = buildJsonObject { }, + ) + + val transport = createTransport { request -> + assertEquals(HttpMethod.Post, request.method) + assertEquals("http://localhost:8080/mcp", request.url.toString()) + assertEquals(ContentType.Application.Json, request.body.contentType) + + val body = (request.body as TextContent).text + val decodedMessage = McpJson.decodeFromString(body) + assertEquals(message, decodedMessage) + + respond( + content = "", + status = HttpStatusCode.Accepted, + ) + } + + transport.start() + transport.send(message) + transport.close() + } + + @Test + fun testStoreSessionId() = runTest { + val initMessage = JSONRPCRequest( + id = RequestId.StringId("test-id"), + method = "initialize", + params = buildJsonObject { + put( + "clientInfo", + buildJsonObject { + put("name", JsonPrimitive("test-client")) + put("version", JsonPrimitive("1.0")) + }, + ) + put("protocolVersion", JsonPrimitive("2025-06-18")) + }, + ) + + val transport = createTransport { request -> + when (val msg = McpJson.decodeFromString((request.body as TextContent).text)) { + is JSONRPCRequest if msg.method == "initialize" -> respond( + content = "", + status = HttpStatusCode.OK, + headers = headersOf("mcp-session-id", "test-session-id"), + ) + + is JSONRPCNotification if msg.method == "test" -> { + assertEquals("test-session-id", request.headers["mcp-session-id"]) + respond( + content = "", + status = HttpStatusCode.Accepted, + ) + } + + else -> error("Unexpected message: $msg") + } + } + + transport.start() + transport.send(initMessage) + + assertEquals("test-session-id", transport.sessionId) + + transport.send(JSONRPCNotification(method = "test")) + + transport.close() + } + + @Test + fun testTerminateSession() = runTest { +// transport.sessionId = "test-session-id" + + val transport = createTransport { request -> + assertEquals(HttpMethod.Delete, request.method) + assertEquals("test-session-id", request.headers["mcp-session-id"]) + respond( + content = "", + status = HttpStatusCode.OK, + ) + } + + transport.start() + transport.terminateSession() + + assertNull(transport.sessionId) + transport.close() + } + + @Test + fun testTerminateSessionHandle405() = runTest { +// transport.sessionId = "test-session-id" + + val transport = createTransport { request -> + assertEquals(HttpMethod.Delete, request.method) + respond( + content = "", + status = HttpStatusCode.MethodNotAllowed, + ) + } + + transport.start() + // Should not throw for 405 + transport.terminateSession() + + // Session ID should still be cleared + assertNull(transport.sessionId) + transport.close() + } + + @Test + fun testProtocolVersionHeader() = runTest { + val transport = createTransport { request -> + assertEquals("2025-06-18", request.headers["mcp-protocol-version"]) + respond( + content = "", + status = HttpStatusCode.Accepted, + ) + } + transport.protocolVersion = "2025-06-18" + + transport.start() + transport.send(JSONRPCNotification(method = "test")) + transport.close() + } + + // Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support + @Ignore + @Test + fun testNotificationSchemaE2E() = runTest { + val receivedMessages = mutableListOf() + var sseStarted = false + + val transport = createTransport { request -> + when (request.method) { + HttpMethod.Post if request.body.toString().contains("notifications/initialized") -> { + respond( + content = "", + status = HttpStatusCode.Accepted, + headers = headersOf("mcp-session-id", "notification-test-session"), + ) + } + + // Handle SSE connection + HttpMethod.Get -> { + sseStarted = true + val sseContent = buildString { + // Server sends various notifications + appendLine("event: message") + appendLine("id: 1") + appendLine( + """data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""", + ) + appendLine() + + appendLine("event: message") + appendLine("id: 2") + appendLine("""data: {"jsonrpc":"2.0","method":"notifications/resources/list_changed"}""") + appendLine() + + appendLine("event: message") + appendLine("id: 3") + appendLine("""data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed"}""") + appendLine() + } + respond( + content = ByteReadChannel(sseContent), + status = HttpStatusCode.OK, + headers = headersOf( + HttpHeaders.ContentType, + ContentType.Text.EventStream.toString(), + ), + ) + } + + // Handle regular notifications + HttpMethod.Post -> { + respond( + content = "", + status = HttpStatusCode.Accepted, + ) + } + + else -> respond("", HttpStatusCode.OK) + } + } + + transport.onMessage { message -> + receivedMessages.add(message) + } + + transport.start() + + // Test 1: Send initialized notification to trigger SSE + val initializedNotification = JSONRPCNotification( + method = "notifications/initialized", + params = buildJsonObject { + put("protocolVersion", JsonPrimitive("1.0")) + put( + "capabilities", + buildJsonObject { + put("tools", JsonPrimitive(true)) + put("resources", JsonPrimitive(true)) + }, + ) + }, + ) + + transport.send(initializedNotification) + + // Verify SSE was triggered + assertTrue(sseStarted, "SSE should start after initialized notification") + + // Test 2: Verify received notifications + assertEquals(3, receivedMessages.size) + assertTrue(receivedMessages.all { it is JSONRPCNotification }) + + val notifications = receivedMessages.filterIsInstance() + + // Verify progress notification + val progressNotif = notifications[0] + assertEquals("notifications/progress", progressNotif.method) + val progressParams = progressNotif.params as JsonObject + assertEquals("upload-123", (progressParams["progressToken"] as JsonPrimitive).content) + assertEquals(50, (progressParams["progress"] as JsonPrimitive).content.toInt()) + + // Verify list changed notifications + assertEquals("notifications/resources/list_changed", notifications[1].method) + assertEquals("notifications/tools/list_changed", notifications[2].method) + + // Test 3: Send various client notifications + val clientNotifications = listOf( + JSONRPCNotification( + method = "notifications/progress", + params = buildJsonObject { + put("progressToken", JsonPrimitive("download-456")) + put("progress", JsonPrimitive(75)) + }, + ), + JSONRPCNotification( + method = "notifications/cancelled", + params = buildJsonObject { + put("requestId", JsonPrimitive("req-789")) + put("reason", JsonPrimitive("user_cancelled")) + }, + ), + JSONRPCNotification( + method = "notifications/message", + params = buildJsonObject { + put("level", JsonPrimitive("info")) + put("message", JsonPrimitive("Operation completed")) + put( + "data", + buildJsonObject { + put("duration", JsonPrimitive(1234)) + }, + ) + }, + ), + ) + + // Send all client notifications + clientNotifications.forEach { notification -> + transport.send(notification) + } + + // Verify session ID is maintained + assertEquals("notification-test-session", transport.sessionId) + transport.close() + } + + // Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support + @Ignore + @Test + fun testNotificationWithResumptionToken() = runTest { + var resumptionTokenReceived: String? = null + var lastEventIdSent: String? = null + + val transport = createTransport { request -> + // Capture Last-Event-ID header + lastEventIdSent = request.headers["Last-Event-ID"] + + when (request.method) { + HttpMethod.Get -> { + val sseContent = buildString { + appendLine("event: message") + appendLine("id: resume-100") + appendLine( + """data: {"jsonrpc":"2.0","method":"notifications/resumed","params":{"fromToken":"$lastEventIdSent"}}""", + ) + appendLine() + } + respond( + content = ByteReadChannel(sseContent), + status = HttpStatusCode.OK, + headers = headersOf( + HttpHeaders.ContentType, + ContentType.Text.EventStream.toString(), + ), + ) + } + + else -> respond("", HttpStatusCode.Accepted) + } + } + + transport.start() + + // Send notification with resumption token + transport.send( + message = JSONRPCNotification( + method = "notifications/test", + params = buildJsonObject { + put("data", JsonPrimitive("test-data")) + }, + ), + resumptionToken = "previous-token-99", + onResumptionToken = { token -> + resumptionTokenReceived = token + }, + ) + + // Wait for response + delay(1.seconds) + + // Verify resumption token was sent in header + assertEquals("previous-token-99", lastEventIdSent) + + // Verify new resumption token was received + assertEquals("resume-100", resumptionTokenReceived) + transport.close() + } + + @Test + fun testClientConnectWithInvalidJson() = runTest { + // Transport under test: respond with invalid JSON for the initialize request + val transport = createTransport { _ -> + respond( + "this is not valid json", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } + + val client = Client( + clientInfo = Implementation( + name = "test-client", + version = "1.0", + ), + ) + + try { + // Real time-keeping is needed; otherwise Protocol will always throw TimeoutCancellationException in tests + assertFailsWith( + message = "Expected client.connect to fail on invalid JSON response", + ) { + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5.seconds) { + client.connect(transport) + } + } + } + } finally { + transport.close() + } + } +} diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/channel/ChannelClientTransportLifecycleTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/channel/ChannelClientTransportLifecycleTest.kt deleted file mode 100644 index 853a433..0000000 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/channel/ChannelClientTransportLifecycleTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.client.channel - -import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi -import io.modelcontextprotocol.kotlin.sdk.client.AbstractClientTransportLifecycleTest -import io.modelcontextprotocol.kotlin.sdk.testing.ChannelTransport -import kotlin.test.Ignore -import kotlin.test.Test - -@OptIn(ExperimentalMcpApi::class) -class ChannelClientTransportLifecycleTest : AbstractClientTransportLifecycleTest() { - - /** - * Dummy method to make IDE treat this class as a test - */ - @Test - @Ignore - fun dummyTest() = Unit - - override fun createTransport(): ChannelTransport = ChannelTransport() -} 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 deleted file mode 100644 index 14af546..0000000 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/MockSseClientEngine.kt +++ /dev/null @@ -1,107 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.client.sse - -import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.engine.HttpClientEngineConfig -import io.ktor.client.plugins.sse.SSECapability -import io.ktor.client.request.HttpRequestData -import io.ktor.client.request.HttpResponseData -import io.ktor.client.request.ResponseAdapterAttributeKey -import io.ktor.http.Headers -import io.ktor.http.HttpMethod -import io.ktor.http.HttpProtocolVersion -import io.ktor.http.HttpStatusCode -import io.ktor.http.headersOf -import io.ktor.util.date.GMTDate -import io.ktor.utils.io.ByteChannel -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.InternalAPI -import io.ktor.utils.io.writeStringUtf8 -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlin.coroutines.CoroutineContext - -@OptIn(InternalAPI::class) -internal open class MockSseClientEngine( - private val endpoint: String, - private val onPostRequest: (postRequest: HttpRequestData) -> Unit, - override val dispatcher: CoroutineDispatcher = Dispatchers.Default, -) : HttpClientEngine { - - override val config = HttpClientEngineConfig() - override val supportedCapabilities = setOf(SSECapability) - - private val job = SupervisorJob() - override val coroutineContext: CoroutineContext = dispatcher + job - - private val channel by lazy { ByteChannel(true) } - - override suspend fun execute(data: HttpRequestData): HttpResponseData = when (data.method) { - HttpMethod.Get -> respondWithSseEndpointEvent(data) - - HttpMethod.Post -> { - onPostRequest(data) - respondWithAccepted() - } - - else -> error("Unsupported method: ${data.method}") - } - - override fun close() { - channel.close() - 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 { - val sseResponseAdapter = requireNotNull(data.attributes.getOrNull(ResponseAdapterAttributeKey)) { - "Missing SSE response adapter." - } - channel.writeStringUtf8(endpointEvent()) - channel.flush() - val responseContext = responseContext() - val responseBody = sseResponseAdapter.adapt( - data, - HttpStatusCode.OK, - eventStreamHeader, - channel, - data.body, - responseContext, - ) ?: error("Failed to adapt SSE response.") - return HttpResponseData( - statusCode = HttpStatusCode.OK, - requestTime = GMTDate(), - headers = eventStreamHeader, - version = HttpProtocolVersion.HTTP_1_1, - body = responseBody, - callContext = responseContext, - ) - } - - private val eventStreamHeader by lazy { headersOf("Content-Type", "text/event-stream") } - - private fun endpointEvent(): String = buildString { - appendLine("event: endpoint") - appendLine("data: $endpoint") - appendLine() - } - - private fun respondWithAccepted(): HttpResponseData = HttpResponseData( - statusCode = HttpStatusCode.Accepted, - requestTime = GMTDate(), - headers = Headers.Empty, - version = HttpProtocolVersion.HTTP_1_1, - body = ByteReadChannel.Empty, - callContext = responseContext(), - ) -} 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 deleted file mode 100644 index 319ba2a..0000000 --- a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/sse/SseClientTransportTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -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 -import io.ktor.client.plugins.sse.SSE -import io.ktor.client.request.HttpRequestData -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 { - - @Test - fun `absolute path endpoint resolves against origin`() = runTest { - // Given - val sseUrl = "http://example.com/api/mcp/sse" - - // And - val endpointEvent = "/messages?sessionId=abc" - - // And - val engine = CapturingSseClientEngine(endpoint = endpointEvent) - val transport = sseTransport(sseUrl, engine) - - // When - transport.start() - transport.send(JSONRPCNotification(method = "test")) - - // Then - val capturedPosts = engine.capturedPosts - capturedPosts shouldHaveSize 1 - capturedPosts[0].url.toString() shouldBe "http://example.com/messages?sessionId=abc" - - // Cleanup - transport.close() - engine.close() - } - - @Test - fun `relative path endpoint resolves against baseUrl`() = runTest { - // Given - val sseUrl = "http://example.com/api/mcp/sse" - - // And - val endpointEvent = "post?sessionId=xyz" - - // And - val engine = CapturingSseClientEngine(endpoint = endpointEvent) - val transport = sseTransport(sseUrl, engine) - - // When - transport.start() - transport.send(JSONRPCNotification(method = "test")) - - // Then - val capturedPosts = engine.capturedPosts - capturedPosts shouldHaveSize 1 - capturedPosts[0].url.toString() shouldBe "http://example.com/api/mcp/post?sessionId=xyz" - - // Cleanup - transport.close() - engine.close() - } - - @Test - fun `full url endpoint is used as-is`() = runTest { - // Given - val sseUrl = "http://example.com/api/mcp/sse" - - // And - val endpointEvent = "https://example.com/messages?sessionId=abc" - - // And - val engine = CapturingSseClientEngine(endpoint = endpointEvent) - val transport = sseTransport(sseUrl, engine) - - // When - transport.start() - transport.send(JSONRPCNotification(method = "test")) - - // Then - val capturedPosts = engine.capturedPosts - capturedPosts shouldHaveSize 1 - capturedPosts[0].url.toString() shouldBe "https://example.com/messages?sessionId=abc" - - // Cleanup - transport.close() - 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) - - private class CapturingSseClientEngine private constructor( - endpoint: String, - private val capturedPostRequests: MutableList = mutableListOf(), - ) : MockSseClientEngine(endpoint, capturedPostRequests::add) { - - constructor(endpoint: String) : this(endpoint, mutableListOf()) - - val capturedPosts: List - get() = capturedPostRequests - } -} 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..5cf96b6 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 @@ -1,10 +1,5 @@ package io.modelcontextprotocol.kotlin.sdk.client.streamable.http -import io.kotest.assertions.fail -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.MockRequestHandler @@ -19,17 +14,11 @@ 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 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.McpJson -import io.modelcontextprotocol.kotlin.sdk.types.RPCError -import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest @@ -44,15 +33,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,19 +45,7 @@ class StreamableHttpClientTransportTest { } } - return StreamableHttpClientTransport( - httpClient, - url = "http://localhost:8080/mcp", - maxInlineSseEventSize = maxInlineSseEventSize, - ) - } - - private fun buildSseMessage(id: String, method: String, params: String): String = buildString { - appendLine("event: message") - appendLine("id: $id") - val data = """{"jsonrpc":"2.0","method":"$method","params":$params}""" - appendLine("data: $data") - appendLine() + return StreamableHttpClientTransport(httpClient, url = "http://localhost:8080/mcp") } @Test @@ -103,42 +76,6 @@ class StreamableHttpClientTransportTest { transport.close() } - @Test - fun testStartTransportTwoTimesThrowsException() = runTest { - val transport = createTransport { _ -> - respond( - content = "", - status = HttpStatusCode.Accepted, - ) - } - - transport.start() - try { - transport.start() - } catch (e: IllegalStateException) { - e shouldBe IllegalStateException("Can't change state: expected transport state New, but found Operational.") - } - transport.close() - } - - @Test - fun testCloseTransportTwoTimesIsPossible() = runTest { - val transport = createTransport { _ -> - respond( - content = "", - status = HttpStatusCode.Accepted, - ) - } - - transport.start() - transport.close() - try { - transport.close() - } catch (_: Exception) { - fail("We expect no exceptions when closing already closed transport") - } - } - @Test fun testStoreSessionId() = runTest { val initMessage = JSONRPCRequest( @@ -157,17 +94,14 @@ class StreamableHttpClientTransportTest { ) val transport = createTransport { request -> - val msg = McpJson.decodeFromString( - (request.body as TextContent).text, - ) - when { - msg is JSONRPCRequest && msg.method == "initialize" -> respond( + when (val msg = McpJson.decodeFromString((request.body as TextContent).text)) { + is JSONRPCRequest if msg.method == "initialize" -> respond( content = "", status = HttpStatusCode.OK, headers = headersOf("mcp-session-id", "test-session-id"), ) - msg is JSONRPCNotification && msg.method == "test" -> { + is JSONRPCNotification if msg.method == "test" -> { assertEquals("test-session-id", request.headers["mcp-session-id"]) respond( content = "", @@ -246,100 +180,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 @@ -348,8 +188,8 @@ class StreamableHttpClientTransportTest { var sseStarted = false val transport = createTransport { request -> - when { - request.method == HttpMethod.Post && request.body.toString().contains("notifications/initialized") -> { + when (request.method) { + HttpMethod.Post if request.body.toString().contains("notifications/initialized") -> { respond( content = "", status = HttpStatusCode.Accepted, @@ -358,7 +198,7 @@ class StreamableHttpClientTransportTest { } // Handle SSE connection - request.method == HttpMethod.Get -> { + HttpMethod.Get -> { sseStarted = true val sseContent = buildString { // Server sends various notifications @@ -390,7 +230,7 @@ class StreamableHttpClientTransportTest { } // Handle regular notifications - request.method == HttpMethod.Post -> { + HttpMethod.Post -> { respond( content = "", status = HttpStatusCode.Accepted, @@ -428,9 +268,10 @@ class StreamableHttpClientTransportTest { assertTrue(sseStarted, "SSE should start after initialized notification") // Test 2: Verify received notifications - receivedMessages shouldHaveSize 3 + assertEquals(3, receivedMessages.size) + assertTrue(receivedMessages.all { it is JSONRPCNotification }) + val notifications = receivedMessages.filterIsInstance() - notifications shouldHaveSize 3 // Verify progress notification val progressNotif = notifications[0] @@ -566,7 +407,7 @@ class StreamableHttpClientTransportTest { try { // Real time-keeping is needed; otherwise Protocol will always throw TimeoutCancellationException in tests - val mcpException = assertFailsWith( + assertFailsWith( message = "Expected client.connect to fail on invalid JSON response", ) { withContext(Dispatchers.Default.limitedParallelism(1)) { @@ -575,576 +416,8 @@ class StreamableHttpClientTransportTest { } } } - mcpException.code shouldBe RPCError.ErrorCode.INTERNAL_ERROR } finally { transport.close() } } - - @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 -> - if (request.method == HttpMethod.Post) { - val sseContent = buildString { - append( - buildSseMessage( - id = "1", - method = "notifications/progress", - params = """{"progressToken":"task-1","progress":50}""", - ), - ) - append( - buildSseMessage( - id = "2", - method = "notifications/tools/list_changed", - params = "{}", - ), - ) - } - - respond( - content = ByteReadChannel(sseContent), - status = HttpStatusCode.OK, - headers = headersOf( - HttpHeaders.ContentType, - ContentType.Text.EventStream.toString(), - ), - ) - } else { - respond("", HttpStatusCode.OK) - } - } - - val receivedMessages = mutableListOf() - val twoMessagesReceived = CompletableDeferred() - - transport.onMessage { message -> - receivedMessages.add(message) - if (receivedMessages.size >= 2 && !twoMessagesReceived.isCompleted) { - twoMessagesReceived.complete(Unit) - } - } - - transport.start() - - transport.send( - JSONRPCRequest( - id = "test-1", - method = "test", - params = buildJsonObject { }, - ), - ) - - eventually { - twoMessagesReceived.await() - } - - receivedMessages shouldHaveSize 2 - - val firstNotification = receivedMessages[0] as JSONRPCNotification - firstNotification.method shouldBe "notifications/progress" - - val secondNotification = receivedMessages[1] as JSONRPCNotification - secondNotification.method shouldBe "notifications/tools/list_changed" - - transport.close() - } - - @Test - fun testErrorInSSEResponse() = runTest { - val errorMessage = "Something went wrong on the server" - val transport = createTransport { request -> - if (request.method == HttpMethod.Post) { - val sseContent = buildString { - appendLine("event: error") - appendLine("data: $errorMessage") - appendLine() - } - - respond( - content = ByteReadChannel(sseContent), - status = HttpStatusCode.OK, - headers = headersOf( - HttpHeaders.ContentType, - ContentType.Text.EventStream.toString(), - ), - ) - } else { - respond("", HttpStatusCode.OK) - } - } - - val receivedErrors = mutableListOf() - val errorDeferred = CompletableDeferred() - - transport.onError { error -> - receivedErrors.add(error) - if (!errorDeferred.isCompleted) { - errorDeferred.complete(error) - } - } - - transport.start() - - transport.send( - JSONRPCRequest( - id = "test-error", - method = "test", - params = buildJsonObject { }, - ), - ) - - val error = eventually { errorDeferred.await() } - - receivedErrors.size shouldBe 1 - error.message shouldBe "Streamable HTTP error: $errorMessage" - - transport.close() - } - - @Test - fun testCloseTransportDuringEventStream() = runTest { - val transport = createTransport { request -> - if (request.method == HttpMethod.Post) { - val sseContent = buildString { - append( - buildSseMessage( - id = "1", - method = "notifications/progress", - params = """{"progressToken":"task-1","progress":25}""", - ), - ) - append( - buildSseMessage( - id = "2", - method = "notifications/progress", - params = """{"progressToken":"task-1","progress":50}""", - ), - ) - append( - buildSseMessage( - id = "3", - method = "notifications/progress", - params = """{"progressToken":"task-1","progress":75}""", - ), - ) - } - respond( - content = ByteReadChannel(sseContent), - status = HttpStatusCode.OK, - headers = headersOf( - HttpHeaders.ContentType, - ContentType.Text.EventStream.toString(), - ), - ) - } else { - respond("", HttpStatusCode.OK) - } - } - - val (receivedMessages, receivedErrors) = setupTransportAndCollectMessages(transport) - - eventually { - while (receivedMessages.isEmpty()) { - delay(10.milliseconds) - } - } - - transport.close() - - receivedMessages shouldHaveSize 3 - - receivedMessages.forEach { message -> - message.shouldBeInstanceOf() - message.method shouldBe "notifications/progress" - } - receivedErrors shouldHaveSize 0 - } - - @Test - fun testInlineSseRetryParsing() = runTest { - val transport = createTransport { request -> - if (request.method == HttpMethod.Post) { - val sseContent = buildString { - appendLine("retry: 5000") - appendLine("id: ev-1") - appendLine("event: message") - appendLine("""data: {"jsonrpc":"2.0","id":"req-1","result":{"tools":[]}}""") - appendLine() - } - - respond( - content = ByteReadChannel(sseContent), - status = HttpStatusCode.OK, - headers = headersOf( - HttpHeaders.ContentType, - ContentType.Text.EventStream.toString(), - ), - ) - } else { - respond("", HttpStatusCode.OK) - } - } - - val receivedMessages = mutableListOf() - val responseReceived = CompletableDeferred() - - transport.onMessage { message -> - receivedMessages.add(message) - if (message is JSONRPCResponse && !responseReceived.isCompleted) { - responseReceived.complete(Unit) - } - } - - transport.start() - - transport.send( - JSONRPCRequest( - id = "req-1", - method = "test", - params = buildJsonObject { }, - ), - ) - - eventually { - responseReceived.await() - } - - receivedMessages shouldHaveSize 1 - val response = receivedMessages[0] as JSONRPCResponse - response.id shouldBe RequestId.StringId("req-1") - - transport.close() - } - - @Test - fun testInlineSseHasPrimingEventTracking() = runTest { - val transport = createTransport { request -> - if (request.method == HttpMethod.Post) { - val sseContent = buildString { - // Event with id = priming event - appendLine("id: priming-1") - appendLine("event: message") - appendLine( - """data: {"jsonrpc":"2.0","method":"notifications/progress",""" + - """"params":{"progressToken":"t1","progress":50}}""", - ) - appendLine() - // Notification without id - appendLine("event: message") - appendLine("""data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed"}""") - appendLine() - } - - respond( - content = ByteReadChannel(sseContent), - status = HttpStatusCode.OK, - headers = headersOf( - HttpHeaders.ContentType, - ContentType.Text.EventStream.toString(), - ), - ) - } else { - respond("", HttpStatusCode.OK) - } - } - - val receivedMessages = mutableListOf() - val twoMessagesReceived = CompletableDeferred() - - transport.onMessage { message -> - receivedMessages.add(message) - if (receivedMessages.size >= 2 && !twoMessagesReceived.isCompleted) { - twoMessagesReceived.complete(Unit) - } - } - - transport.start() - - transport.send( - JSONRPCRequest( - id = "test-1", - method = "test", - params = buildJsonObject { }, - ), - ) - - eventually { - twoMessagesReceived.await() - } - - receivedMessages shouldHaveSize 2 - // Both should be notifications (no JSONRPCResponse → POST-to-GET reconnect would be triggered) - receivedMessages[0].shouldBeInstanceOf() - receivedMessages[1].shouldBeInstanceOf() - - transport.close() - } - - @Test - fun testInlineSseResponseStopsReconnection() = runTest { - val transport = createTransport { request -> - if (request.method == HttpMethod.Post) { - val sseContent = buildString { - appendLine("id: ev-1") - appendLine("event: message") - appendLine("""data: {"jsonrpc":"2.0","id":"req-1","result":{"tools":[]}}""") - appendLine() - } - - respond( - content = ByteReadChannel(sseContent), - status = HttpStatusCode.OK, - headers = headersOf( - HttpHeaders.ContentType, - ContentType.Text.EventStream.toString(), - ), - ) - } else { - respond("", HttpStatusCode.OK) - } - } - - val receivedMessages = mutableListOf() - val responseReceived = CompletableDeferred() - - transport.onMessage { message -> - receivedMessages.add(message) - if (message is JSONRPCResponse && !responseReceived.isCompleted) { - responseReceived.complete(Unit) - } - } - - transport.start() - - transport.send( - JSONRPCRequest( - id = "req-1", - method = "tools/list", - params = buildJsonObject { }, - ), - ) - - eventually { - responseReceived.await() - } - - receivedMessages shouldHaveSize 1 - // Response received → no reconnection triggered (hasPrimingEvent=true, receivedResponse=true) - val response = receivedMessages[0] as JSONRPCResponse - response.id shouldBe RequestId.StringId("req-1") - - transport.close() - } - - @Test - fun testDeprecatedConstructorStillWorks() = runTest { - val mockEngine = MockEngine { _ -> - respond( - content = "", - status = HttpStatusCode.Accepted, - ) - } - val httpClient = HttpClient(mockEngine) { - install(SSE) - } - - val transport = - StreamableHttpClientTransport(httpClient, url = "http://localhost:8080/mcp", reconnectionTime = 2.seconds) - - transport.start() - transport.send(JSONRPCNotification(method = "test")) - transport.close() - } - - private suspend fun setupTransportAndCollectMessages( - transport: StreamableHttpClientTransport, - ): Pair, MutableList> { - val receivedMessages = mutableListOf() - val receivedErrors = mutableListOf() - - transport.onMessage { message -> - receivedMessages.add(message) - } - - transport.onError { error -> - receivedErrors.add(error) - } - - transport.start() - - transport.send( - JSONRPCRequest( - id = "test-close", - method = "test", - params = buildJsonObject { }, - ), - ) - - return receivedMessages to receivedErrors - } } 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..77ba553 --- /dev/null +++ b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractStreamableHttpClientTest.kt @@ -0,0 +1,35 @@ +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.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.checkForUnmatchedRequests() + } + + 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..af2110a --- /dev/null +++ b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/MockMcp.kt @@ -0,0 +1,229 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import dev.mokksy.mokksy.BuildingStep +import dev.mokksy.mokksy.Mokksy +import dev.mokksy.mokksy.StubConfiguration +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) { + + private val mokksy: Mokksy = Mokksy(verbose = verbose) + + fun checkForUnmatchedRequests() { + mokksy.checkForUnmatchedRequests() + } + + 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 (request.body.id) { + is RequestId.NumberId -> (request.body.id as RequestId.NumberId).value.toString() + is RequestId.StringId -> "\"${(request.body.id as RequestId.StringId).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 + } + } +} diff --git a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldStreamableHttpClientTest.kt b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldStreamableHttpClientTest.kt new file mode 100644 index 0000000..33897e6 --- /dev/null +++ b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/OldStreamableHttpClientTest.kt @@ -0,0 +1,219 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.kotest.matchers.collections.shouldContain +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.ClientCapabilities +import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject +import io.modelcontextprotocol.kotlin.sdk.Implementation +import io.modelcontextprotocol.kotlin.sdk.Tool +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +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 + +/** + * Integration tests for the `StreamableHttpClientTransport` implementation + * using the [Mokksy](https://mokksy.dev) library + * to simulate Streaming HTTP with server-sent events (SSE). + * @author Konstantin Pavlov + */ +@OptIn(ExperimentalUuidApi::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Suppress("LongMethod") +internal class OldStreamableHttpClientTest : AbstractStreamableHttpClientTest() { + + @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 = Tool.Input( + properties = buildJsonObject { + putJsonObject("location") { + put("type", "string") + put("description", "City name or zip code") + } + }, + required = listOf("location"), + ), + outputSchema = Tool.Output( + properties = buildJsonObject { + putJsonObject("temperature") { + put("type", "number") + put("description", "Temperature, Celsius") + } + }, + required = listOf("temperature"), + ), + annotations = null, + _meta = EmptyJsonObject, + ) + + mockMcp.mockUnsubscribeRequest(sessionId = sessionId) + + 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 {} + } + + mockMcp.mockUnsubscribeRequest(sessionId = sessionId) + + connect(client) + + delay(1.seconds) + + client.ping() // connection is still alive + + client.close() + } +} 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..06d485f --- /dev/null +++ b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt @@ -0,0 +1,271 @@ +package io.modelcontextprotocol.kotlin.sdk.client + +import io.kotest.matchers.collections.shouldContain +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) +@Suppress("LongMethod") +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, + ) + + mockMcp.mockUnsubscribeRequest(sessionId = sessionId) + + 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 {} + } + + mockMcp.mockUnsubscribeRequest(sessionId = sessionId) + + connect(client) + + delay(1.seconds) + + client.ping() // connection is still alive + + client.close() + } +} diff --git a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/stdio/StdioClientTransportErrorHandlingTest.kt b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/stdio/StdioClientTransportErrorHandlingTest.kt index 3c1915f..b227d5c 100644 --- a/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/stdio/StdioClientTransportErrorHandlingTest.kt +++ b/kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/stdio/StdioClientTransportErrorHandlingTest.kt @@ -1,12 +1,12 @@ package io.modelcontextprotocol.kotlin.sdk.client.stdio -import io.kotest.assertions.nondeterministic.eventually import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import io.kotest.matchers.types.shouldBeSameInstanceAs import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage @@ -14,27 +14,20 @@ import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlinx.io.Buffer -import kotlinx.io.asSource -import kotlinx.io.buffered import kotlinx.io.writeString import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource -import java.io.PipedInputStream -import java.io.PipedOutputStream import java.util.stream.Stream import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.test.Test import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds /** * Tests for StdioClientTransport error handling: EOF, IO errors, and edge cases. @@ -45,23 +38,17 @@ class StdioClientTransportErrorHandlingTest { @OptIn(ExperimentalAtomicApi::class) @Test - fun `should continue on stderr EOF`(): Unit = runBlocking(Dispatchers.IO) { - // Empty stderr = immediate EOF + fun `should continue on stderr EOF`() = runTest { val stderrBuffer = Buffer() + // Empty stderr = immediate EOF - // Create a pipe for stdin that stays open (simulates real stdin behavior) - val pipedOutputStream = PipedOutputStream() - val pipedInputStream = PipedInputStream(pipedOutputStream) - - // Write one message to stdin - pipedOutputStream.write("""data: {"jsonrpc":"2.0","method":"ping","id":1}\n\n""".toByteArray()) - pipedOutputStream.flush() - // Keep the pipe open by not closing pipedOutputStream - this prevents stdin EOF - + val inputBuffer = createNonEmptyBuffer { + """data: {"jsonrpc":"2.0","method":"ping","id":1}\n\n""" + } val outputBuffer = Buffer() transport = StdioClientTransport( - input = pipedInputStream.asSource().buffered(), + input = inputBuffer, output = outputBuffer, error = stderrBuffer, ) @@ -70,22 +57,13 @@ class StdioClientTransportErrorHandlingTest { transport.onClose { closeCalled.store(true) } transport.start() + delay(200.milliseconds) - // Wait for stderr EOF and stdin message to be processed - delay(500.milliseconds) - - // Transport should still be alive because stdin is still open (not EOF'd) + // Stderr EOF should not close transport closeCalled.load() shouldBe false - // Close pipes to trigger stdin EOF. - // `transport.close()` cann't help here, since the underlying Java read() is blocked on I/O operation - pipedOutputStream.close() - pipedInputStream.close() - - // Transport should close when stdin EOF is detected - eventually(2.seconds) { - closeCalled.load() shouldBe true - } + transport.close() + closeCalled.load() shouldBe true } @Test @@ -109,13 +87,9 @@ class StdioClientTransportErrorHandlingTest { transport.onClose { closeCallCount++ } transport.start() + delay(100.milliseconds) - // FATAL stderr should trigger close, wait for it to complete - eventually(2.seconds) { - closeCallCount shouldBe 1 - } - - // Explicit close after error already closed it (should be no-op) + // Explicit close after error already closed it transport.close() closeCallCount shouldBe 1 @@ -135,12 +109,10 @@ class StdioClientTransportErrorHandlingTest { transport.onError { errorCalled = true } transport.start() + delay(100.milliseconds) // Empty input should close cleanly without error - // Wait for EOF processing to complete, verify no error was called - eventually(2.seconds) { - errorCalled.shouldBeFalse() - } + errorCalled.shouldBeFalse() } companion object { @@ -166,21 +138,32 @@ class StdioClientTransportErrorHandlingTest { true, ErrorCode.INTERNAL_ERROR, ), + Arguments.of( + OutOfMemoryError(), + true, + ErrorCode.INTERNAL_ERROR, + ), + ) } @ParameterizedTest @MethodSource("exceptions") - fun `Send should handle exceptions`(throwable: Exception, shouldWrap: Boolean, expectedCode: Int?) = runTest { + fun `Send should handle exceptions`(throwable: Throwable, shouldWrap: Boolean, expectedCode: Int?) = runTest { val sendChannel: Channel = mockk(relaxed = true) - // Create stdin pipe that stays open to prevent transport from closing - val pipedOutputStream = PipedOutputStream() - val pipedInputStream = PipedInputStream(pipedOutputStream) - // Keep pipe open (don't write or close) - stdin will block on read, not EOF + // Create a stdin Source that never returns EOF to prevent transport from closing + // Use mockk since Source is a sealed interface + val stdin: kotlinx.io.Source = mockk(relaxed = true) + every { stdin.readAtMostTo(any(), any()) } coAnswers { + // Return 0 to indicate no data available (but not EOF) + // This keeps the transport alive without blocking + delay(10) // Small delay to prevent busy-waiting + 0L + } transport = StdioClientTransport( - input = pipedInputStream.asSource().buffered(), + input = stdin, output = Buffer(), sendChannel = sendChannel, ) @@ -202,10 +185,6 @@ class StdioClientTransportErrorHandlingTest { } else { exception shouldBeSameInstanceAs throwable } - - // Cleanup - pipedOutputStream.close() - pipedInputStream.close() } fun createNonEmptyBuffer(block: () -> String): Buffer { 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/Module.md b/kotlin-sdk-core/Module.md index b4ed71b..696360a 100644 --- a/kotlin-sdk-core/Module.md +++ b/kotlin-sdk-core/Module.md @@ -1,47 +1,5 @@ # Module kotlin-sdk-core -`kotlin-sdk-core` is the foundation of the MCP Kotlin SDK. It contains protocol message types, JSON handling, and -transport abstractions used by both client and server modules. No platform-specific code lives here; everything is -designed for Kotlin Multiplatform with explicit API mode enabled. +The MCP Kotlin SDK Core module provides fundamental functionality for interacting with Model Context Protocol (MCP). +This module serves as the foundation for building MCP-enabled applications in Kotlin. -## What the module provides - -- **Protocol model**: Complete MCP data classes for requests, results, notifications, capabilities, tools, prompts, - resources, logging, completion, sampling, elicitation, and roots. DSL helpers (`*.dsl.kt`) let you build messages - fluently without repeating boilerplate. -- **JSON utilities**: A shared `McpJson` configuration (kotlinx.serialization) with MCP-friendly settings (ignore - unknown keys, no class discriminator). Helpers for converting maps to `JsonElement`, `EmptyJsonObject`, and - encoding/decoding JSON-RPC envelopes. -- **Transport abstractions**: `Transport` and `AbstractTransport` define the message pipeline, callbacks, and error - handling. `WebSocketMcpTransport` adds a shared WebSocket implementation for both client and server sides, and - `ReadBuffer` handles streaming JSON-RPC framing. -- **Protocol engine**: The `Protocol` base class manages request/response correlation, notifications, progress tokens, - and capability assertions. Higher-level modules extend it to become `Client` and `Server`. -- **Errors and safety**: Common exception types (`McpException`, parsing errors) plus capability enforcement hooks - ensure callers cannot use endpoints the peer does not advertise. - -## Typical usage - -- Import MCP types to define capabilities and message payloads for your own transports or custom integrations. -- Extend `Protocol` if you need a bespoke peer role while reusing correlation logic and JSON-RPC helpers. -- Use the DSL builders to construct well-typed requests (tools, prompts, resources, logging, completion) instead of - crafting raw JSON. - -### Example: building a tool call request - -```kotlin -val request = CallToolRequest { - name = "summarize" - arguments = mapOf("text" to "Hello MCP") -} -``` - -### Example: parsing a JSON-RPC message - -```kotlin -val message: JSONRPCMessage = McpJson.decodeFromString(jsonString) -``` - -Use this module when you need the raw building blocks of MCP—types, JSON config, and transport base classes—whether to -embed in another runtime, author new transports, or contribute higher-level features in the client/server modules. The -APIs are explicit to keep the shared surface stable for downstream users. diff --git a/kotlin-sdk-core/api/kotlin-sdk-core.api b/kotlin-sdk-core/api/kotlin-sdk-core.api index b3e88d8..67acbdd 100644 --- a/kotlin-sdk-core/api/kotlin-sdk-core.api +++ b/kotlin-sdk-core/api/kotlin-sdk-core.api @@ -1,57 +1,423 @@ +public final class io/modelcontextprotocol/kotlin/sdk/CallToolResult { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/CallToolResult; +} + +public abstract interface class io/modelcontextprotocol/kotlin/sdk/CallToolResultBase { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/CallToolResultBase$Companion; + public abstract fun getContent ()Ljava/util/List; + public abstract fun getStructuredContent ()Lkotlinx/serialization/json/JsonObject; + public fun isError ()Ljava/lang/Boolean; +} + +public final class io/modelcontextprotocol/kotlin/sdk/CallToolResultBase$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/CallToolResultBase$DefaultImpls { + public static fun isError (Lio/modelcontextprotocol/kotlin/sdk/CallToolResultBase;)Ljava/lang/Boolean; +} + +public final class io/modelcontextprotocol/kotlin/sdk/CancelledNotification { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/CancelledNotification; + public final fun Params (Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CancelledNotificationParams; + public static synthetic fun Params$default (Lio/modelcontextprotocol/kotlin/sdk/CancelledNotification;Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CancelledNotificationParams; +} + +public final class io/modelcontextprotocol/kotlin/sdk/ClientCapabilities { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/ClientCapabilities; + public final fun Roots (Ljava/lang/Boolean;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots; + public static synthetic fun Roots$default (Lio/modelcontextprotocol/kotlin/sdk/ClientCapabilities;Ljava/lang/Boolean;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities$Roots; +} + +public final class io/modelcontextprotocol/kotlin/sdk/CompatibilityCallToolResult { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/CompatibilityCallToolResult$Companion; + public fun (Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/lang/Boolean;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Ljava/util/List;Lkotlinx/serialization/json/JsonObject;Ljava/lang/Boolean;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getContent ()Ljava/util/List; + public final fun getStructuredContent ()Lkotlinx/serialization/json/JsonObject; + public final fun getToolResult ()Lkotlinx/serialization/json/JsonObject; + public final fun get_meta ()Lkotlinx/serialization/json/JsonObject; + public final fun isError ()Ljava/lang/Boolean; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/CompatibilityCallToolResult$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/CompatibilityCallToolResult$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/CompatibilityCallToolResult; + 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/CompatibilityCallToolResult;)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/CompatibilityCallToolResult$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/CompleteRequest { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/CompleteRequest; + public final fun Argument (Ljava/lang/String;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequestParams$Argument; +} + +public final class io/modelcontextprotocol/kotlin/sdk/CompleteResult { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/CompleteResult; + public final fun Completion (Ljava/util/List;Ljava/lang/Integer;Ljava/lang/Boolean;)Lio/modelcontextprotocol/kotlin/sdk/types/CompleteResult$Completion; + public static synthetic fun Completion$default (Lio/modelcontextprotocol/kotlin/sdk/CompleteResult;Ljava/util/List;Ljava/lang/Integer;Ljava/lang/Boolean;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CompleteResult$Completion; +} + +public final class io/modelcontextprotocol/kotlin/sdk/CreateElicitationRequest { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/CreateElicitationRequest; + public final fun RequestedSchema (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema; + public static synthetic fun RequestedSchema$default (Lio/modelcontextprotocol/kotlin/sdk/CreateElicitationRequest;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema; +} + +public final class io/modelcontextprotocol/kotlin/sdk/CreateElicitationResult { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/CreateElicitationResult; +} + +public final class io/modelcontextprotocol/kotlin/sdk/CreateElicitationResult$Action { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/CreateElicitationResult$Action; + public final fun getAccept ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitResult$Action; + public final fun getCancel ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitResult$Action; + public final fun getDecline ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitResult$Action; +} + +public final class io/modelcontextprotocol/kotlin/sdk/CreateMessageRequest { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/CreateMessageRequest; + public final fun getIncludeContext ()Lio/modelcontextprotocol/kotlin/sdk/types/IncludeContext$Companion; +} + +public abstract interface class io/modelcontextprotocol/kotlin/sdk/ErrorCode { + public abstract fun getCode ()I +} + +public final class io/modelcontextprotocol/kotlin/sdk/ErrorCode$Defined { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/ErrorCode$Defined; + public final fun getConnectionClosed ()I + public final fun getInternalError ()I + public final fun getInvalidParams ()I + public final fun getInvalidRequest ()I + public final fun getMethodNotFound ()I + public final fun getParseError ()I + public final fun getRequestTimeout ()I +} + +public final class io/modelcontextprotocol/kotlin/sdk/ErrorCode$Unknown : io/modelcontextprotocol/kotlin/sdk/ErrorCode { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/ErrorCode$Unknown$Companion; + public fun (I)V + public final fun component1 ()I + public final fun copy (I)Lio/modelcontextprotocol/kotlin/sdk/ErrorCode$Unknown; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/ErrorCode$Unknown;IILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/ErrorCode$Unknown; + public fun equals (Ljava/lang/Object;)Z + public fun getCode ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/ErrorCode$Unknown$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/ErrorCode$Unknown$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/ErrorCode$Unknown; + 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/ErrorCode$Unknown;)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/ErrorCode$Unknown$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public abstract interface annotation class io/modelcontextprotocol/kotlin/sdk/ExperimentalMcpApi : java/lang/annotation/Annotation { } -public abstract interface annotation class io/modelcontextprotocol/kotlin/sdk/InternalMcpApi : java/lang/annotation/Annotation { +public final class io/modelcontextprotocol/kotlin/sdk/InitializedNotification { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/InitializedNotification; + public final fun Params (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams; + public static synthetic fun Params$default (Lio/modelcontextprotocol/kotlin/sdk/InitializedNotification;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams; } public final class io/modelcontextprotocol/kotlin/sdk/LibVersionKt { public static final field LIB_VERSION Ljava/lang/String; } +public final class io/modelcontextprotocol/kotlin/sdk/LoggingLevel { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/LoggingLevel; + public final fun getAlert ()Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; + public final fun getCritical ()Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; + public final fun getDebug ()Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; + public final fun getEmergency ()Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; + public final fun getError ()Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; + public final fun getInfo ()Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; + public final fun getNotice ()Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; + public final fun getWarning ()Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; +} + +public final class io/modelcontextprotocol/kotlin/sdk/LoggingMessageNotification { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/LoggingMessageNotification; + public final fun Params (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;Lkotlinx/serialization/json/JsonElement;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/LoggingMessageNotificationParams; + public static synthetic fun Params$default (Lio/modelcontextprotocol/kotlin/sdk/LoggingMessageNotification;Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel;Lkotlinx/serialization/json/JsonElement;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/LoggingMessageNotificationParams; + public final fun SetLevelRequest (Lio/modelcontextprotocol/kotlin/sdk/types/SetLevelRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/SetLevelRequest; +} + +public abstract interface class io/modelcontextprotocol/kotlin/sdk/Method { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/Method$Companion; + public abstract fun getValue ()Ljava/lang/String; +} + +public final class io/modelcontextprotocol/kotlin/sdk/Method$Companion { + public final fun Custom (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/Method$Custom; +} + +public final class io/modelcontextprotocol/kotlin/sdk/Method$Defined { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/Method$Defined; + public final fun getCompletionComplete ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getElicitationCreate ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getInitialize ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getLoggingSetLevel ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getNotificationsCancelled ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getNotificationsInitialized ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getNotificationsMessage ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getNotificationsProgress ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getNotificationsPromptsListChanged ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getNotificationsResourcesListChanged ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getNotificationsResourcesUpdated ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getNotificationsRootsListChanged ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getNotificationsToolsListChanged ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getPing ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getPromptsGet ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getPromptsList ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getResourcesList ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getResourcesRead ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getResourcesSubscribe ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getResourcesTemplatesList ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getResourcesUnsubscribe ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getRootsList ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getSamplingCreateMessage ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getToolsCall ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; + public final fun getToolsList ()Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; +} + +public abstract interface class io/modelcontextprotocol/kotlin/sdk/ProgressBase { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/ProgressBase$Companion; + public abstract fun getMessage ()Ljava/lang/String; + public abstract fun getProgress ()D + public abstract fun getTotal ()Ljava/lang/Double; +} + +public final class io/modelcontextprotocol/kotlin/sdk/ProgressBase$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/ProgressNotification { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/ProgressNotification; + public final fun Params (Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;DLjava/lang/Double;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ProgressNotificationParams; + public static synthetic fun Params$default (Lio/modelcontextprotocol/kotlin/sdk/ProgressNotification;Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;DLjava/lang/Double;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ProgressNotificationParams; +} + +public final class io/modelcontextprotocol/kotlin/sdk/PromptListChangedNotification { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/PromptListChangedNotification; + public final fun Params (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams; + public static synthetic fun Params$default (Lio/modelcontextprotocol/kotlin/sdk/PromptListChangedNotification;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams; +} + +public final class io/modelcontextprotocol/kotlin/sdk/RequestId { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/RequestId; + public final fun NumberId-Nt6Vbyc (J)J + public final fun StringId-QNUQp7g (Ljava/lang/String;)Ljava/lang/String; +} + +public final class io/modelcontextprotocol/kotlin/sdk/ResourceListChangedNotification { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/ResourceListChangedNotification; + public final fun Params (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams; + public static synthetic fun Params$default (Lio/modelcontextprotocol/kotlin/sdk/ResourceListChangedNotification;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams; +} + +public final class io/modelcontextprotocol/kotlin/sdk/ResourceUpdatedNotification { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/ResourceUpdatedNotification; + public final fun Params (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ResourceUpdatedNotificationParams; + public static synthetic fun Params$default (Lio/modelcontextprotocol/kotlin/sdk/ResourceUpdatedNotification;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ResourceUpdatedNotificationParams; +} + +public final class io/modelcontextprotocol/kotlin/sdk/Role { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/Role; + public final fun getAssistant ()Lio/modelcontextprotocol/kotlin/sdk/types/Role; + public final fun getUser ()Lio/modelcontextprotocol/kotlin/sdk/types/Role; +} + +public final class io/modelcontextprotocol/kotlin/sdk/RootsListChangedNotification { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/RootsListChangedNotification; + public final fun Params (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams; + public static synthetic fun Params$default (Lio/modelcontextprotocol/kotlin/sdk/RootsListChangedNotification;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams; +} + +public final class io/modelcontextprotocol/kotlin/sdk/ServerCapabilities { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/ServerCapabilities; + public final fun Prompts (Ljava/lang/Boolean;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts; + public static synthetic fun Prompts$default (Lio/modelcontextprotocol/kotlin/sdk/ServerCapabilities;Ljava/lang/Boolean;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Prompts; + public final fun Resources (Ljava/lang/Boolean;Ljava/lang/Boolean;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources; + public static synthetic fun Resources$default (Lio/modelcontextprotocol/kotlin/sdk/ServerCapabilities;Ljava/lang/Boolean;Ljava/lang/Boolean;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Resources; + public final fun Tools (Ljava/lang/Boolean;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools; + public static synthetic fun Tools$default (Lio/modelcontextprotocol/kotlin/sdk/ServerCapabilities;Ljava/lang/Boolean;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities$Tools; +} + +public final class io/modelcontextprotocol/kotlin/sdk/Tool { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/Tool; + public final fun Input (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; + public static synthetic fun Input$default (Lio/modelcontextprotocol/kotlin/sdk/Tool;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; + public final fun Output (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; + public static synthetic fun Output$default (Lio/modelcontextprotocol/kotlin/sdk/Tool;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; +} + +public final class io/modelcontextprotocol/kotlin/sdk/ToolListChangedNotification { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/ToolListChangedNotification; + public final fun Params (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams; + public static synthetic fun Params$default (Lio/modelcontextprotocol/kotlin/sdk/ToolListChangedNotification;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams; +} + +public final class io/modelcontextprotocol/kotlin/sdk/TypesKt { + public static final field JSONRPC_VERSION Ljava/lang/String; + public static final field LATEST_PROTOCOL_VERSION Ljava/lang/String; + public static final fun CallToolResult (Ljava/util/List;Ljava/lang/Boolean;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolResult; + public static synthetic fun CallToolResult$default (Ljava/util/List;Ljava/lang/Boolean;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolResult; + public static final fun CancelledNotification (Lio/modelcontextprotocol/kotlin/sdk/types/CancelledNotificationParams;)Lio/modelcontextprotocol/kotlin/sdk/types/CancelledNotification; + public static final fun ClientCapabilities (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 ClientCapabilities$default (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 static final fun CompleteRequest (Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequest; + public static final fun CompleteResult (Lio/modelcontextprotocol/kotlin/sdk/types/CompleteResult$Completion;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CompleteResult; + public static synthetic fun CompleteResult$default (Lio/modelcontextprotocol/kotlin/sdk/types/CompleteResult$Completion;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CompleteResult; + public static final fun CreateElicitationRequest (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequest; + public static final fun CreateElicitationResult (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitResult$Action;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitResult; + public static synthetic fun CreateElicitationResult$default (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitResult$Action;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitResult; + public static final fun CreateMessageRequest (Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest; + public static final fun InitializedNotification (Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams;)Lio/modelcontextprotocol/kotlin/sdk/types/InitializedNotification; + public static synthetic fun InitializedNotification$default (Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/InitializedNotification; + public static final fun LoggingMessageNotification (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingMessageNotificationParams;)Lio/modelcontextprotocol/kotlin/sdk/types/LoggingMessageNotification; + public static final fun ProgressNotification (Lio/modelcontextprotocol/kotlin/sdk/types/ProgressNotificationParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ProgressNotification; + public static final fun PromptListChangedNotification (Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams;)Lio/modelcontextprotocol/kotlin/sdk/types/PromptListChangedNotification; + public static final fun ResourceListChangedNotification (Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ResourceListChangedNotification; + public static synthetic fun ResourceListChangedNotification$default (Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ResourceListChangedNotification; + public static final fun ResourceUpdatedNotification (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceUpdatedNotificationParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ResourceUpdatedNotification; + public static final fun RootsListChangedNotification (Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams;)Lio/modelcontextprotocol/kotlin/sdk/types/RootsListChangedNotification; + public static synthetic fun RootsListChangedNotification$default (Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/RootsListChangedNotification; + public static final fun 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;)Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; + public static synthetic fun ServerCapabilities$default (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 static final fun Tool (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/Tool; + public static synthetic fun Tool$default (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/Tool; + public static final fun ToolListChangedNotification (Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolListChangedNotification; + public static final fun getSUPPORTED_PROTOCOL_VERSIONS ()Ljava/util/List; + public static final fun toJSON (Lio/modelcontextprotocol/kotlin/sdk/types/Notification;)Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCNotification; + public static final fun toJSON (Lio/modelcontextprotocol/kotlin/sdk/types/Request;)Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCRequest; +} + +public final class io/modelcontextprotocol/kotlin/sdk/Types_utilKt { + public static final fun error (Lio/modelcontextprotocol/kotlin/sdk/CallToolResult;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolResult; + public static synthetic fun error$default (Lio/modelcontextprotocol/kotlin/sdk/CallToolResult;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolResult; + public static final fun getEmptyJsonObject ()Lkotlinx/serialization/json/JsonObject; + public static final fun ok (Lio/modelcontextprotocol/kotlin/sdk/CallToolResult;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolResult; + public static synthetic fun ok$default (Lio/modelcontextprotocol/kotlin/sdk/CallToolResult;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CallToolResult; +} + +public final class io/modelcontextprotocol/kotlin/sdk/UnknownContent { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/UnknownContent$Companion; + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/UnknownContent; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/UnknownContent;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/UnknownContent; + public fun equals (Ljava/lang/Object;)Z + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/UnknownContent$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/UnknownContent$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/UnknownContent; + 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/UnknownContent;)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/UnknownContent$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/UnknownMethodRequestOrNotification { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/UnknownMethodRequestOrNotification$Companion; + public fun (Lio/modelcontextprotocol/kotlin/sdk/Method;Lio/modelcontextprotocol/kotlin/sdk/types/NotificationParams;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/Method;Lio/modelcontextprotocol/kotlin/sdk/types/NotificationParams;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/Method; + public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/NotificationParams; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/Method;Lio/modelcontextprotocol/kotlin/sdk/types/NotificationParams;)Lio/modelcontextprotocol/kotlin/sdk/UnknownMethodRequestOrNotification; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/UnknownMethodRequestOrNotification;Lio/modelcontextprotocol/kotlin/sdk/Method;Lio/modelcontextprotocol/kotlin/sdk/types/NotificationParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/UnknownMethodRequestOrNotification; + public fun equals (Ljava/lang/Object;)Z + public final fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/Method; + public final fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/NotificationParams; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/UnknownMethodRequestOrNotification$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/UnknownMethodRequestOrNotification$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/UnknownMethodRequestOrNotification; + 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/UnknownMethodRequestOrNotification;)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/UnknownMethodRequestOrNotification$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/UnknownReference { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/UnknownReference$Companion; + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/UnknownReference; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/UnknownReference;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/UnknownReference; + public fun equals (Ljava/lang/Object;)Z + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/UnknownReference$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/UnknownReference$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/UnknownReference; + 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/UnknownReference;)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/UnknownReference$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class io/modelcontextprotocol/kotlin/sdk/internal/Utils_jvmKt { public static final fun getIODispatcher ()Lkotlinx/coroutines/CoroutineDispatcher; } -public abstract class io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport { - public fun ()V - protected final fun checkState (Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;Lkotlin/jvm/functions/Function1;)V - public static synthetic fun checkState$default (Lio/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport;Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V - public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - protected abstract fun closeResources (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - protected abstract fun getLogger ()Lio/github/oshai/kotlinlogging/KLogger; - protected final fun getState ()Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState; - protected abstract fun initialize (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - protected abstract fun performSend (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lio/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun performSend$default (Lio/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport;Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lio/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)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; - protected final fun stateTransition (Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;)V - protected final fun updateState (Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;)V -} - public abstract class io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport : io/modelcontextprotocol/kotlin/sdk/shared/Transport { public fun ()V + protected final fun get_onClose ()Lkotlin/jvm/functions/Function0; protected final fun get_onError ()Lkotlin/jvm/functions/Function1; protected final fun get_onMessage ()Lkotlin/jvm/functions/Function2; - protected final fun invokeOnCloseCallback ()V public fun onClose (Lkotlin/jvm/functions/Function0;)V public fun onError (Lkotlin/jvm/functions/Function1;)V public fun onMessage (Lkotlin/jvm/functions/Function2;)V } -public final class io/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState : java/lang/Enum { - public static final field InitializationFailed Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState; - public static final field Initializing Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState; - public static final field New Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState; - public static final field Operational Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState; - public static final field ShutdownFailed Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState; - public static final field ShuttingDown Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState; - public static final field Stopped Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState; - public static fun values ()[Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState; -} - public abstract class io/modelcontextprotocol/kotlin/sdk/shared/Protocol { public fun (Lio/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions;)V protected abstract fun assertCapabilityForMethod (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V @@ -59,7 +425,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 +437,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 +445,20 @@ 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 static final fun getMcpJson ()Lkotlinx/serialization/json/Json; } 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 +473,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 +491,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 @@ -237,6 +584,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/AudioContent : io/mo public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getMimeType ()Ljava/lang/String; public fun getType ()Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -275,6 +623,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/BaseNotificationPara public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams; public fun equals (Ljava/lang/Object;)Z public fun getMeta ()Lkotlinx/serialization/json/JsonObject; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -337,6 +686,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/BlobResourceContents public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public fun getMimeType ()Ljava/lang/String; public fun getUri ()Ljava/lang/String; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -356,53 +706,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/BlobResourceContents public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/BooleanSchema : io/modelcontextprotocol/kotlin/sdk/types/PrimitiveSchemaDefinition { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/BooleanSchema$Companion; - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/lang/Boolean; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)Lio/modelcontextprotocol/kotlin/sdk/types/BooleanSchema; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/BooleanSchema;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/BooleanSchema; - public fun equals (Ljava/lang/Object;)Z - public final fun getDefault ()Ljava/lang/Boolean; - public final fun getDescription ()Ljava/lang/String; - public final fun getTitle ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/BooleanSchema$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/BooleanSchema$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/BooleanSchema; - 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/BooleanSchema;)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/BooleanSchema$Companion { - 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 +719,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 +743,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; } @@ -494,6 +794,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolResult : io/ public final fun getContent ()Ljava/util/List; public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getStructuredContent ()Lkotlinx/serialization/json/JsonObject; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public final fun isError ()Ljava/lang/Boolean; public fun toString ()Ljava/lang/String; @@ -514,67 +815,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CallToolResult$Compa public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest, io/modelcontextprotocol/kotlin/sdk/types/ServerRequest { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequest$Companion; - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams;)V - public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequest; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequest;Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequest; - public fun equals (Ljava/lang/Object;)Z - public final fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; - public fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams; - public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestParams; - public final fun getTaskId ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequest$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequest; - 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/CancelTaskRequest;)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/CancelTaskRequest$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams$Companion; - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun copy-D-90kyg (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams; - public static synthetic fun copy-D-90kyg$default (Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams; - public fun equals (Ljava/lang/Object;)Z - public fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun getTaskId ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/CancelTaskRequestParams; - 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/CancelTaskRequestParams;)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/CancelTaskRequestParams$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/CancelledNotification : io/modelcontextprotocol/kotlin/sdk/types/ClientNotification, io/modelcontextprotocol/kotlin/sdk/types/ServerNotification { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CancelledNotification$Companion; public fun (Lio/modelcontextprotocol/kotlin/sdk/types/CancelledNotificationParams;)V @@ -620,6 +860,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CancelledNotificatio public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getReason ()Ljava/lang/String; public final fun getRequestId ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestId; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -639,32 +880,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 +912,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 +946,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 { @@ -935,6 +983,10 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ClientResult$Compani public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ClientResult$DefaultImpls { + public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/ClientResult;)Lkotlinx/serialization/json/JsonObject; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/CommonKt { public static final field DEFAULT_NEGOTIATED_PROTOCOL_VERSION Ljava/lang/String; public static final field LATEST_PROTOCOL_VERSION Ljava/lang/String; @@ -945,6 +997,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CommonKt { public final class io/modelcontextprotocol/kotlin/sdk/types/CompleteRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequest$Companion; + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequestParams$Argument;Lio/modelcontextprotocol/kotlin/sdk/types/Reference;Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequestParams$Context;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequestParams$Argument;Lio/modelcontextprotocol/kotlin/sdk/types/Reference;Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequestParams$Context;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequestParams;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequestParams; public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/CompleteRequest; @@ -980,7 +1034,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 @@ -1089,6 +1143,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CompleteResult : io/ public fun equals (Ljava/lang/Object;)Z public final fun getCompletion ()Lio/modelcontextprotocol/kotlin/sdk/types/CompleteResult$Completion; public fun getMeta ()Lkotlinx/serialization/json/JsonObject; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -1153,6 +1208,10 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ContentBlock$Compani public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ContentBlock$DefaultImpls { + public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/ContentBlock;)Lkotlinx/serialization/json/JsonObject; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/ContentTypes : java/lang/Enum { public static final field AUDIO Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes$Companion; @@ -1160,8 +1219,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 +1247,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 +1270,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 +1291,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 +1301,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 +1313,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,23 +1335,22 @@ 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; public final fun getStopReason-6olV-UY ()Ljava/lang/String; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -1321,36 +1370,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/CreateMessageResult$ public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/CreateTaskResult : io/modelcontextprotocol/kotlin/sdk/types/ClientResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CreateTaskResult$Companion; - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/Task;Lkotlinx/serialization/json/JsonObject;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/Task;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/Task; - public final fun component2 ()Lkotlinx/serialization/json/JsonObject; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/Task;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateTaskResult; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/CreateTaskResult;Lio/modelcontextprotocol/kotlin/sdk/types/Task;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateTaskResult; - public fun equals (Ljava/lang/Object;)Z - public fun getMeta ()Lkotlinx/serialization/json/JsonObject; - public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/Task; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/CreateTaskResult$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/CreateTaskResult$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/CreateTaskResult; - 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/CreateTaskResult;)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/CreateTaskResult$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/CustomNotification : io/modelcontextprotocol/kotlin/sdk/types/ClientNotification, io/modelcontextprotocol/kotlin/sdk/types/ServerNotification { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/CustomNotification$Companion; public fun (Lio/modelcontextprotocol/kotlin/sdk/types/Method;Lio/modelcontextprotocol/kotlin/sdk/types/BaseNotificationParams;)V @@ -1408,142 +1427,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 - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;Ljava/lang/Double;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;Ljava/lang/Double;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/lang/Double; - public final fun component4 ()Ljava/lang/Double; - public final fun component5 ()Ljava/lang/Double; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;Ljava/lang/Double;)Lio/modelcontextprotocol/kotlin/sdk/types/DoubleSchema; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/DoubleSchema;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;Ljava/lang/Double;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/DoubleSchema; - public fun equals (Ljava/lang/Object;)Z - public final fun getDefault ()Ljava/lang/Double; - public final fun getDescription ()Ljava/lang/String; - public final fun getMaximum ()Ljava/lang/Double; - public final fun getMinimum ()Ljava/lang/Double; - public final fun getTitle ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/DoubleSchema$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/DoubleSchema$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/DoubleSchema; - 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/DoubleSchema;)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/DoubleSchema$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequest : io/modelcontextprotocol/kotlin/sdk/types/ServerRequest { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequest$Companion; public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams;)V @@ -1579,70 +1462,56 @@ 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 public final fun setMessage (Ljava/lang/String;)V } -public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestFormParams : io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestFormParams$Companion; - public synthetic fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$Companion; + public synthetic fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; - public final fun component3 ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema; - public final fun component4-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun copy-6R815Ik (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestFormParams; - public static synthetic fun copy-6R815Ik$default (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestFormParams;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestFormParams; + public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema; + public final fun component3-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; + public final fun copy-EDEVuBg (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams; + public static synthetic fun copy-EDEVuBg$default (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams; public fun equals (Ljava/lang/Object;)Z - public fun getMessage ()Ljava/lang/String; + public final fun getMessage ()Ljava/lang/String; public fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun getMode ()Ljava/lang/String; public final fun getRequestedSchema ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema; - public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; public fun hashCode ()I public fun toString ()Ljava/lang/String; } -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestFormParams$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestFormParams$$serializer; +public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$$serializer; public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestFormParams; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams; 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/ElicitRequestFormParams;)V + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams;)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/ElicitRequestFormParams$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$Companion; - public abstract fun getMessage ()Ljava/lang/String; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema$Companion; - public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/List;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/util/Map; - public final fun component3 ()Ljava/util/List; - public final fun copy (Ljava/lang/String;Ljava/util/Map;Ljava/util/List;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Ljava/lang/String;Ljava/util/Map;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema; + public fun (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun component2 ()Ljava/util/List; + public final fun copy (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema; public fun equals (Ljava/lang/Object;)Z - public final fun getProperties ()Ljava/util/Map; + 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; @@ -1663,43 +1532,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$ public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestURLParams : io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestURLParams$Companion; - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/lang/String; - public final fun component4 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; - public final fun component5-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun copy-4asOmiE (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestURLParams; - public static synthetic fun copy-4asOmiE$default (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestURLParams;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestURLParams; - public fun equals (Ljava/lang/Object;)Z - public final fun getElicitationId ()Ljava/lang/String; - public fun getMessage ()Ljava/lang/String; - public fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun getMode ()Ljava/lang/String; - public final fun getTask ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; - public final fun getUrl ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestURLParams$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestURLParams$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestURLParams; - 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/ElicitRequestURLParams;)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/ElicitRequestURLParams$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitRequestedSchemaBuilder { public fun ()V public final fun build ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema; @@ -1722,6 +1554,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitResult : io/mo public final fun getAction ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitResult$Action; public final fun getContent ()Lkotlinx/serialization/json/JsonObject; public fun getMeta ()Lkotlinx/serialization/json/JsonObject; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -1755,70 +1588,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitResult$Compani public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification : io/modelcontextprotocol/kotlin/sdk/types/ServerNotification { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification$Companion; - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams;)V - public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification; - public fun equals (Ljava/lang/Object;)Z - public fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; - public fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams; - public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/NotificationParams; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification; - 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/ElicitationCompleteNotification;)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/ElicitationCompleteNotification$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams : io/modelcontextprotocol/kotlin/sdk/types/NotificationParams { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams$Companion; - public fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)V - public synthetic fun (Ljava/lang/String;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 copy (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams; - public fun equals (Ljava/lang/Object;)Z - public final fun getElicitationId ()Ljava/lang/String; - public fun getMeta ()Lkotlinx/serialization/json/JsonObject; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotificationParams; - 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/ElicitationCompleteNotificationParams;)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/ElicitationCompleteNotificationParams$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/ElicitationKt { - public static final fun ElicitRequestParams-EDEVuBg (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestFormParams; - public static synthetic fun ElicitRequestParams-EDEVuBg$default (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestFormParams; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/Elicitation_dslKt { public static final fun buildElicitRequest (Lkotlin/jvm/functions/Function1;)Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequest; } @@ -1837,6 +1606,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/EmbeddedResource : i public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getResource ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceContents; public fun getType ()Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -1866,6 +1636,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/EmptyResult : io/mod public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/EmptyResult;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/EmptyResult; public fun equals (Ljava/lang/Object;)Z public fun getMeta ()Lkotlinx/serialization/json/JsonObject; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -1885,46 +1656,11 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/EmptyResult$Companio public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/EnumOption { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/EnumOption$Companion; - public fun (Ljava/lang/String;Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/EnumOption; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/EnumOption;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/EnumOption; - public fun equals (Ljava/lang/Object;)Z - public final fun getConst ()Ljava/lang/String; - public final fun getTitle ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/EnumOption$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/EnumOption$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/EnumOption; - 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/EnumOption;)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/EnumOption$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/EnumSchemaDefinition : io/modelcontextprotocol/kotlin/sdk/types/PrimitiveSchemaDefinition { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/EnumSchemaDefinition$Companion; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/EnumSchemaDefinition$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/GetPromptRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/GetPromptRequest$Companion; public fun (Lio/modelcontextprotocol/kotlin/sdk/types/GetPromptRequestParams;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Map;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Map;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/GetPromptRequestParams; public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/GetPromptRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/GetPromptRequest; public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/GetPromptRequest;Lio/modelcontextprotocol/kotlin/sdk/types/GetPromptRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/GetPromptRequest; @@ -1957,7 +1693,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 @@ -2009,6 +1745,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/GetPromptResult : io public final fun getDescription ()Ljava/lang/String; public final fun getMessages ()Ljava/util/List; public fun getMeta ()Lkotlinx/serialization/json/JsonObject; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -2028,203 +1765,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/GetPromptResult$Comp public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest, io/modelcontextprotocol/kotlin/sdk/types/ServerRequest { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequest$Companion; - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams;)V - public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequest; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequest;Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequest; - public fun equals (Ljava/lang/Object;)Z - public final fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; - public fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams; - public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestParams; - public final fun getTaskId ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequest$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequest; - 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/GetTaskPayloadRequest;)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/GetTaskPayloadRequest$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams$Companion; - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun copy-D-90kyg (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams; - public static synthetic fun copy-D-90kyg$default (Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams; - public fun equals (Ljava/lang/Object;)Z - public fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun getTaskId ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadRequestParams; - 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/GetTaskPayloadRequestParams;)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/GetTaskPayloadRequestParams$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadResult : io/modelcontextprotocol/kotlin/sdk/types/ClientResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadResult$Companion; - public static final synthetic fun box-impl (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadResult; - public static fun constructor-impl (Lkotlinx/serialization/json/JsonObject;)Lkotlinx/serialization/json/JsonObject; - public fun equals (Ljava/lang/Object;)Z - 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 final fun getJson ()Lkotlinx/serialization/json/JsonObject; - public fun getMeta ()Lkotlinx/serialization/json/JsonObject; - public static fun getMeta-impl (Lkotlinx/serialization/json/JsonObject;)Lkotlinx/serialization/json/JsonObject; - public fun hashCode ()I - public static fun hashCode-impl (Lkotlinx/serialization/json/JsonObject;)I - public fun toString ()Ljava/lang/String; - public static fun toString-impl (Lkotlinx/serialization/json/JsonObject;)Ljava/lang/String; - public final synthetic fun unbox-impl ()Lkotlinx/serialization/json/JsonObject; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadResult$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadResult$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun deserialize-iveUaak (Lkotlinx/serialization/encoding/Decoder;)Lkotlinx/serialization/json/JsonObject; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public final fun serialize-kradpfw (Lkotlinx/serialization/encoding/Encoder;Lkotlinx/serialization/json/JsonObject;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/GetTaskPayloadResult$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/GetTaskRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest, io/modelcontextprotocol/kotlin/sdk/types/ServerRequest { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequest$Companion; - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams;)V - public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequest; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequest;Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequest; - public fun equals (Ljava/lang/Object;)Z - public final fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; - public fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams; - public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestParams; - public final fun getTaskId ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/GetTaskRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequest$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequest; - 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/GetTaskRequest;)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/GetTaskRequest$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams : io/modelcontextprotocol/kotlin/sdk/types/RequestParams { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams$Companion; - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun copy-D-90kyg (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams; - public static synthetic fun copy-D-90kyg$default (Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams; - public fun equals (Ljava/lang/Object;)Z - public fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public final fun getTaskId ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskRequestParams; - 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/GetTaskRequestParams;)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/GetTaskRequestParams$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/GetTaskResult : io/modelcontextprotocol/kotlin/sdk/types/ClientResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult, io/modelcontextprotocol/kotlin/sdk/types/TaskFields { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskResult$Companion; - public fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Lkotlinx/serialization/json/JsonObject;)V - public synthetic fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public final fun component3 ()Ljava/lang/String; - public final fun component4 ()Ljava/lang/String; - public final fun component5 ()Ljava/lang/String; - public final fun component6 ()Ljava/lang/Long; - public final fun component7 ()Ljava/lang/Long; - public final fun component8 ()Lkotlinx/serialization/json/JsonObject; - public final fun copy (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskResult; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskResult;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskResult; - public fun equals (Ljava/lang/Object;)Z - public fun getCreatedAt ()Ljava/lang/String; - public fun getLastUpdatedAt ()Ljava/lang/String; - public fun getMeta ()Lkotlinx/serialization/json/JsonObject; - public fun getPollInterval ()Ljava/lang/Long; - public fun getStatus ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public fun getStatusMessage ()Ljava/lang/String; - public fun getTaskId ()Ljava/lang/String; - public fun getTtl ()Ljava/lang/Long; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/GetTaskResult$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskResult$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/GetTaskResult; - 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/GetTaskResult;)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/GetTaskResult$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/Icon { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/Icon$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/Icon$Theme;)V @@ -2288,6 +1828,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ImageContent : io/mo public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getMimeType ()Ljava/lang/String; public fun getType ()Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -2402,7 +1943,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; @@ -2463,6 +2004,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/InitializeResult : i public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getProtocolVersion ()Ljava/lang/String; public final fun getServerInfo ()Lio/modelcontextprotocol/kotlin/sdk/types/Implementation; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -2517,44 +2059,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/InitializedNotificat public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/IntegerSchema : io/modelcontextprotocol/kotlin/sdk/types/NumberSchemaDefinition { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/IntegerSchema$Companion; - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/lang/Integer; - public final fun component4 ()Ljava/lang/Integer; - public final fun component5 ()Ljava/lang/Integer; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;)Lio/modelcontextprotocol/kotlin/sdk/types/IntegerSchema; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/IntegerSchema;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/IntegerSchema; - public fun equals (Ljava/lang/Object;)Z - public final fun getDefault ()Ljava/lang/Integer; - public final fun getDescription ()Ljava/lang/String; - public final fun getMaximum ()Ljava/lang/Integer; - public final fun getMinimum ()Ljava/lang/Integer; - public final fun getTitle ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/IntegerSchema$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/IntegerSchema$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/IntegerSchema; - 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/IntegerSchema;)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/IntegerSchema$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/JSONRPCEmptyMessage : io/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage { public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCEmptyMessage; public fun equals (Ljava/lang/Object;)Z @@ -2719,48 +2223,13 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/JsonUtilsKt { public static final fun toJson (Ljava/util/Map;)Ljava/util/Map; } -public final class io/modelcontextprotocol/kotlin/sdk/types/LegacyTitledEnumSchema : io/modelcontextprotocol/kotlin/sdk/types/EnumSchemaDefinition { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/LegacyTitledEnumSchema$Companion; - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/util/List; - public final fun component4 ()Ljava/util/List; - public final fun component5 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/LegacyTitledEnumSchema; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/LegacyTitledEnumSchema;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/LegacyTitledEnumSchema; - public fun equals (Ljava/lang/Object;)Z - public final fun getDefault ()Ljava/lang/String; - public final fun getDescription ()Ljava/lang/String; - public final fun getEnumNames ()Ljava/util/List; - public final fun getEnumValues ()Ljava/util/List; - public final fun getTitle ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/LegacyTitledEnumSchema$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/LegacyTitledEnumSchema$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/LegacyTitledEnumSchema; - 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/LegacyTitledEnumSchema;)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/LegacyTitledEnumSchema$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest, io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequest { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequest$Companion; public fun ()V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;)V public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams; public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequest; public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequest;Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ListPromptsRequest; @@ -2792,7 +2261,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 { @@ -2808,6 +2277,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListPromptsResult : public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public fun getNextCursor ()Ljava/lang/String; public final fun getPrompts ()Ljava/util/List; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -2832,6 +2302,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplate public fun ()V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;)V public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams; public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplatesRequest; public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplatesRequest;Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplatesRequest; @@ -2863,7 +2335,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 { @@ -2879,6 +2351,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourceTemplate public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public fun getNextCursor ()Ljava/lang/String; public final fun getResourceTemplates ()Ljava/util/List; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -2903,6 +2376,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequest public fun ()V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;)V public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams; public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequest; public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequest;Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ListResourcesRequest; @@ -2934,7 +2409,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 { @@ -2950,6 +2425,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListResourcesResult public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public fun getNextCursor ()Ljava/lang/String; public final fun getResources ()Ljava/util/List; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -2974,6 +2450,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest : i public fun ()V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/BaseRequestParams;)V public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/BaseRequestParams;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/BaseRequestParams; public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/BaseRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest; public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest;Lio/modelcontextprotocol/kotlin/sdk/types/BaseRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest; @@ -3004,7 +2481,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 { @@ -3018,6 +2495,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsResult : io public fun equals (Ljava/lang/Object;)Z public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getRoots ()Ljava/util/List; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -3037,76 +2515,13 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListRootsResult$Comp public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/ListTasksRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest, io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequest, io/modelcontextprotocol/kotlin/sdk/types/ServerRequest { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ListTasksRequest$Companion; - public fun ()V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ListTasksRequest; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ListTasksRequest;Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ListTasksRequest; - public fun equals (Ljava/lang/Object;)Z - public fun getCursor ()Ljava/lang/String; - public fun getMeta-VI-3G7E ()Lkotlinx/serialization/json/JsonObject; - public fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; - public fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams; - 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/ListTasksRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ListTasksRequest$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ListTasksRequest; - 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/ListTasksRequest;)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/ListTasksRequest$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/ListTasksResult : io/modelcontextprotocol/kotlin/sdk/types/ClientResult, io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult, io/modelcontextprotocol/kotlin/sdk/types/ServerResult { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ListTasksResult$Companion; - public fun (Ljava/util/List;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)V - public synthetic fun (Ljava/util/List;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/util/List; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Lkotlinx/serialization/json/JsonObject; - public final fun copy (Ljava/util/List;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ListTasksResult; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ListTasksResult;Ljava/util/List;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ListTasksResult; - public fun equals (Ljava/lang/Object;)Z - public fun getMeta ()Lkotlinx/serialization/json/JsonObject; - public fun getNextCursor ()Ljava/lang/String; - public final fun getTasks ()Ljava/util/List; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ListTasksResult$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ListTasksResult$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ListTasksResult; - 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/ListTasksResult;)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/ListTasksResult$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/ListToolsRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest, io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequest { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ListToolsRequest$Companion; public fun ()V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;)V public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams; public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ListToolsRequest; public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ListToolsRequest;Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ListToolsRequest; @@ -3138,7 +2553,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 { @@ -3154,6 +2569,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ListToolsResult : io public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public fun getNextCursor ()Ljava/lang/String; public final fun getTools ()Ljava/util/List; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -3236,6 +2652,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/LoggingMessageNotifi public final fun getLevel ()Lio/modelcontextprotocol/kotlin/sdk/types/LoggingLevel; public final fun getLogger ()Ljava/lang/String; public fun getMeta ()Lkotlinx/serialization/json/JsonObject; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -3262,8 +2679,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 fun (I)V +public final class io/modelcontextprotocol/kotlin/sdk/types/McpException : java/lang/Exception { public fun (ILjava/lang/String;)V public fun (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;)V public fun (ILjava/lang/String;Lkotlinx/serialization/json/JsonElement;Ljava/lang/Throwable;)V @@ -3272,7 +2688,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; } @@ -3280,6 +2696,10 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/MediaContent$Compani public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/MediaContent$DefaultImpls { + public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/MediaContent;)Lkotlinx/serialization/json/JsonObject; +} + public abstract class io/modelcontextprotocol/kotlin/sdk/types/MediaContentBuilder { public fun ()V public final fun annotations (Lio/modelcontextprotocol/kotlin/sdk/types/Annotations;)V @@ -3336,7 +2756,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Method$Defined : jav public static final field Initialize Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field LoggingSetLevel Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field NotificationsCancelled Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; - public static final field NotificationsElicitationComplete Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field NotificationsInitialized Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field NotificationsMessage Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field NotificationsProgress Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; @@ -3344,7 +2763,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Method$Defined : jav public static final field NotificationsResourcesListChanged Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field NotificationsResourcesUpdated Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field NotificationsRootsListChanged Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; - public static final field NotificationsTasksStatus Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field NotificationsToolsListChanged Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field Ping Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field PromptsGet Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; @@ -3356,11 +2774,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; - public static final field TasksResult Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field ToolsCall Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static final field ToolsList Lio/modelcontextprotocol/kotlin/sdk/types/Method$Defined; public static fun getEntries ()Lkotlin/enums/EnumEntries; @@ -3435,14 +2848,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ModelPreferences$Com public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/MultiSelectEnumSchema : io/modelcontextprotocol/kotlin/sdk/types/EnumSchemaDefinition { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/MultiSelectEnumSchema$Companion; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/MultiSelectEnumSchema$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/Notification { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/Notification$Companion; public abstract fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; @@ -3461,12 +2866,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/NotificationParams$C public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/NumberSchemaDefinition : io/modelcontextprotocol/kotlin/sdk/types/PrimitiveSchemaDefinition { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/NumberSchemaDefinition$Companion; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/NumberSchemaDefinition$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; +public final class io/modelcontextprotocol/kotlin/sdk/types/NotificationParams$DefaultImpls { + public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/NotificationParams;)Lkotlinx/serialization/json/JsonObject; } public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/PaginatedRequest : io/modelcontextprotocol/kotlin/sdk/types/Request { @@ -3530,6 +2931,10 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult$Comp public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/PaginatedResult$DefaultImpls { + public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/PaginatedResult;)Lkotlinx/serialization/json/JsonObject; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/PingRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest, io/modelcontextprotocol/kotlin/sdk/types/ServerRequest { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/PingRequest$Companion; public fun ()V @@ -3565,21 +2970,13 @@ 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 { public static final fun buildPingRequest (Lkotlin/jvm/functions/Function1;)Lio/modelcontextprotocol/kotlin/sdk/types/PingRequest; } -public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/PrimitiveSchemaDefinition { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/PrimitiveSchemaDefinition$Companion; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/PrimitiveSchemaDefinition$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/Progress { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/Progress$Companion; public fun (DLjava/lang/Double;Ljava/lang/String;)V @@ -3650,6 +3047,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ProgressNotification public final fun getProgress ()D public final fun getProgressToken ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestId; public final fun getTotal ()Ljava/lang/Double; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -3688,6 +3086,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Prompt : io/modelcon public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getName ()Ljava/lang/String; public final fun getTitle ()Ljava/lang/String; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -3878,14 +3277,13 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/RPCError$ErrorCode { public static final field METHOD_NOT_FOUND I 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 { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest$Companion; public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequestParams;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequestParams; public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest; public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest;Lio/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ReadResourceRequest; @@ -3917,7 +3315,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 } @@ -3963,6 +3361,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ReadResourceResult : public fun equals (Ljava/lang/Object;)Z public final fun getContents ()Ljava/util/List; public fun getMeta ()Lkotlinx/serialization/json/JsonObject; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -4005,33 +3404,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ReferenceType$Compan public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/RelatedTaskMetadata { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/RelatedTaskMetadata$Companion; - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/RelatedTaskMetadata; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/RelatedTaskMetadata;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/RelatedTaskMetadata; - public fun equals (Ljava/lang/Object;)Z - public final fun getTaskId ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/RelatedTaskMetadata$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/RelatedTaskMetadata$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/RelatedTaskMetadata; - 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/RelatedTaskMetadata;)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/RelatedTaskMetadata$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/Request { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/Request$Companion; public abstract fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; @@ -4125,13 +3497,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 public fun toString ()Ljava/lang/String; @@ -4184,6 +3551,10 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/RequestResult$Compan public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/RequestResult$DefaultImpls { + public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/RequestResult;)Lkotlinx/serialization/json/JsonObject; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/Resource : io/modelcontextprotocol/kotlin/sdk/types/ResourceLike { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/Resource$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/Annotations;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;)V @@ -4209,6 +3580,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Resource : io/modelc public final fun getSize ()Ljava/lang/Long; public final fun getTitle ()Ljava/lang/String; public final fun getUri ()Ljava/lang/String; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -4238,6 +3610,10 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ResourceContents$Com public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ResourceContents$DefaultImpls { + public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceContents;)Lkotlinx/serialization/json/JsonObject; +} + public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/ResourceLike : io/modelcontextprotocol/kotlin/sdk/types/WithMeta { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ResourceLike$Companion; } @@ -4246,6 +3622,10 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ResourceLike$Compani public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ResourceLike$DefaultImpls { + public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceLike;)Lkotlinx/serialization/json/JsonObject; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/ResourceLink : io/modelcontextprotocol/kotlin/sdk/types/ContentBlock, io/modelcontextprotocol/kotlin/sdk/types/ResourceLike { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ResourceLink$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/Annotations;Lkotlinx/serialization/json/JsonObject;)V @@ -4272,6 +3652,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ResourceLink : io/mo public final fun getTitle ()Ljava/lang/String; public fun getType ()Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; public final fun getUri ()Ljava/lang/String; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -4345,6 +3726,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate : i public final fun getName ()Ljava/lang/String; public final fun getTitle ()Ljava/lang/String; public final fun getUriTemplate ()Ljava/lang/String; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -4432,6 +3814,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ResourceUpdatedNotif public fun equals (Ljava/lang/Object;)Z public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getUri ()Ljava/lang/String; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -4485,6 +3868,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Root : io/modelconte public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getName ()Ljava/lang/String; public final fun getUri ()Ljava/lang/String; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -4541,18 +3925,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 +3959,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 +3974,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 +4072,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 @@ -4850,6 +4125,10 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/ServerResult$Compani public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/modelcontextprotocol/kotlin/sdk/types/ServerResult$DefaultImpls { + public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/ServerResult;)Lkotlinx/serialization/json/JsonObject; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/SetLevelRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/SetLevelRequest$Companion; public fun (Lio/modelcontextprotocol/kotlin/sdk/types/SetLevelRequestParams;)V @@ -4884,7 +4163,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 } @@ -4919,14 +4198,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/SetLevelRequestParam public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/SingleSelectEnumSchema : io/modelcontextprotocol/kotlin/sdk/types/EnumSchemaDefinition { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/SingleSelectEnumSchema$Companion; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/SingleSelectEnumSchema$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/StopReason { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/StopReason$Companion; public static final synthetic fun box-impl (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/StopReason; @@ -4957,68 +4228,14 @@ 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; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/StringSchema : io/modelcontextprotocol/kotlin/sdk/types/PrimitiveSchemaDefinition { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/StringSchema$Companion; - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/lang/Integer; - public final fun component4 ()Ljava/lang/Integer; - public final fun component5 ()Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat; - public final fun component6 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/StringSchema; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/StringSchema;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/StringSchema; - public fun equals (Ljava/lang/Object;)Z - public final fun getDefault ()Ljava/lang/String; - public final fun getDescription ()Ljava/lang/String; - public final fun getFormat ()Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat; - public final fun getMaxLength ()Ljava/lang/Integer; - public final fun getMinLength ()Ljava/lang/Integer; - public final fun getTitle ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/StringSchema$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/StringSchema$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/StringSchema; - 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/StringSchema;)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/StringSchema$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat : java/lang/Enum { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat$Companion; - public static final field Date Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat; - public static final field DateTime Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat; - public static final field Email Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat; - public static final field Uri Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat; - public static fun values ()[Lio/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/StringSchemaFormat$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } public final class io/modelcontextprotocol/kotlin/sdk/types/SubscribeRequest : io/modelcontextprotocol/kotlin/sdk/types/ClientRequest { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/SubscribeRequest$Companion; public fun (Lio/modelcontextprotocol/kotlin/sdk/types/SubscribeRequestParams;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/SubscribeRequestParams; public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/SubscribeRequestParams;)Lio/modelcontextprotocol/kotlin/sdk/types/SubscribeRequest; public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/SubscribeRequest;Lio/modelcontextprotocol/kotlin/sdk/types/SubscribeRequestParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/SubscribeRequest; @@ -5050,7 +4267,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 } @@ -5085,192 +4302,6 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/SubscribeRequestPara public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/Task : io/modelcontextprotocol/kotlin/sdk/types/TaskFields { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/Task$Companion; - public fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;)V - public synthetic fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public final fun component3 ()Ljava/lang/String; - public final fun component4 ()Ljava/lang/String; - public final fun component5 ()Ljava/lang/String; - public final fun component6 ()Ljava/lang/Long; - public final fun component7 ()Ljava/lang/Long; - public final fun copy (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;)Lio/modelcontextprotocol/kotlin/sdk/types/Task; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/Task;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/Task; - public fun equals (Ljava/lang/Object;)Z - public fun getCreatedAt ()Ljava/lang/String; - public fun getLastUpdatedAt ()Ljava/lang/String; - public fun getPollInterval ()Ljava/lang/Long; - public fun getStatus ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public fun getStatusMessage ()Ljava/lang/String; - public fun getTaskId ()Ljava/lang/String; - public fun getTtl ()Ljava/lang/Long; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/Task$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/Task$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/Task; - 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/Task;)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/Task$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public abstract interface class io/modelcontextprotocol/kotlin/sdk/types/TaskFields { - public abstract fun getCreatedAt ()Ljava/lang/String; - public abstract fun getLastUpdatedAt ()Ljava/lang/String; - public abstract fun getPollInterval ()Ljava/lang/Long; - public abstract fun getStatus ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public abstract fun getStatusMessage ()Ljava/lang/String; - public abstract fun getTaskId ()Ljava/lang/String; - public abstract fun getTtl ()Ljava/lang/Long; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/TaskMetadata { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata$Companion; - public fun ()V - public fun (Ljava/lang/Long;)V - public synthetic fun (Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/Long; - public final fun copy (Ljava/lang/Long;)Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata;Ljava/lang/Long;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; - public fun equals (Ljava/lang/Object;)Z - public final fun getTtl ()Ljava/lang/Long; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/TaskMetadata$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/TaskMetadata; - 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/TaskMetadata;)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/TaskMetadata$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/TaskStatus : java/lang/Enum { - public static final field Cancelled Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus$Companion; - public static final field Completed Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public static final field Failed Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public static final field InputRequired Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public static final field Working Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public static fun values ()[Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/TaskStatus$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotification : io/modelcontextprotocol/kotlin/sdk/types/ClientNotification, io/modelcontextprotocol/kotlin/sdk/types/ServerNotification { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotification$Companion; - public fun ()V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams;)Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotification; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotification;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotification; - public fun equals (Ljava/lang/Object;)Z - public fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method; - public synthetic fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/NotificationParams; - public fun getParams ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotification$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotification$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotification; - 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/TaskStatusNotification;)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/TaskStatusNotification$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams : io/modelcontextprotocol/kotlin/sdk/types/NotificationParams, io/modelcontextprotocol/kotlin/sdk/types/TaskFields { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams$Companion; - public fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Lkotlinx/serialization/json/JsonObject;)V - public synthetic fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public final fun component3 ()Ljava/lang/String; - public final fun component4 ()Ljava/lang/String; - public final fun component5 ()Ljava/lang/String; - public final fun component6 ()Ljava/lang/Long; - public final fun component7 ()Ljava/lang/Long; - public final fun component8 ()Lkotlinx/serialization/json/JsonObject; - public final fun copy (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams; - public fun equals (Ljava/lang/Object;)Z - public fun getCreatedAt ()Ljava/lang/String; - public fun getLastUpdatedAt ()Ljava/lang/String; - public fun getMeta ()Lkotlinx/serialization/json/JsonObject; - public fun getPollInterval ()Ljava/lang/Long; - public fun getStatus ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatus; - public fun getStatusMessage ()Ljava/lang/String; - public fun getTaskId ()Ljava/lang/String; - public fun getTtl ()Ljava/lang/Long; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/TaskStatusNotificationParams; - 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/TaskStatusNotificationParams;)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/TaskStatusNotificationParams$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/TaskSupport : java/lang/Enum { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/TaskSupport$Companion; - public static final field Forbidden Lio/modelcontextprotocol/kotlin/sdk/types/TaskSupport; - public static final field Optional Lio/modelcontextprotocol/kotlin/sdk/types/TaskSupport; - public static final field Required Lio/modelcontextprotocol/kotlin/sdk/types/TaskSupport; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/TaskSupport; - public static fun values ()[Lio/modelcontextprotocol/kotlin/sdk/types/TaskSupport; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/TaskSupport$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/TasksKt { - public static final field RELATED_TASK_META_KEY Ljava/lang/String; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/TextContent : io/modelcontextprotocol/kotlin/sdk/types/MediaContent { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/TextContent$Companion; public fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/Annotations;Lkotlinx/serialization/json/JsonObject;)V @@ -5285,6 +4316,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/TextContent : io/mod public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getText ()Ljava/lang/String; public fun getType ()Lio/modelcontextprotocol/kotlin/sdk/types/ContentTypes; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -5326,6 +4358,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/TextResourceContents public fun getMimeType ()Ljava/lang/String; public final fun getText ()Ljava/lang/String; public fun getUri ()Ljava/lang/String; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -5345,111 +4378,10 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/TextResourceContents public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema : io/modelcontextprotocol/kotlin/sdk/types/MultiSelectEnumSchema { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Companion; - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items;Ljava/util/List;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/lang/Integer; - public final fun component4 ()Ljava/lang/Integer; - public final fun component5 ()Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items; - public final fun component6 ()Ljava/util/List; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items;Ljava/util/List;)Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema; - public fun equals (Ljava/lang/Object;)Z - public final fun getDefault ()Ljava/util/List; - public final fun getDescription ()Ljava/lang/String; - public final fun getItems ()Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items; - public final fun getMaxItems ()Ljava/lang/Integer; - public final fun getMinItems ()Ljava/lang/Integer; - public final fun getTitle ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema; - 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/TitledMultiSelectEnumSchema;)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/TitledMultiSelectEnumSchema$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items$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/TitledMultiSelectEnumSchema$Items; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items; - public fun equals (Ljava/lang/Object;)Z - public final fun getAnyOf ()Ljava/util/List; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/TitledMultiSelectEnumSchema$Items; - 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/TitledMultiSelectEnumSchema$Items;)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/TitledMultiSelectEnumSchema$Items$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/TitledSingleSelectEnumSchema : io/modelcontextprotocol/kotlin/sdk/types/SingleSelectEnumSchema { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/TitledSingleSelectEnumSchema$Companion; - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/util/List; - public final fun component4 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/TitledSingleSelectEnumSchema; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/TitledSingleSelectEnumSchema;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/TitledSingleSelectEnumSchema; - public fun equals (Ljava/lang/Object;)Z - public final fun getDefault ()Ljava/lang/String; - public final fun getDescription ()Ljava/lang/String; - public final fun getOneOf ()Ljava/util/List; - public final fun getTitle ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/TitledSingleSelectEnumSchema$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/TitledSingleSelectEnumSchema$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/TitledSingleSelectEnumSchema; - 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/TitledSingleSelectEnumSchema;)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/TitledSingleSelectEnumSchema$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/Tool : io/modelcontextprotocol/kotlin/sdk/types/WithMeta { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/Tool$Companion; - public fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution;Lkotlinx/serialization/json/JsonObject;)V - public synthetic fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; public final fun component3 ()Ljava/lang/String; @@ -5457,20 +4389,19 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Tool : io/modelconte public final fun component5 ()Ljava/lang/String; public final fun component6 ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations; public final fun component7 ()Ljava/util/List; - public final fun component8 ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution; - public final fun component9 ()Lkotlinx/serialization/json/JsonObject; - public final fun copy (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/Tool; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/Tool;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/Tool; + public final fun component8 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/Tool; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/Tool;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Ljava/util/List;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/Tool; public fun equals (Ljava/lang/Object;)Z public final fun getAnnotations ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations; public final fun getDescription ()Ljava/lang/String; - public final fun getExecution ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution; public final fun getIcons ()Ljava/util/List; public final fun getInputSchema ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public final fun getName ()Ljava/lang/String; public final fun getOutputSchema ()Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; public final fun getTitle ()Ljava/lang/String; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -5527,78 +4458,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 - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/TaskSupport;)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/TaskSupport;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskSupport; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/TaskSupport;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution;Lio/modelcontextprotocol/kotlin/sdk/types/TaskSupport;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution; - public fun equals (Ljava/lang/Object;)Z - public final fun getTaskSupport ()Lio/modelcontextprotocol/kotlin/sdk/types/TaskSupport; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/ToolExecution$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution; - 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/ToolExecution;)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/ToolExecution$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class io/modelcontextprotocol/kotlin/sdk/types/ToolListChangedNotification : io/modelcontextprotocol/kotlin/sdk/types/ServerNotification { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/ToolListChangedNotification$Companion; public fun ()V @@ -5630,59 +4489,18 @@ 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;)V + public synthetic fun (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lkotlinx/serialization/json/JsonObject; + public final fun component2 ()Ljava/util/List; + public final fun copy (Lkotlinx/serialization/json/JsonObject;Ljava/util/List;)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;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 +4521,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; @@ -5763,6 +4546,7 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/UnknownResourceConte public fun getMeta ()Lkotlinx/serialization/json/JsonObject; public fun getMimeType ()Ljava/lang/String; public fun getUri ()Ljava/lang/String; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -5818,7 +4602,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,209 +4637,17 @@ 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 - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/lang/Integer; - public final fun component4 ()Ljava/lang/Integer; - public final fun component5 ()Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items; - public final fun component6 ()Ljava/util/List; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items;Ljava/util/List;)Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema; - public fun equals (Ljava/lang/Object;)Z - public final fun getDefault ()Ljava/util/List; - public final fun getDescription ()Ljava/lang/String; - public final fun getItems ()Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items; - public final fun getMaxItems ()Ljava/lang/Integer; - public final fun getMinItems ()Ljava/lang/Integer; - public final fun getTitle ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema; - 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/UntitledMultiSelectEnumSchema;)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/UntitledMultiSelectEnumSchema$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items$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/UntitledMultiSelectEnumSchema$Items; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items;Ljava/util/List;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items; - public fun equals (Ljava/lang/Object;)Z - public final fun getEnumValues ()Ljava/util/List; - public final fun getType ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/UntitledMultiSelectEnumSchema$Items; - 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/UntitledMultiSelectEnumSchema$Items;)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/UntitledMultiSelectEnumSchema$Items$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class io/modelcontextprotocol/kotlin/sdk/types/UntitledSingleSelectEnumSchema : io/modelcontextprotocol/kotlin/sdk/types/SingleSelectEnumSchema { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UntitledSingleSelectEnumSchema$Companion; - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/util/List; - public final fun component4 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/types/UntitledSingleSelectEnumSchema; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/types/UntitledSingleSelectEnumSchema;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/types/UntitledSingleSelectEnumSchema; - public fun equals (Ljava/lang/Object;)Z - public final fun getDefault ()Ljava/lang/String; - public final fun getDescription ()Ljava/lang/String; - public final fun getEnumValues ()Ljava/util/List; - public final fun getTitle ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final synthetic class io/modelcontextprotocol/kotlin/sdk/types/UntitledSingleSelectEnumSchema$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/types/UntitledSingleSelectEnumSchema$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/types/UntitledSingleSelectEnumSchema; - 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/UntitledSingleSelectEnumSchema;)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/UntitledSingleSelectEnumSchema$Companion { - 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; + public fun get_meta ()Lkotlinx/serialization/json/JsonObject; } public final class io/modelcontextprotocol/kotlin/sdk/types/WithMeta$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class io/modelcontextprotocol/kotlin/sdk/utils/MatchResult { - public fun (Ljava/util/Map;I)V - public fun equals (Ljava/lang/Object;)Z - public final fun getScore ()I - public final fun getVariables ()Ljava/util/Map; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher : io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher$Companion; - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;I)V - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;II)V - public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V - public static final fun getFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory; - public fun getResourceTemplate ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate; - public fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult; -} - -public final class io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher$Companion { - public final fun getFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory; -} - -public abstract interface class io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher { - public abstract fun getResourceTemplate ()Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate; - public abstract fun match (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/utils/MatchResult; -} - -public abstract interface class io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory { - public abstract fun create (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;)Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher; +public final class io/modelcontextprotocol/kotlin/sdk/types/WithMeta$DefaultImpls { + public static fun get_meta (Lio/modelcontextprotocol/kotlin/sdk/types/WithMeta;)Lkotlinx/serialization/json/JsonObject; } diff --git a/kotlin-sdk-core/build.gradle.kts b/kotlin-sdk-core/build.gradle.kts index 1d19f5d..dd095e6 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 { @@ -64,15 +121,15 @@ kotlin { commonTest { dependencies { - implementation(project(":test-utils")) implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.kotest.assertions.core) + implementation(libs.kotest.assertions.json) } } jvmTest { dependencies { - implementation(libs.junit.jupiter.params) - implementation(libs.mockk) runtimeOnly(libs.slf4j.simple) } } diff --git a/kotlin-sdk-core/detekt-baseline-main.xml b/kotlin-sdk-core/detekt-baseline-main.xml deleted file mode 100644 index d483d7e..0000000 --- a/kotlin-sdk-core/detekt-baseline-main.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - UnsafeCallOnNullableType:Protocol.kt:Protocol$response!! - - diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/InternalMcpApi.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/InternalMcpApi.kt deleted file mode 100644 index ba9b348..0000000 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/InternalMcpApi.kt +++ /dev/null @@ -1,49 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk - -import kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS -import kotlin.annotation.AnnotationTarget.CLASS -import kotlin.annotation.AnnotationTarget.CONSTRUCTOR -import kotlin.annotation.AnnotationTarget.FIELD -import kotlin.annotation.AnnotationTarget.FUNCTION -import kotlin.annotation.AnnotationTarget.LOCAL_VARIABLE -import kotlin.annotation.AnnotationTarget.PROPERTY -import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER -import kotlin.annotation.AnnotationTarget.PROPERTY_SETTER -import kotlin.annotation.AnnotationTarget.TYPEALIAS -import kotlin.annotation.AnnotationTarget.VALUE_PARAMETER - -/** - * Denotes that the annotated API is internal to the MCP SDK and its usage is restricted to components - * within the SDK. It is not intended to be used in general application code or by external consumers. - * - * This annotation is used to communicate and enforce the limited visibility of certain APIs to prevent - * accidental usage outside their intended context. - * - * Applying this annotation requires consumers to explicitly opt-in, acknowledging the warning level - * associated with its use. - * - * This annotation can be used on classes, annotations, properties, fields, local variables, value - * parameters, constructors, functions, property accessors, and type aliases. - * - * The annotation is retained in the binary and must be documented. - */ -@RequiresOptIn( - message = "This API is internal, and its usage is restricted to internal components of the MCP SDK.", - level = RequiresOptIn.Level.WARNING, -) -@MustBeDocumented -@Target( - CLASS, - ANNOTATION_CLASS, - PROPERTY, - FIELD, - LOCAL_VARIABLE, - VALUE_PARAMETER, - CONSTRUCTOR, - FUNCTION, - PROPERTY_GETTER, - PROPERTY_SETTER, - TYPEALIAS, -) -@Retention(AnnotationRetention.BINARY) -public annotation class InternalMcpApi diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/internal/utils.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/internal/utils.kt index 37a57b7..d1cbe78 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/internal/utils.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/internal/utils.kt @@ -2,5 +2,4 @@ package io.modelcontextprotocol.kotlin.sdk.internal import kotlinx.coroutines.CoroutineDispatcher -/** Platform-specific [CoroutineDispatcher] for I/O-bound operations. */ public expect val IODispatcher: CoroutineDispatcher 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 deleted file mode 100644 index 27013d3..0000000 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport.kt +++ /dev/null @@ -1,276 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.shared - -import io.github.oshai.kotlinlogging.KLogger -import io.modelcontextprotocol.kotlin.sdk.InternalMcpApi -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage -import io.modelcontextprotocol.kotlin.sdk.types.McpException -import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED -import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.INTERNAL_ERROR -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.coroutines.cancellation.CancellationException - -/** - * Abstract base class representing a client-side transport layer for communication. - * - * This class is responsible for managing the lifecycle of a transport instance, including its initialization, - * state transitions, message transmission, and graceful shutdown. It provides a framework for implementing - * specific transport mechanisms by defining abstract methods that subclasses must implement. - * - * Thread-safety is achieved through the use of atomic references to manage transport state transitions and - * to ensure invariants during lifecycle events. The initialization and shutdown logic is designed to handle - * async workflows, ensuring proper resource cleanup in the case of failure or cancellation. - */ -@OptIn(ExperimentalAtomicApi::class, InternalMcpApi::class) -public abstract class AbstractClientTransport : AbstractTransport() { - - protected abstract val logger: KLogger - - /** - * Represents the current state of the client transport in an atomic and thread-safe manner. - * - * This private field is used internally to manage state transitions of the transport, ensuring - * consistency across concurrent operations. The state transitions are validated and managed - * using the `stateTransition` method, adhering to defined rules in [ClientTransportState.VALID_TRANSITIONS]. - * - * @see ClientTransportState - * @see stateTransition - * @see checkState - */ - private val _state: AtomicReference = AtomicReference(ClientTransportState.New) - - /** - * Current transport state. Thread-safe via atomic reference. - * Initial state is [ClientTransportState.New]. - */ - @InternalMcpApi - protected val state: ClientTransportState - get() = _state.load() - - /** - * Updates the internal state of the client transport to the specified new state. - * - * This method is used to manage the lifecycle and operational state of the client transport - * by transitioning it to the provided [new] state. The new state is stored atomically, - * ensuring thread safety during concurrent modifications. - * - * @param new The new state to transition the client transport to. It must be a valid state - * as defined in the transport's lifecycle logic. - */ - @InternalMcpApi - protected fun updateState(new: ClientTransportState) { - _state.store(new) - } - - /** - * Performs transport-specific initialization (connections, I/O streams, background coroutines). - * - * Called by [start] during [ClientTransportState.Initializing]. On exception, state transitions to - * [ClientTransportState.InitializationFailed], [closeResources] is called, and exception propagates. - */ - protected abstract suspend fun initialize() - - /** - * Atomically transitions state from `from` to `to`. - * - * Validates transition per [ClientTransportState.VALID_TRANSITIONS] and performs atomic compare-and-exchange. - * - * @throws IllegalArgumentException if transition isn't allowed - * @throws IllegalStateException if the actual state doesn't match `from` - */ - @InternalMcpApi - protected fun stateTransition(from: ClientTransportState, to: ClientTransportState) { - require(to in ClientTransportState.VALID_TRANSITIONS.getValue(from)) { - "Invalid transition: $from → $to" - } - val actualState = _state.compareAndExchange( - expectedValue = from, - newValue = to, - ) - check(actualState == from) { - "Can't change state: expected transport state $from, but found $actualState." - } - } - - /** - * Verifies that the current transport state matches the expected state and throws an exception if it does not. - * - * This method ensures that the transport's state is in alignment with the caller’s expectations before - * 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. - */ - protected fun checkState( - expected: ClientTransportState, - lazyMessage: (ClientTransportState) -> Any = { - "Expected transport state $expected, but found $it" - }, - ) { - val actualState = state - check(actualState == expected) { lazyMessage } - } - - /** - * Initiates the transport by transitioning through the required states to prepare it for operation. - * - * This method transitions the transport state from `New` to `Initializing`, performs initialization - * logic specific to the transport implementation, and then transitions the state to `Operational` - * upon successful initialization. If an exception occurs during initialization, the state is transitioned - * to `InitializationFailed`, resources are cleaned up, and the exception is rethrown. - * - * This method operates within a suspend context to allow for asynchronous initialization tasks. - * - * **Important! This method will eventually become `final`. - * Please don't override it, or consistency guarantees might be lost. Override [initialize] instead.** - * - * @throws Exception if the initialization process fails and transfers state - * to [ClientTransportState.InitializationFailed]. - * @see initialize - */ - public override suspend fun start() { - stateTransition(from = ClientTransportState.New, to = ClientTransportState.Initializing) - try { - initialize() - stateTransition(from = ClientTransportState.Initializing, to = ClientTransportState.Operational) - } catch (e: Exception) { - _state.store(ClientTransportState.InitializationFailed) - closeResources() - throw e - } - } - - /** - * Sends a JSON-RPC message using the transport layer. - * - * This method ensures that the transport is operational before attempting to send the message. - * If the transport's state does not allow sending operations, an exception is thrown. - * It delegates the actual transmission to the abstract [performSend] method, which must be - * implemented by subclasses to handle the specifics of the transmission mechanism. Errors - * during the send operation are handled appropriately, and cancellation exceptions are always - * propagated. - * - * **Important! This method will eventually become `final`. - * Please don't override it, or consistency guarantees might be lost. Override [performSend] instead.** - * - * @param message The JSON-RPC message to be sent. Must adhere to the JSON-RPC 2.0 specification. - * @param options Optional parameters that influence how the message is transmitted, such as - * timeout specifications or message delivery guarantees. Can be null if no - * specific options are required. - * @throws McpException If the transport is not operational or other application-specific errors occur. - * @throws CancellationException If the coroutine is canceled. - * @see performSend - */ - public override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { - // fast path - state check to avoid nonsense operations - if (state != ClientTransportState.Operational) { - throw McpException( - code = CONNECTION_CLOSED, - message = "Transport is not ready", - ) - } - // limitation: state could still change at the time of use - try { - performSend(message, options) - } catch (e: CancellationException) { - throw e // Always propagate cancellation - } catch (e: Exception) { - _onError(e) - if (e is McpException) { - throw e - } else { - throw McpException( - code = INTERNAL_ERROR, - message = "Error while sending message: ${e.message}", - cause = e, - ) - } - } - } - - /** - * Transmits a JSON-RPC message via the underlying protocol. - * - * The method is responsible for transmitting a given JSON-RPC message to the intended destination. - * It is implemented by concrete subclasses of the transport to handle specific transmission - * mechanisms. - * - * Called by [send] after verifying transport [state]. - * - * @param message The JSON-RPC message to be sent. Must comply with the JSON-RPC 2.0 specification. - * @param options Optional parameters that influence how the message is transmitted, such as - * timeout settings or delivery guarantees. May be null if no specific options - * are required. - * @see send - */ - protected abstract suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions? = null) - - /** - * Closes the client transport gracefully, transitioning its state to a non-operational state. - * - * **Important! This method will eventually become `final`. - * Please don't override it, or consistency guarantees might be lost.** - * - * The method is designed to operate within a suspendable context, allowing support for asynchronous - * behavior during the resource cleanup and state transitions. - * - * Transitions [ClientTransportState.Operational] → [ClientTransportState.ShuttingDown] → [ClientTransportState.Stopped]. - * Calls [closeResources] then invokes [_onClose] callbacks exactly once. - * On [kotlin.coroutines.cancellation.CancellationException], transitions to [ClientTransportState.ShutdownFailed] and propagates. - * - * The method also handles unexpected exceptions gracefully during resource cleanup by transitioning - * the transport to the `ShutdownFailed` state. - * - * **Important! This method will eventually become `final`. - * Please don't override it, or consistency guarantees might be lost. Override [closeResources] instead.** - * - * @see closeResources - */ - public override suspend fun close() { - val performClose: Boolean - when (state) { - ClientTransportState.Operational -> { - // Only Operational state can transition to ShuttingDown - stateTransition(ClientTransportState.Operational, ClientTransportState.ShuttingDown) - performClose = true - } - - ClientTransportState.New -> { - // New state transitions directly to Stopped without any cleanup - stateTransition(ClientTransportState.New, ClientTransportState.Stopped) - performClose = false - } - - else -> { - // Already shutting down or in terminal state - idempotent, do nothing - performClose = false - } - } - if (performClose) { - try { - closeResources() - stateTransition(from = ClientTransportState.ShuttingDown, to = ClientTransportState.Stopped) - } catch (e: CancellationException) { - stateTransition(from = ClientTransportState.ShuttingDown, to = ClientTransportState.ShutdownFailed) - throw e // Always propagate cancellation - } catch (e: Exception) { - // Ignore errors during cleanup - logger.error(e) { "Error during transport shutdown" } - stateTransition(from = ClientTransportState.ShuttingDown, to = ClientTransportState.ShutdownFailed) - } finally { - invokeOnCloseCallback() - } - } - } - - /** - * Releases transport-specific resources (coroutines, connections, streams). - * - * Called by [close] during [ClientTransportState.ShuttingDown]. - * May be called even if [initialize] failed partially. - */ - protected abstract suspend fun closeResources() -} 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..cbab7ae 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 @@ -2,20 +2,15 @@ package io.modelcontextprotocol.kotlin.sdk.shared import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import kotlinx.coroutines.CompletableDeferred -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) = {} + protected var _onClose: (() -> Unit) = {} + private set protected var _onError: ((Throwable) -> Unit) = {} private set @@ -56,18 +51,4 @@ public abstract class AbstractTransport : Transport { _onMessageInitialized.complete(Unit) } - - /** - * Invokes the `_onClose` callback if it has not been already triggered. - * - * This method ensures the `_onClose` callback is executed only once by utilizing - * an atomic flag (`onCloseCalled`). If the callback has already been executed, - * the method does nothing. Any exceptions thrown during the execution of the - * `_onClose` callback are caught and suppressed. - */ - protected fun invokeOnCloseCallback() { - if (onCloseCalled.compareAndSet(expectedValue = false, newValue = true)) { - runCatching { _onClose() } - } - } } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState.kt deleted file mode 100644 index 4f14d03..0000000 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState.kt +++ /dev/null @@ -1,79 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.shared - -/** - * States of a client transport within the protocol lifecycle - * - * These states are generally aligned with the corresponding phases - * defined under the MCP [Protocol Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle). - */ -public enum class ClientTransportState { - - /** - * The transport has just been created. - * - * This is the Initial state - */ - New, - - /** - * Indicates that the protocol - * is in the [Initialization Phase](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization). - */ - Initializing, - - /** - * Indicates that the initialization phase of the protocol lifecycle has failed. - * - * This state suggests that the transport encountered an error or issue during - * initialization, preventing it from successfully transitioning to a fully operational state. - */ - InitializationFailed, - - /** - * Represents the [operational phase](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#operation) - * of the protocol lifecycle. - * - * During the operation phase, the client and server exchange messages according to the negotiated capabilities. - */ - Operational, - - /** - * Represents the shutting down phase of the protocol lifecycle. - * - * This state indicates that the transport is in the process of shutting down - * and transitioning to a non-operational state. - * - * During this phase, no new outgoing messages should be accepted. - */ - ShuttingDown, - - /** - * Indicates that the shutdown phase of the protocol lifecycle has failed. - * - * This state signifies that an error was encountered during the shutdown phase. - */ - ShutdownFailed, - - /** - * Indicates that the transport has fully stopped. - * - * This state represents the final phase of the lifecycle, where the transport is no longer - * operational and all cleanup routines or resource deallocations have been completed. - */ - Stopped, - - ; - - internal companion object { - val VALID_TRANSITIONS: Map> = mapOf( - New to setOf(Initializing, Stopped), - Initializing to setOf(Operational, InitializationFailed), - Operational to setOf(ShuttingDown), - ShuttingDown to setOf(Stopped, ShutdownFailed), - // Terminal states allow no transitions - InitializationFailed to emptySet(), - Stopped to emptySet(), - ShutdownFailed to emptySet(), - ) - } -} 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..358b565 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,41 +27,23 @@ 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.ExperimentalSerializationApi +import kotlinx.serialization.json.ClassDiscriminatorMode +import kotlinx.serialization.json.Json 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 private val logger = KotlinLogging.logger { } -/** Default implementation name used in MCP handshake. */ public const val IMPLEMENTATION_NAME: String = "mcp-ktor" /** @@ -69,29 +51,40 @@ public const val IMPLEMENTATION_NAME: String = "mcp-ktor" */ public typealias ProgressCallback = (Progress) -> Unit +@OptIn(ExperimentalSerializationApi::class) +@Deprecated( + message = "Use McpJson instead", + replaceWith = ReplaceWith("McpJson", "io.modelcontextprotocol.kotlin.sdk.types.McpJson"), + level = DeprecationLevel.WARNING, +) +public val McpJson: Json by lazy { + Json { + ignoreUnknownKeys = true + encodeDefaults = true + isLenient = true + classDiscriminatorMode = ClassDiscriminatorMode.NONE + explicitNulls = false + } +} + /** * 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 +92,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, @@ -128,13 +115,9 @@ public class RequestOptions( public val onProgress: ProgressCallback? = null, public val timeout: Duration = DEFAULT_REQUEST_TIMEOUT, ) : TransportSendOptions(relatedRequestId, resumptionToken, onResumptionToken) { - /** Destructuring component for [onProgress]. */ public operator fun component4(): ProgressCallback? = onProgress - - /** Destructuring component for [timeout]. */ public operator fun component5(): Duration = timeout - /** Creates a copy of this [RequestOptions] with the specified fields replaced. */ public fun copy( relatedRequestId: RequestId? = this.relatedRequestId, resumptionToken: String? = this.resumptionToken, @@ -155,7 +138,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 +149,22 @@ 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 */ 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 +176,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 +219,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 +229,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 +245,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 - } + return 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 +271,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 +282,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 +305,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,80 +312,38 @@ 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, - ) + try { + val result = handler(request, RequestHandlerExtra()) + logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" } - // 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 + transport?.send( + JSONRPCResponse( + id = request.id, + result = result ?: EmptyResult(), + ), + ) + } catch (cause: Throwable) { + logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" } - 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( + transport?.send( + JSONRPCError( id = request.id, - result = result ?: EmptyResult(), + error = RPCError( + code = RPCError.ErrorCode.INTERNAL_ERROR, + message = cause.message ?: "Internal error", + ), ), ) - } 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 + } catch (sendError: Throwable) { + logger.error(sendError) { + "Failed to send error response for request: ${request.method} (id: ${request.id})" } - respondWithError(capturedTransport, request, cause) + // Optionally implement fallback behavior here } } } - private fun registerInFlight(connection: Connection, id: RequestId, job: Job) { - val previous = connection.inFlightRequestJobs.getAndUpdate { current -> current.putting(id, job) }[id] - if (previous != null) { - logger.warn { "Duplicate in-flight request id $id; replacing the previous handler job" } - } - job.invokeOnCompletion { - // Identity guard: never evict a successor job registered under a reused id. - connection.inFlightRequestJobs.update { current -> - if (current[id] === job) current.removing(id) else current - } - } - } - - private suspend fun respondWithError(transport: Transport, request: JSONRPCRequest, cause: Throwable) { - logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" } - try { - val rpcError = if (cause is McpException) { - RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data) - } else { - RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error") - } - transport.send(JSONRPCError(id = request.id, error = rpcError)) - } catch (e: CancellationException) { - throw e - } catch (sendError: Throwable) { - logger.error(sendError) { - "Failed to send error response for request: ${request.method} (id: ${request.id})" - } - onError(sendError) - } - } - private fun onProgress(notification: ProgressNotification) { logger.trace { "Received progress notification: token=${notification.params.progressToken}, progress=${notification.params.progress}/${notification.params.total}" @@ -683,10 +355,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 +371,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 +380,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 +393,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 +439,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 +449,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 +468,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 +539,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 +554,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 +589,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..d991e7f 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,131 +4,68 @@ 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 { } private val buffer: Buffer = Buffer() - /** Appends raw bytes to the internal buffer for subsequent message parsing. */ public fun append(chunk: ByteArray) { buffer.write(chunk) } - /** - * Reads and deserializes a JSON-RPC message from the input buffer. - * - * The method attempts to read lines from the buffer until a valid `JSONRPCMessage` is successfully - * deserialized. Blank lines are ignored, and if a deserialization error occurs, the method attempts - * to recover and process the message. - * - * Recovery involves attempting to parse from the first detected JSON object in the line when an - * error is encountered during deserialization. - * - * @return A deserialized `JSONRPCMessage` if successfully processed; otherwise, `null` if the - * input buffer is exhausted or no valid message is found. - */ public fun readMessage(): JSONRPCMessage? { - while (true) { - val line = readNextLine() ?: return null - if (line.isBlank()) continue + if (buffer.exhausted()) return null + var lfIndex = buffer.indexOf('\n'.code.toByte()) + val line = when (lfIndex) { + -1L -> return null - val message = try { - deserializeMessage(line) - } catch (e: Exception) { - logger.error(e) { "Failed to deserialize message from line: $line\nAttempting to recover..." } - tryRecover(line) + 0L -> { + buffer.skip(1) + return null } - if (message != null) { - return message + + else -> { + var skipBytes = 1 + if (buffer[lfIndex - 1] == '\r'.code.toByte()) { + lfIndex -= 1 + skipBytes += 1 + } + val string = buffer.readString(lfIndex) + buffer.skip(skipBytes.toLong()) + string } } - } - - 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) - - return if (lfIndex == 0L) { - buffer.skip(1) - "" - } else { - var skipBytes = 1 - var messageLength = lfIndex - if (buffer[lfIndex - 1] == '\r'.code.toByte()) { - messageLength -= 1 - skipBytes += 1 + try { + return deserializeMessage(line) + } catch (e: Exception) { + logger.error(e) { "Failed to deserialize message from line: $line\nAttempting to recover..." } + // if there is a non-JSON object prefix, try to parse from the first '{' onward. + val braceIndex = line.indexOf('{') + if (braceIndex != -1) { + val trimmed = line.substring(braceIndex) + try { + return deserializeMessage(trimmed) + } catch (ignored: Exception) { + logger.error(ignored) { "Deserialization failed for line: $line\nSkipping..." } + } } - val string = buffer.readString(messageLength) - buffer.skip(skipBytes.toLong()) - string } + + return null } - private fun failIfFrameTooLong(frameSize: Long) { - if (maxFrameSize in 1..(line) -/** Serializes a [JSONRPCMessage] to its JSON string representation with a trailing newline. */ public fun serializeMessage(message: JSONRPCMessage): String = McpJson.encodeToString(message) + "\n" 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..52f1438 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 @@ -11,8 +11,6 @@ public interface Transport { * * This method should only be called after callbacks are installed, or else messages may be lost. * - * Method waits for the initialization flow to complete and then returns, so the transport is ready to send and receive user messages. - * * NOTE: This method should not be called explicitly when using Client, Server, or Protocol classes, * as they will implicitly call start(). */ @@ -21,14 +19,14 @@ 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) /** - * Closes the transport. + * Closes the connection. */ public suspend fun close() diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions.kt index 5490288..c56df04 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions.kt @@ -17,16 +17,10 @@ public open class TransportSendOptions( public val resumptionToken: String? = null, public val onResumptionToken: ((String) -> Unit)? = null, ) { - /** Destructuring component for [relatedRequestId]. */ public operator fun component1(): RequestId? = relatedRequestId - - /** Destructuring component for [resumptionToken]. */ public operator fun component2(): String? = resumptionToken - - /** Destructuring component for [onResumptionToken]. */ public operator fun component3(): ((String) -> Unit)? = onResumptionToken - /** Creates a copy of this [TransportSendOptions] with the specified fields replaced. */ public open fun copy( relatedRequestId: RequestId? = this.relatedRequestId, resumptionToken: String? = this.resumptionToken, 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..392efdc 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,9 +16,7 @@ import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.coroutines.cancellation.CancellationException -/** WebSocket subprotocol identifier for MCP connections. */ public const val MCP_SUBPROTOCOL: String = "mcp" private val logger = KotlinLogging.logger {} @@ -75,8 +73,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 @@ -89,7 +85,7 @@ public abstract class WebSocketMcpTransport : AbstractTransport() { if (it != null) { _onError.invoke(it) } else { - invokeOnCloseCallback() + _onClose.invoke() } } } diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types.kt new file mode 100644 index 0000000..23b0e45 --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types.kt @@ -0,0 +1,2254 @@ +@file:Suppress("FunctionName") + +package io.modelcontextprotocol.kotlin.sdk + +import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities.Roots +import io.modelcontextprotocol.kotlin.sdk.types.ContentBlock +import io.modelcontextprotocol.kotlin.sdk.types.fromJSON +import io.modelcontextprotocol.kotlin.sdk.types.toJSON +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlin.concurrent.atomics.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +@Deprecated( + message = "Use `LATEST_PROTOCOL_VERSION` instead", + replaceWith = ReplaceWith( + "LATEST_PROTOCOL_VERSION", + "io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION", + ), + level = DeprecationLevel.WARNING, +) +public const val LATEST_PROTOCOL_VERSION: String = io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION + +@Deprecated( + message = "Use `SUPPORTED_PROTOCOL_VERSIONS` instead", + replaceWith = ReplaceWith( + "SUPPORTED_PROTOCOL_VERSIONS", + "io.modelcontextprotocol.kotlin.sdk.types.SUPPORTED_PROTOCOL_VERSIONS", + ), + level = DeprecationLevel.WARNING, +) +public val SUPPORTED_PROTOCOL_VERSIONS: List = + io.modelcontextprotocol.kotlin.sdk.types.SUPPORTED_PROTOCOL_VERSIONS + +@Deprecated( + message = "Use `JSONRPC_VERSION` instead", + replaceWith = ReplaceWith("JSONRPC_VERSION", "io.modelcontextprotocol.kotlin.sdk.types.JSONRPC_VERSION"), + level = DeprecationLevel.WARNING, +) +public const val JSONRPC_VERSION: String = io.modelcontextprotocol.kotlin.sdk.types.JSONRPC_VERSION + +@OptIn(ExperimentalAtomicApi::class) +@Deprecated( + message = "Use `REQUEST_MESSAGE_ID` instead", + replaceWith = ReplaceWith("REQUEST_MESSAGE_ID", "io.modelcontextprotocol.kotlin.sdk.types.REQUEST_MESSAGE_ID"), + level = DeprecationLevel.WARNING, +) +private val REQUEST_MESSAGE_ID: AtomicLong = AtomicLong(0L) + +/** + * A progress token, used to associate progress notifications with the original request. + * Stores message ID. + */ +@Deprecated( + message = "Use `ProgressToken` instead", + replaceWith = ReplaceWith("ProgressToken", "io.modelcontextprotocol.kotlin.sdk.types.ProgressToken"), + level = DeprecationLevel.WARNING, +) +public typealias ProgressToken = io.modelcontextprotocol.kotlin.sdk.types.ProgressToken + +/** + * An opaque token used to represent a cursor for pagination. + */ +@Deprecated( + message = "This alias will be removed. Use String directly instead.", + replaceWith = ReplaceWith("String"), + level = DeprecationLevel.WARNING, +) +public typealias Cursor = String + +/** + * Represents an entity that includes additional metadata in its responses. + */ +@Deprecated( + message = "Use `WithMeta` instead", + replaceWith = ReplaceWith("WithMeta", "io.modelcontextprotocol.kotlin.sdk.types.WithMeta"), + level = DeprecationLevel.WARNING, +) +public typealias WithMeta = io.modelcontextprotocol.kotlin.sdk.types.WithMeta + +/** + * An implementation of [WithMeta] containing custom metadata. + * + * @param _meta The JSON object holding metadata. Defaults to an empty JSON object. + */ +@Deprecated( + message = "This class will be removed. Use `WithMeta` instead", + replaceWith = ReplaceWith("WithMeta", "io.modelcontextprotocol.kotlin.sdk.types.WithMeta"), + level = DeprecationLevel.WARNING, +) +public typealias CustomMeta = io.modelcontextprotocol.kotlin.sdk.types.WithMeta + +/** + * Represents a method in the protocol, which can be predefined or custom. + */ +@Deprecated( + message = "Use `Method` instead", + replaceWith = ReplaceWith("Method", "io.modelcontextprotocol.kotlin.sdk.types.Method"), + level = DeprecationLevel.WARNING, +) +public sealed interface Method { + public val value: String + + public companion object { + /** + * Represents a custom method defined by the user. + */ + @Deprecated( + message = "Use `Method.Custom` instead", + replaceWith = ReplaceWith("Method.Custom", "io.modelcontextprotocol.kotlin.sdk.types.Method.Custom"), + level = DeprecationLevel.WARNING, + ) + public fun Custom(value: String): io.modelcontextprotocol.kotlin.sdk.types.Method.Custom = + io.modelcontextprotocol.kotlin.sdk.types.Method.Custom(value) + } + + public object Defined { + @Deprecated( + message = "Use `Method.Defined.Initialize` instead", + replaceWith = ReplaceWith( + "Method.Defined.Initialize", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.Initialize", + ), + level = DeprecationLevel.WARNING, + ) + public val Initialize: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.Initialize + + @Deprecated( + message = "Use `Method.Defined.Ping` instead", + replaceWith = ReplaceWith( + "Method.Defined.Ping", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.Ping", + ), + level = DeprecationLevel.WARNING, + ) + public val Ping: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.Ping + + @Deprecated( + message = "Use `Method.Defined.ResourcesList` instead", + replaceWith = ReplaceWith( + "Method.Defined.ResourcesList", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ResourcesList", + ), + level = DeprecationLevel.WARNING, + ) + public val ResourcesList: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ResourcesList + + @Deprecated( + message = "Use `Method.Defined.ResourcesTemplatesList` instead", + replaceWith = ReplaceWith( + "Method.Defined.ResourcesTemplatesList", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ResourcesTemplatesList", + ), + level = DeprecationLevel.WARNING, + ) + public val ResourcesTemplatesList: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ResourcesTemplatesList + + @Deprecated( + message = "Use `Method.Defined.ResourcesRead` instead", + replaceWith = ReplaceWith( + "Method.Defined.ResourcesRead", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ResourcesRead", + ), + level = DeprecationLevel.WARNING, + ) + public val ResourcesRead: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ResourcesRead + + @Deprecated( + message = "Use `Method.Defined.ResourcesSubscribe` instead", + replaceWith = ReplaceWith( + "Method.Defined.ResourcesSubscribe", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ResourcesSubscribe", + ), + level = DeprecationLevel.WARNING, + ) + public val ResourcesSubscribe: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ResourcesSubscribe + + @Deprecated( + message = "Use `Method.Defined.ResourcesUnsubscribe` instead", + replaceWith = ReplaceWith( + "Method.Defined.ResourcesUnsubscribe", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ResourcesUnsubscribe", + ), + level = DeprecationLevel.WARNING, + ) + public val ResourcesUnsubscribe: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ResourcesUnsubscribe + + @Deprecated( + message = "Use `Method.Defined.PromptsList` instead", + replaceWith = ReplaceWith( + "Method.Defined.PromptsList", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.PromptsList", + ), + level = DeprecationLevel.WARNING, + ) + public val PromptsList: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.PromptsList + + @Deprecated( + message = "Use `Method.Defined.PromptsGet` instead", + replaceWith = ReplaceWith( + "Method.Defined.PromptsGet", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.PromptsGet", + ), + level = DeprecationLevel.WARNING, + ) + public val PromptsGet: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.PromptsGet + + @Deprecated( + message = "Use `Method.Defined.NotificationsCancelled` instead", + replaceWith = ReplaceWith( + "Method.Defined.NotificationsCancelled", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsCancelled", + ), + level = DeprecationLevel.WARNING, + ) + public val NotificationsCancelled: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsCancelled + + @Deprecated( + message = "Use `Method.Defined.NotificationsInitialized` instead", + replaceWith = ReplaceWith( + "Method.Defined.NotificationsInitialized", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsInitialized", + ), + level = DeprecationLevel.WARNING, + ) + public val NotificationsInitialized: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsInitialized + + @Deprecated( + message = "Use `Method.Defined.NotificationsProgress` instead", + replaceWith = ReplaceWith( + "Method.Defined.NotificationsProgress", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsProgress", + ), + level = DeprecationLevel.WARNING, + ) + public val NotificationsProgress: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsProgress + + @Deprecated( + message = "Use `Method.Defined.NotificationsMessage` instead", + replaceWith = ReplaceWith( + "Method.Defined.NotificationsMessage", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsMessage", + ), + level = DeprecationLevel.WARNING, + ) + public val NotificationsMessage: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsMessage + + @Deprecated( + message = "Use `Method.Defined.NotificationsResourcesUpdated` instead", + replaceWith = ReplaceWith( + "Method.Defined.NotificationsResourcesUpdated", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsResourcesUpdated", + ), + level = DeprecationLevel.WARNING, + ) + public val NotificationsResourcesUpdated: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsResourcesUpdated + + @Deprecated( + message = "Use `Method.Defined.NotificationsResourcesListChanged` instead", + replaceWith = ReplaceWith( + "Method.Defined.NotificationsResourcesListChanged", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsResourcesListChanged", + ), + level = DeprecationLevel.WARNING, + ) + public val NotificationsResourcesListChanged: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsResourcesListChanged + + @Deprecated( + message = "Use `Method.Defined.NotificationsToolsListChanged` instead", + replaceWith = ReplaceWith( + "Method.Defined.NotificationsToolsListChanged", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsToolsListChanged", + ), + level = DeprecationLevel.WARNING, + ) + public val NotificationsToolsListChanged: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsToolsListChanged + + @Deprecated( + message = "Use `Method.Defined.NotificationsRootsListChanged` instead", + replaceWith = ReplaceWith( + "Method.Defined.NotificationsRootsListChanged", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsRootsListChanged", + ), + level = DeprecationLevel.WARNING, + ) + public val NotificationsRootsListChanged: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsRootsListChanged + + @Deprecated( + message = "Use `Method.Defined.NotificationsPromptsListChanged` instead", + replaceWith = ReplaceWith( + "Method.Defined.NotificationsPromptsListChanged", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsPromptsListChanged", + ), + level = DeprecationLevel.WARNING, + ) + public val NotificationsPromptsListChanged: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsPromptsListChanged + + @Deprecated( + message = "Use `Method.Defined.ToolsList` instead", + replaceWith = ReplaceWith( + "Method.Defined.ToolsList", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ToolsList", + ), + level = DeprecationLevel.WARNING, + ) + public val ToolsList: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ToolsList + + @Deprecated( + message = "Use `Method.Defined.ToolsCall` instead", + replaceWith = ReplaceWith( + "Method.Defined.ToolsCall", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ToolsCall", + ), + level = DeprecationLevel.WARNING, + ) + public val ToolsCall: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ToolsCall + + @Deprecated( + message = "Use `Method.Defined.LoggingSetLevel` instead", + replaceWith = ReplaceWith( + "Method.Defined.LoggingSetLevel", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.LoggingSetLevel", + ), + level = DeprecationLevel.WARNING, + ) + public val LoggingSetLevel: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.LoggingSetLevel + + @Deprecated( + message = "Use `Method.Defined.SamplingCreateMessage` instead", + replaceWith = ReplaceWith( + "Method.Defined.SamplingCreateMessage", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.SamplingCreateMessage", + ), + level = DeprecationLevel.WARNING, + ) + public val SamplingCreateMessage: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.SamplingCreateMessage + + @Deprecated( + message = "Use `Method.Defined.CompletionComplete` instead", + replaceWith = ReplaceWith( + "Method.Defined.CompletionComplete", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.CompletionComplete", + ), + level = DeprecationLevel.WARNING, + ) + public val CompletionComplete: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.CompletionComplete + + @Deprecated( + message = "Use `Method.Defined.RootsList` instead", + replaceWith = ReplaceWith( + "Method.Defined.RootsList", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.RootsList", + ), + level = DeprecationLevel.WARNING, + ) + public val RootsList: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.RootsList + + @Deprecated( + message = "Use `Method.Defined.ElicitationCreate` instead", + replaceWith = ReplaceWith( + "Method.Defined.ElicitationCreate", + "io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ElicitationCreate", + ), + level = DeprecationLevel.WARNING, + ) + public val ElicitationCreate: io.modelcontextprotocol.kotlin.sdk.types.Method.Defined = + io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.ElicitationCreate + } +} + +/** + * Represents a request in the protocol. + */ +@Deprecated( + message = "Use `Request` instead", + replaceWith = ReplaceWith("Request", "io.modelcontextprotocol.kotlin.sdk.types.Request"), + level = DeprecationLevel.WARNING, +) +public typealias Request = io.modelcontextprotocol.kotlin.sdk.types.Request + +/** + * Converts the request to a JSON-RPC request. + * + * @return The JSON-RPC request representation. + */ +@Deprecated( + message = "Use `toJSON` instead", + replaceWith = ReplaceWith("toJSON", "io.modelcontextprotocol.kotlin.sdk.types.toJSON"), + level = DeprecationLevel.WARNING, +) +public fun Request.toJSON(): JSONRPCRequest = this.toJSON() + +/** + * Decodes a JSON-RPC request into a protocol-specific [Request]. + * + * @return The decoded [Request] or null + */ +@Deprecated( + message = "Use `fromJSON` instead", + replaceWith = ReplaceWith("fromJSON", "io.modelcontextprotocol.kotlin.sdk.types.fromJSON"), + level = DeprecationLevel.WARNING, +) +internal fun JSONRPCRequest.fromJSON(): Request = this.fromJSON() + +/** + * A custom request with a specified method. + * + * @param method The method associated with the request. + */ +@Deprecated( + message = "Use `CustomRequest` instead", + replaceWith = ReplaceWith("CustomRequest", "io.modelcontextprotocol.kotlin.sdk.types.CustomRequest"), + level = DeprecationLevel.WARNING, +) +public typealias CustomRequest = io.modelcontextprotocol.kotlin.sdk.types.CustomRequest + +/** + * Represents a notification in the protocol. + */ +@Deprecated( + message = "Use `Notification` instead", + replaceWith = ReplaceWith("Notification", "io.modelcontextprotocol.kotlin.sdk.types.Notification"), + level = DeprecationLevel.WARNING, +) +public typealias Notification = io.modelcontextprotocol.kotlin.sdk.types.Notification + +/** + * Converts the notification to a JSON-RPC notification. + * + * @return The JSON-RPC notification representation. + */ +@Deprecated( + message = "Use `toJSON` instead", + replaceWith = ReplaceWith("toJSON", "io.modelcontextprotocol.kotlin.sdk.types.toJSON"), + level = DeprecationLevel.WARNING, +) +public fun Notification.toJSON(): JSONRPCNotification = this.toJSON() + +/** + * Decodes a JSON-RPC notification into a protocol-specific [Notification]. + * + * @return The decoded [Notification]. + */ +@Deprecated( + message = "Use `fromJSON` instead", + replaceWith = ReplaceWith("toJSON", "io.modelcontextprotocol.kotlin.sdk.types.fromJSON"), + level = DeprecationLevel.WARNING, +) +internal fun JSONRPCNotification.fromJSON(): Notification = this.fromJSON() + +/** + * Represents the result of a request, including additional metadata. + */ +@Deprecated( + message = "Use `RequestResult` instead", + replaceWith = ReplaceWith("RequestResult", "io.modelcontextprotocol.kotlin.sdk.types.RequestResult"), + level = DeprecationLevel.WARNING, +) +public typealias RequestResult = io.modelcontextprotocol.kotlin.sdk.types.RequestResult + +/** + * An empty result for a request containing optional metadata. + * + * @param _meta Additional metadata for the response. Defaults to an empty JSON object. + */ +@Deprecated( + message = "Use `EmptyResult` instead", + replaceWith = ReplaceWith("EmptyResult", "io.modelcontextprotocol.kotlin.sdk.types.EmptyResult"), + level = DeprecationLevel.WARNING, +) +public typealias EmptyRequestResult = io.modelcontextprotocol.kotlin.sdk.types.EmptyResult + +/** + * A uniquely identifying ID for a request in JSON-RPC. + */ +@Deprecated( + message = "Use `RequestId` instead", + replaceWith = ReplaceWith("RequestId", "io.modelcontextprotocol.kotlin.sdk.types.RequestId"), + level = DeprecationLevel.WARNING, +) +public object RequestId { + @Deprecated( + message = "Use `RequestId.StringId` instead", + replaceWith = ReplaceWith("RequestId.StringId", "io.modelcontextprotocol.kotlin.sdk.types.RequestId.StringId"), + level = DeprecationLevel.WARNING, + ) + public fun StringId(value: String): io.modelcontextprotocol.kotlin.sdk.types.RequestId.StringId = + io.modelcontextprotocol.kotlin.sdk.types.RequestId.StringId(value) + + @Deprecated( + message = "Use `RequestId.NumberId` instead", + replaceWith = ReplaceWith("RequestId.NumberId", "io.modelcontextprotocol.kotlin.sdk.types.RequestId.NumberId"), + level = DeprecationLevel.WARNING, + ) + public fun NumberId(value: Long): io.modelcontextprotocol.kotlin.sdk.types.RequestId.NumberId = + io.modelcontextprotocol.kotlin.sdk.types.RequestId.NumberId(value) +} + +/** + * Represents a JSON-RPC message in the protocol. + */ +@Deprecated( + message = "Use `JSONRPCMessage` instead", + replaceWith = ReplaceWith("JSONRPCMessage", "io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage"), + level = DeprecationLevel.WARNING, +) +public typealias JSONRPCMessage = io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage + +/** + * A request that expects a response. + */ +@Deprecated( + message = "Use `JSONRPCRequest` instead", + replaceWith = ReplaceWith("JSONRPCRequest", "io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest"), + level = DeprecationLevel.WARNING, +) +public typealias JSONRPCRequest = io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest + +/** + * A notification which does not expect a response. + */ +@Deprecated( + message = "Use `JSONRPCNotification` instead", + replaceWith = ReplaceWith("JSONRPCNotification", "io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification"), + level = DeprecationLevel.WARNING, +) +public typealias JSONRPCNotification = io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification + +/** + * A successful (non-error) response to a request. + */ +@Deprecated( + message = "Use `JSONRPCResponse` instead", + replaceWith = ReplaceWith("JSONRPCResponse", "io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse"), + level = DeprecationLevel.WARNING, +) +public typealias JSONRPCResponse = io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse + +/** + * An incomplete set of error codes that may appear in JSON-RPC responses. + */ +@Deprecated( + message = "Use `RPCError` instead", + replaceWith = ReplaceWith("RPCError", "io.modelcontextprotocol.kotlin.sdk.types.RPCError"), + level = DeprecationLevel.WARNING, +) +public sealed interface ErrorCode { + public val code: Int + + public object Defined { + @Deprecated( + message = "Use `RPCError.ErrorCode.CONNECTION_CLOSED` instead", + replaceWith = ReplaceWith( + "RPCError.ErrorCode.CONNECTION_CLOSED", + "io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED", + ), + level = DeprecationLevel.WARNING, + ) + public val ConnectionClosed: Int = io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED + + @Deprecated( + message = "Use `RPCError.ErrorCode.REQUEST_TIMEOUT` instead", + replaceWith = ReplaceWith( + "RPCError.ErrorCode.REQUEST_TIMEOUT", + "io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.REQUEST_TIMEOUT", + ), + level = DeprecationLevel.WARNING, + ) + public val RequestTimeout: Int = io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.REQUEST_TIMEOUT + + @Deprecated( + message = "Use `RPCError.ErrorCode.PARSE_ERROR` instead", + replaceWith = ReplaceWith( + "RPCError.ErrorCode.PARSE_ERROR", + "io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.PARSE_ERROR", + ), + level = DeprecationLevel.WARNING, + ) + public val ParseError: Int = io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.PARSE_ERROR + + @Deprecated( + message = "Use `RPCError.ErrorCode.INVALID_REQUEST` instead", + replaceWith = ReplaceWith( + "RPCError.ErrorCode.INVALID_REQUEST", + "io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.INVALID_REQUEST", + ), + level = DeprecationLevel.WARNING, + ) + public val InvalidRequest: Int = io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.INVALID_REQUEST + + @Deprecated( + message = "Use `RPCError.ErrorCode.METHOD_NOT_FOUND` instead", + replaceWith = ReplaceWith( + "RPCError.ErrorCode.METHOD_NOT_FOUND", + "io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.METHOD_NOT_FOUND", + ), + level = DeprecationLevel.WARNING, + ) + public val MethodNotFound: Int = io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.METHOD_NOT_FOUND + + @Deprecated( + message = "Use `RPCError.ErrorCode.INVALID_PARAMS` instead", + replaceWith = ReplaceWith( + "RPCError.ErrorCode.INVALID_PARAMS", + "io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.INVALID_PARAMS", + ), + level = DeprecationLevel.WARNING, + ) + public val InvalidParams: Int = io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.INVALID_PARAMS + + @Deprecated( + message = "Use `RPCError.ErrorCode.INTERNAL_ERROR` instead", + replaceWith = ReplaceWith( + "RPCError.ErrorCode.INTERNAL_ERROR", + "io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.INTERNAL_ERROR", + ), + level = DeprecationLevel.WARNING, + ) + public val InternalError: Int = io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.INTERNAL_ERROR + } + + @Deprecated( + message = "Use `RPCError` instead", + replaceWith = ReplaceWith("RPCError", "io.modelcontextprotocol.kotlin.sdk.types.RPCError"), + level = DeprecationLevel.WARNING, + ) + @Serializable + public data class Unknown(override val code: Int) : ErrorCode +} + +/** + * A response to a request that indicates an error occurred. + */ +@Deprecated( + message = "Use `JSONRPCError` instead", + replaceWith = ReplaceWith("JSONRPCError", "io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError"), + level = DeprecationLevel.WARNING, +) +public typealias JSONRPCError = io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError + +/** + * Base interface for notification parameters with optional metadata. + */ +@Deprecated( + message = "Use `NotificationParams` instead", + replaceWith = ReplaceWith("NotificationParams", "io.modelcontextprotocol.kotlin.sdk.types.NotificationParams"), + level = DeprecationLevel.WARNING, +) +public typealias NotificationParams = io.modelcontextprotocol.kotlin.sdk.types.NotificationParams + +/* Cancellation */ + +@Deprecated( + message = "Use `CancelledNotification` instead", + replaceWith = ReplaceWith( + "CancelledNotification", + "io.modelcontextprotocol.kotlin.sdk.types.CancelledNotification", + ), + level = DeprecationLevel.WARNING, +) +public object CancelledNotification { + @Deprecated( + message = "Use `CancelledNotificationParams` instead", + replaceWith = ReplaceWith( + "CancelledNotificationParams", + "io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams", + ), + level = DeprecationLevel.WARNING, + ) + public fun Params( + requestId: io.modelcontextprotocol.kotlin.sdk.types.RequestId, + reason: String? = null, + meta: JsonObject? = null, + ): io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams = + io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams(requestId, reason, meta) +} + +/** + * This notification can be sent by either side to indicate that it is cancelling a previously issued request. + * + * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + * + * This notification indicates that the result will be unused, so any associated processing SHOULD cease. + * + * A client MUST NOT attempt to cancel its `initialize` request. + */ +@Deprecated( + message = "Use `CancelledNotification` instead", + replaceWith = ReplaceWith( + "CancelledNotification", + "io.modelcontextprotocol.kotlin.sdk.types.CancelledNotification", + ), + level = DeprecationLevel.WARNING, +) +public fun CancelledNotification( + params: io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams, +): io.modelcontextprotocol.kotlin.sdk.types.CancelledNotification = + io.modelcontextprotocol.kotlin.sdk.types.CancelledNotification(params) + +/* Initialization */ + +/** + * Describes the name and version of an MCP implementation. + */ +@Deprecated( + message = "Use `Implementation` instead", + replaceWith = ReplaceWith("Implementation", "io.modelcontextprotocol.kotlin.sdk.types.Implementation"), + level = DeprecationLevel.WARNING, +) +public typealias Implementation = io.modelcontextprotocol.kotlin.sdk.types.Implementation + +@Deprecated( + message = "Use `ClientCapabilities` instead", + replaceWith = ReplaceWith("ClientCapabilities", "io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities"), + level = DeprecationLevel.WARNING, +) +public object ClientCapabilities { + @Deprecated( + message = "Use `ClientCapabilities.Roots` instead", + replaceWith = ReplaceWith( + "ClientCapabilities.Roots", + "io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities.Roots", + ), + level = DeprecationLevel.WARNING, + ) + public fun Roots(listChanged: Boolean? = null): io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities.Roots = + io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities.Roots(listChanged) +} + +/** + * Capabilities a client may support. + * Known capabilities are defined here, in this, but this is not a closed set: + * any client can define its own, additional capabilities. + */ +@Deprecated( + message = "Use `ClientCapabilities` instead", + replaceWith = ReplaceWith("ClientCapabilities", "io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities"), + level = DeprecationLevel.WARNING, +) +public fun ClientCapabilities( + sampling: JsonObject? = null, + roots: Roots? = null, + elicitation: JsonObject? = null, + experimental: JsonObject? = null, +): io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities = + io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities(sampling, roots, elicitation, experimental) + +/** + * Represents a request sent by the client. + */ +@Deprecated( + message = "Use `ClientRequest` instead", + replaceWith = ReplaceWith("ClientRequest", "io.modelcontextprotocol.kotlin.sdk.types.ClientRequest"), + level = DeprecationLevel.WARNING, +) +public typealias ClientRequest = io.modelcontextprotocol.kotlin.sdk.types.ClientRequest + +/** + * Represents a notification sent by the client. + */ +@Deprecated( + message = "Use `ClientNotification` instead", + replaceWith = ReplaceWith("ClientNotification", "io.modelcontextprotocol.kotlin.sdk.types.ClientNotification"), + level = DeprecationLevel.WARNING, +) +public typealias ClientNotification = io.modelcontextprotocol.kotlin.sdk.types.ClientNotification + +/** + * Represents a result returned to the client. + */ +@Deprecated( + message = "Use `ClientResult` instead", + replaceWith = ReplaceWith("ClientResult", "io.modelcontextprotocol.kotlin.sdk.types.ClientResult"), + level = DeprecationLevel.WARNING, +) +public typealias ClientResult = io.modelcontextprotocol.kotlin.sdk.types.ClientResult + +/** + * Represents a request sent by the server. + */ +@Deprecated( + message = "Use `ServerRequest` instead", + replaceWith = ReplaceWith("ServerRequest", "io.modelcontextprotocol.kotlin.sdk.types.ServerRequest"), + level = DeprecationLevel.WARNING, +) +public typealias ServerRequest = io.modelcontextprotocol.kotlin.sdk.types.ServerRequest + +/** + * Represents a notification sent by the server. + */ +@Deprecated( + message = "Use `ServerNotification` instead", + replaceWith = ReplaceWith("ServerNotification", "io.modelcontextprotocol.kotlin.sdk.types.ServerNotification"), + level = DeprecationLevel.WARNING, +) +public typealias ServerNotification = io.modelcontextprotocol.kotlin.sdk.types.ServerNotification + +/** + * Represents a result returned by the server. + */ +@Deprecated( + message = "Use `ServerResult` instead", + replaceWith = ReplaceWith("ServerResult", "io.modelcontextprotocol.kotlin.sdk.types.ServerResult"), + level = DeprecationLevel.WARNING, +) +public typealias ServerResult = io.modelcontextprotocol.kotlin.sdk.types.ServerResult + +/** + * Represents a request or notification for an unknown method. + * + * @param method The method that is unknown. + */ +@Serializable +@Deprecated( + message = "This class will be removed", + level = DeprecationLevel.WARNING, +) +public data class UnknownMethodRequestOrNotification(val method: Method, val params: NotificationParams? = null) + +/** + * This request is sent from the client to the server when it first connects, asking it to begin initialization. + */ +@Deprecated( + message = "Use `InitializeRequest` instead", + replaceWith = ReplaceWith("InitializeRequest", "io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest"), + level = DeprecationLevel.WARNING, +) +public typealias InitializeRequest = io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest + +@Deprecated( + message = "Use `ServerCapabilities` instead", + replaceWith = ReplaceWith("ServerCapabilities", "io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities"), + level = DeprecationLevel.WARNING, +) +public object ServerCapabilities { + + @Deprecated( + message = "Use `ServerCapabilities.Prompts` instead", + replaceWith = ReplaceWith( + "ServerCapabilities.Prompts", + "io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities.Prompts", + ), + level = DeprecationLevel.WARNING, + ) + public fun Prompts( + listChanged: Boolean? = null, + ): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities.Prompts = + io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities.Prompts(listChanged) + + @Deprecated( + message = "Use `ServerCapabilities.Resources` instead", + replaceWith = ReplaceWith( + "ServerCapabilities.Resources", + "io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities.Resources", + ), + level = DeprecationLevel.WARNING, + ) + public fun Resources( + listChanged: Boolean? = null, + subscribe: Boolean? = null, + ): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities.Resources = + io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities.Resources(listChanged, subscribe) + + @Deprecated( + message = "Use `ServerCapabilities.Tools` instead", + replaceWith = ReplaceWith( + "ServerCapabilities.Tools", + "io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities.Tools", + ), + level = DeprecationLevel.WARNING, + ) + public fun Tools(listChanged: Boolean? = null): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities.Tools = + io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities.Tools(listChanged) +} + +/** + * Represents the capabilities that a server can support. + * + * @property experimental Experimental, non-standard capabilities that the server supports. + * @property logging Present if the server supports sending log messages to the client. + * @property prompts Capabilities related to prompt templates offered by the server. + * @property resources Capabilities related to resources available on the server. + * @property tools Capabilities related to tools that can be called on the server. + */ +@Deprecated( + message = "Use `ServerCapabilities` instead", + replaceWith = ReplaceWith("ServerCapabilities", "io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities"), + level = DeprecationLevel.WARNING, +) +public fun ServerCapabilities( + tools: io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities.Tools? = null, + resources: io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities.Resources? = null, + prompts: io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities.Prompts? = null, + logging: JsonObject? = null, + completions: JsonObject? = null, + experimental: JsonObject? = null, +): io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities = + io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities( + tools, + resources, + prompts, + logging, + completions, + experimental, + ) + +/** + * After receiving an initialized request from the client, the server sends this response. + */ +@Deprecated( + message = "Use `InitializeResult` instead", + replaceWith = ReplaceWith("InitializeResult", "io.modelcontextprotocol.kotlin.sdk.types.InitializeResult"), + level = DeprecationLevel.WARNING, +) +public typealias InitializeResult = io.modelcontextprotocol.kotlin.sdk.types.InitializeResult + +@Deprecated( + message = "Use `InitializedNotification` instead", + replaceWith = ReplaceWith( + "InitializedNotification", + "io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification", + ), + level = DeprecationLevel.WARNING, +) +public object InitializedNotification { + @Deprecated( + message = "Use `BaseNotificationParams` instead", + replaceWith = ReplaceWith( + "BaseNotificationParams", + "io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams", + ), + level = DeprecationLevel.WARNING, + ) + public fun Params( + meta: JsonObject = EmptyJsonObject, + ): io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams = + io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams(meta) +} + +/** + * This notification is sent from the client to the server after initialization has finished. + */ +@Deprecated( + message = "Use `InitializedNotification` instead", + replaceWith = ReplaceWith( + "InitializedNotification", + "io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification", + ), + level = DeprecationLevel.WARNING, +) +public fun InitializedNotification( + params: io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams? = null, +): io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification = + io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification(params) + +/* Ping */ + +/** + * A ping, issued by either the server or the client, to check that the other party is still alive. + * The receiver must promptly respond, or else it may be disconnected. + */ +@Deprecated( + message = "Use `PingRequest` instead", + replaceWith = ReplaceWith("PingRequest", "io.modelcontextprotocol.kotlin.sdk.types.PingRequest"), + level = DeprecationLevel.WARNING, +) +public typealias PingRequest = io.modelcontextprotocol.kotlin.sdk.types.PingRequest + +/** + * Represents the base interface for progress tracking. + */ +@Serializable +@Deprecated( + message = "This interface will be removed", + level = DeprecationLevel.WARNING, +) +public sealed interface ProgressBase { + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + */ + public val progress: Double + + /** + * Total number of items to a process (or total progress required), if known. + */ + public val total: Double? + + /** + * An optional message describing the current progress. + */ + public val message: String? +} + +/* Progress notifications */ + +/** + * Represents a progress notification. + * + * @property progress The current progress value. + * @property total The total progress required, if known. + */ +@Deprecated( + message = "Use `Progress` instead", + replaceWith = ReplaceWith("Progress", "io.modelcontextprotocol.kotlin.sdk.types.Progress"), + level = DeprecationLevel.WARNING, +) +public typealias Progress = io.modelcontextprotocol.kotlin.sdk.types.Progress + +@Deprecated( + message = "Use `ProgressNotification` instead", + replaceWith = ReplaceWith("ProgressNotification", "io.modelcontextprotocol.kotlin.sdk.types.ProgressNotification"), + level = DeprecationLevel.WARNING, +) +public object ProgressNotification { + @Deprecated( + message = "Use `ProgressNotificationParams` instead", + replaceWith = ReplaceWith( + "ProgressNotificationParams", + "io.modelcontextprotocol.kotlin.sdk.types.ProgressNotificationParams", + ), + level = DeprecationLevel.WARNING, + ) + public fun Params( + progressToken: io.modelcontextprotocol.kotlin.sdk.types.ProgressToken, + progress: Double, + total: Double? = null, + message: String? = null, + meta: JsonObject? = null, + ): io.modelcontextprotocol.kotlin.sdk.types.ProgressNotificationParams = + io.modelcontextprotocol.kotlin.sdk.types.ProgressNotificationParams( + progressToken, + progress, + total, + message, + meta, + ) +} + +/** + * An out-of-band notification used to inform the receiver of a progress update for a long-running request. + */ +@Deprecated( + message = "Use `ProgressNotification` instead", + replaceWith = ReplaceWith("ProgressNotification", "io.modelcontextprotocol.kotlin.sdk.types.ProgressNotification"), + level = DeprecationLevel.WARNING, +) +public fun ProgressNotification( + params: io.modelcontextprotocol.kotlin.sdk.types.ProgressNotificationParams, +): io.modelcontextprotocol.kotlin.sdk.types.ProgressNotification = + io.modelcontextprotocol.kotlin.sdk.types.ProgressNotification(params) + +/* Pagination */ + +/** + * Represents a request supporting pagination. + */ +@Deprecated( + message = "Use `PaginatedRequest` instead", + replaceWith = ReplaceWith("PaginatedRequest", "io.modelcontextprotocol.kotlin.sdk.types.PaginatedRequest"), + level = DeprecationLevel.WARNING, +) +public typealias PaginatedRequest = io.modelcontextprotocol.kotlin.sdk.types.PaginatedRequest + +/** + * Represents a paginated result of a request. + */ +@Deprecated( + message = "Use `PaginatedResult` instead", + replaceWith = ReplaceWith("PaginatedResult", "io.modelcontextprotocol.kotlin.sdk.types.PaginatedResult"), + level = DeprecationLevel.WARNING, +) +public typealias PaginatedResult = io.modelcontextprotocol.kotlin.sdk.types.PaginatedResult + +/* Resources */ + +/** + * The contents of a specific resource or sub-resource. + */ +@Deprecated( + message = "Use `ResourceContents` instead", + replaceWith = ReplaceWith("ResourceContents", "io.modelcontextprotocol.kotlin.sdk.types.ResourceContents"), + level = DeprecationLevel.WARNING, +) +public typealias ResourceContents = io.modelcontextprotocol.kotlin.sdk.types.ResourceContents + +/** + * Represents the text contents of a resource. + * + * @property text The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ +@Deprecated( + message = "Use `TextResourceContents` instead", + replaceWith = ReplaceWith("TextResourceContents", "io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents"), + level = DeprecationLevel.WARNING, +) +public typealias TextResourceContents = io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents + +/** + * Represents the binary contents of a resource encoded as a base64 string. + * + * @property blob A base64-encoded string representing the binary data of the item. + */ +@Deprecated( + message = "Use `BlobResourceContents` instead", + replaceWith = ReplaceWith("BlobResourceContents", "io.modelcontextprotocol.kotlin.sdk.types.BlobResourceContents"), + level = DeprecationLevel.WARNING, +) +public typealias BlobResourceContents = io.modelcontextprotocol.kotlin.sdk.types.BlobResourceContents + +/** + * Represents resource contents with unknown or unspecified data. + */ +@Deprecated( + message = "Use `UnknownResourceContents` instead", + replaceWith = ReplaceWith( + "UnknownResourceContents", + "io.modelcontextprotocol.kotlin.sdk.types.UnknownResourceContents", + ), + level = DeprecationLevel.WARNING, +) +public typealias UnknownResourceContents = io.modelcontextprotocol.kotlin.sdk.types.UnknownResourceContents + +/** + * A known resource that the server is capable of reading. + */ +@Deprecated( + message = "Use `Resource` instead", + replaceWith = ReplaceWith("Resource", "io.modelcontextprotocol.kotlin.sdk.types.Resource"), + level = DeprecationLevel.WARNING, +) +public typealias Resource = io.modelcontextprotocol.kotlin.sdk.types.Resource + +/** + * A template description for resources available on the server. + */ +@Deprecated( + message = "Use `ResourceTemplate` instead", + replaceWith = ReplaceWith("ResourceTemplate", "io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate"), + level = DeprecationLevel.WARNING, +) +public typealias ResourceTemplate = io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate + +/** + * Sent from the client to request a list of resources the server has. + */ +@Deprecated( + message = "Use `ListResourcesRequest` instead", + replaceWith = ReplaceWith("ListResourcesRequest", "io.modelcontextprotocol.kotlin.sdk.types.ListResourcesRequest"), + level = DeprecationLevel.WARNING, +) +public typealias ListResourcesRequest = io.modelcontextprotocol.kotlin.sdk.types.ListResourcesRequest + +/** + * The server's response to a resources/list request from the client. + */ +@Deprecated( + message = "Use `ListResourcesResult` instead", + replaceWith = ReplaceWith("ListResourcesResult", "io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult"), + level = DeprecationLevel.WARNING, +) +public typealias ListResourcesResult = io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult + +/** + * Sent from the client to request a list of resource templates the server has. + */ +@Deprecated( + message = "Use `ListResourceTemplatesRequest` instead", + replaceWith = ReplaceWith( + "ListResourceTemplatesRequest", + "io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesRequest", + ), + level = DeprecationLevel.WARNING, +) +public typealias ListResourceTemplatesRequest = io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesRequest + +/** + * The server's response to a resources/templates/list request from the client. + */ +@Deprecated( + message = "Use `ListResourceTemplatesResult` instead", + replaceWith = ReplaceWith( + "ListResourceTemplatesResult", + "io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesResult", + ), + level = DeprecationLevel.WARNING, +) +public typealias ListResourceTemplatesResult = io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesResult + +/** + * Sent from the client to the server to read a specific resource URI. + */ +@Deprecated( + message = "Use `ReadResourceRequest` instead", + replaceWith = ReplaceWith("ReadResourceRequest", "io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest"), + level = DeprecationLevel.WARNING, +) +public typealias ReadResourceRequest = io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest + +/** + * The server's response to a resources/read request from the client. + */ +@Deprecated( + message = "Use `ReadResourceResult` instead", + replaceWith = ReplaceWith("ReadResourceResult", "io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult"), + level = DeprecationLevel.WARNING, +) +public typealias ReadResourceResult = io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult + +@Deprecated( + message = "Use `ResourceListChangedNotification` instead", + replaceWith = ReplaceWith( + "ResourceListChangedNotification", + "io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification", + ), + level = DeprecationLevel.WARNING, +) +public object ResourceListChangedNotification { + @Deprecated( + message = "Use `BaseNotificationParams` instead", + replaceWith = ReplaceWith( + "BaseNotificationParams", + "io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams", + ), + level = DeprecationLevel.WARNING, + ) + public fun Params(meta: JsonObject? = null): io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams = + io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams(meta) +} + +/** + * An optional notification from the server to the client, + * informing it that the list of resources it can read from has changed. + * Servers may issue this without any previous subscription from the client. + */ +@Deprecated( + message = "Use `ResourceListChangedNotification` instead", + replaceWith = ReplaceWith( + "ResourceListChangedNotification", + "io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification", + ), + level = DeprecationLevel.WARNING, +) +public fun ResourceListChangedNotification( + params: io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams? = null, +): io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification = + io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification(params) + +/** + * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. + */ +@Deprecated( + message = "Use `SubscribeRequest` instead", + replaceWith = ReplaceWith("SubscribeRequest", "io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest"), + level = DeprecationLevel.WARNING, +) +public typealias SubscribeRequest = io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest + +/** + * Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. + */ +@Deprecated( + message = "Use `UnsubscribeRequest` instead", + replaceWith = ReplaceWith("UnsubscribeRequest", "io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest"), + level = DeprecationLevel.WARNING, +) +public typealias UnsubscribeRequest = io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest + +@Deprecated( + message = "Use `ResourceUpdatedNotification` instead", + replaceWith = ReplaceWith( + "ResourceUpdatedNotification", + "io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification", + ), + level = DeprecationLevel.WARNING, +) +public object ResourceUpdatedNotification { + @Deprecated( + message = "Use `ResourceUpdatedNotificationParams` instead", + replaceWith = ReplaceWith( + "ResourceUpdatedNotificationParams", + "io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotificationParams", + ), + level = DeprecationLevel.WARNING, + ) + public fun Params( + uri: String, + meta: JsonObject? = null, + ): io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotificationParams = + io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotificationParams(uri, meta) +} + +/** + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request. + */ +@Deprecated( + message = "Use `ResourceUpdatedNotification` instead", + replaceWith = ReplaceWith( + "ResourceUpdatedNotification", + "io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification", + ), + level = DeprecationLevel.WARNING, +) +public fun ResourceUpdatedNotification( + params: io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotificationParams, +): io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification = + io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification(params) + +/* Prompts */ + +/** + * Describes an argument that a prompt can accept. + */ +@Deprecated( + message = "Use `PromptArgument` instead", + replaceWith = ReplaceWith("PromptArgument", "io.modelcontextprotocol.kotlin.sdk.types.PromptArgument"), + level = DeprecationLevel.WARNING, +) +public typealias PromptArgument = io.modelcontextprotocol.kotlin.sdk.types.PromptArgument + +/** + * A prompt or prompt template that the server offers. + */ +@Deprecated( + message = "Use `Prompt` instead", + replaceWith = ReplaceWith("Prompt", "io.modelcontextprotocol.kotlin.sdk.types.Prompt"), + level = DeprecationLevel.WARNING, +) +public typealias Prompt = io.modelcontextprotocol.kotlin.sdk.types.Prompt + +/** + * Sent from the client to request a list of prompts and prompt templates the server has. + */ +@Deprecated( + message = "Use `ListPromptsRequest` instead", + replaceWith = ReplaceWith("ListPromptsRequest", "io.modelcontextprotocol.kotlin.sdk.types.ListPromptsRequest"), + level = DeprecationLevel.WARNING, +) +public typealias ListPromptsRequest = io.modelcontextprotocol.kotlin.sdk.types.ListPromptsRequest + +/** + * The server's response to a prompts/list request from the client. + */ +@Deprecated( + message = "Use `ListPromptsResult` instead", + replaceWith = ReplaceWith("ListPromptsResult", "io.modelcontextprotocol.kotlin.sdk.types.ListPromptsResult"), + level = DeprecationLevel.WARNING, +) +public typealias ListPromptsResult = io.modelcontextprotocol.kotlin.sdk.types.ListPromptsResult + +/** + * Used by the client to get a prompt provided by the server. + */ +@Deprecated( + message = "Use `GetPromptRequest` instead", + replaceWith = ReplaceWith("GetPromptRequest", "io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest"), + level = DeprecationLevel.WARNING, +) +public typealias GetPromptRequest = io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest + +/** + * Represents the content of a prompt message. + */ +@Deprecated( + message = "Use `ContentBlock` instead", + replaceWith = ReplaceWith("ContentBlock"), + level = DeprecationLevel.WARNING, +) +public typealias PromptMessageContent = io.modelcontextprotocol.kotlin.sdk.types.ContentBlock + +/** + * Represents prompt message content that is either text, image or audio. + */ +@Deprecated( + message = "Use `MediaContent` instead", + replaceWith = ReplaceWith("MediaContent"), + level = DeprecationLevel.WARNING, +) +public typealias PromptMessageContentMultimodal = io.modelcontextprotocol.kotlin.sdk.types.MediaContent + +/** + * Text provided to or from an LLM. + */ +@Deprecated( + message = "Use `TextContent` instead", + replaceWith = ReplaceWith("TextContent", "io.modelcontextprotocol.kotlin.sdk.types.TextContent"), + level = DeprecationLevel.WARNING, +) +public typealias TextContent = io.modelcontextprotocol.kotlin.sdk.types.TextContent + +/** + * An image provided to or from an LLM. + */ +@Deprecated( + message = "Use `ImageContent` instead", + replaceWith = ReplaceWith("ImageContent", "io.modelcontextprotocol.kotlin.sdk.types.ImageContent"), + level = DeprecationLevel.WARNING, +) +public typealias ImageContent = io.modelcontextprotocol.kotlin.sdk.types.ImageContent + +/** + * Audio provided to or from an LLM. + */ +@Deprecated( + message = "Use `AudioContent` instead", + replaceWith = ReplaceWith("AudioContent", "io.modelcontextprotocol.kotlin.sdk.types.AudioContent"), + level = DeprecationLevel.WARNING, +) +public typealias AudioContent = io.modelcontextprotocol.kotlin.sdk.types.AudioContent + +/** + * Unknown content provided to or from an LLM. + */ +@Serializable +@Deprecated( + message = "This class will be removed.", + replaceWith = ReplaceWith("MediaContent"), + level = DeprecationLevel.WARNING, +) +public data class UnknownContent(val type: String) + +/** + * The contents of a resource, embedded into a prompt or tool call result. + */ +@Deprecated( + message = "Use `EmbeddedResource` instead", + replaceWith = ReplaceWith("EmbeddedResource", "io.modelcontextprotocol.kotlin.sdk.types.EmbeddedResource"), + level = DeprecationLevel.WARNING, +) +public typealias EmbeddedResource = io.modelcontextprotocol.kotlin.sdk.types.EmbeddedResource + +/** + * Enum representing the role of a participant. + */ +@Deprecated( + message = "Use `Role` instead", + replaceWith = ReplaceWith("Role", "io.modelcontextprotocol.kotlin.sdk.types.Role"), + level = DeprecationLevel.WARNING, +) +public object Role { + @Deprecated( + message = "Use `Role.User` instead", + replaceWith = ReplaceWith("Role.User", "io.modelcontextprotocol.kotlin.sdk.types.Role"), + level = DeprecationLevel.WARNING, + ) + public val user: io.modelcontextprotocol.kotlin.sdk.types.Role = io.modelcontextprotocol.kotlin.sdk.types.Role.User + + @Deprecated( + message = "Use `Role.Assistant` instead", + replaceWith = ReplaceWith("Role.Assistant", "io.modelcontextprotocol.kotlin.sdk.types.Role"), + level = DeprecationLevel.WARNING, + ) + public val assistant: io.modelcontextprotocol.kotlin.sdk.types.Role = + io.modelcontextprotocol.kotlin.sdk.types.Role.Assistant +} + +/** + * Optional annotations for the client. + * The client can use annotations to inform how objects are used or displayed. + */ +@Deprecated( + message = "Use `Annotations` instead", + replaceWith = ReplaceWith("Annotations", "io.modelcontextprotocol.kotlin.sdk.types.Annotations"), + level = DeprecationLevel.WARNING, +) +public typealias Annotations = io.modelcontextprotocol.kotlin.sdk.types.Annotations + +/** + * Describes a message returned as part of a prompt. + */ +@Deprecated( + message = "Use `PromptMessage` instead", + replaceWith = ReplaceWith("PromptMessage", "io.modelcontextprotocol.kotlin.sdk.types.PromptMessage"), + level = DeprecationLevel.WARNING, +) +public typealias PromptMessage = io.modelcontextprotocol.kotlin.sdk.types.PromptMessage + +/** + * The server's response to a prompts/get request from the client. + */ +@Deprecated( + message = "Use `GetPromptResult` instead", + replaceWith = ReplaceWith("GetPromptResult", "io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult"), + level = DeprecationLevel.WARNING, +) +public typealias GetPromptResult = io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult + +@Deprecated( + message = "Use `PromptListChangedNotification` instead", + replaceWith = ReplaceWith( + "PromptListChangedNotification", + "io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification", + ), + level = DeprecationLevel.WARNING, +) +public object PromptListChangedNotification { + @Deprecated( + message = "Use `BaseNotificationParams` instead", + replaceWith = ReplaceWith( + "BaseNotificationParams", + "io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams", + ), + level = DeprecationLevel.WARNING, + ) + public fun Params(meta: JsonObject? = null): io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams = + io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams(meta) +} + +/** + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. + * Servers may issue this without any previous subscription from the client. + */ +@Deprecated( + message = "Use `PromptListChangedNotification` instead", + replaceWith = ReplaceWith( + "PromptListChangedNotification", + "io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification", + ), + level = DeprecationLevel.WARNING, +) +public fun PromptListChangedNotification( + params: io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams, +): io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification = + io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification(params) + +/* Tools */ + +/** + * Additional properties describing a Tool to clients. + * + * NOTE: all properties in ToolAnnotations are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on ToolAnnotations + * received from untrusted servers. + */ +@Deprecated( + message = "Use `ToolAnnotations` instead", + replaceWith = ReplaceWith("ToolAnnotations", "io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations"), + level = DeprecationLevel.WARNING, +) +public typealias ToolAnnotations = io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations + +@Deprecated( + message = "Use `Tool` instead", + replaceWith = ReplaceWith("Tool", "io.modelcontextprotocol.kotlin.sdk.types.Tool"), + level = DeprecationLevel.WARNING, +) +public object Tool { + @Deprecated( + message = "Use `ToolSchema` instead", + replaceWith = ReplaceWith("ToolSchema", "io.modelcontextprotocol.kotlin.sdk.types.ToolSchema"), + level = DeprecationLevel.WARNING, + ) + public fun Input( + properties: JsonObject? = null, + required: List? = null, + ): io.modelcontextprotocol.kotlin.sdk.types.ToolSchema = + io.modelcontextprotocol.kotlin.sdk.types.ToolSchema(properties, required) + + @Deprecated( + message = "Use `ToolSchema` instead", + replaceWith = ReplaceWith("ToolSchema", "io.modelcontextprotocol.kotlin.sdk.types.ToolSchema"), + level = DeprecationLevel.WARNING, + ) + public fun Output( + properties: JsonObject? = null, + required: List? = null, + ): io.modelcontextprotocol.kotlin.sdk.types.ToolSchema = + io.modelcontextprotocol.kotlin.sdk.types.ToolSchema(properties, required) +} + +/** + * Definition for a tool the client can call. + */ +@Deprecated( + message = "Use `Tool` instead", + replaceWith = ReplaceWith("Tool", "io.modelcontextprotocol.kotlin.sdk.types.Tool"), + level = DeprecationLevel.WARNING, +) +public fun Tool( + name: String, + inputSchema: io.modelcontextprotocol.kotlin.sdk.types.ToolSchema, + description: String? = null, + outputSchema: io.modelcontextprotocol.kotlin.sdk.types.ToolSchema? = null, + title: String? = null, + annotations: io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations? = null, + icons: List? = null, + _meta: JsonObject? = null, +): io.modelcontextprotocol.kotlin.sdk.types.Tool = io.modelcontextprotocol.kotlin.sdk.types.Tool( + name, + inputSchema, + description, + outputSchema, + title, + annotations, + icons, + _meta, +) + +/** + * Sent from the client to request a list of tools the server has. + */ +@Deprecated( + message = "Use `ListToolsRequest` instead", + replaceWith = ReplaceWith("ListToolsRequest", "io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest"), + level = DeprecationLevel.WARNING, +) +public typealias ListToolsRequest = io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest + +/** + * The server's response to a tools/list request from the client. + */ +@Deprecated( + message = "Use `ListToolsResult` instead", + replaceWith = ReplaceWith("ListToolsResult", "io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult"), + level = DeprecationLevel.WARNING, +) +public typealias ListToolsResult = io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult + +/** + * The server's response to a tool call. + */ +@Serializable +@Deprecated( + message = "This interface will be removed.", + replaceWith = ReplaceWith("CallToolResult", "io.modelcontextprotocol.kotlin.sdk.types.CallToolResult"), + level = DeprecationLevel.WARNING, +) +public sealed interface CallToolResultBase { + public val content: List + public val structuredContent: JsonObject? + public val isError: Boolean? get() = false +} + +@Deprecated( + message = "Use `CallToolResult` instead", + replaceWith = ReplaceWith("CallToolResult", "io.modelcontextprotocol.kotlin.sdk.types.CallToolResult"), + level = DeprecationLevel.WARNING, +) +public object CallToolResult + +/** + * The server's response to a tool call. + */ +@Deprecated( + message = "Use `CallToolResult` instead", + replaceWith = ReplaceWith("CallToolResult", "io.modelcontextprotocol.kotlin.sdk.types.CallToolResult"), + level = DeprecationLevel.WARNING, +) +public fun CallToolResult( + content: List, + isError: Boolean? = null, + structuredContent: JsonObject? = null, + meta: JsonObject? = null, +): io.modelcontextprotocol.kotlin.sdk.types.CallToolResult = + io.modelcontextprotocol.kotlin.sdk.types.CallToolResult(content, isError, structuredContent, meta) + +/** + * [CallToolResult] extended with backwards compatibility to protocol version 2024-11-05. + */ +@Serializable +@Deprecated( + message = "This class will be removed.", + replaceWith = ReplaceWith("ToolCallResult"), + level = DeprecationLevel.WARNING, +) +public class CompatibilityCallToolResult( + public val content: List, + public val structuredContent: JsonObject? = null, + public val isError: Boolean? = false, + public val _meta: JsonObject = EmptyJsonObject, + public val toolResult: JsonObject = EmptyJsonObject, +) + +/** + * Used by the client to invoke a tool provided by the server. + */ +@Deprecated( + message = "Use `CallToolRequest` instead", + replaceWith = ReplaceWith("CallToolRequest", "io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest"), + level = DeprecationLevel.WARNING, +) +public typealias CallToolRequest = io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest + +@Deprecated( + message = "Use `ToolListChangedNotification` instead", + replaceWith = ReplaceWith( + "ToolListChangedNotification", + "io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification", + ), + level = DeprecationLevel.WARNING, +) +public object ToolListChangedNotification { + @Deprecated( + message = "Use `BaseNotificationParams` instead", + replaceWith = ReplaceWith( + "BaseNotificationParams", + "io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams", + ), + level = DeprecationLevel.WARNING, + ) + public fun Params(meta: JsonObject? = null): io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams = + io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams(meta) +} + +/** + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. + * Servers may issue this without any previous subscription from the client. + */ +@Deprecated( + message = "Use `ToolListChangedNotification` instead", + replaceWith = ReplaceWith( + "ToolListChangedNotification", + "io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification", + ), + level = DeprecationLevel.WARNING, +) +public fun ToolListChangedNotification( + params: io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams, +): io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification = + io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification(params) + +/* Logging */ + +/** + * The severity of a log message. + */ +@Deprecated( + message = "Use `LoggingLevel` instead", + replaceWith = ReplaceWith("LoggingLevel", "io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel"), + level = DeprecationLevel.WARNING, +) +public object LoggingLevel { + @Deprecated( + message = "Use `LoggingLevel.Debug` instead", + replaceWith = ReplaceWith("LoggingLevel.Debug", "io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel"), + level = DeprecationLevel.WARNING, + ) + public val debug: io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel = + io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel.Debug + + @Deprecated( + message = "Use `LoggingLevel.Info` instead", + replaceWith = ReplaceWith("LoggingLevel.Info", "io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel"), + level = DeprecationLevel.WARNING, + ) + public val info: io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel = + io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel.Info + + @Deprecated( + message = "Use `LoggingLevel.Notice` instead", + replaceWith = ReplaceWith("LoggingLevel.Notice", "io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel"), + level = DeprecationLevel.WARNING, + ) + public val notice: io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel = + io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel.Notice + + @Deprecated( + message = "Use `LoggingLevel.Warning` instead", + replaceWith = ReplaceWith("LoggingLevel.Warning", "io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel"), + level = DeprecationLevel.WARNING, + ) + public val warning: io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel = + io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel.Warning + + @Deprecated( + message = "Use `LoggingLevel.Error` instead", + replaceWith = ReplaceWith("LoggingLevel.Error", "io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel"), + level = DeprecationLevel.WARNING, + ) + public val error: io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel = + io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel.Error + + @Deprecated( + message = "Use `LoggingLevel.Critical` instead", + replaceWith = ReplaceWith("LoggingLevel.Critical", "io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel"), + level = DeprecationLevel.WARNING, + ) + public val critical: io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel = + io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel.Critical + + @Deprecated( + message = "Use `LoggingLevel.Alert` instead", + replaceWith = ReplaceWith("LoggingLevel.Alert", "io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel"), + level = DeprecationLevel.WARNING, + ) + public val alert: io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel = + io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel.Alert + + @Deprecated( + message = "Use `LoggingLevel.Emergency` instead", + replaceWith = ReplaceWith("LoggingLevel.Emergency", "io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel"), + level = DeprecationLevel.WARNING, + ) + public val emergency: io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel = + io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel.Emergency +} + +@Deprecated( + message = "Use `LoggingMessageNotification` instead", + replaceWith = ReplaceWith( + "LoggingMessageNotification", + "io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification", + ), + level = DeprecationLevel.WARNING, +) +public object LoggingMessageNotification { + @Deprecated( + message = "Use `LoggingMessageNotificationParams` instead", + replaceWith = ReplaceWith( + "LoggingMessageNotificationParams", + "io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams", + ), + level = DeprecationLevel.WARNING, + ) + public fun Params( + level: io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel, + data: JsonElement, + logger: String? = null, + meta: JsonObject? = null, + ): io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams = + io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams(level, data, logger, meta) + + @Deprecated( + message = "Use `SetLevelRequest` instead", + replaceWith = ReplaceWith( + "SetLevelRequest", + "io.modelcontextprotocol.kotlin.sdk.types.SetLevelRequest", + ), + level = DeprecationLevel.WARNING, + ) + public fun SetLevelRequest( + params: io.modelcontextprotocol.kotlin.sdk.types.SetLevelRequestParams, + ): io.modelcontextprotocol.kotlin.sdk.types.SetLevelRequest = + io.modelcontextprotocol.kotlin.sdk.types.SetLevelRequest(params) +} + +/** + * Notification of a log message passed from server to client. + * If no logging level request has been sent from the client, + * the server MAY decide which messages to send automatically. + */ +@Deprecated( + message = "Use `LoggingMessageNotification` instead", + replaceWith = ReplaceWith( + "LoggingMessageNotification", + "io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification", + ), + level = DeprecationLevel.WARNING, +) +public fun LoggingMessageNotification( + params: io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams, +): io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification = + io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification(params) + +/* Sampling */ + +/** + * Hints to use for model selection. + */ +@Deprecated( + message = "Use `ModelHint` instead", + replaceWith = ReplaceWith("ModelHint", "io.modelcontextprotocol.kotlin.sdk.types.ModelHint"), + level = DeprecationLevel.WARNING, +) +public typealias ModelHint = io.modelcontextprotocol.kotlin.sdk.types.ModelHint + +/** + * The server's preferences for model selection, requested by the client during sampling. + */ +@Deprecated( + message = "Use `ModelPreferences` instead", + replaceWith = ReplaceWith("ModelPreferences", "io.modelcontextprotocol.kotlin.sdk.types.ModelPreferences"), + level = DeprecationLevel.WARNING, +) +public typealias ModelPreferences = io.modelcontextprotocol.kotlin.sdk.types.ModelPreferences + +/** + * Describes a message issued to or received from an LLM API. + */ +@Deprecated( + message = "Use `SamplingMessage` instead", + replaceWith = ReplaceWith("SamplingMessage", "io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage"), + level = DeprecationLevel.WARNING, +) +public typealias SamplingMessage = io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage + +@Deprecated( + message = "Use `CreateMessageRequest` instead", + replaceWith = ReplaceWith("CreateMessageRequest", "io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest"), + level = DeprecationLevel.WARNING, +) +public object CreateMessageRequest { + public val IncludeContext: io.modelcontextprotocol.kotlin.sdk.types.IncludeContext.Companion = + io.modelcontextprotocol.kotlin.sdk.types.IncludeContext +} + +/** + * A request from the server to sample an LLM via the client. + * The client has full discretion over which model to select. + * The client should also inform the user before beginning sampling to allow them to inspect the request + * (human in the loop) and decide whether to approve it. + */ +@Deprecated( + message = "Use `CreateMessageRequest` instead", + replaceWith = ReplaceWith("CreateMessageRequest", "io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest"), + level = DeprecationLevel.WARNING, +) +public fun CreateMessageRequest( + params: io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequestParams, +): io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest = + io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest(params) + +@Deprecated( + message = "Use `StopReason` instead", + replaceWith = ReplaceWith("StopReason", "io.modelcontextprotocol.kotlin.sdk.types.StopReason"), + level = DeprecationLevel.WARNING, +) +public typealias StopReason = io.modelcontextprotocol.kotlin.sdk.types.StopReason + +/** + * The client's response to a sampling/create_message request from the server. + * The client should inform the user before returning the sampled message to allow them to inspect the response + * (human in the loop) and decide whether to allow the server to see it. + */ +@Deprecated( + message = "Use `CreateMessageResult` instead", + replaceWith = ReplaceWith("CreateMessageResult", "io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult"), + level = DeprecationLevel.WARNING, +) +public typealias CreateMessageResult = io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult + +/* Autocomplete */ +@Deprecated( + message = "Use `Reference` instead", + replaceWith = ReplaceWith("Reference", "io.modelcontextprotocol.kotlin.sdk.types.Reference"), + level = DeprecationLevel.WARNING, +) +public typealias Reference = io.modelcontextprotocol.kotlin.sdk.types.Reference + +/** + * A reference to a resource or resource template definition. + */ +@Deprecated( + message = "Use `ResourceTemplateReference` instead", + replaceWith = ReplaceWith( + "ResourceTemplateReference", + "io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplateReference", + ), + level = DeprecationLevel.WARNING, +) +public typealias ResourceTemplateReference = io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplateReference + +/** + * Identifies a prompt. + */ +@Deprecated( + message = "Use `PromptReference` instead", + replaceWith = ReplaceWith("PromptReference", "io.modelcontextprotocol.kotlin.sdk.types.PromptReference"), + level = DeprecationLevel.WARNING, +) +public typealias PromptReference = io.modelcontextprotocol.kotlin.sdk.types.PromptReference + +/** + * Identifies a prompt. + */ +@Serializable +@Deprecated( + message = "This class will be removed.", + level = DeprecationLevel.WARNING, +) +public data class UnknownReference(val type: String) + +@Deprecated( + message = "Use `CompleteRequest` instead", + replaceWith = ReplaceWith("CompleteRequest", "io.modelcontextprotocol.kotlin.sdk.types.CompleteRequest"), + level = DeprecationLevel.WARNING, +) +public object CompleteRequest { + @Deprecated( + message = "Use `CompleteRequestParams.Argument` instead", + replaceWith = ReplaceWith( + "CompleteRequestParams.Argument", + "io.modelcontextprotocol.kotlin.sdk.types.CompleteRequestParams.Argument", + ), + level = DeprecationLevel.WARNING, + ) + public fun Argument( + name: String, + value: String, + ): io.modelcontextprotocol.kotlin.sdk.types.CompleteRequestParams.Argument = + io.modelcontextprotocol.kotlin.sdk.types.CompleteRequestParams.Argument(name, value) +} + +/** + * A request from the client to the server to ask for completion options. + */ +@Deprecated( + message = "Use `CompleteRequest` instead", + replaceWith = ReplaceWith("CompleteRequest", "io.modelcontextprotocol.kotlin.sdk.types.CompleteRequest"), + level = DeprecationLevel.WARNING, +) +public fun CompleteRequest( + params: io.modelcontextprotocol.kotlin.sdk.types.CompleteRequestParams, +): io.modelcontextprotocol.kotlin.sdk.types.CompleteRequest = + io.modelcontextprotocol.kotlin.sdk.types.CompleteRequest(params) + +@Deprecated( + message = "Use `CompleteResult` instead", + replaceWith = ReplaceWith("CompleteResult", "io.modelcontextprotocol.kotlin.sdk.types.CompleteResult"), + level = DeprecationLevel.WARNING, +) +public object CompleteResult { + @Deprecated( + message = "Use `CompleteResult.Completion` instead", + replaceWith = ReplaceWith( + "CompleteResult.Completion", + "io.modelcontextprotocol.kotlin.sdk.types.CompleteResult.Completion", + ), + level = DeprecationLevel.WARNING, + ) + public fun Completion( + values: List, + total: Int? = null, + hasMore: Boolean? = null, + ): io.modelcontextprotocol.kotlin.sdk.types.CompleteResult.Completion = + io.modelcontextprotocol.kotlin.sdk.types.CompleteResult.Completion(values, total, hasMore) +} + +/** + * The server's response to a completion/complete request + */ +@Deprecated( + message = "Use `CompleteResult` instead", + replaceWith = ReplaceWith("CompleteResult", "io.modelcontextprotocol.kotlin.sdk.types.CompleteResult"), + level = DeprecationLevel.WARNING, +) +public fun CompleteResult( + completion: io.modelcontextprotocol.kotlin.sdk.types.CompleteResult.Completion, + meta: JsonObject? = null, +): io.modelcontextprotocol.kotlin.sdk.types.CompleteResult = + io.modelcontextprotocol.kotlin.sdk.types.CompleteResult(completion, meta) + +/* Roots */ + +/** + * Represents a root directory or file that the server can operate on. + */ +@Deprecated( + message = "Use `Root` instead", + replaceWith = ReplaceWith("Root", "io.modelcontextprotocol.kotlin.sdk.types.Root"), + level = DeprecationLevel.WARNING, +) +public typealias Root = io.modelcontextprotocol.kotlin.sdk.types.Root + +/** + * Sent from the server to request a list of root URIs from the client. + */ +@Deprecated( + message = "Use `ListRootsRequest` instead", + replaceWith = ReplaceWith("ListRootsRequest", "io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest"), + level = DeprecationLevel.WARNING, +) +public typealias ListRootsRequest = io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest + +/** + * The client's response to a roots/list request from the server. + */ +@Deprecated( + message = "Use `ListRootsResult` instead", + replaceWith = ReplaceWith("ListRootsResult", "io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult"), + level = DeprecationLevel.WARNING, +) +public typealias ListRootsResult = io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult + +@Deprecated( + message = "Use `RootsListChangedNotification` instead", + replaceWith = ReplaceWith( + "RootsListChangedNotification", + "io.modelcontextprotocol.kotlin.sdk.types.RootsListChangedNotification", + ), + level = DeprecationLevel.WARNING, +) +public object RootsListChangedNotification { + @Deprecated( + message = "Use `BaseNotificationParams` instead", + replaceWith = ReplaceWith( + "BaseNotificationParams", + "io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams", + ), + level = DeprecationLevel.WARNING, + ) + public fun Params(meta: JsonObject? = null): io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams = + io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams(meta) +} + +/** + * A notification from the client to the server, informing it that the list of roots has changed. + */ +@Deprecated( + message = "Use `RootsListChangedNotification` instead", + replaceWith = ReplaceWith( + "RootsListChangedNotification", + "io.modelcontextprotocol.kotlin.sdk.types.RootsListChangedNotification", + ), + level = DeprecationLevel.WARNING, +) +public fun RootsListChangedNotification( + params: io.modelcontextprotocol.kotlin.sdk.types.BaseNotificationParams? = null, +): io.modelcontextprotocol.kotlin.sdk.types.RootsListChangedNotification = + io.modelcontextprotocol.kotlin.sdk.types.RootsListChangedNotification(params) + +@Deprecated( + message = "Use `ElicitRequest` instead", + replaceWith = ReplaceWith( + "ElicitRequest", + "io.modelcontextprotocol.kotlin.sdk.types.ElicitRequest", + ), + level = DeprecationLevel.WARNING, +) +public object CreateElicitationRequest { + public fun RequestedSchema( + properties: JsonObject, + required: List? = null, + ): io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams.RequestedSchema = + io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams.RequestedSchema(properties, required) +} + +/** + * Sent from the server to create an elicitation from the client. + */ +@Deprecated( + message = "Use `ElicitRequest` instead", + replaceWith = ReplaceWith( + "ElicitRequest", + "io.modelcontextprotocol.kotlin.sdk.types.ElicitRequest", + ), + level = DeprecationLevel.WARNING, +) +public fun CreateElicitationRequest( + params: io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams, +): io.modelcontextprotocol.kotlin.sdk.types.ElicitRequest = + io.modelcontextprotocol.kotlin.sdk.types.ElicitRequest(params) + +@Deprecated( + message = "Use `ElicitResult` instead", + replaceWith = ReplaceWith( + "ElicitResult", + "io.modelcontextprotocol.kotlin.sdk.types.ElicitResult", + ), + level = DeprecationLevel.WARNING, +) +public object CreateElicitationResult { + @Deprecated( + message = "Use `ElicitResult.Action` instead", + replaceWith = ReplaceWith( + "ElicitResult.Action", + "io.modelcontextprotocol.kotlin.sdk.types.ElicitResult.Action", + ), + level = DeprecationLevel.WARNING, + ) + public object Action { + @Deprecated( + message = "Use `ElicitResult.Action.Accept` instead", + replaceWith = ReplaceWith( + "ElicitResult.Action.Accept", + "io.modelcontextprotocol.kotlin.sdk.types.ElicitResult.Action", + ), + level = DeprecationLevel.WARNING, + ) + public val accept: io.modelcontextprotocol.kotlin.sdk.types.ElicitResult.Action = + io.modelcontextprotocol.kotlin.sdk.types.ElicitResult.Action.Accept + + @Deprecated( + message = "Use `ElicitResult.Action.Decline` instead", + replaceWith = ReplaceWith( + "ElicitResult.Action.Decline", + "io.modelcontextprotocol.kotlin.sdk.types.ElicitResult.Action", + ), + level = DeprecationLevel.WARNING, + ) + public val decline: io.modelcontextprotocol.kotlin.sdk.types.ElicitResult.Action = + io.modelcontextprotocol.kotlin.sdk.types.ElicitResult.Action.Decline + + @Deprecated( + message = "Use `ElicitResult.Action.Cancel` instead", + replaceWith = ReplaceWith( + "ElicitResult.Action.Cancel", + "io.modelcontextprotocol.kotlin.sdk.types.ElicitResult.Action", + ), + level = DeprecationLevel.WARNING, + ) + public val cancel: io.modelcontextprotocol.kotlin.sdk.types.ElicitResult.Action = + io.modelcontextprotocol.kotlin.sdk.types.ElicitResult.Action.Cancel + } +} + +/** + * The client's response to an elicitation/create request from the server. + */ +@Deprecated( + message = "Use `ElicitResult` instead", + replaceWith = ReplaceWith( + "ElicitResult", + "io.modelcontextprotocol.kotlin.sdk.types.ElicitResult", + ), + level = DeprecationLevel.WARNING, +) +public fun CreateElicitationResult( + action: io.modelcontextprotocol.kotlin.sdk.types.ElicitResult.Action, + content: JsonObject? = null, + meta: JsonObject? = null, +): io.modelcontextprotocol.kotlin.sdk.types.ElicitResult = + io.modelcontextprotocol.kotlin.sdk.types.ElicitResult(action, content, meta) + +/** + * Represents an error specific to the MCP protocol. + * + * @property code The error code. + * @property message The error message. + * @property data Additional error data as a JSON object. + */ +@Deprecated( + message = "Use `McpException` instead", + replaceWith = ReplaceWith("McpException"), + level = DeprecationLevel.WARNING, +) +public typealias McpError = io.modelcontextprotocol.kotlin.sdk.types.McpException diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types.util.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types.util.kt new file mode 100644 index 0000000..305efe7 --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types.util.kt @@ -0,0 +1,40 @@ +package io.modelcontextprotocol.kotlin.sdk + +import io.modelcontextprotocol.kotlin.sdk.types.error +import io.modelcontextprotocol.kotlin.sdk.types.success +import kotlinx.serialization.json.JsonObject + +@Deprecated( + message = "Use `io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject` instead", + replaceWith = ReplaceWith("EmptyJsonObject", "io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject"), + level = DeprecationLevel.WARNING, +) +public val EmptyJsonObject: JsonObject = io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject + +/** + * Creates a [CallToolResult] with single [TextContent] and [meta]. + */ +@Deprecated( + message = "Use `CallToolResult.success` instead", + replaceWith = ReplaceWith("CallToolResult.Companion.success"), + level = DeprecationLevel.WARNING, +) +public fun CallToolResult.ok( + content: String, + meta: JsonObject = EmptyJsonObject, +): io.modelcontextprotocol.kotlin.sdk.types.CallToolResult = + io.modelcontextprotocol.kotlin.sdk.types.CallToolResult.success(content, meta) + +/** + * Creates a [CallToolResult] with single [TextContent] and [meta], with `isError` being true. + */ +@Deprecated( + message = "Use `CallToolResult.error` instead", + replaceWith = ReplaceWith("CallToolResult.Companion.error"), + level = DeprecationLevel.WARNING, +) +public fun CallToolResult.error( + content: String, + meta: JsonObject = EmptyJsonObject, +): io.modelcontextprotocol.kotlin.sdk.types.CallToolResult = + io.modelcontextprotocol.kotlin.sdk.types.CallToolResult.error(content, meta) 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..f63f4a9 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,14 @@ 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. + * @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", + message: String, 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("MCP error $code: $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..2f1e3ae 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 @@ -4,11 +4,6 @@ import kotlinx.serialization.EncodeDefault import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable -/** - * Represents a ping request used to check if the connection is alive. - * - * @property params optional request parameters carrying metadata - */ @Serializable public data class PingRequest(override val params: BaseRequestParams? = null) : ClientRequest, @@ -17,7 +12,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..b132a5e 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. @@ -166,13 +171,11 @@ public class ClientCapabilitiesBuilder @PublishedApi internal constructor() { */ public fun experimental(block: JsonObjectBuilder.() -> Unit): Unit = experimental(buildJsonObject(block)) - /** Constructs the [ClientCapabilities] instance from the current builder state. */ @PublishedApi internal fun build(): ClientCapabilities = ClientCapabilities( 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..cff3b89 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,22 @@ 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 - */ 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 +71,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 +88,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,15 +97,9 @@ public data class ServerCapabilities( val prompts: Prompts? = null, val logging: JsonObject? = null, val completions: JsonObject? = null, - val tasks: Tasks? = null, val experimental: JsonObject? = null, - val extensions: Map? = null, ) { - /** - * @property Logging convenience value to enable the logging capability - * @property Completions convenience value to enable the completions capability - */ public companion object { public val Logging: JsonObject = EmptyJsonObject public val Completions: JsonObject = EmptyJsonObject @@ -277,35 +142,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..e22dab7 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 @@ -8,16 +8,12 @@ import kotlinx.serialization.json.JsonObject // Protocol Version Constants // ============================================================================ -/** The latest supported MCP protocol version string. */ -public const val LATEST_PROTOCOL_VERSION: String = "2025-11-25" +public const val LATEST_PROTOCOL_VERSION: String = "2025-06-18" -/** The default protocol version used when negotiation is not performed. */ public const val DEFAULT_NEGOTIATED_PROTOCOL_VERSION: String = "2025-03-26" -/** All MCP protocol versions supported by this SDK. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( LATEST_PROTOCOL_VERSION, - "2025-06-18", "2025-03-26", "2024-11-05", ) @@ -31,9 +27,16 @@ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( */ @Serializable public sealed interface WithMeta { - /** Optional metadata attached to this entity. */ @SerialName("_meta") public val meta: JsonObject? + + @Deprecated( + message = "Use 'meta' instead.", + replaceWith = ReplaceWith("meta"), + ) + @Suppress("PropertyName", "VariableNaming") + public val _meta: JsonObject + get() = meta ?: EmptyJsonObject } // ============================================================================ @@ -45,10 +48,8 @@ public sealed interface WithMeta { */ public typealias ProgressToken = RequestId -/** Creates a [ProgressToken] from a string value. */ public fun ProgressToken(value: String): ProgressToken = RequestId(value) -/** Creates a [ProgressToken] from a numeric value. */ public fun ProgressToken(value: Long): ProgressToken = RequestId(value) // ============================================================================ @@ -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 { @@ -127,15 +131,9 @@ public enum class Role { */ @Serializable(with = ReferencePolymorphicSerializer::class) public sealed interface Reference { - /** Discriminator identifying the reference subtype. */ public val type: ReferenceType } -/** - * Discriminator for [Reference] subtypes used in completion and other operations. - * - * @property value serialized string representation of this reference type - */ @Serializable public enum class ReferenceType(public val value: String) { @SerialName("ref/prompt") diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/completion.dsl.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/completion.dsl.kt index 29d3c99..bbcfe6c 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/completion.dsl.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/completion.dsl.kt @@ -160,8 +160,7 @@ public class CompleteRequestBuilder @PublishedApi internal constructor() : Reque } val reference = requireNotNull(ref) { - "Missing required field 'ref(Reference)'. Use ref(PromptReference(\"name\")) " + - "or ref(ResourceTemplateReference(\"uri\"))" + "Missing required field 'ref(Reference)'. Use ref(PromptReference(\"name\")) or ref(ResourceTemplateReference(\"uri\"))" } val params = CompleteRequestParams(argument = argument, ref = reference, context = ctx, meta = meta) 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..59f558c 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 @@ -37,9 +37,20 @@ 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 + + @Deprecated( + message = "Will be removed in a future release.", + replaceWith = ReplaceWith(expression = "CompleteRequest(CompleteRequestParams(argument, ref, context, meta))"), + level = DeprecationLevel.WARNING, + ) + public constructor( + argument: Argument, + ref: Reference, + context: Context? = null, + meta: RequestMeta? = null, + ) : this(CompleteRequestParams(argument, ref, context, meta)) } /** @@ -48,8 +59,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.dsl.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/content.dsl.kt index 8718dec..e538af5 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/content.dsl.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/content.dsl.kt @@ -88,7 +88,6 @@ public abstract class MediaContentBuilder { meta = buildJsonObject(block) } - /** Constructs the [MediaContent] instance from the current builder state. */ @PublishedApi internal abstract fun build(): MediaContent } 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..0e6377f 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 @@ -8,11 +8,6 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject -/** - * Discriminator values for the polymorphic [ContentBlock] hierarchy. - * - * @property value serialized string representation of this content type - */ @Serializable public enum class ContentTypes(public val value: String) { @SerialName("text") @@ -29,45 +24,15 @@ 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. - */ @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 +161,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.dsl.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.dsl.kt index 12309fa..e398d98 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.dsl.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/elicitation.dsl.kt @@ -4,7 +4,6 @@ import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.decodeFromJsonElement import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract @@ -224,9 +223,6 @@ public class ElicitRequestedSchemaBuilder @PublishedApi internal constructor() { val properties = requireNotNull(properties) { "Missing required field 'properties'. Use properties { put(\"fieldName\", schema) }" } - val typedProperties: Map = properties.mapValues { (_, value) -> - McpJson.decodeFromJsonElement(value) - } - return ElicitRequestParams.RequestedSchema(properties = typedProperties, required = required) + return ElicitRequestParams.RequestedSchema(properties, required) } } 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..dfda9bf 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 @@ -1,20 +1,23 @@ package io.modelcontextprotocol.kotlin.sdk.types import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject /** - * Represents an `elicitation/create` request from the server to the client. + * A request from the server to elicit additional information from the user via the client. * - * Supports two modes: form mode ([ElicitRequestFormParams]) for collecting structured data - * in-band, and URL mode ([ElicitRequestURLParams]) for directing the user to an external URL. + * This request type allows servers to prompt users for structured input through forms + * or dialogs presented by the client. The server defines a schema for the requested data, + * and the client presents an appropriate UI to collect this information. * - * @property params The elicitation parameters — either form or URL mode. + * @property params The elicitation parameters including the message and requested schema. */ @Serializable public data class ElicitRequest(override val params: ElicitRequestParams) : ServerRequest { + @OptIn(ExperimentalSerializationApi::class) @EncodeDefault public override val method: Method = Method.Defined.ElicitationCreate @@ -27,13 +30,8 @@ public data class ElicitRequest(override val params: ElicitRequestParams) : Serv /** * A restricted subset of JSON Schema defining the structure of the requested data. */ - @Deprecated( - "Use (params as ElicitRequestFormParams).requestedSchema", - ReplaceWith("(params as ElicitRequestFormParams).requestedSchema"), - DeprecationLevel.WARNING, - ) - public val requestedSchema: ElicitRequestParams.RequestedSchema? - get() = (params as? ElicitRequestFormParams)?.requestedSchema + public val requestedSchema: ElicitRequestParams.RequestedSchema + get() = params.requestedSchema /** * Metadata for this request. May include a progressToken for out-of-band progress notifications. @@ -43,14 +41,23 @@ public data class ElicitRequest(override val params: ElicitRequestParams) : Serv } /** - * Represents the parameters for an `elicitation/create` request. + * Parameters for an elicitation/create request. * - * Implementations: [ElicitRequestFormParams], [ElicitRequestURLParams]. + * @property message The message to present to the user. This should clearly explain + * what information is being requested and why. + * @property requestedSchema A restricted subset of JSON Schema defining the structure + * of the requested data. Only top-level properties are allowed, + * without nesting. + * @property meta Optional metadata for this request. May include a progressToken for + * out-of-band progress notifications. */ -@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 +@Serializable +public data class ElicitRequestParams( + val message: String, + val requestedSchema: RequestedSchema, + @SerialName("_meta") + override val meta: RequestMeta? = null, +) : RequestParams { /** * A restricted JSON Schema for elicitation requests. @@ -58,117 +65,31 @@ public sealed interface ElicitRequestParams : RequestParams { * Only supports top-level primitive properties without nesting. Each property * represents a field in the form or dialog presented to the user. * - * @property schema Optional URI to a JSON Schema definition. * @property properties A map of property names to their schema definitions. * 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( - @SerialName($$"$schema") - val schema: String? = null, - val properties: Map, - val required: List? = null, - ) { - /** Always "object" for elicitation schemas. */ + public data class RequestedSchema(val properties: JsonObject, val required: List? = null) { + @OptIn(ExperimentalSerializationApi::class) @EncodeDefault val type: String = "object" } } /** - * Creates an [ElicitRequestFormParams] for backwards compatibility. + * The client's response to an [ElicitRequest]. * - * @param message The message to present to the user. - * @param requestedSchema The JSON Schema for the requested data. - * @param meta Optional request metadata. - * @return A configured [ElicitRequestFormParams] instance. - */ -@Deprecated( - "Use ElicitRequestFormParams instead", - ReplaceWith("ElicitRequestFormParams(message, requestedSchema = requestedSchema, meta = meta)"), - DeprecationLevel.WARNING, -) -@Suppress("FunctionName") -public fun ElicitRequestParams( - message: String, - requestedSchema: ElicitRequestParams.RequestedSchema, - meta: RequestMeta? = null, -): ElicitRequestFormParams = ElicitRequestFormParams( - message = message, - requestedSchema = requestedSchema, - meta = meta, -) - -/** - * Represents form mode parameters for an `elicitation/create` request. + * Represents the user's action and, if accepted, the submitted form data. * - * Collects non-sensitive structured data from the user via a form presented by the client. - * - * @property message The message to present to the user describing what information is being requested. - * @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 requestedSchema A restricted subset of JSON Schema. Only top-level properties - * are allowed, without nesting. - * @property meta Optional metadata. May include a progressToken for out-of-band progress notifications. - */ -@Serializable -public data class ElicitRequestFormParams( - override val message: String, - val task: TaskMetadata? = null, - val requestedSchema: ElicitRequestParams.RequestedSchema, - @SerialName("_meta") - override val meta: RequestMeta? = null, -) : ElicitRequestParams { - /** The elicitation mode discriminator, always `"form"`. */ - @EncodeDefault - public val mode: String = "form" -} - -/** - * Represents URL mode parameters for an `elicitation/create` request. - * - * 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. - * @property url The URL that the user should navigate to. - * @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 meta Optional metadata. May include a progressToken for out-of-band progress notifications. - */ -@Serializable -public data class ElicitRequestURLParams( - override val message: String, - val elicitationId: String, - val url: String, - val task: TaskMetadata? = null, - @SerialName("_meta") - override val meta: RequestMeta? = null, -) : ElicitRequestParams { - /** The elicitation mode discriminator, always `"url"`. */ - @EncodeDefault - public val mode: String = "url" -} - -/** - * Represents the client's response to an [ElicitRequest]. - * - * @property action The user action in response to the elicitation. - * @property content The submitted form data, only present when [action] is [Action.Accept] - * and mode was form. Contains values matching the requested schema. Omitted for - * URL mode responses. + * @property action The user action in response to the elicitation prompt. + * @property content The submitted form data, only present when [action] is [Action.Accept]. + * Contains values matching the requested schema, where keys correspond + * to property names and values are primitives (string, number, or boolean). * @property meta Optional metadata for this response. + * @throws IllegalArgumentException if content is provided with a non-accept action. */ @Serializable public data class ElicitResult( @@ -186,6 +107,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..50fd02f 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 @@ -14,25 +14,10 @@ import kotlin.jvm.JvmOverloads import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid -/** JSON-RPC protocol version used by MCP (`"2.0"`). */ public const val JSONRPC_VERSION: String = "2.0" -/** - * Creates a `RequestId` instance using the provided string value. - * - * @param value The string representation of the request ID. This value is used - * to uniquely identify a request in the JSON-RPC context. - * @return A `RequestId` instance of type `StringId` with the provided string value. - */ public fun RequestId(value: String): RequestId = RequestId.StringId(value) -/** - * Creates a `RequestId` instance using the given numeric value. - * - * @param value The numeric value to be used for the `RequestId`. This should uniquely - * identify a request when represented as a `NumberId`. - * @return A `RequestId.NumberId` instance encapsulating the provided numeric value. - */ public fun RequestId(value: Long): RequestId = RequestId.NumberId(value) /** @@ -41,20 +26,10 @@ public fun RequestId(value: Long): RequestId = RequestId.NumberId(value) @Serializable(with = RequestIdPolymorphicSerializer::class) public sealed interface RequestId { - /** - * A string-based request identifier. - * - * @property value the string representation of this request ID - */ @JvmInline @Serializable public value class StringId(public val value: String) : RequestId - /** - * A numeric request identifier. - * - * @property value the numeric representation of this request ID - */ @JvmInline @Serializable public value class NumberId(public val value: Long) : RequestId @@ -107,13 +82,9 @@ internal fun JSONRPCNotification.fromJSON(): Notification = */ @Serializable(with = JSONRPCMessagePolymorphicSerializer::class) public sealed interface JSONRPCMessage { - /** The JSON-RPC protocol version, always `"2.0"`. */ public val jsonrpc: String } -/** - * Represents an empty JSON-RPC message used as a placeholder or no-op response. - */ @Serializable public data object JSONRPCEmptyMessage : JSONRPCMessage { override val jsonrpc: String = JSONRPC_VERSION @@ -130,6 +101,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 +114,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 +156,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 +177,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 +197,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 } @@ -266,19 +237,6 @@ public data class RPCError(val code: Int, val message: String, val data: JsonEle /** Request timed out */ public const val REQUEST_TIMEOUT: Int = -32001 - /** 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/jsonUtils.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonUtils.kt index d83e012..b9fce4a 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonUtils.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/jsonUtils.kt @@ -11,10 +11,8 @@ import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -/** Reusable empty [JsonObject] instance. */ public val EmptyJsonObject: JsonObject = JsonObject(emptyMap()) -/** Pre-configured [Json] instance for MCP serialization. */ @OptIn(ExperimentalSerializationApi::class) public val McpJson: Json by lazy { Json { @@ -26,17 +24,6 @@ public val McpJson: Json by lazy { } } -/** - * Converts a [Map] with [String] keys and nullable [Any] values into a [Map] with - * [String] keys and [JsonElement] values. - * - * Each value in the resulting map is derived from the corresponding value - * in the input map, transformed into a [JsonElement]. If the transformation - * fails for any reason, the value is replaced with a [JsonPrimitive] containing - * the original value's `toString` representation. - * - * @return A new map where the values are converted to [JsonElement] instances. - */ public fun Map.toJson(): Map = this.mapValues { (_, value) -> runCatching { convertToJsonElement(value) } .getOrElse { JsonPrimitive(value.toString()) } @@ -90,5 +77,7 @@ private fun convertToJsonElement(value: Any?): JsonElement = when (value) { is UByteArray -> buildJsonArray { value.forEach { add(JsonPrimitive(it)) } } - else -> JsonPrimitive(value.toString()) + else -> { + JsonPrimitive(value.toString()) + } } 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..9970015 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 @@ -7,7 +7,6 @@ import kotlinx.serialization.Serializable */ @Serializable(with = MethodSerializer::class) public sealed interface Method { - /** The string representation of this method name. */ public val value: String /** @@ -16,7 +15,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"), @@ -34,8 +32,6 @@ public sealed interface Method { NotificationsToolsListChanged("notifications/tools/list_changed"), NotificationsRootsListChanged("notifications/roots/list_changed"), NotificationsPromptsListChanged("notifications/prompts/list_changed"), - NotificationsElicitationComplete("notifications/elicitation/complete"), - NotificationsTasksStatus("notifications/tasks/status"), ToolsList("tools/list"), ToolsCall("tools/call"), LoggingSetLevel("logging/setLevel"), @@ -43,10 +39,6 @@ public sealed interface Method { CompletionComplete("completion/complete"), RootsList("roots/list"), ElicitationCreate("elicitation/create"), - TasksGet("tasks/get"), - TasksResult("tasks/result"), - TasksList("tasks/list"), - TasksCancel("tasks/cancel"), } /** @@ -54,13 +46,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..9270884 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 @@ -14,10 +14,7 @@ import kotlinx.serialization.json.JsonObject */ @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 +31,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 @@ -80,7 +79,6 @@ public data class CustomNotification(override val method: Method, override val p ClientNotification, ServerNotification { - /** Convenience accessor for [params]'s metadata. */ public val meta: JsonObject? get() = params?.meta } @@ -264,8 +262,7 @@ public data class PromptListChangedNotification(override val params: BaseNotific // ============================================================================ /** - * An optional notification from the server to the client, - * informing it that the list of resources it can read from has changed. + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. * * Servers may issue this without any previous subscription from the client. * Sent only if the server's [ServerCapabilities.resources] has `listChanged = true`. @@ -332,36 +329,6 @@ public data class RootsListChangedNotification(override val params: BaseNotifica override val method: Method = Method.Defined.NotificationsRootsListChanged } -// ============================================================================ -// Elicitation Complete Notification -// ============================================================================ - -/** - * An optional notification from the server to the client, - * informing it of a completion of an out-of-band elicitation request. - * - * @property params Parameters identifying which elicitation completed. - */ -@Serializable -public data class ElicitationCompleteNotification(override val params: ElicitationCompleteNotificationParams) : - ServerNotification { - @EncodeDefault - override val method: Method = Method.Defined.NotificationsElicitationComplete -} - -/** - * Parameters for a notifications/elicitation/complete notification. - * - * @property elicitationId The ID of the elicitation that completed. - * @property meta Optional metadata for this notification. - */ -@Serializable -public data class ElicitationCompleteNotificationParams( - val elicitationId: String, - @SerialName("_meta") - override val meta: JsonObject? = null, -) : NotificationParams - // ============================================================================ // Tools List Changed Notification // ============================================================================ @@ -380,48 +347,3 @@ public data class ToolListChangedNotification(override val params: BaseNotificat @EncodeDefault override val method: Method = Method.Defined.NotificationsToolsListChanged } - -// ============================================================================ -// Task Status Notification -// ============================================================================ - -/** - * An optional notification from the receiver to the requestor, informing them that a task’s status has changed. - * Receivers are not required to send these notifications. - * - * This notification can be sent by either side (both [ClientNotification] and [ServerNotification]). - * - * @property params The task status notification parameters containing the current task state. - */ -@Serializable -public data class TaskStatusNotification(override val params: TaskStatusNotificationParams? = null) : - ClientNotification, - ServerNotification { - @EncodeDefault - override val method: Method = Method.Defined.NotificationsTasksStatus -} - -/** - * Parameters for a notifications/tasks/status notification. - * - * @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, null for unlimited. - * @property pollInterval Suggested polling interval in milliseconds. - * @property meta Optional metadata for this notification. - */ -@Serializable -public data class TaskStatusNotificationParams( - override val taskId: String, - override val status: TaskStatus, - override val statusMessage: String? = null, - override val createdAt: String, - override val lastUpdatedAt: String, - override val ttl: Long?, - override val pollInterval: Long? = null, - @SerialName("_meta") override val meta: JsonObject? = null, -) : NotificationParams, - TaskFields diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/prompts.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/prompts.kt index 5d7054b..68a892f 100644 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/prompts.kt +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/prompts.kt @@ -113,6 +113,22 @@ public data class GetPromptRequest(override val params: GetPromptRequestParams) @EncodeDefault override val method: Method = Method.Defined.PromptsGet + @Deprecated( + message = "Use constructor with GetPromptRequestParams instead", + replaceWith = ReplaceWith("GetPromptRequest(GetPromptRequestParams(name, arguments, meta))"), + ) + public constructor( + name: String, + arguments: Map? = null, + meta: RequestMeta? = null, + ) : this( + GetPromptRequestParams( + name = name, + arguments = arguments, + meta = meta, + ), + ) + /** * The name of the prompt or prompt template to retrieve. */ @@ -187,6 +203,26 @@ public data class ListPromptsRequest(override val params: PaginatedRequestParams PaginatedRequest { @EncodeDefault override val method: Method = Method.Defined.PromptsList + + /** + * Secondary constructor for creating a [ListPromptsRequest] instance + * using optional cursor and metadata parameters. + * + * This constructor simplifies the creation of the [ListPromptsRequest] by allowing a cursor + * and metadata to be provided. + * + * @param cursor Optional cursor string to specify the starting point of the paginated request. + * @param meta Optional metadata associated with the request. + */ + @Deprecated( + message = "Use constructor with ListPromptsParams instead", + replaceWith = ReplaceWith("ListPromptsRequest(ListPromptsParams(name, arguments, meta))"), + level = DeprecationLevel.WARNING, + ) + public constructor( + cursor: String?, + meta: RequestMeta? = null, + ) : this(paginatedRequestParams(cursor, 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..7e10204 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,148 +1,30 @@ 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 -import kotlinx.serialization.json.decodeFromJsonElement 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 - */ @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 { - element is JsonPrimitive && element.isString -> ProgressToken(element.content) - element is JsonPrimitive && element.longOrNull != null -> ProgressToken(element.long) + when (element) { + is JsonPrimitive if (element.isString) -> ProgressToken(element.content) + is JsonPrimitive if (element.longOrNull != null) -> ProgressToken(element.long) else -> null } } - /** - * The related task metadata, if this request is associated with a task. - * - * @see RelatedTaskMetadata - * @see RELATED_TASK_META_KEY - */ - public val relatedTask: RelatedTaskMetadata? - get() = json[RELATED_TASK_META_KEY]?.let { element -> - 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. - * - * @param key the key whose corresponding value is to be returned - * @return the JsonElement associated with the specified key, or null if the key does not exist - */ public operator fun get(key: String): JsonElement? = json[key] } -/** - * Base interface for parameters attached to a [Request]. - */ @Serializable public sealed interface RequestParams { /** @@ -155,9 +37,6 @@ public sealed interface RequestParams { public val meta: RequestMeta? } -/** - * Default [RequestParams] implementation carrying only optional metadata. - */ @Serializable public data class BaseRequestParams(@SerialName("_meta") override val meta: RequestMeta? = null) : RequestParams @@ -167,10 +46,7 @@ public data class BaseRequestParams(@SerialName("_meta") override val meta: Requ @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? } @@ -218,22 +94,16 @@ public sealed interface PaginatedRequest : Request { @Serializable(with = RequestResultPolymorphicSerializer::class) public sealed interface RequestResult : WithMeta -/** - * Represents a result returned by the server in response to a [ClientRequest]. - */ @Serializable(with = ClientResultPolymorphicSerializer::class) public sealed interface ClientResult : RequestResult -/** - * Represents a result returned by the client in response to a [ServerRequest]. - */ @Serializable(with = ServerResultPolymorphicSerializer::class) 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) : @@ -241,11 +111,10 @@ public data class EmptyResult(@SerialName("_meta") override val meta: JsonObject ServerResult /** - * Represents a paginated result. + * Represents a request supporting pagination. */ @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..ba9533e 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 @@ -8,9 +8,6 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject -/** - * Common interface for resource-like types that share URI, name, and description fields. - */ @Serializable public sealed interface ResourceLike : WithMeta @@ -117,14 +114,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 +133,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 +149,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 +164,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( @@ -196,6 +191,26 @@ public data class ListResourcesRequest(override val params: PaginatedRequestPara PaginatedRequest { @EncodeDefault override val method: Method = Method.Defined.ResourcesList + + /** + * Secondary constructor for creating a [ListResourcesRequest] instance + * using optional cursor and metadata parameters. + * + * This constructor simplifies the creation of the [ListResourcesRequest] by allowing a cursor + * and metadata to be provided. + * + * @param cursor Optional cursor string to specify the starting point of the paginated request. + * @param meta Optional metadata associated with the request. + */ + @Deprecated( + message = "Use the constructor with PaginatedRequestParams property instead", + replaceWith = ReplaceWith("ReadResourceRequest(PaginatedRequestParams(cursor, meta))"), + level = DeprecationLevel.WARNING, + ) + public constructor( + cursor: String?, + meta: RequestMeta? = null, + ) : this(paginatedRequestParams(cursor, meta)) } /** @@ -246,6 +261,16 @@ public data class ReadResourceRequest(override val params: ReadResourceRequestPa */ public val meta: RequestMeta? get() = params.meta + + @Deprecated( + message = "Use the constructor with ReadResourceRequestParams property instead", + replaceWith = ReplaceWith("ReadResourceRequest(ReadResourceRequestParams(uri, meta))"), + level = DeprecationLevel.WARNING, + ) + public constructor( + uri: String, + meta: RequestMeta? = null, + ) : this(ReadResourceRequestParams(uri, meta)) } /** @@ -312,6 +337,16 @@ public data class SubscribeRequest(override val params: SubscribeRequestParams) */ public val meta: RequestMeta? get() = params.meta + + @Deprecated( + message = "Use the constructor with SubscribeRequestParams property instead", + replaceWith = ReplaceWith("ReadResourceRequest(SubscribeRequestParams(uri, meta))"), + level = DeprecationLevel.WARNING, + ) + public constructor( + uri: String, + meta: RequestMeta? = null, + ) : this(SubscribeRequestParams(uri, meta)) } /** @@ -398,6 +433,26 @@ public data class ListResourceTemplatesRequest(override val params: PaginatedReq PaginatedRequest { @EncodeDefault override val method: Method = Method.Defined.ResourcesTemplatesList + + /** + * Secondary constructor for creating a [ListResourceTemplatesRequest] instance + * using optional cursor and metadata parameters. + * + * This constructor simplifies the creation of the [ListResourceTemplatesRequest] by allowing a cursor + * and metadata to be provided. + * + * @param cursor Optional cursor string to specify the starting point of the paginated request. + * @param meta Optional metadata associated with the request. + */ + @Deprecated( + message = "Use the constructor with PaginatedRequestParams property instead", + replaceWith = ReplaceWith("ListResourceTemplatesRequest(PaginatedRequestParams(cursor, meta))"), + level = DeprecationLevel.WARNING, + ) + public constructor( + cursor: String?, + meta: RequestMeta? = null, + ) : this(paginatedRequestParams(cursor, meta)) } /** 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..cc08739 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 @@ -60,9 +60,17 @@ 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 + + @Deprecated( + message = "Use the constructor with BaseRequestParams property instead", + replaceWith = ReplaceWith("ListRootsRequest(BaseRequestParams(meta))"), + level = DeprecationLevel.WARNING, + ) + public constructor( + meta: RequestMeta?, + ) : this(BaseRequestParams(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..41dc864 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,86 +226,29 @@ 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" } - } +) : ClientResult - /** - * Convenience constructor for a single-block response. Wraps [content] in a - * singleton list so call sites can write - * `CreateMessageResult(Role.Assistant, TextContent("ok"), "model-name")` - * without the explicit `listOf(...)`. - */ - public constructor( - role: Role, - content: SamplingMessageContent, - model: String, - stopReason: StopReason? = null, - meta: JsonObject? = null, - ) : this(role, listOf(content), model, stopReason, meta) -} - -/** - * The reason why the LLM stopped generating tokens. - * - * @property value the string representation of this stop reason - */ @JvmInline @Serializable 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 deleted file mode 100644 index 0982500..0000000 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/schema.kt +++ /dev/null @@ -1,302 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.types - -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Restricted schema definition for elicitation request properties. - * - * Supports primitive types (string, number, integer, boolean) and enum schemas - * (single-select and multi-select). Only flat, top-level properties are allowed — - * arbitrary nested objects or arrays are not supported. - * - * Implementations: [StringSchema], [NumberSchemaDefinition], [BooleanSchema], [EnumSchemaDefinition]. - */ -@Serializable(with = PrimitiveSchemaDefinitionSerializer::class) -public sealed interface PrimitiveSchemaDefinition - -/** - * Defines a string-typed property in an elicitation schema. - * - * @property title Optional display title for the field. - * @property description Optional description for the field. - * @property minLength Minimum string length. - * @property maxLength Maximum string length. - * @property format Optional format constraint (e.g., email, URI, date). - * @property default Optional default value. - */ -@Serializable -public data class StringSchema( - val title: String? = null, - val description: String? = null, - val minLength: Int? = null, - val maxLength: Int? = null, - val format: StringSchemaFormat? = null, - val default: String? = null, -) : PrimitiveSchemaDefinition { - /** JSON Schema type discriminator, always `"string"`. */ - @EncodeDefault - public val type: String = "string" -} - -/** - * Supported format constraints for [StringSchema]. - */ -@Serializable -public enum class StringSchemaFormat { - @SerialName("email") - Email, - - @SerialName("uri") - Uri, - - @SerialName("date") - Date, - - @SerialName("date-time") - DateTime, -} - -/** - * Defines a numeric property in an elicitation schema. - * - * Implementations: [IntegerSchema] (`"integer"`), [DoubleSchema] (`"number"`). - */ -@Serializable -public sealed interface NumberSchemaDefinition : PrimitiveSchemaDefinition - -/** - * Defines an integer-typed property in an elicitation schema. - * - * @property title Optional display title for the field. - * @property description Optional description for the field. - * @property minimum Minimum allowed value. - * @property maximum Maximum allowed value. - * @property default Optional default value. - */ -@Serializable -public data class IntegerSchema( - val title: String? = null, - val description: String? = null, - val minimum: Int? = null, - val maximum: Int? = null, - val default: Int? = null, -) : NumberSchemaDefinition { - /** JSON Schema type discriminator, always `"integer"`. */ - @EncodeDefault - val type: String = "integer" -} - -/** - * Defines a floating-point number property in an elicitation schema. - * - * @property title Optional display title for the field. - * @property description Optional description for the field. - * @property minimum Minimum allowed value. - * @property maximum Maximum allowed value. - * @property default Optional default value. - */ -@Serializable -public data class DoubleSchema( - val title: String? = null, - val description: String? = null, - val minimum: Double? = null, - val maximum: Double? = null, - val default: Double? = null, -) : NumberSchemaDefinition { - /** JSON Schema type discriminator, always `"number"`. */ - @EncodeDefault - val type: String = "number" -} - -/** - * Defines a boolean-typed property in an elicitation schema. - * - * @property title Optional display title for the field. - * @property description Optional description for the field. - * @property default Optional default value. - */ -@Serializable -public data class BooleanSchema( - val title: String? = null, - val description: String? = null, - val default: Boolean? = null, -) : PrimitiveSchemaDefinition { - /** JSON Schema type discriminator, always `"boolean"`. */ - @EncodeDefault - val type: String = "boolean" -} - -/** - * Defines an enumeration property in an elicitation schema. - * - * Implementations: [SingleSelectEnumSchema], [MultiSelectEnumSchema], [LegacyTitledEnumSchema]. - */ -@Serializable -public sealed interface EnumSchemaDefinition : PrimitiveSchemaDefinition - -/** - * Represents an enum option with a value and a display label. - * - * Used in [TitledSingleSelectEnumSchema] and [TitledMultiSelectEnumSchema]. - * - * @property const The enum value. - * @property title Display label for this option. - */ -@Serializable -public data class EnumOption(val const: String, val title: String) - -/** - * Defines a single-selection enumeration property. - * - * Implementations: [UntitledSingleSelectEnumSchema], [TitledSingleSelectEnumSchema]. - */ -@Serializable -public sealed interface SingleSelectEnumSchema : EnumSchemaDefinition - -/** - * Defines a single-selection enumeration without display titles for options. - * - * @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 default Optional default value. - */ -@Serializable -public data class UntitledSingleSelectEnumSchema( - val title: String? = null, - val description: String? = null, - @SerialName("enum") - val enumValues: List, - val default: String? = null, -) : SingleSelectEnumSchema { - /** JSON Schema type discriminator, always `"string"`. */ - @EncodeDefault - val type: String = "string" -} - -/** - * Defines a single-selection enumeration with display titles for each option. - * - * @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 default Optional default value. - */ -@Serializable -public data class TitledSingleSelectEnumSchema( - val title: String? = null, - val description: String? = null, - val oneOf: List, - val default: String? = null, -) : SingleSelectEnumSchema { - /** JSON Schema type discriminator, always `"string"`. */ - @EncodeDefault - val type: String = "string" -} - -/** - * Defines a single-selection enumeration with display names via the deprecated `enumNames` array. - * - * Use [TitledSingleSelectEnumSchema] instead. This class will be removed in a future version. - * - * @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 enumNames Display names for enum values. Non-standard according to JSON Schema 2020-12. - * @property default Optional default value. - */ -@Deprecated("Use TitledSingleSelectEnumSchema instead") -@Serializable -public data class LegacyTitledEnumSchema( - val title: String? = null, - val description: String? = null, - @SerialName("enum") - val enumValues: List, - val enumNames: List? = null, - val default: String? = null, -) : EnumSchemaDefinition { - /** JSON Schema type discriminator, always `"string"`. */ - @EncodeDefault - val type: String = "string" -} - -/** - * Defines a multiple-selection enumeration property. - * - * Implementations: [UntitledMultiSelectEnumSchema], [TitledMultiSelectEnumSchema]. - */ -@Serializable -public sealed interface MultiSelectEnumSchema : EnumSchemaDefinition - -/** - * Defines a multiple-selection enumeration without display titles for options. - * - * @property title Optional display title for the field. - * @property description Optional description for the field. - * @property minItems Minimum number of items to select. - * @property maxItems Maximum number of items to select. - * @property items Schema for the array items. - * @property default Optional default value. - */ -@Serializable -public data class UntitledMultiSelectEnumSchema( - val title: String? = null, - val description: String? = null, - val minItems: Int? = null, - val maxItems: Int? = null, - 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 enumValues Array of enum values to choose from. - */ - @Serializable - public data class Items( - @SerialName("enum") - val enumValues: List, - ) { - /** JSON Schema type discriminator, always `"string"`. */ - @EncodeDefault - val type: String = "string" - } -} - -/** - * Defines a multiple-selection enumeration with display titles for each option. - * - * @property title Optional display title for the field. - * @property description Optional description for the field. - * @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 default Optional default value. - */ -@Serializable -public data class TitledMultiSelectEnumSchema( - val title: String? = null, - val description: String? = null, - val minItems: Int? = null, - val maxItems: Int? = null, - val items: Items, - val default: List? = null, -) : MultiSelectEnumSchema { - /** JSON Schema type discriminator, always `"array"`. */ - @EncodeDefault - val type: String = "array" - - /** - * Schema for array items with enum options and display labels. - * - * @property anyOf Array of enum options with values and display labels. - */ - @Serializable - public data class Items(val anyOf: List) -} 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..a00b26b 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,24 +1,17 @@ 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 import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull @@ -60,11 +53,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 +120,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 +144,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(), @@ -256,10 +159,6 @@ private val clientRequestDeserializers: Map CreateMessageResult.serializer() "roots" in jsonObject -> ListRootsResult.serializer() "action" in jsonObject -> ElicitResult.serializer() - "task" in jsonObject -> CreateTaskResult.serializer() - "tasks" in jsonObject -> ListTasksResult.serializer() - "taskId" in jsonObject -> GetTaskResult.serializer() else -> null } } @@ -420,16 +309,9 @@ private fun selectClientResultDeserializer(element: JsonElement): Deserializatio * Selects the appropriate deserializer for server results based on JSON content. * Returns null if the structure doesn't match any known server result type. */ -@OptIn(ExperimentalMcpApi::class) private fun selectServerResultDeserializer(element: JsonElement): DeserializationStrategy? { val jsonObject = element.jsonObject - val isDiscoverResult = - "supportedVersions" in jsonObject && - "capabilities" in jsonObject && - "ttlMs" in jsonObject && - "cacheScope" in jsonObject return when { - isDiscoverResult -> DiscoverResult.serializer() "protocolVersion" in jsonObject && "capabilities" in jsonObject -> InitializeResult.serializer() "completion" in jsonObject -> CompleteResult.serializer() "tools" in jsonObject -> ListToolsResult.serializer() @@ -439,9 +321,6 @@ private fun selectServerResultDeserializer(element: JsonElement): Deserializatio "messages" in jsonObject -> GetPromptResult.serializer() "contents" in jsonObject -> ReadResourceResult.serializer() "content" in jsonObject -> CallToolResult.serializer() - "task" in jsonObject -> CreateTaskResult.serializer() - "tasks" in jsonObject -> ListTasksResult.serializer() - "taskId" in jsonObject -> GetTaskResult.serializer() else -> null } } @@ -492,23 +371,21 @@ internal object ServerResultPolymorphicSerializer : * Polymorphic serializer for [JSONRPCMessage] types. * Determines the message type based on the presence of specific fields: * - "error" -> JSONRPCError - * - "result" + "id" -> JSONRPCResponse - * - "result" -> JSONRPCEmptyMessage + * - "result" -> JSONRPCResponse * - "method" + "id" -> JSONRPCRequest * - "method" -> JSONRPCNotification */ internal object JSONRPCMessagePolymorphicSerializer : JsonContentPolymorphicSerializer(JSONRPCMessage::class) { override fun selectDeserializer(element: JsonElement): DeserializationStrategy { - val jsonObj = element.jsonObject + val jsonObject = element.jsonObject return when { - "error" in jsonObj -> JSONRPCError.serializer() - "result" in jsonObj && "id" in jsonObj -> JSONRPCResponse.serializer() - "result" in jsonObj && jsonObj["result"]?.jsonObject?.isEmpty() == true -> JSONRPCEmptyMessage.serializer() - "method" in jsonObj && "id" in jsonObj -> JSONRPCRequest.serializer() - "method" in jsonObj -> JSONRPCNotification.serializer() - jsonObj.isEmpty() || jsonObj.keys == setOf("jsonrpc") -> JSONRPCEmptyMessage.serializer() - else -> throw SerializationException("Invalid JSONRPCMessage type: ${jsonObj.keys}") + "error" in jsonObject -> JSONRPCError.serializer() + "result" in jsonObject -> JSONRPCResponse.serializer() + "method" in jsonObject && "id" in jsonObject -> JSONRPCRequest.serializer() + "method" in jsonObject -> JSONRPCNotification.serializer() + jsonObject.isEmpty() || jsonObject.keys == setOf("jsonrpc") -> JSONRPCEmptyMessage.serializer() + else -> throw SerializationException("Invalid JSONRPCMessage type: ${jsonObject.keys}") } } } @@ -518,83 +395,9 @@ internal object JSONRPCMessagePolymorphicSerializer : * Supports both string and number IDs. */ internal object RequestIdPolymorphicSerializer : JsonContentPolymorphicSerializer(RequestId::class) { - override fun selectDeserializer(element: JsonElement): DeserializationStrategy = when { - element is JsonPrimitive && element.isString -> RequestId.StringId.serializer() - element is JsonPrimitive && element.longOrNull != null -> RequestId.NumberId.serializer() + override fun selectDeserializer(element: JsonElement): DeserializationStrategy = when (element) { + is JsonPrimitive if (element.isString) -> RequestId.StringId.serializer() + is JsonPrimitive if (element.longOrNull != null) -> RequestId.NumberId.serializer() else -> throw SerializationException("Invalid RequestId type: $element") } } - -// ============================================================================ -// ElicitationParams Serializers -// ============================================================================ - -internal object ElicitRequestParamsSerializer : JsonContentPolymorphicSerializer( - ElicitRequestParams::class, -) { - override fun selectDeserializer(element: JsonElement): DeserializationStrategy { - val mode = when (val modeElement = element.jsonObject["mode"]) { - null -> null - - is JsonPrimitive -> { - if (!modeElement.isString) { - throw SerializationException("Invalid 'mode' type: expected string but was: $modeElement") - } - modeElement.contentOrNull - } - - else -> throw SerializationException("Invalid 'mode' type: expected string but was: $modeElement") - } - return when (mode) { - "url" -> ElicitRequestURLParams.serializer() - "form", null -> ElicitRequestFormParams.serializer() - else -> throw SerializationException("Unsupported elicitation mode: '$mode'") - } - } -} - -// ============================================================================ -// PrimitiveSchemaDefinition Serializers -// ============================================================================ - -internal object PrimitiveSchemaDefinitionSerializer : JsonContentPolymorphicSerializer( - PrimitiveSchemaDefinition::class, -) { - override fun selectDeserializer(element: JsonElement): DeserializationStrategy { - val obj = element.jsonObject - val typeElement = obj["type"] - ?: throw SerializationException("Missing 'type' in PrimitiveSchemaDefinition: $element") - val type = if (typeElement is JsonPrimitive && typeElement.isString) { - typeElement.content - } else { - throw SerializationException("Expected 'type' to be a string but was: $typeElement") - } - return when (type) { - "boolean" -> BooleanSchema.serializer() - "integer" -> IntegerSchema.serializer() - "number" -> DoubleSchema.serializer() - "string" -> selectStringTypeDeserializer(obj) - "array" -> selectArrayTypeDeserializer(obj) - else -> throw SerializationException("Unknown PrimitiveSchemaDefinition type: '$type'") - } - } - - private fun selectArrayTypeDeserializer( - obj: Map, - ): DeserializationStrategy { - val items = obj["items"]?.jsonObject - return when { - items != null && "anyOf" in items -> TitledMultiSelectEnumSchema.serializer() - else -> UntitledMultiSelectEnumSchema.serializer() - } - } - - private fun selectStringTypeDeserializer( - obj: Map, - ): DeserializationStrategy = when { - "enumNames" in obj -> LegacyTitledEnumSchema.serializer() - "enum" in obj -> UntitledSingleSelectEnumSchema.serializer() - "oneOf" in obj -> TitledSingleSelectEnumSchema.serializer() - else -> StringSchema.serializer() - } -} 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 deleted file mode 100644 index 7363561..0000000 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tasks.kt +++ /dev/null @@ -1,361 +0,0 @@ -@file:OptIn(ExperimentalSerializationApi::class) - -package io.modelcontextprotocol.kotlin.sdk.types - -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlin.jvm.JvmInline - -/** - * The key used in `_meta` to associate a message with a task. - */ -public const val RELATED_TASK_META_KEY: String = "io.modelcontextprotocol/related-task" - -/** - * Common fields shared by all types representing the task state. - */ -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? -} - -/** - * Data associated with a task. - * - * @property taskId The task identifier. - * @property status Current task state. - * @property statusMessage Optional human-readable message describing the current task state. - * This can provide context for any status, including reasons for "cancelled" status, - * summaries for "completed" status, or diagnostic information for "failed" status. - * @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, null for unlimited. - * @property pollInterval Suggested polling interval in milliseconds. - */ -@Serializable -public data class Task( - override val taskId: String, - override val status: TaskStatus, - override val statusMessage: String? = null, - override val createdAt: String, - override val lastUpdatedAt: String, - override val ttl: Long?, - override val pollInterval: Long? = null, -) : TaskFields - -/** - * Metadata for augmenting a request with task execution. - * Include this in the `task` field of the request parameters. - * - * @property ttl Requested duration in milliseconds to retain task from creation. - */ -@Serializable -public data class TaskMetadata(val ttl: Long? = null) - -/** - * The status of a task. - */ -@Serializable -public enum class TaskStatus { - @SerialName("working") - Working, - - @SerialName("input_required") - InputRequired, - - @SerialName("completed") - Completed, - - @SerialName("failed") - Failed, - - @SerialName("cancelled") - Cancelled, -} - -/** - * Metadata for associating messages with a task. - * Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. - * - * @property taskId The task identifier this message is associated with. - */ -@Serializable -public data class RelatedTaskMetadata(val taskId: String) - -/** - * A response to a task-augmented request. - * - * @property task The task data associated with the response. - * @property meta Optional metadata for this response. - */ -@Serializable -public data class CreateTaskResult( - val task: Task, - @SerialName("_meta") - override val meta: JsonObject? = null, -) : ClientResult, - ServerResult - -// ============================================================================ -// tasks/get -// ============================================================================ - -/** - * A request to retrieve the state of a task. - * - * This request can be sent by either side (both [ClientRequest] and [ServerRequest]). - * - * @property params The parameters specifying which task to retrieve. - */ -@Serializable -public data class GetTaskRequest(override val params: GetTaskRequestParams) : - ClientRequest, - ServerRequest { - @EncodeDefault - override val method: Method = Method.Defined.TasksGet - - /** - * The task identifier to query. - */ - public val taskId: String - get() = params.taskId - - /** - * Metadata for this request. May include a progressToken for out-of-band progress notifications. - */ - public val meta: RequestMeta? - get() = params.meta -} - -/** - * Parameters for a tasks/get request. - * - * @property taskId The task identifier to query. - * @property meta Optional metadata for this request. - */ -@Serializable -public data class GetTaskRequestParams( - val taskId: String, - @SerialName("_meta") - override val meta: RequestMeta? = null, -) : RequestParams - -/** - * The response to a tasks/get request. - * - * Contains all [Task] fields flattened into the result (intersection of Result and Task). - * - * @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, null for unlimited. - * @property pollInterval Suggested polling interval in milliseconds. - * @property meta Optional metadata for this response. - */ -@Serializable -public data class GetTaskResult( - override val taskId: String, - override val status: TaskStatus, - override val statusMessage: String? = null, - override val createdAt: String, - override val lastUpdatedAt: String, - override val ttl: Long?, - override val pollInterval: Long? = null, - @SerialName("_meta") - override val meta: JsonObject? = null, -) : ClientResult, - ServerResult, - TaskFields - -// ============================================================================ -// tasks/result -// ============================================================================ - -/** - * A request to retrieve the result of a completed task. - * - * This request can be sent by either side (both [ClientRequest] and [ServerRequest]). - * - * @property params The parameters specifying which task to retrieve results for. - */ -@Serializable -public data class GetTaskPayloadRequest(override val params: GetTaskPayloadRequestParams) : - ClientRequest, - ServerRequest { - @EncodeDefault - override val method: Method = Method.Defined.TasksResult - - /** - * The task identifier to retrieve results for. - */ - public val taskId: String - get() = params.taskId - - /** - * Metadata for this request. May include a progressToken for out-of-band progress notifications. - */ - public val meta: RequestMeta? - get() = params.meta -} - -/** - * Parameters for a tasks/result request. - * - * @property taskId The task identifier to retrieve results for. - * @property meta Optional metadata for this request. - */ -@Serializable -public data class GetTaskPayloadRequestParams( - val taskId: String, - @SerialName("_meta") - override val meta: RequestMeta? = null, -) : RequestParams - -/** - * The response to a tasks/result request. - * - * The structure matches the result type of the original request. - * For example, a tools/call task would return the [CallToolResult] structure. - * - * @property json the raw JSON object containing the result payload - */ -@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 - - /** - * Retrieves the value associated with the specified key from the result payload. - * - * @param key the key whose corresponding value is to be returned - * @return the [JsonElement] associated with the specified key, or null if the key does not exist - */ - public operator fun get(key: String): JsonElement? = json[key] -} - -// ============================================================================ -// tasks/list -// ============================================================================ - -/** - * A request to retrieve a list of tasks. - * - * This request can be sent by either side (both [ClientRequest] and [ServerRequest]). - * Supports pagination through the cursor parameter. - * - * @property params Optional pagination parameters to control which page of results to return. - */ -@Serializable -public data class ListTasksRequest(override val params: PaginatedRequestParams? = null) : - ClientRequest, - ServerRequest, - PaginatedRequest { - @EncodeDefault - override val method: Method = Method.Defined.TasksList -} - -/** - * The response to a tasks/list request. - * - * @property tasks The list of tasks. - * @property nextCursor An opaque token representing the pagination position after the last returned result. - * If present, there may be more results available. - * @property meta Optional metadata for this response. - */ -@Serializable -public data class ListTasksResult( - val tasks: List, - override val nextCursor: String? = null, - @SerialName("_meta") - override val meta: JsonObject? = null, -) : ClientResult, - ServerResult, - PaginatedResult - -// ============================================================================ -// tasks/cancel -// ============================================================================ - -/** - * A request to cancel a task. - * - * This request can be sent by either side (both [ClientRequest] and [ServerRequest]). - * - * @property params The parameters specifying which task to cancel. - */ -@Serializable -public data class CancelTaskRequest(override val params: CancelTaskRequestParams) : - ClientRequest, - ServerRequest { - @EncodeDefault - override val method: Method = Method.Defined.TasksCancel - - /** - * The task identifier to cancel. - */ - public val taskId: String - get() = params.taskId - - /** - * Metadata for this request. May include a progressToken for out-of-band progress notifications. - */ - public val meta: RequestMeta? - get() = params.meta -} - -/** - * Parameters for a tasks/cancel request. - * - * @property taskId The task identifier to cancel. - * @property meta Optional metadata for this request. - */ -@Serializable -public data class CancelTaskRequestParams( - val taskId: String, - @SerialName("_meta") - override val meta: RequestMeta? = null, -) : RequestParams - -/** - * The response to a tasks/cancel request. - * - * Contains all [Task] fields flattened into the result (intersection of Result and Task). - * - * @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, null for unlimited. - * @property pollInterval Suggested polling interval in milliseconds. - * @property meta Optional metadata for this response. - */ -public typealias CancelTaskResult = GetTaskResult 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..6cba208 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. @@ -57,8 +55,6 @@ public fun CallToolResult.Companion.error(content: String, meta: JsonObject? = n * @property icons Optional set of sized icons that clients can display in their user interface. * Clients MUST support at least PNG and JPEG formats. * Clients SHOULD also support SVG and WebP formats. - * @property execution Optional execution-related properties for this tool, - * such as task-augmented execution support. * @property meta Optional metadata for this tool. */ @Serializable @@ -70,7 +66,6 @@ public data class Tool( val title: String? = null, val annotations: ToolAnnotations? = null, val icons: List? = null, - val execution: ToolExecution? = null, @SerialName("_meta") override val meta: JsonObject? = null, ) : WithMeta @@ -79,62 +74,18 @@ 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. */ +public data class ToolSchema(val properties: JsonObject? = null, val required: List? = null) { @OptIn(ExperimentalSerializationApi::class) @EncodeDefault val type: String = "object" } -/** - * Execution-related properties for a tool. - * - * @property taskSupport Indicates whether this tool supports task-augmented execution. - * This allows clients to handle long-running operations through polling the task system. - * If omitted (`null`), consumers should treat it as [TaskSupport.Forbidden] per the spec. - */ -@Serializable -public data class ToolExecution(val taskSupport: TaskSupport? = null) - -/** - * Indicates whether a tool supports task-augmented execution. - * - * - [Forbidden]: Tool does not support task-augmented execution (default when absent). - * - [Optional]: Tool may support task-augmented execution. - * - [Required]: Tool requires task-augmented execution. - */ -@Serializable -public enum class TaskSupport { - /** Tool does not support task-augmented execution. */ - @SerialName("forbidden") - Forbidden, - - /** Tool may support task-augmented execution. */ - @SerialName("optional") - Optional, - - /** Tool requires task-augmented execution. */ - @SerialName("required") - Required, -} - /** * Additional properties describing a Tool to clients. * @@ -203,12 +154,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 +167,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 +174,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 @@ -284,6 +226,26 @@ public data class ListToolsRequest(override val params: PaginatedRequestParams? PaginatedRequest { @EncodeDefault override val method: Method = Method.Defined.ToolsList + + /** + * Secondary constructor for creating a [ListToolsRequest] instance + * using optional cursor and metadata parameters. + * + * This constructor simplifies the creation of the [ListToolsRequest] by allowing a cursor + * and metadata to be provided. + * + * @param cursor Optional cursor string to specify the starting point of the paginated request. + * @param meta Optional metadata associated with the request. + */ + @Deprecated( + message = "Use the constructor with BaseRequestParams property instead", + replaceWith = ReplaceWith("ListToolsRequest(BaseRequestParams(meta))"), + level = DeprecationLevel.WARNING, + ) + public constructor( + cursor: String?, + meta: RequestMeta? = null, + ) : this(paginatedRequestParams(cursor, meta)) } /** 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 deleted file mode 100644 index aa2abb9..0000000 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcher.kt +++ /dev/null @@ -1,154 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.utils - -import io.github.oshai.kotlinlogging.KotlinLogging -import io.ktor.http.decodeURLPart -import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate -import kotlinx.collections.immutable.toImmutableMap -import kotlin.jvm.JvmOverloads -import kotlin.jvm.JvmStatic - -// Max URL/path length to prevent DoS or unexpected large payloads. -private const val MAX_URL_LENGTH = 2048 - -// Max template/uri depth to prevent overly nested or complex templates. -private const val MAX_DEPTH = 50 - -// Literal segments are more specific than variable captures. -private const val LITERAL_MATCH_SCORE = 2 -private const val VARIABLE_MATCH_SCORE = 1 - -/** - * A [ResourceTemplateMatcher] that matches resource URIs against an RFC 6570 Level 1 - * URI template by splitting both the URI and the template on `/` and comparing each - * segment in order. - * - * ### Supported template syntax - * - * Only RFC 6570 **Level 1** is supported: simple `{variable}` expressions where the - * entire path segment is a single variable. Operator expressions (`{+var}`, `{#var}`, - * `{.var}`, `{/var}`, etc.) and multi-variable expressions (`{a,b}`) are **not** - * recognized — segments containing them are treated as literals. - * - * ### Matching rules - * - * - The URI and template must have the same number of `/`-delimited segments. - * - Literal segments must match exactly (after percent-decoding the URI segment). - * - `{variable}` segments capture the percent-decoded URI segment value. - * - Query strings and fragments (`?`, `#`) are **not** stripped — they become part of - * the captured variable value for the segment that contains them. - * - * ### Specificity scoring - * - * When multiple templates match the same URI, each matched literal segment contributes - * 2 points and each variable capture contributes 1 point. The highest-scoring match wins. - * - * ### Safety limits - * - * - URIs longer than [maxUriLength] characters are rejected. - * - Templates and URIs with more than [maxDepth] segments are rejected. - * - * ### Security contract for handler authors - * - * Values in [MatchResult.variables] are attacker-controlled strings extracted from - * the incoming URI. They are percent-decoded (one pass only) before being returned. - * Handlers **must** treat them as untrusted input and validate or sanitize them - * before using them to construct file paths, database queries, downstream URLs, or - * any other security-sensitive operation. - * - * In particular: - * - A decoded value may contain `/`, `..`, null bytes (`\u0000`), `?`, or `#`. - * - `%252F` in the URI becomes `%2F` in the variable — exactly one decode pass is applied. - * - * ### Platform limitations - * - * Dot-segment normalization (resolving `..` and `.` in the URI path) is performed - * on JVM and JS targets using the platform-native URI parser. On native and WASM targets - * 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) - */ -public class PathSegmentTemplateMatcher @JvmOverloads constructor( - override val resourceTemplate: ResourceTemplate, - private val maxDepth: Int = MAX_DEPTH, - private val maxUriLength: Int = MAX_URL_LENGTH, -) : ResourceTemplateMatcher { - - public companion object { - /** - * A [ResourceTemplateMatcherFactory] that creates [PathSegmentTemplateMatcher] instances - * with default limits. Pass this to [io.modelcontextprotocol.kotlin.sdk.server.ServerOptions] - * to use path-segment matching, or supply a custom factory to override the matching strategy. - */ - @JvmStatic - public val factory: ResourceTemplateMatcherFactory = ResourceTemplateMatcherFactory { - PathSegmentTemplateMatcher(it) - } - - @JvmStatic - private val logger = KotlinLogging.logger {} - } - - private val templateParts: List - - // Maps segment index to variable name; indices absent from this map are literal segments. - private val variableIndices: Map - - init { - val template = resourceTemplate.uriTemplate - require(template.isNotBlank()) { "Resource template cannot be blank" } - templateParts = template.trim('/').split("/") - require(templateParts.size <= maxDepth) { - "Template is too complex (max depth=$maxDepth)" - } - - val vars = mutableMapOf() - for (i in templateParts.indices) { - val segment = templateParts[i] - if (segment.startsWith("{") && segment.endsWith("}")) { - val name = segment.removeSurrounding("{", "}").trim() - require(name.isNotEmpty()) { "Invalid variable name in template: $segment" } - vars[i] = name - } - } - variableIndices = vars.toImmutableMap() - } - - override fun match(resourceUri: String): MatchResult? { - if (resourceUri.length > maxUriLength) { - logger.debug { "URL is too long (max=$maxUriLength)" } - return null - } - - // Resolve dot-segments (e.g. /a/../b → /a/b) before splitting. - // Prevents traversal attacks on JVM/JS; no-op on native/WASM targets. - val normalized = normalizeUri(resourceUri) - - val urlParts = normalized.trim('/').split("/") - if (urlParts.size > maxDepth) { - logger.debug { "URI has too many segments (max=$maxDepth)" } - return null - } - if (urlParts.size != templateParts.size) return null - - val variables = mutableMapOf() - var score = 0 - - for (i in templateParts.indices) { - val urlSegment = urlParts[i].decodeURLPart() - val variableName = variableIndices[i] - if (variableName != null) { - variables[variableName] = urlSegment - score += VARIABLE_MATCH_SCORE - } else if (templateParts[i] == urlSegment) { - score += LITERAL_MATCH_SCORE - } else { - return null - } - } - - return MatchResult(variables = variables.toImmutableMap(), score = score) - } -} 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 deleted file mode 100644 index d99bfda..0000000 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcher.kt +++ /dev/null @@ -1,68 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.utils - -import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate - -/** - * Represents the result of a successful template match. - * - * A higher [score] indicates a more specific match. Implementations must ensure that - * literal segment matches contribute more to the score than variable captures, so that - * a fully literal template (e.g., `users/profile`) always outscores a parameterized - * template (e.g., `users/{id}`) for the same URI. - * - * @property variables A mapping of variable names in the template to their matched values in the URL. - * @property score A non-negative measure of match specificity — higher means more literal segments matched. - */ -public class MatchResult(public val variables: Map, public val score: Int) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is MatchResult) return false - return score == other.score && variables == other.variables - } - - override fun hashCode(): Int = 31 * variables.hashCode() + score - - override fun toString(): String = "MatchResult(variables=$variables, score=$score)" -} - -/** - * Matches resource URIs against a [ResourceTemplate]. - * - * Implementations parse a URI template once at construction time and then match - * 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. - */ -public interface ResourceTemplateMatcher { - - /** The template to match URIs against. */ - public val resourceTemplate: ResourceTemplate - - /** - * Matches a given resource URI against the defined resource template. - * - * @param resourceUri The resource URI to be matched. - * @return A [MatchResult] containing the mapping of variables and a match score - * if the URI matches the template, or null if no match is found. - */ - public fun match(resourceUri: String): MatchResult? -} - -/** - * Factory interface for creating instances of [ResourceTemplateMatcher]. - * - * A [ResourceTemplateMatcher] is used to match resource URIs against a given - * [ResourceTemplate], which adheres to the RFC 6570 URI Template specification. - * This factory abstracts the creation process of a matcher, allowing different - * implementations to define custom matching logic or safeguards (e.g., security - * measures or restrictions on template complexity). - */ -public fun interface ResourceTemplateMatcherFactory { - /** - * Creates a resource template matcher for the given resource template. - * - * @param resourceTemplate The resource template to create a matcher for. - * @return A [ResourceTemplateMatcher] instance that can match URIs against the provided template. - */ - public fun create(resourceTemplate: ResourceTemplate): ResourceTemplateMatcher -} diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.kt deleted file mode 100644 index 452333b..0000000 --- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.utils - -/** - * Normalizes [uri] by resolving dot-segments (`/a/b/../c` → `/a/c`) using the - * platform-native URI parser. - * - * On JVM uses [java.net.URI.normalize]. On JS uses the browser/Node.js `URL` API. - * On native and WASM targets [uri] is returned unchanged — no platform normalizer - * is available, so callers on those targets do not receive dot-segment resolution. - * - * Returns [uri] unchanged if it cannot be parsed or normalized. - * - * **Security note**: call this before splitting a URI into segments to prevent - * dot-segment traversal attacks (e.g. `public/../private/secret`). - */ -internal expect fun normalizeUri(uri: String): String diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaAudioContentSerializationTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaAudioContentSerializationTest.kt new file mode 100644 index 0000000..738539f --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaAudioContentSerializationTest.kt @@ -0,0 +1,33 @@ +package io.modelcontextprotocol.kotlin.sdk + +import io.kotest.assertions.json.shouldEqualJson +import io.modelcontextprotocol.kotlin.sdk.shared.McpJson +import kotlin.test.Test +import kotlin.test.assertEquals + +class OldSchemaAudioContentSerializationTest { + + private val audioContentJson = """ + { + "data": "base64-encoded-audio-data", + "mimeType": "audio/wav", + "type": "audio" + } + """.trimIndent() + + private val audioContent = AudioContent( + data = "base64-encoded-audio-data", + mimeType = "audio/wav", + ) + + @Test + fun `should serialize audio content`() { + McpJson.encodeToString(audioContent) shouldEqualJson audioContentJson + } + + @Test + fun `should deserialize audio content`() { + val content = McpJson.decodeFromString(audioContentJson) + assertEquals(expected = audioContent, actual = content) + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaCallToolResultUtilsTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaCallToolResultUtilsTest.kt new file mode 100644 index 0000000..b7bac6d --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaCallToolResultUtilsTest.kt @@ -0,0 +1,63 @@ +package io.modelcontextprotocol.kotlin.sdk + +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OldSchemaCallToolResultUtilsTest { + + @Test + fun testOkWithOnlyText() { + val content = "TextMessage" + val result = CallToolResult.ok(content) + + assertEquals(1, result.content.size) + assertEquals(content, (result.content[0] as TextContent).text) + assertFalse(result.isError == true) + assertEquals(EmptyJsonObject, result.meta) + } + + @Test + fun testOkWithMeta() { + val content = "TextMessageWithMeta" + val meta = buildJsonObject { + put("key1", JsonPrimitive("value1")) + put("key2", JsonPrimitive(42)) + } + val result = CallToolResult.ok(content, meta) + + assertEquals(1, result.content.size) + assertEquals(content, (result.content[0] as TextContent).text) + assertFalse(result.isError == true) + assertEquals(meta, result.meta) + } + + @Test + fun testErrorWithOnlyText() { + val content = "ErrorMessage" + val result = CallToolResult.error(content) + + assertEquals(1, result.content.size) + assertEquals(content, (result.content[0] as TextContent).text) + assertTrue(result.isError == true) + assertEquals(EmptyJsonObject, result.meta) + } + + @Test + fun testErrorWithMeta() { + val content = "ErrorMessageWithMeta" + val meta = buildJsonObject { + put("errorCode", JsonPrimitive(404)) + put("errorDetail", JsonPrimitive("资源未找到")) + } + val result = CallToolResult.error(content, meta) + + assertEquals(1, result.content.size) + assertEquals(content, (result.content[0] as TextContent).text) + assertTrue(result.isError == true) + assertEquals(meta, result.meta) + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaRequestSerializerTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaRequestSerializerTest.kt new file mode 100644 index 0000000..2360b5b --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaRequestSerializerTest.kt @@ -0,0 +1,329 @@ +package io.modelcontextprotocol.kotlin.sdk + +import io.modelcontextprotocol.kotlin.sdk.shared.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.CompleteResult +import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class OldSchemaRequestSerializerTest { + + // Client Result Tests + @Test + fun `should deserialize CreateMessageResult polymorphically`() { + val json = """{ + "model": "test-model", + "role": "assistant", + "content": { + "type": "text", + "text": "Hello" + }, + "stopReason": "endTurn" + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals("test-model", decoded.model) + assertEquals(Role.assistant, decoded.role) + assertEquals(StopReason.EndTurn, decoded.stopReason) + } + + @Test + fun `should deserialize ListRootsResult polymorphically`() { + val json = """{ + "roots": [ + { + "uri": "file:///test", + "name": "Test Root" + } + ] + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(1, decoded.roots.size) + assertEquals("file:///test", decoded.roots[0].uri) + assertEquals("Test Root", decoded.roots[0].name) + } + + @Test + fun `should deserialize CreateElicitationResult polymorphically`() { + val json = """{ + "action": "accept", + "content": { + "timezone": "Europe/Amsterdam" + } + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(CreateElicitationResult.Action.accept, decoded.action) + } + + // Server Result Tests + @Test + fun `should deserialize ListToolsResult polymorphically`() { + val json = """{ + "tools": [ + { + "name": "test-tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": {} + }, + "_meta": {} + } + ] + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(1, decoded.tools.size) + assertEquals("test-tool", decoded.tools[0].name) + assertEquals("A test tool", decoded.tools[0].description) + } + + @Test + fun `should deserialize ListResourcesResult polymorphically`() { + val json = """{ + "resources": [ + { + "uri": "file:///test.txt", + "name": "test.txt", + "mimeType": "text/plain" + } + ] + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(1, decoded.resources.size) + assertEquals("file:///test.txt", decoded.resources[0].uri) + assertEquals("test.txt", decoded.resources[0].name) + assertEquals("text/plain", decoded.resources[0].mimeType) + } + + @Test + fun `should deserialize ListResourceTemplatesResult polymorphically`() { + val json = """{ + "resourceTemplates": [ + { + "uriTemplate": "file:///templates/{name}", + "name": "template", + "mimeType": "text/plain" + } + ] + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(1, decoded.resourceTemplates.size) + assertEquals("file:///templates/{name}", decoded.resourceTemplates[0].uriTemplate) + assertEquals("template", decoded.resourceTemplates[0].name) + assertEquals("text/plain", decoded.resourceTemplates[0].mimeType) + } + + @Test + fun `should deserialize ListPromptsResult polymorphically`() { + val json = """{ + "prompts": [ + { + "name": "test-prompt", + "description": "A test prompt" + } + ] + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(1, decoded.prompts.size) + assertEquals("test-prompt", decoded.prompts[0].name) + assertEquals("A test prompt", decoded.prompts[0].description) + } + + @Test + fun `should deserialize InitializeResult polymorphically`() { + val json = """{ + "capabilities": { + "logging": {}, + "prompts": { + "listChanged": true + }, + "resources": { + "subscribe": true, + "listChanged": true + }, + "tools": { + "listChanged": true + } + }, + "protocolVersion": "2024-11-05", + "serverInfo": { + "name": "Test Server", + "version": "1.0.0" + } + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals("2024-11-05", decoded.protocolVersion) + assertEquals("Test Server", decoded.serverInfo.name) + assertEquals("1.0.0", decoded.serverInfo.version) + } + + @Test + fun `should deserialize GetPromptResult polymorphically`() { + val json = """{ + "description": "A test prompt", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Hello" + } + } + ] + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals("A test prompt", decoded.description) + assertEquals(1, decoded.messages.size) + assertEquals(Role.user, decoded.messages[0].role) + } + + @Test + fun `should deserialize CompleteResult polymorphically`() { + val json = """{ + "completion": { + "values": ["option1", "option2"], + "total": 2, + "hasMore": false + } + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(2, decoded.completion.values.size) + assertEquals("option1", decoded.completion.values[0]) + assertEquals("option2", decoded.completion.values[1]) + assertEquals(2, decoded.completion.total) + assertEquals(false, decoded.completion.hasMore) + } + + @Test + fun `should deserialize ReadResourceResult polymorphically`() { + val json = """{ + "contents": [ + { + "uri": "file:///test.txt", + "mimeType": "text/plain", + "text": "Hello World" + } + ] + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(1, decoded.contents.size) + assertIs(decoded.contents[0]) + val textContent = decoded.contents[0] as TextResourceContents + assertEquals("file:///test.txt", textContent.uri) + assertEquals("text/plain", textContent.mimeType) + assertEquals("Hello World", textContent.text) + } + + @Test + fun `should deserialize CallToolResult polymorphically`() { + val json = """{ + "content": [ + { + "type": "text", + "text": "Tool result" + } + ], + "isError": false + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(1, decoded.content.size) + assertIs(decoded.content[0]) + assertEquals("Tool result", (decoded.content[0] as TextContent).text) + assertEquals(false, decoded.isError) + } + + @Test + fun `should deserialize CompatibilityCallToolResult polymorphically`() { + val json = """{ + "toolResult": {"result": "Legacy tool result"}, + "content": [], + "isError": false + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(buildJsonObject { put("result", "Legacy tool result") }, decoded.toolResult) + } + + // Fallback Test + @Test + fun `should deserialize EmptyRequestResult for unknown result type`() { + val json = """{"unknownField": "value"}""" + + assertFailsWith { + McpJson.decodeFromString(json) + } + } + + @Test + fun `should handle empty JSON object`() { + val json = """{}""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + } + + // Priority Test - Client results should take precedence over server results + @Test + fun `should prioritize client results over server results when both match`() { + // This JSON could potentially match both CreateMessageResult (client) and CallToolResult (server) + // but CreateMessageResult should be selected first due to the order + val json = """{ + "model": "test-model", + "role": "assistant", + "content": { + "type": "text", + "text": "Test message" + }, + "stopReason": "endTurn" + }""" + + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals("test-model", decoded.model) + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaToolSerializationTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaToolSerializationTest.kt new file mode 100644 index 0000000..7e96dcf --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaToolSerializationTest.kt @@ -0,0 +1,507 @@ +package io.modelcontextprotocol.kotlin.sdk + +import io.kotest.assertions.json.shouldEqualJson +import io.modelcontextprotocol.kotlin.sdk.shared.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertEquals + +class OldSchemaToolSerializationTest { + + // see https://docs.anthropic.com/en/docs/build-with-claude/tool-use + /* language=json */ + private val getWeatherToolJson = """ + { + "name": "get_weather", + "title": "Get weather", + "description": "Get the current weather in a given location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + } + }, + "required": ["location"] + }, + "outputSchema": { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "description": "Temperature in celsius" + }, + "conditions": { + "type": "string", + "description": "Weather conditions description" + }, + "humidity": { + "type": "number", + "description": "Humidity percentage" + } + }, + "required": ["temperature", "conditions", "humidity"] + }, + "_meta": { + "_for_test_only": true + } + } + """.trimIndent() + + val getWeatherTool = Tool( + name = "get_weather", + title = "Get weather", + description = "Get the current weather in a given location", + annotations = null, + inputSchema = Tool.Input( + properties = buildJsonObject { + put( + "location", + buildJsonObject { + put("type", JsonPrimitive("string")) + put("description", JsonPrimitive("The city and state, e.g. San Francisco, CA")) + }, + ) + }, + required = listOf("location"), + ), + outputSchema = Tool.Output( + properties = buildJsonObject { + put( + "temperature", + buildJsonObject { + put("type", JsonPrimitive("number")) + put("description", JsonPrimitive("Temperature in celsius")) + }, + ) + put( + "conditions", + buildJsonObject { + put("type", JsonPrimitive("string")) + put("description", JsonPrimitive("Weather conditions description")) + }, + ) + put( + "humidity", + buildJsonObject { + put("type", JsonPrimitive("number")) + put("description", JsonPrimitive("Humidity percentage")) + }, + ) + }, + required = listOf("temperature", "conditions", "humidity"), + ), + _meta = buildJsonObject { + put("_for_test_only", JsonPrimitive(true)) + }, + ) + + //region Serialize + + @Test + fun `should serialize get_weather tool`() { + McpJson.encodeToString(getWeatherTool) shouldEqualJson getWeatherToolJson + } + + @Test + fun `should always serialize default value`() { + val json = Json(from = McpJson) { + encodeDefaults = false + } + json.encodeToString(getWeatherTool) shouldEqualJson getWeatherToolJson + } + + @Test + fun `should serialize get_weather tool without optional properties`() { + val weatherTool = createWeatherTool(name = "get_weather") + val expectedJson = createWeatherToolJson(name = "get_weather") + val actualJson = McpJson.encodeToString(weatherTool) + + actualJson shouldEqualJson expectedJson + } + + @Test + fun `should serialize get_weather tool with title optional property specified`() { + val weatherTool = createWeatherTool(name = "get_weather", title = "Get weather") + val expectedJson = createWeatherToolJson(name = "get_weather", title = "Get weather") + val actualJson = McpJson.encodeToString(weatherTool) + + actualJson shouldEqualJson expectedJson + } + + @Test + fun `should serialize get_weather tool with outputSchema optional property specified`() { + val weatherTool = createWeatherTool( + name = "get_weather", + outputSchema = Tool.Output( + properties = buildJsonObject { + put( + "temperature", + buildJsonObject { + put("type", JsonPrimitive("number")) + put("description", JsonPrimitive("Temperature in celsius")) + }, + ) + put( + "conditions", + buildJsonObject { + put("type", JsonPrimitive("string")) + put("description", JsonPrimitive("Weather conditions description")) + }, + ) + put( + "humidity", + buildJsonObject { + put("type", JsonPrimitive("number")) + put("description", JsonPrimitive("Humidity percentage")) + }, + ) + }, + required = listOf("temperature", "conditions", "humidity"), + ), + ) + val expectedJson = + createWeatherToolJson( + name = "get_weather", + outputSchema = """ + { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "description": "Temperature in celsius" + }, + "conditions": { + "type": "string", + "description": "Weather conditions description" + }, + "humidity": { + "type": "number", + "description": "Humidity percentage" + } + }, + "required": ["temperature", "conditions", "humidity"] + } + """.trimIndent(), + ) + + val actualJson = McpJson.encodeToString(weatherTool) + + actualJson shouldEqualJson expectedJson + } + + @Test + fun `should serialize get_weather tool with all properties specified`() { + val weatherTool = createWeatherTool( + name = "get_weather", + title = "Get weather", + outputSchema = Tool.Output( + properties = buildJsonObject { + put( + "temperature", + buildJsonObject { + put("type", JsonPrimitive("number")) + put("description", JsonPrimitive("Temperature in celsius")) + }, + ) + put( + "conditions", + buildJsonObject { + put("type", JsonPrimitive("string")) + put("description", JsonPrimitive("Weather conditions description")) + }, + ) + put( + "humidity", + buildJsonObject { + put("type", JsonPrimitive("number")) + put("description", JsonPrimitive("Humidity percentage")) + }, + ) + }, + required = listOf("temperature", "conditions", "humidity"), + ), + ) + val expectedJson = createWeatherToolJson( + name = "get_weather", + title = "Get weather", + outputSchema = """ + { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "description": "Temperature in celsius" + }, + "conditions": { + "type": "string", + "description": "Weather conditions description" + }, + "humidity": { + "type": "number", + "description": "Humidity percentage" + } + }, + "required": ["temperature", "conditions", "humidity"] + } + """.trimIndent(), + ) + + val actualJson = McpJson.encodeToString(weatherTool) + + actualJson shouldEqualJson expectedJson + } + + //endregion Serialize + + //region Deserialize + + @Test + fun `should deserialize get_weather tool`() { + val actualTool = McpJson.decodeFromString(getWeatherToolJson) + assertEquals(expected = getWeatherTool, actual = actualTool) + } + + @Test + fun `should deserialize get_weather tool without optional properties`() { + val toolJson = createWeatherToolJson(name = "get_weather") + val expectedTool = createWeatherTool(name = "get_weather") + val actualTool = McpJson.decodeFromString(toolJson) + + assertEquals(expected = expectedTool, actual = actualTool) + } + + @Test + fun `should deserialize get_weather tool with title properties specified`() { + val toolJson = createWeatherToolJson(name = "get_weather", title = "Get weather") + val expectedTool = createWeatherTool(name = "get_weather", title = "Get weather") + + val actualTool = McpJson.decodeFromString(toolJson) + + assertEquals(expected = expectedTool, actual = actualTool) + } + + @Test + fun `should deserialize get_weather tool with outputSchema optional property specified`() { + val toolJson = + createWeatherToolJson( + name = "get_weather", + outputSchema = """ + { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "description": "Temperature in celsius" + }, + "conditions": { + "type": "string", + "description": "Weather conditions description" + }, + "humidity": { + "type": "number", + "description": "Humidity percentage" + } + }, + "required": ["temperature", "conditions", "humidity"] + } + """.trimIndent(), + ) + + val expectedTool = createWeatherTool( + name = "get_weather", + outputSchema = Tool.Output( + properties = buildJsonObject { + put( + "temperature", + buildJsonObject { + put("type", JsonPrimitive("number")) + put("description", JsonPrimitive("Temperature in celsius")) + }, + ) + put( + "conditions", + buildJsonObject { + put("type", JsonPrimitive("string")) + put("description", JsonPrimitive("Weather conditions description")) + }, + ) + put( + "humidity", + buildJsonObject { + put("type", JsonPrimitive("number")) + put("description", JsonPrimitive("Humidity percentage")) + }, + ) + }, + required = listOf("temperature", "conditions", "humidity"), + ), + ) + + val actualTool = McpJson.decodeFromString(toolJson) + + assertEquals(expected = expectedTool, actual = actualTool) + } + + @Test + fun `should deserialize get_weather tool with all properties specified`() { + val toolJson = createWeatherToolJson( + name = "get_weather", + title = "Get weather", + outputSchema = """ + { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "description": "Temperature in celsius" + }, + "conditions": { + "type": "string", + "description": "Weather conditions description" + }, + "humidity": { + "type": "number", + "description": "Humidity percentage" + } + }, + "required": ["temperature", "conditions", "humidity"] + } + """.trimIndent(), + ) + + val expectedTool = createWeatherTool( + name = "get_weather", + title = "Get weather", + outputSchema = Tool.Output( + properties = buildJsonObject { + put( + "temperature", + buildJsonObject { + put("type", JsonPrimitive("number")) + put("description", JsonPrimitive("Temperature in celsius")) + }, + ) + put( + "conditions", + buildJsonObject { + put("type", JsonPrimitive("string")) + put("description", JsonPrimitive("Weather conditions description")) + }, + ) + put( + "humidity", + buildJsonObject { + put("type", JsonPrimitive("number")) + put("description", JsonPrimitive("Humidity percentage")) + }, + ) + }, + required = listOf("temperature", "conditions", "humidity"), + ), + ) + + val actualTool = McpJson.decodeFromString(toolJson) + + assertEquals(expected = expectedTool, actual = actualTool) + } + + //endregion Deserialize + + //region Private Methods + + private fun createWeatherToolJson( + name: String = "get_weather", + title: String? = null, + outputSchema: String? = null, + @Suppress("LocalVariableName") _meta: String? = null, + ): String { + val stringBuilder = StringBuilder() + + stringBuilder + .appendLine("{") + .append(" \"name\": \"$name\"") + + if (title != null) { + stringBuilder + .appendLine(",") + .append(" \"title\": \"$title\"") + } + + stringBuilder + .appendLine(",") + .append(" \"description\": \"Get the current weather in a given location\"") + .appendLine(",") + .append( + """ + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + } + }, + "required": ["location"] + } + """.trimIndent(), + ) + + if (outputSchema != null) { + stringBuilder + .appendLine(",") + .append( + """ + "outputSchema": $outputSchema + """.trimIndent(), + ) + } + + stringBuilder + .appendLine(",") + .append( + """ + "_meta": ${_meta ?: "{}"} + """.trimIndent(), + ) + + stringBuilder + .appendLine() + .appendLine("}") + + return stringBuilder.toString().trimIndent() + } + + private fun createWeatherTool( + name: String = "get_weather", + title: String? = null, + outputSchema: ToolSchema? = null, + @Suppress("LocalVariableName") _meta: JsonObject? = null, + ): io.modelcontextprotocol.kotlin.sdk.types.Tool = Tool( + name = name, + title = title, + description = "Get the current weather in a given location", + annotations = null, + inputSchema = Tool.Input( + properties = buildJsonObject { + put( + "location", + buildJsonObject { + put("type", JsonPrimitive("string")) + put("description", JsonPrimitive("The city and state, e.g. San Francisco, CA")) + }, + ) + }, + required = listOf("location"), + ), + outputSchema = outputSchema, + _meta = _meta ?: EmptyJsonObject, + ) + + //endregion Private Methods +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaTypesTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaTypesTest.kt new file mode 100644 index 0000000..f8ad0be --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/OldSchemaTypesTest.kt @@ -0,0 +1,450 @@ +package io.modelcontextprotocol.kotlin.sdk + +import io.modelcontextprotocol.kotlin.sdk.shared.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.CompleteRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.ContentTypes +import io.modelcontextprotocol.kotlin.sdk.types.ReferenceType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime + +class OldSchemaTypesTest { + + @Test + fun `should have correct latest protocol version`() { + assertNotEquals("", LATEST_PROTOCOL_VERSION) + assertEquals("2025-06-18", LATEST_PROTOCOL_VERSION) + } + + @Test + fun `should have correct supported protocol versions`() { + assertIs>(SUPPORTED_PROTOCOL_VERSIONS) + assertTrue(SUPPORTED_PROTOCOL_VERSIONS.contains(LATEST_PROTOCOL_VERSION)) + assertTrue(SUPPORTED_PROTOCOL_VERSIONS.contains("2024-11-05")) + assertEquals(3, SUPPORTED_PROTOCOL_VERSIONS.size) + } + + @Test + fun `should validate JSONRPC version constant`() { + assertEquals("2.0", JSONRPC_VERSION) + } + + // Reference Tests + @Test + fun `should validate ResourceTemplateReference`() { + val resourceRef = ResourceTemplateReference(uri = "file:///path/to/file.txt") + + assertEquals(ReferenceType.ResourceTemplate, resourceRef.type) + assertEquals("file:///path/to/file.txt", resourceRef.uri) + } + + @Test + fun `should serialize and deserialize ResourceTemplateReference correctly`() { + val resourceRef = ResourceTemplateReference(uri = "https://example.com/resource") + + val json = McpJson.encodeToString(resourceRef) + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(ReferenceType.ResourceTemplate, decoded.type) + assertEquals("https://example.com/resource", decoded.uri) + } + + @Test + fun `should validate PromptReference`() { + val promptRef = PromptReference(name = "greeting") + + assertEquals(ReferenceType.Prompt, promptRef.type) + assertEquals("greeting", promptRef.name) + } + + @Test + fun `should serialize and deserialize PromptReference correctly`() { + val promptRef = PromptReference(name = "test-prompt") + + val json = McpJson.encodeToString(promptRef) + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(ReferenceType.Prompt, decoded.type) + assertEquals("test-prompt", decoded.name) + } + + // PromptMessageContent Tests + @Test + fun `should validate text content`() { + val textContent = TextContent(text = "Hello, world!") + + assertEquals(ContentTypes.TEXT, textContent.type) + assertEquals("Hello, world!", textContent.text) + } + + @Test + fun `should serialize and deserialize text content correctly`() { + val textContent = TextContent(text = "Test message") + + val json = McpJson.encodeToString(textContent) + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(ContentTypes.TEXT, decoded.type) + assertEquals("Test message", decoded.text) + } + + @Test + fun `should validate image content`() { + val imageContent = ImageContent( + data = "aGVsbG8=", // base64 encoded "hello" + mimeType = "image/png", + ) + + assertEquals(ContentTypes.IMAGE, imageContent.type) + assertEquals("aGVsbG8=", imageContent.data) + assertEquals("image/png", imageContent.mimeType) + } + + @Test + fun `should serialize and deserialize image content correctly`() { + val imageContent = ImageContent( + data = "dGVzdA==", // base64 encoded "test" + mimeType = "image/jpeg", + ) + + val json = McpJson.encodeToString(imageContent) + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(ContentTypes.IMAGE, decoded.type) + assertEquals("dGVzdA==", decoded.data) + assertEquals("image/jpeg", decoded.mimeType) + } + + @Test + fun `should validate audio content`() { + val audioContent = AudioContent( + data = "aGVsbG8=", // base64 encoded "hello" + mimeType = "audio/mp3", + ) + + assertEquals(ContentTypes.AUDIO, audioContent.type) + assertEquals("aGVsbG8=", audioContent.data) + assertEquals("audio/mp3", audioContent.mimeType) + } + + @Test + fun `should serialize and deserialize audio content correctly`() { + val audioContent = AudioContent( + data = "YXVkaW8=", // base64 encoded "audio" + mimeType = "audio/wav", + ) + + val json = McpJson.encodeToString(audioContent) + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(ContentTypes.AUDIO, decoded.type) + assertEquals("YXVkaW8=", decoded.data) + assertEquals("audio/wav", decoded.mimeType) + } + + @Test + fun `should validate embedded resource content`() { + val resource = TextResourceContents( + text = "File contents", + uri = "file:///path/to/file.txt", + mimeType = "text/plain", + ) + val embeddedResource = EmbeddedResource(resource = resource) + + assertEquals(ContentTypes.EMBEDDED_RESOURCE, embeddedResource.type) + assertEquals(resource, embeddedResource.resource) + } + + @Test + fun `should serialize and deserialize embedded resource content correctly`() { + val resource = BlobResourceContents( + blob = "YmluYXJ5ZGF0YQ==", + uri = "file:///path/to/binary.dat", + mimeType = "application/octet-stream", + ) + val embeddedResource = EmbeddedResource(resource = resource) + + val json = McpJson.encodeToString(embeddedResource) + val decoded = McpJson.decodeFromString(json) + + assertIs(decoded) + assertEquals(ContentTypes.EMBEDDED_RESOURCE, decoded.type) + assertIs(decoded.resource) + val decodedBlob = decoded.resource + assertEquals("YmluYXJ5ZGF0YQ==", decodedBlob.blob) + assertEquals("file:///path/to/binary.dat", decodedBlob.uri) + assertEquals("application/octet-stream", decodedBlob.mimeType) + } + + // PromptMessage Tests + @Test + fun `should validate prompt message with text content`() { + val textContent = TextContent(text = "Hello, assistant!") + val promptMessage = PromptMessage( + role = Role.user, + content = textContent, + ) + + assertEquals(Role.user, promptMessage.role) + assertEquals(textContent, promptMessage.content) + assertEquals(ContentTypes.TEXT, promptMessage.content.type) + } + + @Test + fun `should validate prompt message with embedded resource`() { + val resource = TextResourceContents( + text = "Primary application entry point", + uri = "file:///project/src/main.rs", + mimeType = "text/x-rust", + ) + val embeddedResource = EmbeddedResource(resource = resource) + val promptMessage = PromptMessage( + role = Role.assistant, + content = embeddedResource, + ) + + assertEquals(Role.assistant, promptMessage.role) + assertEquals(ContentTypes.EMBEDDED_RESOURCE, promptMessage.content.type) + val content = promptMessage.content as EmbeddedResource + val textResource = content.resource as TextResourceContents + assertEquals("Primary application entry point", textResource.text) + assertEquals("file:///project/src/main.rs", textResource.uri) + assertEquals("text/x-rust", textResource.mimeType) + } + + @OptIn(ExperimentalTime::class) + @Test + fun `should serialize and deserialize annotations correctly`() { + val annotations = Annotations( + audience = listOf(Role.assistant), + lastModified = "2025-06-18T00:00:00Z", + priority = 0.5, + ) + + val json = McpJson.encodeToString(annotations) + val decoded = McpJson.decodeFromString(json) + + assertEquals(listOf(Role.assistant), decoded.audience) + assertEquals("2025-06-18T00:00:00Z", decoded.lastModified) + assertEquals(0.5, decoded.priority) + } + + @Test + fun `should serialize and deserialize prompt message correctly`() { + val imageContent = ImageContent( + data = "aW1hZ2VkYXRh", // base64 encoded "imagedata" + mimeType = "image/png", + ) + val promptMessage = PromptMessage( + role = Role.assistant, + content = imageContent, + ) + + val json = McpJson.encodeToString(promptMessage) + val decoded = McpJson.decodeFromString(json) + + assertEquals(Role.assistant, decoded.role) + assertIs(decoded.content) + val decodedContent = decoded.content + assertEquals("aW1hZ2VkYXRh", decodedContent.data) + assertEquals("image/png", decodedContent.mimeType) + } + + // CallToolResult Tests + @Test + fun `should validate tool result with multiple content types`() { + val toolResult = CallToolResult( + content = listOf( + TextContent(text = "Found the following files:"), + EmbeddedResource( + resource = TextResourceContents( + text = "fn main() {}", + uri = "file:///project/src/main.rs", + mimeType = "text/x-rust", + ), + ), + EmbeddedResource( + resource = TextResourceContents( + text = "pub mod lib;", + uri = "file:///project/src/lib.rs", + mimeType = "text/x-rust", + ), + ), + ), + ) + + assertEquals(3, toolResult.content.size) + assertEquals(ContentTypes.TEXT, toolResult.content[0].type) + assertEquals(ContentTypes.EMBEDDED_RESOURCE, toolResult.content[1].type) + assertEquals(ContentTypes.EMBEDDED_RESOURCE, toolResult.content[2].type) + assertEquals(null, toolResult.isError) + } + + @Test + fun `should validate empty content array with default`() { + val toolResult = CallToolResult(content = emptyList()) + + assertEquals(0, toolResult.content.size) + assertEquals(null, toolResult.isError) + } + + @Test + fun `should serialize and deserialize CallToolResult correctly`() { + val toolResult = CallToolResult( + content = listOf( + TextContent(text = "Operation completed"), + ImageContent(data = "aW1hZ2U=", mimeType = "image/png"), + ), + isError = false, + ) + + val json = McpJson.encodeToString(toolResult) + val decoded = McpJson.decodeFromString(json) + + assertEquals(2, decoded.content.size) + assertIs(decoded.content[0]) + assertIs(decoded.content[1]) + assertEquals(false, decoded.isError) + } + + // CompleteRequest Tests + @Test + fun `should validate CompleteRequest with prompt reference`() { + val request = CompleteRequest( + CompleteRequestParams( + ref = PromptReference(name = "greeting"), + argument = CompleteRequest.Argument(name = "name", value = "A"), + ), + ) + + assertEquals("completion/complete", request.method.value) + assertIs(request.params.ref) + val promptRef = request.params.ref + assertEquals("greeting", promptRef.name) + assertEquals("name", request.params.argument.name) + assertEquals("A", request.params.argument.value) + } + + @Test + fun `should validate CompleteRequest with resource reference`() { + val request = CompleteRequest( + CompleteRequestParams( + ref = ResourceTemplateReference(uri = "github://repos/{owner}/{repo}"), + argument = CompleteRequest.Argument(name = "repo", value = "t"), + ), + ) + + assertEquals("completion/complete", request.method.value) + assertIs(request.params.ref) + val resourceRef = request.params.ref + assertEquals("github://repos/{owner}/{repo}", resourceRef.uri) + assertEquals("repo", request.params.argument.name) + assertEquals("t", request.params.argument.value) + } + + @Test + fun `should serialize and deserialize CompleteRequest correctly`() { + val request = CompleteRequest( + CompleteRequestParams( + ref = PromptReference(name = "test"), + argument = CompleteRequest.Argument(name = "arg", value = ""), + ), + ) + + val json = McpJson.encodeToString(request) + val decoded = McpJson.decodeFromString(json) + + assertEquals("completion/complete", decoded.method.value) + assertIs(decoded.params.ref) + val promptRef = decoded.params.ref + assertEquals("test", promptRef.name) + assertEquals("arg", decoded.params.argument.name) + assertEquals("", decoded.params.argument.value) + } + + @Test + fun `should validate CompleteRequest with complex URIs`() { + val request = CompleteRequest( + CompleteRequestParams( + ref = ResourceTemplateReference(uri = "api://v1/{tenant}/{resource}/{id}"), + argument = CompleteRequest.Argument(name = "id", value = "123"), + ), + ) + + val resourceRef = request.params.ref as ResourceTemplateReference + assertEquals("api://v1/{tenant}/{resource}/{id}", resourceRef.uri) + assertEquals("id", request.params.argument.name) + assertEquals("123", request.params.argument.value) + } + + // InitializeResult Tests + @Test + fun `should create InitializeResult with default instructions`() { + val serverInfo = Implementation(name = "test-server", version = "1.0.0") + val result = InitializeResult( + capabilities = ServerCapabilities(), + serverInfo = serverInfo, + ) + + assertEquals(LATEST_PROTOCOL_VERSION, result.protocolVersion) + assertEquals(serverInfo, result.serverInfo) + assertEquals(null, result.instructions) + } + + @Test + fun `should create InitializeResult with custom instructions`() { + val serverInfo = Implementation(name = "test-server", version = "1.0.0") + val instructions = "Use this server to perform calculations. Call the 'add' tool to add numbers." + val result = InitializeResult( + capabilities = ServerCapabilities(), + serverInfo = serverInfo, + instructions = instructions, + ) + + assertEquals(LATEST_PROTOCOL_VERSION, result.protocolVersion) + assertEquals(serverInfo, result.serverInfo) + assertEquals(instructions, result.instructions) + } + + @Test + fun `should serialize and deserialize InitializeResult with instructions`() { + val serverInfo = Implementation(name = "test-server", version = "1.0.0") + val instructions = "This server provides file system access. Use the 'read' tool to read files." + val result = InitializeResult( + capabilities = ServerCapabilities(), + serverInfo = serverInfo, + instructions = instructions, + ) + + val json = McpJson.encodeToString(result) + val decoded = McpJson.decodeFromString(json) + + assertEquals(LATEST_PROTOCOL_VERSION, decoded.protocolVersion) + assertEquals(serverInfo, decoded.serverInfo) + assertEquals(instructions, decoded.instructions) + } + + @Test + fun `should serialize and deserialize InitializeResult without instructions`() { + val serverInfo = Implementation(name = "test-server", version = "1.0.0") + val result = InitializeResult( + capabilities = ServerCapabilities(), + serverInfo = serverInfo, + ) + + val json = McpJson.encodeToString(result) + val decoded = McpJson.decodeFromString(json) + + assertEquals(LATEST_PROTOCOL_VERSION, decoded.protocolVersion) + assertEquals(serverInfo, decoded.serverInfo) + assertEquals(null, decoded.instructions) + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/models/OldSchemaProgressNotificationsTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/models/OldSchemaProgressNotificationsTest.kt new file mode 100644 index 0000000..29bc12d --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/models/OldSchemaProgressNotificationsTest.kt @@ -0,0 +1,71 @@ +package io.modelcontextprotocol.kotlin.sdk.models + +import io.kotest.matchers.shouldBe +import io.modelcontextprotocol.kotlin.sdk.ProgressNotification +import io.modelcontextprotocol.kotlin.sdk.RequestId +import io.modelcontextprotocol.kotlin.sdk.shared.McpJson +import kotlin.test.Test + +class OldSchemaProgressNotificationsTest { + + /** + * https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress#progress-flow + */ + @Test + fun `Read ProgressNotifications with string token`() { + //language=json + val json = """ + { + "jsonrpc": "2.0", + "method": "notifications/progress", + "params": { + "progressToken": "abc123", + "progress": 50, + "total": 100, + "message": "Reticulating splines..." + } + } + """.trimIndent() + + val result = McpJson.decodeFromString(json) + + result shouldBe ProgressNotification( + params = ProgressNotification.Params( + progressToken = RequestId.StringId("abc123"), + progress = 50.0, + message = "Reticulating splines...", + total = 100.0, + ), + ) + } + + /** + * https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress#progress-flow + */ + @Test + fun `Read ProgressNotifications with integer token`() { + //language=json + val json = """ + { + "jsonrpc": "2.0", + "method": "notifications/progress", + "params": { + "progressToken": 100500, + "progress": 50, + "total": 100, + "message": "Reticulating splines..." + } + } + """.trimIndent() + + val result = McpJson.decodeFromString(json) + result shouldBe ProgressNotification( + params = ProgressNotification.Params( + progressToken = RequestId.NumberId(100500), + progress = 50.0, + message = "Reticulating splines...", + total = 100.0, + ), + ) + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/OldSchemaReadBufferTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/OldSchemaReadBufferTest.kt new file mode 100644 index 0000000..0311c13 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/OldSchemaReadBufferTest.kt @@ -0,0 +1,62 @@ +package io.modelcontextprotocol.kotlin.sdk.shared + +import io.ktor.utils.io.charsets.Charsets +import io.ktor.utils.io.core.toByteArray +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.JSONRPCNotification +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class OldSchemaReadBufferTest { + private val testMessage: JSONRPCMessage = JSONRPCNotification(method = "foobar") + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + @Test + fun `should have no messages after initialization`() { + val readBuffer = ReadBuffer() + assertNull(readBuffer.readMessage()) + } + + @Test + fun `should only yield a message after a newline`() { + val readBuffer = ReadBuffer() + + // Append message without a newline + val messageBytes = json.encodeToString(testMessage).encodeToByteArray() + readBuffer.append(messageBytes) + assertNull(readBuffer.readMessage()) + + // Append a newline and verify message is now available + readBuffer.append("\n".encodeToByteArray()) + assertEquals(testMessage, readBuffer.readMessage()) + assertNull(readBuffer.readMessage()) + } + + @Test + fun `skip empty line`() { + val readBuffer = ReadBuffer() + readBuffer.append("\n".toByteArray()) + assertNull(readBuffer.readMessage()) + } + + @Test + fun `should be reusable after clearing`() { + val readBuffer = ReadBuffer() + + readBuffer.append("foobar".toByteArray(Charsets.UTF_8)) + readBuffer.clear() + assertNull(readBuffer.readMessage()) + + val messageJson = serializeMessage(testMessage) + readBuffer.append(messageJson.toByteArray(Charsets.UTF_8)) + readBuffer.append("\n".toByteArray(Charsets.UTF_8)) + val message = readBuffer.readMessage() + assertEquals(testMessage, message) + } +} 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..142c2af 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,48 @@ 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) {} + override fun assertNotificationCapability(method: Method) {} + override fun assertRequestHandlerCapability(method: Method) {} +} - 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")) + override suspend fun start() {} - seenByParameter shouldNotBe null - seenByParameter?.requestId shouldBe RequestId(7L) - seenByParameter?.method shouldBe Method.Custom("custom/echo") - // the SAME instance flows through both delivery paths - seenAmbient shouldBe seenByParameter + override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { + sentMessages.send(message) } - @Test - fun `extra method resolves defined methods to Defined entries`() = runTest { - protocol.connect(transport) - - var seenMethod: Method? = null - protocol.setRequestHandler(Method.Defined.Ping) { _, extra -> - seenMethod = extra.method - EmptyResult() - } - transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "ping")) - - seenMethod shouldBe Method.Defined.Ping + override suspend fun close() { + onCloseCallback?.invoke() } - @Test - fun `extra sendNotification stamps relatedRequestId`() = runTest { - protocol.connect(transport) - - protocol.fallbackRequestHandler = { _, extra -> - extra.sendNotification( - ProgressNotification( - ProgressNotificationParams(progressToken = ProgressToken(1L), progress = 0.5), - ), - ) - EmptyResult() - } - - transport.deliver(JSONRPCRequest(id = RequestId(42L), method = "custom/progressing")) - - val sent = transport.sentWithOptions.first { it.first is JSONRPCNotification } - sent.second?.relatedRequestId shouldBe RequestId(42L) + override fun onClose(block: () -> Unit) { + onCloseCallback = block } - @Test - fun `extra sendRequest stamps relatedRequestId and preserves caller options`() = runTest { - protocol.connect(transport) + override fun onError(block: (Throwable) -> Unit) {} - 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 onMessage(block: suspend (JSONRPCMessage) -> Unit) { + onMessageCallback = block } - @Test - fun `currentRequestHandlerExtra returns null outside handlers`() = runTest { - currentRequestHandlerExtra() shouldBe null + suspend fun awaitRequest(): JSONRPCRequest { + val message = sentMessages.receive() + return message as? JSONRPCRequest + ?: error("Expected JSONRPCRequest but received ${message::class.simpleName}") } - @Test - fun `connect while already connected throws IllegalStateException`() = runTest { - protocol.connect(transport) - shouldThrow { - protocol.connect(RecordingTransport()) - } - } - - @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 - } - - @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..a49ff3d 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 { @@ -33,8 +31,6 @@ class ReadBufferTest { val messageBytes = json.encodeToString(testMessage).encodeToByteArray() readBuffer.append(messageBytes) assertNull(readBuffer.readMessage()) - readBuffer.append("\r".encodeToByteArray()) - assertNull(readBuffer.readMessage()) // Append a newline and verify message is now available readBuffer.append("\n".encodeToByteArray()) @@ -49,20 +45,6 @@ class ReadBufferTest { assertNull(readBuffer.readMessage()) } - @Test - fun `skip blank line`() { - val readBuffer = ReadBuffer() - readBuffer.append(" \n".toByteArray()) - assertNull(readBuffer.readMessage()) - } - - @Test - fun `skip invalid json line`() { - val readBuffer = ReadBuffer() - readBuffer.append(" {ah=oh\n".toByteArray()) - assertNull(readBuffer.readMessage()) - } - @Test fun `should be reusable after clearing`() { val readBuffer = ReadBuffer() @@ -77,35 +59,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..c288cca 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,15 +1,10 @@ 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 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 { @@ -20,16 +15,14 @@ class CapabilitiesTest { name = "test-server", version = "1.0.0", ) - verifySerialization( - implementation, - McpJson, - """ + val json = McpJson.encodeToString(implementation) + + json shouldEqualJson """ { "name": "test-server", "version": "1.0.0" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -43,10 +36,9 @@ class CapabilitiesTest { Icon(src = "https://example.com/icon.png"), ), ) - verifySerialization( - implementation, - McpJson, - """ + val json = McpJson.encodeToString(implementation) + + json shouldEqualJson """ { "name": "test-server", "version": "1.0.0", @@ -56,8 +48,7 @@ class CapabilitiesTest { {"src": "https://example.com/icon.png"} ] } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -71,7 +62,7 @@ class CapabilitiesTest { } """.trimIndent() - val implementation = verifyDeserialization(McpJson, json) + val implementation = McpJson.decodeFromString(json) assertEquals("test-server", implementation.name) assertEquals("2.1.3-beta", implementation.version) @@ -88,7 +79,7 @@ class CapabilitiesTest { } """.trimIndent() - val implementation = verifyDeserialization(McpJson, json) + val implementation = McpJson.decodeFromString(json) assertEquals("minimal-server", implementation.name) assertEquals("0.1.0", implementation.version) @@ -106,7 +97,13 @@ class CapabilitiesTest { websiteUrl = "https://test.com", ) - verifySerializationRoundTrip(original, McpJson) + val json = McpJson.encodeToString(original) + val decoded = McpJson.decodeFromString(json) + + assertEquals(original.name, decoded.name) + assertEquals(original.version, decoded.version) + assertEquals(original.title, decoded.title) + assertEquals(original.websiteUrl, decoded.websiteUrl) } // ClientCapabilities tests @@ -121,17 +118,15 @@ class CapabilitiesTest { @Test fun `should serialize ClientCapabilities with sampling`() { val capabilities = ClientCapabilities( - sampling = ClientCapabilities.Sampling(), + sampling = ClientCapabilities.sampling, ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "sampling": {} } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -139,15 +134,13 @@ class CapabilitiesTest { val capabilities = ClientCapabilities( roots = ClientCapabilities.Roots(), ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "roots": {} } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -155,17 +148,15 @@ class CapabilitiesTest { val capabilities = ClientCapabilities( roots = ClientCapabilities.Roots(listChanged = true), ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "roots": { "listChanged": true } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -173,62 +164,13 @@ class CapabilitiesTest { val capabilities = ClientCapabilities( elicitation = ClientCapabilities.elicitation, ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "elicitation": {} } - """.trimIndent(), - ) - } - - @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(), - ) + """.trimIndent() } @Test @@ -244,10 +186,9 @@ class CapabilitiesTest { val capabilities = ClientCapabilities( experimental = experimental, ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "experimental": { "customFeature": { @@ -255,8 +196,7 @@ class CapabilitiesTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -264,20 +204,15 @@ class CapabilitiesTest { val experimental = buildJsonObject { put("feature1", buildJsonObject { put("enabled", true) }) } - val extensions = mapOf( - "io.modelcontextprotocol/ui" to EmptyJsonObject, - ) val capabilities = ClientCapabilities( - sampling = ClientCapabilities.Sampling(), + sampling = ClientCapabilities.sampling, roots = ClientCapabilities.Roots(listChanged = true), elicitation = ClientCapabilities.elicitation, experimental = experimental, - extensions = extensions, ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "sampling": {}, "roots": { @@ -288,13 +223,9 @@ class CapabilitiesTest { "feature1": { "enabled": true } - }, - "extensions": { - "io.modelcontextprotocol/ui": {} } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -309,40 +240,39 @@ class CapabilitiesTest { } """.trimIndent() - val capabilities = verifyDeserialization(McpJson, json) + val capabilities = McpJson.decodeFromString(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 fun `should deserialize empty ClientCapabilities from JSON`() { val json = "{}" - val capabilities = verifyDeserialization(McpJson, json) + val capabilities = McpJson.decodeFromString(json) assertNull(capabilities.sampling) 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) + val json = McpJson.encodeToString(original) + val decoded = McpJson.decodeFromString(json) + + assertEquals(original.sampling, decoded.sampling) + assertEquals(original.roots?.listChanged, decoded.roots?.listChanged) + assertEquals(original.elicitation, decoded.elicitation) } // ServerCapabilities tests @@ -359,15 +289,13 @@ class CapabilitiesTest { val capabilities = ServerCapabilities( tools = ServerCapabilities.Tools(), ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "tools": {} } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -375,17 +303,15 @@ class CapabilitiesTest { val capabilities = ServerCapabilities( tools = ServerCapabilities.Tools(listChanged = true), ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "tools": { "listChanged": true } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -393,15 +319,13 @@ class CapabilitiesTest { val capabilities = ServerCapabilities( resources = ServerCapabilities.Resources(), ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "resources": {} } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -409,17 +333,15 @@ class CapabilitiesTest { val capabilities = ServerCapabilities( resources = ServerCapabilities.Resources(listChanged = true), ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "resources": { "listChanged": true } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -427,17 +349,15 @@ class CapabilitiesTest { val capabilities = ServerCapabilities( resources = ServerCapabilities.Resources(subscribe = true), ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "resources": { "subscribe": true } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -448,18 +368,16 @@ class CapabilitiesTest { subscribe = false, ), ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "resources": { "listChanged": true, "subscribe": false } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -467,15 +385,13 @@ class CapabilitiesTest { val capabilities = ServerCapabilities( prompts = ServerCapabilities.Prompts(), ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "prompts": {} } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -483,17 +399,15 @@ class CapabilitiesTest { val capabilities = ServerCapabilities( prompts = ServerCapabilities.Prompts(listChanged = false), ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "prompts": { "listChanged": false } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -501,15 +415,13 @@ class CapabilitiesTest { val capabilities = ServerCapabilities( logging = ServerCapabilities.Logging, ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "logging": {} } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -517,41 +429,13 @@ class CapabilitiesTest { val capabilities = ServerCapabilities( completions = ServerCapabilities.Completions, ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "completions": {} } - """.trimIndent(), - ) - } - - @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(), - ) + """.trimIndent() } @Test @@ -567,10 +451,9 @@ class CapabilitiesTest { val capabilities = ServerCapabilities( experimental = experimental, ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "experimental": { "customCapability": { @@ -578,8 +461,7 @@ class CapabilitiesTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -587,9 +469,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,12 +479,10 @@ class CapabilitiesTest { logging = ServerCapabilities.Logging, completions = ServerCapabilities.Completions, experimental = experimental, - extensions = extensions, ) - verifySerialization( - capabilities, - McpJson, - """ + val json = McpJson.encodeToString(capabilities) + + json shouldEqualJson """ { "tools": { "listChanged": true @@ -623,13 +500,9 @@ class CapabilitiesTest { "feature1": { "enabled": true } - }, - "extensions": { - "io.modelcontextprotocol/ui": {} } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -651,7 +524,7 @@ class CapabilitiesTest { } """.trimIndent() - val capabilities = verifyDeserialization(McpJson, json) + val capabilities = McpJson.decodeFromString(json) assertEquals(true, capabilities.tools?.listChanged) assertEquals(false, capabilities.resources?.listChanged) @@ -665,7 +538,7 @@ class CapabilitiesTest { fun `should deserialize empty ServerCapabilities from JSON`() { val json = "{}" - val capabilities = verifyDeserialization(McpJson, json) + val capabilities = McpJson.decodeFromString(json) assertNull(capabilities.tools) assertNull(capabilities.resources) @@ -673,7 +546,6 @@ class CapabilitiesTest { assertNull(capabilities.logging) assertNull(capabilities.completions) assertNull(capabilities.experimental) - assertNull(capabilities.extensions) } @Test @@ -686,12 +558,16 @@ class CapabilitiesTest { ), prompts = ServerCapabilities.Prompts(listChanged = true), logging = ServerCapabilities.Logging, - extensions = mapOf( - "io.modelcontextprotocol/ui" to EmptyJsonObject, - ), ) - verifySerializationRoundTrip(original, McpJson) + val json = McpJson.encodeToString(original) + val decoded = McpJson.decodeFromString(json) + + assertEquals(original.tools?.listChanged, decoded.tools?.listChanged) + assertEquals(original.resources?.listChanged, decoded.resources?.listChanged) + assertEquals(original.resources?.subscribe, decoded.resources?.subscribe) + assertEquals(original.prompts?.listChanged, decoded.prompts?.listChanged) + assertEquals(original.logging, decoded.logging) } // Additional nested type tests @@ -703,7 +579,7 @@ class CapabilitiesTest { } """.trimIndent() - val capabilities = verifyDeserialization(McpJson, json) + val capabilities = McpJson.decodeFromString(json) assertNull(capabilities.roots?.listChanged) } @@ -718,7 +594,7 @@ class CapabilitiesTest { } """.trimIndent() - val capabilities = verifyDeserialization(McpJson, json) + val capabilities = McpJson.decodeFromString(json) assertNull(capabilities.tools?.listChanged) assertNull(capabilities.resources?.listChanged) @@ -736,11 +612,10 @@ class CapabilitiesTest { } """.trimIndent() - // Sampling is now a typed struct; unknown fields are ignored on deserialization val capabilities = McpJson.decodeFromString(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 @@ -753,194 +628,9 @@ class CapabilitiesTest { } """.trimIndent() - val capabilities = verifyDeserialization(McpJson, json) + val capabilities = McpJson.decodeFromString(json) // 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/CommonTypeTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CommonTypeTest.kt index a29a19c..679d14a 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CommonTypeTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CommonTypeTest.kt @@ -1,12 +1,10 @@ package io.modelcontextprotocol.kotlin.sdk.types -import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder -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 +import io.kotest.assertions.json.shouldEqualJson import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue @@ -14,31 +12,29 @@ class CommonTypeTest { @Test fun `should have correct latest protocol version`() { - LATEST_PROTOCOL_VERSION shouldBe "2025-11-25" + assertNotEquals("", LATEST_PROTOCOL_VERSION) + assertEquals("2025-06-18", LATEST_PROTOCOL_VERSION) } @Test fun `should have correct supported protocol versions`() { - SUPPORTED_PROTOCOL_VERSIONS shouldContainExactlyInAnyOrder listOf( - LATEST_PROTOCOL_VERSION, - "2025-06-18", - "2025-03-26", - "2024-11-05", - ) + assertIs>(SUPPORTED_PROTOCOL_VERSIONS) + assertTrue(SUPPORTED_PROTOCOL_VERSIONS.contains(LATEST_PROTOCOL_VERSION)) + assertTrue(SUPPORTED_PROTOCOL_VERSIONS.contains("2025-03-26")) + assertTrue(SUPPORTED_PROTOCOL_VERSIONS.contains("2024-11-05")) + assertEquals(3, SUPPORTED_PROTOCOL_VERSIONS.size) } @Test fun `should serialize Icon with minimal fields`() { val icon = Icon(src = "https://example.com/icon.png") - verifySerialization( - icon, - McpJson, - """ + val json = McpJson.encodeToString(icon) + + json shouldEqualJson """ { "src": "https://example.com/icon.png" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -49,18 +45,16 @@ class CommonTypeTest { sizes = listOf("48x48", "96x96"), theme = Icon.Theme.Light, ) - verifySerialization( - icon, - McpJson, - """ + val json = McpJson.encodeToString(icon) + + json shouldEqualJson """ { "src": "https://example.com/icon.png", "mimeType": "image/png", "sizes": ["48x48", "96x96"], "theme": "light" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -74,7 +68,7 @@ class CommonTypeTest { } """.trimIndent() - val icon = verifyDeserialization(McpJson, json) + val icon = McpJson.decodeFromString(json) assertEquals("https://example.com/icon.png", icon.src) assertEquals("image/png", icon.mimeType) @@ -86,7 +80,7 @@ class CommonTypeTest { fun `should deserialize Icon with minimal fields from JSON`() { val json = """{"src": "https://example.com/icon.png"}""" - val icon = verifyDeserialization(McpJson, json) + val icon = McpJson.decodeFromString(json) assertEquals("https://example.com/icon.png", icon.src) assertNull(icon.mimeType) @@ -117,8 +111,8 @@ class CommonTypeTest { @Test fun `should deserialize Role from lowercase strings`() { - val user = verifyDeserialization(McpJson, "\"user\"") - val assistant = verifyDeserialization(McpJson, "\"assistant\"") + val user = McpJson.decodeFromString("\"user\"") + val assistant = McpJson.decodeFromString("\"assistant\"") assertEquals(Role.User, user) assertEquals(Role.Assistant, assistant) @@ -132,32 +126,28 @@ class CommonTypeTest { lastModified = "2025-01-12T15:00:58Z", ) - verifySerialization( - annotations, - McpJson, - """ + val json = McpJson.encodeToString(annotations) + + json shouldEqualJson """ { "audience": ["user", "assistant"], "priority": 0.8, "lastModified": "2025-01-12T15:00:58Z" } - """.trimIndent(), - ) + """.trimIndent() } @Test fun `should serialize Annotations with minimal fields`() { val annotations = Annotations(priority = 0.5) - verifySerialization( - annotations, - McpJson, - """ + val json = McpJson.encodeToString(annotations) + + json shouldEqualJson """ { "priority": 0.5 } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -170,7 +160,7 @@ class CommonTypeTest { } """.trimIndent() - val annotations = verifyDeserialization(McpJson, json) + val annotations = McpJson.decodeFromString(json) assertEquals(listOf(Role.User), annotations.audience) assertEquals(0.9, annotations.priority) @@ -185,6 +175,11 @@ class CommonTypeTest { lastModified = "2025-01-12T15:00:58Z", ) - verifySerializationRoundTrip(original, McpJson) + val json = McpJson.encodeToString(original) + val decoded = McpJson.decodeFromString(json) + + assertEquals(original.audience, decoded.audience) + assertEquals(original.priority, decoded.priority) + assertEquals(original.lastModified, decoded.lastModified) } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CompletionTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CompletionTest.kt index 03a76e4..9a27ac5 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CompletionTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/CompletionTest.kt @@ -1,8 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.types import io.kotest.assertions.json.shouldEqualJson -import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization -import io.modelcontextprotocol.kotlin.test.utils.verifySerialization import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put @@ -27,10 +25,9 @@ class CompletionTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "completion/complete", "params": { @@ -44,8 +41,7 @@ class CompletionTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -71,10 +67,9 @@ class CompletionTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "completion/complete", "params": { @@ -97,8 +92,7 @@ class CompletionTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -127,7 +121,7 @@ class CompletionTest { } """.trimIndent() - val request = verifyDeserialization(McpJson, json) + val request = McpJson.decodeFromString(json) assertEquals(Method.Defined.CompletionComplete, request.method) val params = request.params @@ -154,10 +148,9 @@ class CompletionTest { }, ) - verifySerialization( - result, - McpJson, - """ + val json = McpJson.encodeToString(result) + + json shouldEqualJson """ { "completion": { "values": [ @@ -171,8 +164,7 @@ class CompletionTest { "source": "cache" } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -190,7 +182,7 @@ class CompletionTest { } """.trimIndent() - val result = verifyDeserialization(McpJson, json) + val result = McpJson.decodeFromString(json) assertEquals(listOf("README.md", "CONTRIBUTING.md"), result.completion.values) assertEquals(2, result.completion.total) assertEquals(false, result.completion.hasMore) @@ -217,7 +209,7 @@ class CompletionTest { } """.trimIndent() - val decoded = verifyDeserialization(McpJson, json) + val decoded = McpJson.decodeFromString(json) assertEquals(listOf("foo"), decoded.completion.values) assertNull(decoded.completion.total) assertNull(decoded.completion.hasMore) 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..2290dce 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,40 +1,28 @@ 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 io.kotest.assertions.json.shouldEqualJson 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!") - verifySerialization( - content, - McpJson, - """ + val json = McpJson.encodeToString(content) + + json shouldEqualJson """ { "type": "text", "text": "Hello, MCP!" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -45,10 +33,9 @@ class ContentTest { meta = buildJsonObject { put("origin", "assistant") }, ) - verifySerialization( - content, - McpJson, - """ + val json = McpJson.encodeToString(content) + + json shouldEqualJson """ { "type": "text", "text": "Need help?", @@ -59,8 +46,12 @@ class ContentTest { "origin": "assistant" } } - """.trimIndent(), - ) + """.trimIndent() + + val decoded = McpJson.decodeFromString(json) as TextContent + assertEquals("Need help?", decoded.text) + assertEquals(listOf(Role.User), decoded.annotations?.audience) + assertEquals("assistant", decoded.meta?.get("origin")?.jsonPrimitive?.content) } @Test @@ -70,17 +61,15 @@ class ContentTest { mimeType = "image/png", ) - verifySerialization( - content, - McpJson, - """ + val json = McpJson.encodeToString(content) + + json shouldEqualJson """ { "type": "image", "data": "aW1hZ2VEYXRh", "mimeType": "image/png" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -94,7 +83,7 @@ class ContentTest { } """.trimIndent() - val content = verifyDeserialization(McpJson, json) as ImageContent + val content = McpJson.decodeFromString(json) as ImageContent assertEquals("Zm9vYmFy", content.data) assertEquals("image/jpeg", content.mimeType) assertEquals(0.6, content.annotations?.priority) @@ -109,10 +98,9 @@ class ContentTest { meta = buildJsonObject { put("durationMs", 1200) }, ) - verifySerialization( - content, - McpJson, - """ + val json = McpJson.encodeToString(content) + + json shouldEqualJson """ { "type": "audio", "data": "YXVkaW9kYXRh", @@ -121,8 +109,7 @@ class ContentTest { "durationMs": 1200 } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -136,7 +123,7 @@ class ContentTest { } """.trimIndent() - val content = verifyDeserialization(McpJson, json) as AudioContent + val content = McpJson.decodeFromString(json) as AudioContent assertEquals("audio/mpeg", content.mimeType) assertEquals("YmF6", content.data) assertEquals("user", content.meta?.get("speaker")?.jsonPrimitive?.content) @@ -149,17 +136,15 @@ class ContentTest { uri = "file:///workspace/README.md", ) - verifySerialization( - resource, - McpJson, - """ + val json = McpJson.encodeToString(resource) + + json shouldEqualJson """ { "type": "resource_link", "name": "README", "uri": "file:///workspace/README.md" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -176,10 +161,9 @@ class ContentTest { meta = buildJsonObject { put("etag", "1234") }, ) - verifySerialization( - resource, - McpJson, - """ + val json = McpJson.encodeToString(resource) + + json shouldEqualJson """ { "type": "resource_link", "name": "README", @@ -200,8 +184,7 @@ class ContentTest { "etag": "1234" } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -216,7 +199,7 @@ class ContentTest { } """.trimIndent() - val resource = verifyDeserialization(McpJson, json) as ResourceLink + val resource = McpJson.decodeFromString(json) as ResourceLink assertEquals("config", resource.name) assertEquals("file:///config.yaml", resource.uri) assertEquals(512, resource.size) @@ -237,10 +220,9 @@ class ContentTest { meta = buildJsonObject { put("source", "analysis") }, ) - verifySerialization( - embedded, - McpJson, - """ + val json = McpJson.encodeToString(embedded) + + json shouldEqualJson """ { "type": "resource", "resource": { @@ -258,8 +240,7 @@ class ContentTest { "source": "analysis" } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -276,7 +257,7 @@ class ContentTest { } """.trimIndent() - val embedded = verifyDeserialization(McpJson, json) as EmbeddedResource + val embedded = McpJson.decodeFromString(json) as EmbeddedResource assertEquals(ContentTypes.EMBEDDED_RESOURCE, embedded.type) assertNull(embedded.annotations) assertEquals("base64", embedded.meta?.get("encoding")?.jsonPrimitive?.content) @@ -311,139 +292,10 @@ class ContentTest { ] """.trimIndent() - val content = verifyDeserialization>(McpJson, json) + val content = McpJson.decodeFromString>(json) assertEquals(3, content.size) assertIs(content[0]) 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..c59c23f 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 @@ -1,48 +1,62 @@ package io.modelcontextprotocol.kotlin.sdk.types import io.kotest.assertions.json.shouldEqualJson -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.matchers.nulls.shouldBeNull -import io.kotest.matchers.nulls.shouldNotBeNull -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.json.boolean import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject 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.assertNotNull +import kotlin.test.assertNull class ElicitationTest { - // ── Form mode ─────────────────────────────────────────────────────── - @Test - fun `should serialize form mode ElicitRequest`() { + fun `should serialize ElicitRequest with requested schema`() { val request = ElicitRequest( - ElicitRequestFormParams( + ElicitRequestParams( message = "Provide repository details", requestedSchema = ElicitRequestParams.RequestedSchema( - properties = mapOf( - "owner" to StringSchema(description = "GitHub organization"), - "repository" to StringSchema(title = "Repository name"), - "private" to BooleanSchema(description = "Is the repository private?"), - ), + properties = buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "string") + put("description", "GitHub organization") + }, + ) + put( + "repository", + buildJsonObject { + put("type", "string") + put("title", "Repository name") + }, + ) + put( + "private", + buildJsonObject { + put("type", "boolean") + put("description", "Is the repository private?") + }, + ) + }, required = listOf("owner", "repository"), ), meta = RequestMeta( - buildJsonObject { put("progressToken", "token-42") }, + buildJsonObject { + put("progressToken", "token-42") + }, ), ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "elicitation/create", "params": { @@ -50,16 +64,16 @@ class ElicitationTest { "requestedSchema": { "properties": { "owner": { - "description": "GitHub organization", - "type": "string" + "type": "string", + "description": "GitHub organization" }, "repository": { - "title": "Repository name", - "type": "string" + "type": "string", + "title": "Repository name" }, "private": { - "description": "Is the repository private?", - "type": "boolean" + "type": "boolean", + "description": "Is the repository private?" } }, "required": ["owner", "repository"], @@ -67,51 +81,29 @@ class ElicitationTest { }, "_meta": { "progressToken": "token-42" - }, - "mode": "form" - } - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize form mode ElicitRequest with explicit mode`() { - val json = """ - { - "method": "elicitation/create", - "params": { - "mode": "form", - "message": "Enter your name", - "requestedSchema": { - "properties": { - "name": {"type": "string", "title": "Full name"} - }, - "type": "object" } } } """.trimIndent() - - val request = verifyDeserialization(McpJson, json) - request.method shouldBe Method.Defined.ElicitationCreate - request.message shouldBe "Enter your name" - - val params = request.params.shouldBeInstanceOf() - val nameSchema = params.requestedSchema.properties["name"].shouldBeInstanceOf() - nameSchema.title shouldBe "Full name" } @Test - fun `should deserialize form mode ElicitRequest without mode field`() { + fun `should deserialize ElicitRequest from JSON`() { val json = """ { "method": "elicitation/create", "params": { - "message": "Enter your name", + "message": "Tell us about the repository", "requestedSchema": { "properties": { - "name": {"type": "string"} + "name": { + "type": "string", + "title": "Repository name" + }, + "stars": { + "type": "number", + "description": "GitHub stars" + } }, "type": "object" } @@ -120,92 +112,27 @@ class ElicitationTest { """.trimIndent() val request = McpJson.decodeFromString(json) - request.params.shouldBeInstanceOf() - request.params.message shouldBe "Enter your name" + assertEquals(Method.Defined.ElicitationCreate, request.method) + + val params = request.params + assertEquals("Tell us about the repository", params.message) + assertNull(params.meta) + + val schema = params.requestedSchema + assertEquals("object", schema.type) + assertNull(schema.required) + + val nameDefinition = schema.properties["name"]?.jsonObject + assertNotNull(nameDefinition) + assertEquals("string", nameDefinition["type"]?.jsonPrimitive?.content) + assertEquals("Repository name", nameDefinition["title"]?.jsonPrimitive?.content) + + val starsDefinition = schema.properties["stars"]?.jsonObject + assertNotNull(starsDefinition) + assertEquals("number", starsDefinition["type"]?.jsonPrimitive?.content) + assertEquals("GitHub stars", starsDefinition["description"]?.jsonPrimitive?.content) } - // ── URL mode ──────────────────────────────────────────────────────── - - @Test - fun `should serialize URL mode ElicitRequest`() { - val request = ElicitRequest( - ElicitRequestURLParams( - message = "Please provide your API key", - elicitationId = "550e8400-e29b-41d4-a716-446655440000", - url = "https://mcp.example.com/ui/set_api_key", - ), - ) - - verifySerialization( - request, - McpJson, - """ - { - "method": "elicitation/create", - "params": { - "message": "Please provide your API key", - "elicitationId": "550e8400-e29b-41d4-a716-446655440000", - "url": "https://mcp.example.com/ui/set_api_key", - "mode": "url" - } - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize URL mode ElicitRequest`() { - val json = """ - { - "method": "elicitation/create", - "params": { - "mode": "url", - "message": "Authorize access", - "elicitationId": "id-123", - "url": "https://example.com/auth" - } - } - """.trimIndent() - - val request = verifyDeserialization(McpJson, json) - val params = request.params.shouldBeInstanceOf() - params.message shouldBe "Authorize access" - params.elicitationId shouldBe "id-123" - params.url shouldBe "https://example.com/auth" - } - - // ── Deprecated compat ─────────────────────────────────────────────── - - @Test - fun `deprecated factory function should create ElicitRequestFormParams`() { - val schema = ElicitRequestParams.RequestedSchema( - properties = mapOf("name" to StringSchema()), - ) - val params = ElicitRequestParams(message = "Test", requestedSchema = schema) - params.shouldBeInstanceOf() - params.message shouldBe "Test" - params.requestedSchema shouldBe schema - } - - @Test - fun `deprecated requestedSchema should return schema for form mode`() { - val schema = ElicitRequestParams.RequestedSchema( - properties = mapOf("x" to BooleanSchema()), - ) - val request = ElicitRequest(ElicitRequestFormParams(message = "m", requestedSchema = schema)) - request.requestedSchema shouldBe schema - } - - @Test - fun `deprecated requestedSchema should return null for URL mode`() { - val request = ElicitRequest( - ElicitRequestURLParams(message = "m", elicitationId = "id", url = "https://example.com"), - ) - request.requestedSchema.shouldBeNull() - } - - // ── ElicitResult ──────────────────────────────────────────────────── - @Test fun `should serialize and deserialize accept result with content`() { val result = ElicitResult( @@ -236,16 +163,18 @@ class ElicitationTest { } """.trimIndent() - val decoded = verifyDeserialization(McpJson, json) - decoded.action shouldBe ElicitResult.Action.Accept + val decoded = McpJson.decodeFromString(json) + assertEquals(ElicitResult.Action.Accept, decoded.action) - val content = decoded.content.shouldNotBeNull() - content["repository"]?.jsonPrimitive?.content shouldBe "kotlin-sdk" - content["stars"]?.jsonPrimitive?.int shouldBe 128 - content["private"]?.jsonPrimitive?.boolean shouldBe false + val content = decoded.content + assertNotNull(content) + assertEquals("kotlin-sdk", content["repository"]?.jsonPrimitive?.content) + assertEquals(128, content["stars"]?.jsonPrimitive?.int) + assertEquals(false, content["private"]?.jsonPrimitive?.boolean) - val meta = decoded.meta.shouldNotBeNull() - meta["submittedAt"]?.jsonPrimitive?.content shouldBe "2025-01-12T15:00:58Z" + val meta = decoded.meta + assertNotNull(meta) + assertEquals("2025-01-12T15:00:58Z", meta["submittedAt"]?.jsonPrimitive?.content) } @Test @@ -259,101 +188,19 @@ class ElicitationTest { } """.trimIndent() - val result = verifyDeserialization(McpJson, json) - result.action shouldBe ElicitResult.Action.Decline - result.content.shouldBeNull() - result.meta?.get("reason")?.jsonPrimitive?.content shouldBe "User skipped" + val result = McpJson.decodeFromString(json) + assertEquals(ElicitResult.Action.Decline, result.action) + assertNull(result.content) + assertEquals("User skipped", result.meta?.get("reason")?.jsonPrimitive?.content) } @Test fun `should require content only for accept action`() { - shouldThrow { + assertFailsWith { ElicitResult( action = ElicitResult.Action.Cancel, content = buildJsonObject { put("value", "ignored") }, ) } } - - // ── 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..6af7c4e 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 @@ -1,7 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.types -import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization -import io.modelcontextprotocol.kotlin.test.utils.verifySerialization +import io.kotest.assertions.json.shouldEqualJson import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject @@ -20,7 +19,7 @@ class InitializeTest { InitializeRequestParams( protocolVersion = "2024-11-05", capabilities = ClientCapabilities( - sampling = ClientCapabilities.Sampling(), + sampling = ClientCapabilities.sampling, roots = ClientCapabilities.Roots(listChanged = true), elicitation = ClientCapabilities.elicitation, experimental = buildJsonObject { @@ -42,10 +41,9 @@ class InitializeTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "initialize", "params": { @@ -75,8 +73,7 @@ class InitializeTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -109,7 +106,7 @@ class InitializeTest { } """.trimIndent() - val request = verifyDeserialization(McpJson, json) + val request = McpJson.decodeFromString(json) assertEquals(Method.Defined.Initialize, request.method) val params = request.params @@ -152,10 +149,9 @@ class InitializeTest { meta = buildJsonObject { put("issuedAt", "2025-01-12T15:00:58Z") }, ) - verifySerialization( - result, - McpJson, - """ + val json = McpJson.encodeToString(result) + + json shouldEqualJson """ { "protocolVersion": "2024-11-05", "capabilities": { @@ -184,36 +180,7 @@ class InitializeTest { "issuedAt": "2025-01-12T15:00:58Z" } } - """.trimIndent(), - ) - } - - @Test - fun `should serialize InitializeResult without meta - meta must be omitted`() { - val result = InitializeResult( - protocolVersion = "2025-03-26", - capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = false)), - serverInfo = Implementation(name = "test-server", version = "1.0.0"), - ) - - verifySerialization( - result, - McpJson, - """ - { - "protocolVersion": "2025-03-26", - "capabilities": { - "tools": { - "listChanged": false - } - }, - "serverInfo": { - "name": "test-server", - "version": "1.0.0" - } - } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -234,7 +201,7 @@ class InitializeTest { } """.trimIndent() - val result = verifyDeserialization(McpJson, json) + val result = McpJson.decodeFromString(json) assertEquals("2025-03-26", result.protocolVersion) assertEquals("result-server", result.serverInfo.name) diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonRpcTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonRpcTest.kt index 7de360d..ce02cc0 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonRpcTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonRpcTest.kt @@ -3,8 +3,6 @@ package io.modelcontextprotocol.kotlin.sdk.types import io.kotest.assertions.json.shouldEqualJson import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeSameInstanceAs -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.int @@ -45,8 +43,7 @@ class JsonRpcTest { @Test fun `should convert JSONRPCRequest to Request`() { - val jsonRpc = verifyDeserialization( - McpJson, + val jsonRpc = McpJson.decodeFromString( """ { "id": 17, @@ -104,8 +101,7 @@ class JsonRpcTest { @Test fun `should convert JSONRPCNotification to Notification`() { - val jsonRpc = verifyDeserialization( - McpJson, + val jsonRpc = McpJson.decodeFromString( """ { "method": "notifications/message", @@ -151,10 +147,9 @@ class JsonRpcTest { }, ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "id": "req-1", "method": "tools/list", @@ -164,8 +159,7 @@ class JsonRpcTest { }, "jsonrpc": "2.0" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -181,7 +175,7 @@ class JsonRpcTest { } """.trimIndent() - val request = verifyDeserialization(McpJson, json) + val request = McpJson.decodeFromString(json) val id = request.id assertIs(id) @@ -202,10 +196,9 @@ class JsonRpcTest { }, ) - verifySerialization( - notification, - McpJson, - """ + val json = McpJson.encodeToString(notification) + + json shouldEqualJson """ { "method": "notifications/log", "params": { @@ -214,8 +207,7 @@ class JsonRpcTest { }, "jsonrpc": "2.0" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -231,7 +223,7 @@ class JsonRpcTest { } """.trimIndent() - val notification = verifyDeserialization(McpJson, json) + val notification = McpJson.decodeFromString(json) assertEquals("notifications/progress", notification.method) val params = notification.params?.jsonObject assertNotNull(params) @@ -248,10 +240,9 @@ class JsonRpcTest { ), ) - verifySerialization( - response, - McpJson, - """ + val json = McpJson.encodeToString(response) + + json shouldEqualJson """ { "id": "call-1", "result": { @@ -261,28 +252,7 @@ class JsonRpcTest { }, "jsonrpc": "2.0" } - """.trimIndent(), - ) - } - - @Test - fun `should serialize JSONRPCResponse with EmptyResult - null meta must be omitted`() { - val response = JSONRPCResponse( - id = RequestId("call-2"), - result = EmptyResult(), // meta = null by default - ) - - verifySerialization( - response, - McpJson, - """ - { - "id": "call-2", - "result": {}, - "jsonrpc": "2.0" - } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -299,7 +269,7 @@ class JsonRpcTest { } """.trimIndent() - val response = verifyDeserialization(McpJson, json) + val response = McpJson.decodeFromString(json) val result = response.result assertIs(result) assertEquals(RequestId.NumberId(7L), response.id) @@ -319,10 +289,9 @@ class JsonRpcTest { ), ) - verifySerialization( - error, - McpJson, - """ + val json = McpJson.encodeToString(error) + + json shouldEqualJson """ { "id": 99, "error": { @@ -334,8 +303,7 @@ class JsonRpcTest { }, "jsonrpc": "2.0" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -355,7 +323,7 @@ class JsonRpcTest { } """.trimIndent() - val error = verifyDeserialization(McpJson, json) + val error = McpJson.decodeFromString(json) assertEquals(RequestId("req-404"), error.id) assertEquals(RPCError.ErrorCode.INVALID_PARAMS, error.error.code) assertEquals("Invalid params", error.error.message) @@ -380,7 +348,7 @@ class JsonRpcTest { } """.trimIndent() - val message = verifyDeserialization(McpJson, json) + val message = McpJson.decodeFromString(json) val request = assertIs(message) assertEquals("sampling/create", request.method) val params = request.params?.jsonObject @@ -405,7 +373,7 @@ class JsonRpcTest { } """.trimIndent() - val message = verifyDeserialization(McpJson, json) + val message = McpJson.decodeFromString(json) val error = assertIs(message) assertEquals(RPCError.ErrorCode.REQUEST_TIMEOUT, error.error.code) val data = error.error.data?.jsonObject @@ -420,10 +388,9 @@ class JsonRpcTest { params = buildJsonObject { put("message", "Polymorphic") }, ) - verifySerialization( - message, - McpJson, - """ + val json = McpJson.encodeToString(message) + + json shouldEqualJson """ { "method": "notifications/log", "params": { @@ -431,8 +398,7 @@ class JsonRpcTest { }, "jsonrpc": "2.0" } - """.trimIndent(), - ) + """.trimIndent() } @Test diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/LoggingTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/LoggingTest.kt index bc03a47..2b8c61a 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/LoggingTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/LoggingTest.kt @@ -1,7 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.types -import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization -import io.modelcontextprotocol.kotlin.test.utils.verifySerialization +import io.kotest.assertions.json.shouldEqualJson import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject @@ -48,10 +47,9 @@ class LoggingTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "logging/setLevel", "params": { @@ -61,8 +59,7 @@ class LoggingTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -79,7 +76,7 @@ class LoggingTest { } """.trimIndent() - val request = verifyDeserialization(McpJson, json) + val request = McpJson.decodeFromString(json) assertEquals(Method.Defined.LoggingSetLevel, request.method) assertEquals(LoggingLevel.Alert, request.params.level) assertEquals(ProgressToken(1001), request.params.meta?.progressToken) @@ -96,10 +93,9 @@ class LoggingTest { ), ) - verifySerialization( - notification, - McpJson, - """ + val json = McpJson.encodeToString(notification) + + json shouldEqualJson """ { "method": "notifications/message", "params": { @@ -113,8 +109,7 @@ class LoggingTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -129,7 +124,7 @@ class LoggingTest { } """.trimIndent() - val notification = verifyDeserialization(McpJson, json) + val notification = McpJson.decodeFromString(json) assertEquals(Method.Defined.NotificationsMessage, notification.method) val params = notification.params @@ -158,7 +153,7 @@ class LoggingTest { } """.trimIndent() - val notification = verifyDeserialization(McpJson, json) + val notification = McpJson.decodeFromString(json) val params = notification.params assertEquals(LoggingLevel.Critical, params.level) val data = params.data.jsonObject diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/NotificationTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/NotificationTest.kt index 500b303..bbd209d 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/NotificationTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/NotificationTest.kt @@ -1,7 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.types -import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization -import io.modelcontextprotocol.kotlin.test.utils.verifySerialization +import io.kotest.assertions.json.shouldEqualJson import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put @@ -21,10 +20,9 @@ class NotificationTest { ), ) - verifySerialization( - notification, - McpJson, - """ + val json = McpJson.encodeToString(notification) + + json shouldEqualJson """ { "method": "notifications/cancelled", "params": { @@ -35,8 +33,7 @@ class NotificationTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -51,7 +48,7 @@ class NotificationTest { } """.trimIndent() - val notification = verifyDeserialization(McpJson, json) + val notification = McpJson.decodeFromString(json) val params = notification.params assertEquals(Method.Defined.NotificationsCancelled, notification.method) @@ -68,10 +65,9 @@ class NotificationTest { ), ) - verifySerialization( - notification, - McpJson, - """ + val json = McpJson.encodeToString(notification) + + json shouldEqualJson """ { "method": "notifications/initialized", "params": { @@ -80,8 +76,7 @@ class NotificationTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -92,7 +87,7 @@ class NotificationTest { } """.trimIndent() - val notification = verifyDeserialization(McpJson, json) + val notification = McpJson.decodeFromString(json) assertEquals(Method.Defined.NotificationsInitialized, notification.method) assertNull(notification.params) @@ -110,10 +105,9 @@ class NotificationTest { ), ) - verifySerialization( - notification, - McpJson, - """ + val json = McpJson.encodeToString(notification) + + json shouldEqualJson """ { "method": "notifications/progress", "params": { @@ -126,8 +120,7 @@ class NotificationTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -142,7 +135,7 @@ class NotificationTest { } """.trimIndent() - val notification = verifyDeserialization(McpJson, json) + val notification = McpJson.decodeFromString(json) val params = notification.params assertEquals(Method.Defined.NotificationsProgress, notification.method) @@ -162,10 +155,9 @@ class NotificationTest { ), ) - verifySerialization( - notification, - McpJson, - """ + val json = McpJson.encodeToString(notification) + + json shouldEqualJson """ { "method": "notifications/resources/updated", "params": { @@ -175,8 +167,7 @@ class NotificationTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -193,7 +184,7 @@ class NotificationTest { } """.trimIndent() - val notification = verifyDeserialization(McpJson, json) + val notification = McpJson.decodeFromString(json) val params = notification.params assertEquals(Method.Defined.NotificationsResourcesUpdated, notification.method) @@ -209,10 +200,9 @@ class NotificationTest { ), ) - verifySerialization( - notification, - McpJson, - """ + val json = McpJson.encodeToString(notification) + + json shouldEqualJson """ { "method": "notifications/prompts/list_changed", "params": { @@ -221,8 +211,7 @@ class NotificationTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -233,7 +222,7 @@ class NotificationTest { } """.trimIndent() - val notification = verifyDeserialization(McpJson, json) + val notification = McpJson.decodeFromString(json) assertEquals(Method.Defined.NotificationsPromptsListChanged, notification.method) assertNull(notification.params) @@ -247,10 +236,9 @@ class NotificationTest { ), ) - verifySerialization( - notification, - McpJson, - """ + val json = McpJson.encodeToString(notification) + + json shouldEqualJson """ { "method": "notifications/resources/list_changed", "params": { @@ -259,8 +247,7 @@ class NotificationTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -271,10 +258,9 @@ class NotificationTest { ), ) - verifySerialization( - notification, - McpJson, - """ + val json = McpJson.encodeToString(notification) + + json shouldEqualJson """ { "method": "notifications/roots/list_changed", "params": { @@ -283,8 +269,7 @@ class NotificationTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -295,10 +280,9 @@ class NotificationTest { ), ) - verifySerialization( - notification, - McpJson, - """ + val json = McpJson.encodeToString(notification) + + json shouldEqualJson """ { "method": "notifications/tools/list_changed", "params": { @@ -307,164 +291,6 @@ class NotificationTest { } } } - """.trimIndent(), - ) - } - - @Test - fun `should serialize ElicitationCompleteNotification with meta`() { - val notification = ElicitationCompleteNotification( - ElicitationCompleteNotificationParams( - elicitationId = "elicit-42", - meta = buildJsonObject { put("source", "oauth-flow") }, - ), - ) - - verifySerialization( - notification, - McpJson, - """ - { - "method": "notifications/elicitation/complete", - "params": { - "elicitationId": "elicit-42", - "_meta": { - "source": "oauth-flow" - } - } - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize ElicitationCompleteNotification`() { - val json = """ - { - "method": "notifications/elicitation/complete", - "params": { - "elicitationId": "elicit-99" - } - } """.trimIndent() - - val notification = verifyDeserialization(McpJson, json) - val params = notification.params - - assertEquals(Method.Defined.NotificationsElicitationComplete, notification.method) - assertEquals("elicit-99", params.elicitationId) - assertNull(params.meta) - } - - @Test - fun `should serialize TaskStatusNotification with all fields`() { - val notification = TaskStatusNotification( - TaskStatusNotificationParams( - taskId = "task-1", - status = TaskStatus.Working, - statusMessage = "Processing data", - createdAt = "2025-01-01T00:00:00Z", - lastUpdatedAt = "2025-01-01T00:01:00Z", - ttl = 60000, - pollInterval = 5000, - meta = buildJsonObject { put("source", "worker-1") }, - ), - ) - - verifySerialization( - notification, - McpJson, - """ - { - "method": "notifications/tasks/status", - "params": { - "taskId": "task-1", - "status": "working", - "statusMessage": "Processing data", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:01:00Z", - "ttl": 60000, - "pollInterval": 5000, - "_meta": { - "source": "worker-1" - } - } - } - """.trimIndent(), - ) - } - - @Test - fun `should serialize TaskStatusNotification with minimal fields`() { - val notification = TaskStatusNotification( - TaskStatusNotificationParams( - taskId = "task-2", - status = TaskStatus.Completed, - createdAt = "2025-01-01T00:00:00Z", - lastUpdatedAt = "2025-01-01T00:02:00Z", - ttl = null, - ), - ) - - verifySerialization( - notification, - McpJson, - """ - { - "method": "notifications/tasks/status", - "params": { - "taskId": "task-2", - "status": "completed", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:02:00Z" - } - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize TaskStatusNotification`() { - val json = """ - { - "method": "notifications/tasks/status", - "params": { - "taskId": "task-3", - "status": "failed", - "statusMessage": "Connection lost", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:03:00Z", - "ttl": 30000, - "pollInterval": 1000 - } - } - """.trimIndent() - - val notification = verifyDeserialization(McpJson, json) - val params = notification.params!! - - assertEquals(Method.Defined.NotificationsTasksStatus, notification.method) - assertEquals("task-3", params.taskId) - assertEquals(TaskStatus.Failed, params.status) - assertEquals("Connection lost", params.statusMessage) - assertEquals("2025-01-01T00:00:00Z", params.createdAt) - assertEquals("2025-01-01T00:03:00Z", params.lastUpdatedAt) - assertEquals(30000L, params.ttl) - assertEquals(1000L, params.pollInterval) - assertNull(params.meta) - } - - @Test - fun `should deserialize TaskStatusNotification without params`() { - val json = """ - { - "method": "notifications/tasks/status" - } - """.trimIndent() - - val notification = verifyDeserialization(McpJson, json) - - assertEquals(Method.Defined.NotificationsTasksStatus, notification.method) - assertNull(notification.params) } } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PingRequestTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PingRequestTest.kt index 72bddb0..070d58e 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PingRequestTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PingRequestTest.kt @@ -1,7 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.types -import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization -import io.modelcontextprotocol.kotlin.test.utils.verifySerialization +import io.kotest.assertions.json.shouldEqualJson import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject @@ -18,15 +17,13 @@ class PingRequestTest { fun `should serialize PingRequest without params`() { val request = PingRequest() - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "ping" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -39,10 +36,9 @@ class PingRequestTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "ping", "params": { @@ -51,8 +47,7 @@ class PingRequestTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -89,7 +84,7 @@ class PingRequestTest { } """.trimIndent() - val jsonRpc = verifyDeserialization(McpJson, json) + val jsonRpc = McpJson.decodeFromString(json) val request = jsonRpc.fromJSON() val pingRequest = assertIs(request) 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 deleted file mode 100644 index 904454f..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PrimitiveSchemaTest.kt +++ /dev/null @@ -1,479 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.types - -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.kotest.matchers.types.shouldBeInstanceOf -import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization -import io.modelcontextprotocol.kotlin.test.utils.verifySerialization -import kotlinx.serialization.SerializationException -import kotlin.test.Test - -class PrimitiveSchemaTest { - - // ── StringSchema ──────────────────────────────────────────────────── - - @Test - fun `should serialize StringSchema with minimal fields`() { - verifySerialization( - StringSchema() as PrimitiveSchemaDefinition, - McpJson, - """{"type": "string"}""", - ) - } - - @Test - fun `should serialize StringSchema with all fields`() { - val schema = StringSchema( - title = "Email address", - description = "User email", - minLength = 5, - maxLength = 100, - format = StringSchemaFormat.Email, - default = "user@example.com", - ) - - verifySerialization( - schema as PrimitiveSchemaDefinition, - McpJson, - """ - { - "title": "Email address", - "description": "User email", - "minLength": 5, - "maxLength": 100, - "format": "email", - "default": "user@example.com", - "type": "string" - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize StringSchema from JSON`() { - val json = """ - { - "type": "string", - "title": "Name", - "format": "uri" - } - """.trimIndent() - - val schema = verifyDeserialization(McpJson, json) - schema.shouldBeInstanceOf() - schema.title shouldBe "Name" - schema.format shouldBe StringSchemaFormat.Uri - } - - // ── StringSchemaFormat ────────────────────────────────────────────── - - @Test - fun `should serialize all StringSchemaFormat values`() { - val cases = mapOf( - StringSchemaFormat.Email to "email", - StringSchemaFormat.Uri to "uri", - StringSchemaFormat.Date to "date", - StringSchemaFormat.DateTime to "date-time", - ) - for ((format, expectedValue) in cases) { - val schema = StringSchema(format = format) - val json = McpJson.encodeToString(schema) - json shouldContain "\"format\":\"$expectedValue\"" - } - } - - // ── IntegerSchema ─────────────────────────────────────────────────── - - @Test - fun `should serialize IntegerSchema with minimal fields`() { - verifySerialization( - IntegerSchema() as PrimitiveSchemaDefinition, - McpJson, - """{"type": "integer"}""", - ) - } - - @Test - fun `should serialize IntegerSchema with all fields`() { - verifySerialization( - IntegerSchema( - title = "Age", - description = "User age", - minimum = 0, - maximum = 150, - default = 25, - ) as PrimitiveSchemaDefinition, - McpJson, - """ - { - "title": "Age", - "description": "User age", - "minimum": 0, - "maximum": 150, - "default": 25, - "type": "integer" - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize IntegerSchema from JSON`() { - val json = """{"type": "integer", "minimum": 1, "maximum": 10}""" - val schema = verifyDeserialization(McpJson, json) - schema.shouldBeInstanceOf() - schema.minimum shouldBe 1 - schema.maximum shouldBe 10 - } - - // ── DoubleSchema ──────────────────────────────────────────────────── - - @Test - fun `should serialize DoubleSchema with minimal fields`() { - verifySerialization( - DoubleSchema() as PrimitiveSchemaDefinition, - McpJson, - """{"type": "number"}""", - ) - } - - @Test - fun `should serialize DoubleSchema with all fields`() { - verifySerialization( - DoubleSchema( - title = "Score", - description = "Test score", - minimum = 0.0, - maximum = 100.0, - default = 50.0, - ) as PrimitiveSchemaDefinition, - McpJson, - """ - { - "title": "Score", - "description": "Test score", - "minimum": 0.0, - "maximum": 100.0, - "default": 50.0, - "type": "number" - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize DoubleSchema from JSON`() { - val json = """{"type": "number", "minimum": 0.5}""" - val schema = verifyDeserialization(McpJson, json) - schema.shouldBeInstanceOf() - schema.minimum shouldBe 0.5 - } - - // ── BooleanSchema ─────────────────────────────────────────────────── - - @Test - fun `should serialize BooleanSchema with minimal fields`() { - verifySerialization( - BooleanSchema() as PrimitiveSchemaDefinition, - McpJson, - """{"type": "boolean"}""", - ) - } - - @Test - fun `should serialize BooleanSchema with all fields`() { - verifySerialization( - BooleanSchema( - title = "Agree", - description = "Terms acceptance", - default = false, - ) as PrimitiveSchemaDefinition, - McpJson, - """ - { - "title": "Agree", - "description": "Terms acceptance", - "default": false, - "type": "boolean" - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize BooleanSchema from JSON`() { - val json = """{"type": "boolean", "default": true}""" - val schema = verifyDeserialization(McpJson, json) - schema.shouldBeInstanceOf() - schema.default shouldBe true - } - - // ── UntitledSingleSelectEnumSchema ────────────────────────────────── - - @Test - fun `should serialize UntitledSingleSelectEnumSchema`() { - verifySerialization( - UntitledSingleSelectEnumSchema( - title = "Color", - enumValues = listOf("Red", "Green", "Blue"), - default = "Red", - ) as PrimitiveSchemaDefinition, - McpJson, - """ - { - "title": "Color", - "enum": ["Red", "Green", "Blue"], - "default": "Red", - "type": "string" - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize UntitledSingleSelectEnumSchema from JSON`() { - val json = """ - { - "type": "string", - "enum": ["A", "B", "C"] - } - """.trimIndent() - - val schema = verifyDeserialization(McpJson, json) - schema.shouldBeInstanceOf() - schema.enumValues shouldBe listOf("A", "B", "C") - } - - // ── TitledSingleSelectEnumSchema ──────────────────────────────────── - - @Test - fun `should serialize TitledSingleSelectEnumSchema`() { - verifySerialization( - TitledSingleSelectEnumSchema( - title = "Color", - oneOf = listOf( - EnumOption(const = "#FF0000", title = "Red"), - EnumOption(const = "#00FF00", title = "Green"), - ), - default = "#FF0000", - ) as PrimitiveSchemaDefinition, - McpJson, - """ - { - "title": "Color", - "oneOf": [ - {"const": "#FF0000", "title": "Red"}, - {"const": "#00FF00", "title": "Green"} - ], - "default": "#FF0000", - "type": "string" - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize TitledSingleSelectEnumSchema from JSON`() { - val json = """ - { - "type": "string", - "oneOf": [ - {"const": "a", "title": "Alpha"}, - {"const": "b", "title": "Beta"} - ] - } - """.trimIndent() - - val schema = verifyDeserialization(McpJson, json) - schema.shouldBeInstanceOf() - schema.oneOf shouldHaveSize 2 - schema.oneOf[0].const shouldBe "a" - schema.oneOf[0].title shouldBe "Alpha" - } - - // ── UntitledMultiSelectEnumSchema ──────────────────────────────────── - - @Test - fun `should serialize UntitledMultiSelectEnumSchema`() { - verifySerialization( - UntitledMultiSelectEnumSchema( - title = "Colors", - minItems = 1, - maxItems = 3, - items = UntitledMultiSelectEnumSchema.Items(enumValues = listOf("Red", "Green", "Blue")), - default = listOf("Red"), - ) as PrimitiveSchemaDefinition, - McpJson, - """ - { - "title": "Colors", - "minItems": 1, - "maxItems": 3, - "items": { - "enum": ["Red", "Green", "Blue"], - "type": "string" - }, - "default": ["Red"], - "type": "array" - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize UntitledMultiSelectEnumSchema from JSON`() { - val json = """ - { - "type": "array", - "items": { - "type": "string", - "enum": ["X", "Y"] - } - } - """.trimIndent() - - val schema = verifyDeserialization(McpJson, json) - schema.shouldBeInstanceOf() - schema.items.enumValues shouldBe listOf("X", "Y") - } - - // ── TitledMultiSelectEnumSchema ────────────────────────────────────── - - @Test - fun `should serialize TitledMultiSelectEnumSchema`() { - verifySerialization( - TitledMultiSelectEnumSchema( - title = "Colors", - minItems = 1, - maxItems = 2, - items = TitledMultiSelectEnumSchema.Items( - anyOf = listOf( - EnumOption(const = "#FF0000", title = "Red"), - EnumOption(const = "#00FF00", title = "Green"), - ), - ), - default = listOf("#FF0000"), - ) as PrimitiveSchemaDefinition, - McpJson, - """ - { - "title": "Colors", - "minItems": 1, - "maxItems": 2, - "items": { - "anyOf": [ - {"const": "#FF0000", "title": "Red"}, - {"const": "#00FF00", "title": "Green"} - ] - }, - "default": ["#FF0000"], - "type": "array" - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize TitledMultiSelectEnumSchema from JSON`() { - val json = """ - { - "type": "array", - "items": { - "anyOf": [ - {"const": "x", "title": "X"}, - {"const": "y", "title": "Y"} - ] - } - } - """.trimIndent() - - val schema = verifyDeserialization(McpJson, json) - schema.shouldBeInstanceOf() - schema.items.anyOf shouldHaveSize 2 - schema.items.anyOf[0].const shouldBe "x" - } - - // ── LegacyTitledEnumSchema ──────────────────────────────────────────── - - @Test - fun `should serialize LegacyTitledEnumSchema`() { - verifySerialization( - LegacyTitledEnumSchema( - title = "Status", - enumValues = listOf("opt1", "opt2", "opt3"), - enumNames = listOf("Option One", "Option Two", "Option Three"), - default = "opt1", - ) as PrimitiveSchemaDefinition, - McpJson, - """ - { - "title": "Status", - "enum": ["opt1", "opt2", "opt3"], - "enumNames": ["Option One", "Option Two", "Option Three"], - "default": "opt1", - "type": "string" - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize LegacyTitledEnumSchema from JSON`() { - val json = """ - { - "type": "string", - "enum": ["a", "b"], - "enumNames": ["Alpha", "Beta"] - } - """.trimIndent() - - val schema = verifyDeserialization(McpJson, json) - schema.shouldBeInstanceOf() - schema.enumValues shouldBe listOf("a", "b") - schema.enumNames shouldBe listOf("Alpha", "Beta") - } - - // ── Error cases ───────────────────────────────────────────────────── - - @Test - fun `should throw on unknown type`() { - val json = """{"type": "unknown"}""" - shouldThrow { - McpJson.decodeFromString(json) - } - } - - // ── RequestedSchema with typed properties ─────────────────────────── - - @Test - fun `should round-trip RequestedSchema with mixed property types`() { - val schema = ElicitRequestParams.RequestedSchema( - properties = mapOf( - "name" to StringSchema(title = "Name"), - "age" to IntegerSchema(minimum = 0), - "confirmed" to BooleanSchema(default = false), - "color" to UntitledSingleSelectEnumSchema(enumValues = listOf("Red", "Blue")), - ), - required = listOf("name"), - ) - - verifySerialization( - schema, - McpJson, - """ - { - "properties": { - "name": {"title": "Name", "type": "string"}, - "age": {"minimum": 0, "type": "integer"}, - "confirmed": {"default": false, "type": "boolean"}, - "color": {"enum": ["Red", "Blue"], "type": "string"} - }, - "required": ["name"], - "type": "object" - } - """.trimIndent(), - ) - } -} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PromptsTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PromptsTest.kt index d7101d3..01eb01c 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PromptsTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/PromptsTest.kt @@ -1,8 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.types import io.kotest.assertions.json.shouldEqualJson -import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization -import io.modelcontextprotocol.kotlin.test.utils.verifySerialization import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.double import kotlinx.serialization.json.int @@ -20,15 +18,13 @@ class PromptsTest { fun `should serialize PromptArgument with minimal fields`() { val argument = PromptArgument(name = "repository") - verifySerialization( - argument, - McpJson, - """ + val json = McpJson.encodeToString(argument) + + json shouldEqualJson """ { "name": "repository" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -40,18 +36,16 @@ class PromptsTest { title = "Language", ) - verifySerialization( - argument, - McpJson, - """ + val json = McpJson.encodeToString(argument) + + json shouldEqualJson """ { "name": "language", "description": "Programming language to use", "required": true, "title": "Language" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -75,10 +69,9 @@ class PromptsTest { meta = buildJsonObject { put("category", "status-report") }, ) - verifySerialization( - prompt, - McpJson, - """ + val json = McpJson.encodeToString(prompt) + + json shouldEqualJson """ { "name": "summarize_update", "description": "Summarize the latest repository changes.", @@ -104,8 +97,7 @@ class PromptsTest { "category": "status-report" } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -135,7 +127,7 @@ class PromptsTest { } """.trimIndent() - val prompt = verifyDeserialization(McpJson, json) + val prompt = McpJson.decodeFromString(json) assertEquals("code_review", prompt.name) assertEquals("Generate a succinct code review.", prompt.description) @@ -159,10 +151,9 @@ class PromptsTest { content = TextContent(text = "Provide a concise summary of the changes."), ) - verifySerialization( - message, - McpJson, - """ + val json = McpJson.encodeToString(message) + + json shouldEqualJson """ { "role": "assistant", "content": { @@ -170,8 +161,7 @@ class PromptsTest { "text": "Provide a concise summary of the changes." } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -190,7 +180,7 @@ class PromptsTest { } """.trimIndent() - val message = verifyDeserialization(McpJson, json) + val message = McpJson.decodeFromString(json) assertEquals(Role.User, message.role) val content = message.content @@ -213,7 +203,7 @@ class PromptsTest { } """.trimIndent() - val decoded = verifyDeserialization(McpJson, json) + val decoded = McpJson.decodeFromString(json) val promptReference = assertIs(decoded) assertEquals("daily-summary", promptReference.name) assertEquals("Daily Summary", promptReference.title) @@ -232,10 +222,9 @@ class PromptsTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "prompts/get", "params": { @@ -248,8 +237,7 @@ class PromptsTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -270,7 +258,7 @@ class PromptsTest { } """.trimIndent() - val request = verifyDeserialization(McpJson, json) + val request = McpJson.decodeFromString(json) assertEquals(Method.Defined.PromptsGet, request.method) val params = request.params @@ -299,10 +287,9 @@ class PromptsTest { meta = buildJsonObject { put("generatedAt", "2025-01-12T15:00:58Z") }, ) - verifySerialization( - result, - McpJson, - """ + val json = McpJson.encodeToString(result) + + json shouldEqualJson """ { "messages": [ { @@ -325,8 +312,7 @@ class PromptsTest { "generatedAt": "2025-01-12T15:00:58Z" } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -346,7 +332,7 @@ class PromptsTest { } """.trimIndent() - val result = verifyDeserialization(McpJson, json) + val result = McpJson.decodeFromString(json) val messages = result.messages assertEquals(1, messages.size) @@ -368,10 +354,9 @@ class PromptsTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "prompts/list", "params": { @@ -381,23 +366,20 @@ class PromptsTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test fun `should serialize ListPromptsRequest without params`() { val request = ListPromptsRequest() - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "prompts/list" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -411,10 +393,9 @@ class PromptsTest { meta = buildJsonObject { put("page", 1) }, ) - verifySerialization( - result, - McpJson, - """ + val json = McpJson.encodeToString(result) + + json shouldEqualJson """ { "prompts": [ {"name": "morning-briefing"}, @@ -425,8 +406,7 @@ class PromptsTest { "page": 1 } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -453,7 +433,7 @@ class PromptsTest { } """.trimIndent() - val result = verifyDeserialization(McpJson, json) + val result = McpJson.decodeFromString(json) val prompts = result.prompts assertEquals(1, prompts.size) 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..9341265 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 @@ -1,121 +1,18 @@ 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") }) @@ -143,18 +40,16 @@ class RequestTest { ), ) - verifySerialization( - params, - McpJson, - """ + val json = McpJson.encodeToString(params) + + json shouldEqualJson """ { "_meta": { "progressToken": "base-42", "origin": "test-suite" } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -168,7 +63,7 @@ class RequestTest { } """.trimIndent() - val params = verifyDeserialization(McpJson, json) + val params = McpJson.decodeFromString(json) val meta = params.meta assertNotNull(meta) @@ -183,18 +78,16 @@ class RequestTest { meta = RequestMeta(buildJsonObject { put("progressToken", "page-req") }), ) - verifySerialization( - params, - McpJson, - """ + val json = McpJson.encodeToString(params) + + json shouldEqualJson """ { "cursor": "cursor-1", "_meta": { "progressToken": "page-req" } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -208,7 +101,7 @@ class RequestTest { } """.trimIndent() - val params = verifyDeserialization(McpJson, json) + val params = McpJson.decodeFromString(json) assertEquals("cursor-2", params.cursor) assertEquals(ProgressToken(99), params.meta?.progressToken) @@ -256,9 +149,7 @@ class RequestTest { val method = custom.method val customMethod = assertIs(method) assertEquals("extensions/customAction", customMethod.value) - custom.params?.meta?.json - ?.get("progressToken") - ?.jsonPrimitive?.content shouldBe "custom-1" + assertEquals("custom-1", custom.params?.meta?.json?.get("progressToken")?.jsonPrimitive?.content) } @Test @@ -269,24 +160,22 @@ class RequestTest { }, ) - verifySerialization( - result, - McpJson, - """ + val json = McpJson.encodeToString(result) + + json shouldEqualJson """ { "_meta": { "processedBy": "worker-1" } } - """.trimIndent(), - ) + """.trimIndent() } @Test fun `should deserialize EmptyResult without meta`() { val json = "{}" - val result = verifyDeserialization(McpJson, json) + val result = McpJson.decodeFromString(json) assertNull(result.meta) } diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ResourcesTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ResourcesTest.kt index e47a0f5..0313b75 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ResourcesTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ResourcesTest.kt @@ -1,8 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.types import io.kotest.assertions.json.shouldEqualJson -import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization -import io.modelcontextprotocol.kotlin.test.utils.verifySerialization import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive @@ -22,16 +20,14 @@ class ResourcesTest { name = "README", ) - verifySerialization( - resource, - McpJson, - """ + val json = McpJson.encodeToString(resource) + + json shouldEqualJson """ { "uri": "file:///workspace/README.md", "name": "README" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -50,10 +46,9 @@ class ResourcesTest { meta = buildJsonObject { put("etag", "abc123") }, ) - verifySerialization( - resource, - McpJson, - """ + val json = McpJson.encodeToString(resource) + + json shouldEqualJson """ { "uri": "file:///workspace/CHANGELOG.md", "name": "CHANGELOG", @@ -72,8 +67,7 @@ class ResourcesTest { "etag": "abc123" } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -98,7 +92,7 @@ class ResourcesTest { } """.trimIndent() - val resource = verifyDeserialization(McpJson, json) + val resource = McpJson.decodeFromString(json) assertEquals("file:///config/settings.json", resource.uri) assertEquals("config-settings", resource.name) @@ -128,10 +122,9 @@ class ResourcesTest { meta = buildJsonObject { put("requiresAuth", true) }, ) - verifySerialization( - template, - McpJson, - """ + val json = McpJson.encodeToString(template) + + json shouldEqualJson """ { "uriTemplate": "file:///workspace/{path}", "name": "workspace-file", @@ -152,8 +145,7 @@ class ResourcesTest { "requiresAuth": true } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -169,7 +161,7 @@ class ResourcesTest { } """.trimIndent() - val template = verifyDeserialization(McpJson, json) + val template = McpJson.decodeFromString(json) assertEquals("db://users/{id}", template.uriTemplate) assertEquals("user-record", template.name) @@ -191,7 +183,7 @@ class ResourcesTest { } """.trimIndent() - val decoded = verifyDeserialization(McpJson, json) + val decoded = McpJson.decodeFromString(json) val templateReference = assertIs(decoded) assertEquals("file:///workspace/{path}", templateReference.uri) assertEquals(ReferenceType.ResourceTemplate, templateReference.type) @@ -206,10 +198,9 @@ class ResourcesTest { meta = buildJsonObject { put("encoding", "utf-8") }, ) - verifySerialization( - contents, - McpJson, - """ + val json = McpJson.encodeToString(contents) + + json shouldEqualJson """ { "text": "Hello, MCP!", "uri": "file:///workspace/hello.txt", @@ -218,8 +209,7 @@ class ResourcesTest { "encoding": "utf-8" } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -235,7 +225,7 @@ class ResourcesTest { } """.trimIndent() - val contents = verifyDeserialization(McpJson, json) + val contents = McpJson.decodeFromString(json) val blobContents = assertIs(contents) assertEquals("YmluYXJ5ZGF0YQ==", blobContents.blob) assertEquals("file:///workspace/logo.png", blobContents.uri) @@ -250,16 +240,14 @@ class ResourcesTest { mimeType = "application/octet-stream", ) - verifySerialization( - contents, - McpJson, - """ + val json = McpJson.encodeToString(contents) + + json shouldEqualJson """ { "uri": "custom://resource/42", "mimeType": "application/octet-stream" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -271,10 +259,9 @@ class ResourcesTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "resources/list", "params": { @@ -284,23 +271,20 @@ class ResourcesTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test fun `should serialize ListResourcesRequest without params`() { val request = ListResourcesRequest() - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "resources/list" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -314,10 +298,9 @@ class ResourcesTest { meta = buildJsonObject { put("page", 1) }, ) - verifySerialization( - result, - McpJson, - """ + val json = McpJson.encodeToString(result) + + json shouldEqualJson """ { "resources": [ {"uri": "file:///workspace/README.md", "name": "README"}, @@ -328,8 +311,7 @@ class ResourcesTest { "page": 1 } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -350,7 +332,7 @@ class ResourcesTest { } """.trimIndent() - val result = verifyDeserialization(McpJson, json) + val result = McpJson.decodeFromString(json) assertEquals("cursor-next", result.nextCursor) val resources = result.resources @@ -371,10 +353,9 @@ class ResourcesTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "resources/read", "params": { @@ -384,8 +365,7 @@ class ResourcesTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -410,7 +390,7 @@ class ResourcesTest { } """.trimIndent() - val result = verifyDeserialization(McpJson, json) + val result = McpJson.decodeFromString(json) val contents = result.contents assertEquals(2, contents.size) @@ -430,10 +410,9 @@ class ResourcesTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "resources/subscribe", "params": { @@ -443,8 +422,7 @@ class ResourcesTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -456,10 +434,9 @@ class ResourcesTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "resources/unsubscribe", "params": { @@ -469,8 +446,7 @@ class ResourcesTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -482,10 +458,9 @@ class ResourcesTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "resources/templates/list", "params": { @@ -495,8 +470,7 @@ class ResourcesTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -509,10 +483,9 @@ class ResourcesTest { meta = buildJsonObject { put("page", 3) }, ) - verifySerialization( - result, - McpJson, - """ + val json = McpJson.encodeToString(result) + + json shouldEqualJson """ { "resourceTemplates": [ { @@ -525,8 +498,7 @@ class ResourcesTest { "page": 3 } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -550,7 +522,7 @@ class ResourcesTest { } """.trimIndent() - val result = verifyDeserialization(McpJson, json) + val result = McpJson.decodeFromString(json) assertEquals("cursor-next", result.nextCursor) val templates = result.resourceTemplates diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RootsTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RootsTest.kt index 2350c25..3f1b8d6 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RootsTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/RootsTest.kt @@ -1,7 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.types -import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization -import io.modelcontextprotocol.kotlin.test.utils.verifySerialization +import io.kotest.assertions.json.shouldEqualJson import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive @@ -16,15 +15,13 @@ class RootsTest { fun `should serialize Root with minimal fields`() { val root = Root(uri = "file:///workspace/project") - verifySerialization( - root, - McpJson, - """ + val json = McpJson.encodeToString(root) + + json shouldEqualJson """ { "uri": "file:///workspace/project" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -35,10 +32,9 @@ class RootsTest { meta = buildJsonObject { put("writable", true) }, ) - verifySerialization( - root, - McpJson, - """ + val json = McpJson.encodeToString(root) + + json shouldEqualJson """ { "uri": "file:///workspace/docs", "name": "Docs", @@ -46,8 +42,7 @@ class RootsTest { "writable": true } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -62,7 +57,7 @@ class RootsTest { } """.trimIndent() - val root = verifyDeserialization(McpJson, json) + val root = McpJson.decodeFromString(json) assertEquals("file:///workspace/code", root.uri) assertEquals("Code", root.name) @@ -79,10 +74,9 @@ class RootsTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "roots/list", "params": { @@ -91,23 +85,20 @@ class RootsTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test fun `should serialize ListRootsRequest without params`() { val request = ListRootsRequest() - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "roots/list" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -120,10 +111,9 @@ class RootsTest { meta = buildJsonObject { put("issuedAt", "2025-01-12T15:00:58Z") }, ) - verifySerialization( - result, - McpJson, - """ + val json = McpJson.encodeToString(result) + + json shouldEqualJson """ { "roots": [ {"uri": "file:///workspace/project", "name": "Project"}, @@ -133,8 +123,7 @@ class RootsTest { "issuedAt": "2025-01-12T15:00:58Z" } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -153,7 +142,7 @@ class RootsTest { } """.trimIndent() - val result = verifyDeserialization(McpJson, json) + val result = McpJson.decodeFromString(json) val roots = result.roots assertEquals(1, roots.size) 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..7dc3fe6 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,6 @@ 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 io.kotest.assertions.json.shouldEqualJson import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.double import kotlinx.serialization.json.jsonPrimitive @@ -20,34 +13,17 @@ 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") - verifySerialization( - hint, - McpJson, - """ + val json = McpJson.encodeToString(hint) + + json shouldEqualJson """ { "name": "claude-3-5-sonnet" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -59,10 +35,9 @@ class SamplingTest { intelligencePriority = 1.0, ) - verifySerialization( - preferences, - McpJson, - """ + val json = McpJson.encodeToString(preferences) + + json shouldEqualJson """ { "hints": [ {"name": "haiku"}, @@ -72,8 +47,7 @@ class SamplingTest { "speedPriority": 0.75, "intelligencePriority": 1.0 } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -87,13 +61,12 @@ 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( - message, - McpJson, - """ + val json = McpJson.encodeToString(message) + + json shouldEqualJson """ { "role": "user", "content": { @@ -101,8 +74,7 @@ class SamplingTest { "text": "Summarize the latest release." } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -113,11 +85,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( @@ -133,10 +105,9 @@ class SamplingTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "sampling/createMessage", "params": { @@ -175,8 +146,7 @@ class SamplingTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -211,7 +181,7 @@ class SamplingTest { } """.trimIndent() - val request = verifyDeserialization(McpJson, json) + val request = McpJson.decodeFromString(json) assertEquals(Method.Defined.SamplingCreateMessage, request.method) val params = request.params @@ -222,7 +192,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) @@ -239,10 +209,9 @@ class SamplingTest { meta = buildJsonObject { put("latencyMs", 850) }, ) - verifySerialization( - result, - McpJson, - """ + val json = McpJson.encodeToString(result) + + json shouldEqualJson """ { "role": "assistant", "content": { @@ -255,8 +224,7 @@ class SamplingTest { "latencyMs": 850 } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -276,10 +244,10 @@ class SamplingTest { } """.trimIndent() - val result = verifyDeserialization(McpJson, json) + val result = McpJson.decodeFromString(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 +255,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/TasksTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/TasksTest.kt deleted file mode 100644 index 14bd248..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/TasksTest.kt +++ /dev/null @@ -1,802 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.types - -import io.kotest.matchers.nulls.shouldBeNull -import io.kotest.matchers.nulls.shouldNotBeNull -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.json.buildJsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put -import kotlin.test.Test - -class TasksTest { - - // ======================================================================== - // Task - // ======================================================================== - - @Test - fun `should serialize Task with minimal fields`() { - val task = Task( - taskId = "task-1", - status = TaskStatus.Working, - createdAt = "2025-01-01T00:00:00Z", - lastUpdatedAt = "2025-01-01T00:00:00Z", - ttl = null, - ) - - verifySerialization( - task, - McpJson, - """ - { - "taskId": "task-1", - "status": "working", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:00:00Z" - } - """.trimIndent(), - ) - } - - @Test - fun `should serialize Task with all fields`() { - val task = Task( - taskId = "task-2", - status = TaskStatus.Completed, - statusMessage = "Processing complete", - createdAt = "2025-01-01T00:00:00Z", - lastUpdatedAt = "2025-01-01T00:01:00Z", - ttl = 60000, - pollInterval = 5000, - ) - - verifySerialization( - task, - McpJson, - """ - { - "taskId": "task-2", - "status": "completed", - "statusMessage": "Processing complete", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:01:00Z", - "ttl": 60000, - "pollInterval": 5000 - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize Task and verify TaskFields contract`() { - val json = """ - { - "taskId": "task-3", - "status": "failed", - "statusMessage": "Connection lost", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:02:00Z", - "ttl": 30000, - "pollInterval": 1000 - } - """.trimIndent() - - val task = verifyDeserialization(McpJson, json) - task.shouldBeInstanceOf() - task.taskId shouldBe "task-3" - task.status shouldBe TaskStatus.Failed - task.statusMessage shouldBe "Connection lost" - task.createdAt shouldBe "2025-01-01T00:00:00Z" - task.lastUpdatedAt shouldBe "2025-01-01T00:02:00Z" - task.ttl shouldBe 30000L - task.pollInterval shouldBe 1000L - } - - // ======================================================================== - // TaskStatus - // ======================================================================== - - @Test - fun `should serialize all TaskStatus values`() { - verifySerialization(TaskStatus.Working, McpJson, "\"working\"") - verifySerialization(TaskStatus.InputRequired, McpJson, "\"input_required\"") - verifySerialization(TaskStatus.Completed, McpJson, "\"completed\"") - verifySerialization(TaskStatus.Failed, McpJson, "\"failed\"") - verifySerialization(TaskStatus.Cancelled, McpJson, "\"cancelled\"") - } - - // ======================================================================== - // TaskMetadata - // ======================================================================== - - @Test - fun `should serialize TaskMetadata with ttl`() { - val metadata = TaskMetadata(ttl = 120000) - - verifySerialization( - metadata, - McpJson, - """ - { - "ttl": 120000 - } - """.trimIndent(), - ) - } - - @Test - fun `should serialize TaskMetadata without ttl`() { - val metadata = TaskMetadata() - - verifySerialization( - metadata, - McpJson, - "{}", - ) - } - - // ======================================================================== - // RelatedTaskMetadata - // ======================================================================== - - @Test - fun `should serialize RelatedTaskMetadata`() { - val related = RelatedTaskMetadata(taskId = "task-42") - - verifySerialization( - related, - McpJson, - """ - { - "taskId": "task-42" - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize RequestMeta with RelatedTaskMetadata`() { - val json = """ - { - "io.modelcontextprotocol/related-task": { - "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840" - } - } - """.trimIndent() - - val meta = McpJson.decodeFromString(json) - val related = meta.relatedTask - related.shouldNotBeNull() - related.taskId shouldBe "786512e2-9e0d-44bd-8f29-789f320fe840" - } - - // ======================================================================== - // CreateTaskResult - // ======================================================================== - - @Test - fun `should serialize CreateTaskResult`() { - val result = CreateTaskResult( - task = Task( - taskId = "task-1", - status = TaskStatus.Working, - createdAt = "2025-01-01T00:00:00Z", - lastUpdatedAt = "2025-01-01T00:00:00Z", - ttl = null, - ), - ) - - verifySerialization( - result, - McpJson, - """ - { - "task": { - "taskId": "task-1", - "status": "working", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:00:00Z" - } - } - """.trimIndent(), - ) - } - - @Test - fun `should serialize CreateTaskResult with meta`() { - val result = CreateTaskResult( - task = Task( - taskId = "task-1", - status = TaskStatus.Working, - createdAt = "2025-01-01T00:00:00Z", - lastUpdatedAt = "2025-01-01T00:00:00Z", - ttl = 60000, - ), - meta = buildJsonObject { put("trace", "abc") }, - ) - - verifySerialization( - result, - McpJson, - """ - { - "task": { - "taskId": "task-1", - "status": "working", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:00:00Z", - "ttl": 60000 - }, - "_meta": { - "trace": "abc" - } - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize CreateTaskResult`() { - val json = """ - { - "task": { - "taskId": "task-5", - "status": "input_required", - "statusMessage": "Need more data", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:01:00Z", - "pollInterval": 2000 - } - } - """.trimIndent() - - val result = verifyDeserialization(McpJson, json) - result.task.taskId shouldBe "task-5" - result.task.status shouldBe TaskStatus.InputRequired - result.task.statusMessage shouldBe "Need more data" - result.task.pollInterval shouldBe 2000L - result.task.ttl.shouldBeNull() - result.meta.shouldBeNull() - } - - // ======================================================================== - // GetTaskRequest - // ======================================================================== - - @Test - fun `should serialize GetTaskRequest`() { - val request = GetTaskRequest( - GetTaskRequestParams(taskId = "task-10"), - ) - - verifySerialization( - request, - McpJson, - """ - { - "method": "tasks/get", - "params": { - "taskId": "task-10" - } - } - """.trimIndent(), - ) - } - - @Test - fun `should serialize GetTaskRequest with meta`() { - val request = GetTaskRequest( - GetTaskRequestParams( - taskId = "task-10", - meta = RequestMeta( - buildJsonObject { put("progressToken", "pt-1") }, - ), - ), - ) - - verifySerialization( - request, - McpJson, - """ - { - "method": "tasks/get", - "params": { - "taskId": "task-10", - "_meta": { - "progressToken": "pt-1" - } - } - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize GetTaskRequest`() { - val json = """ - { - "method": "tasks/get", - "params": { - "taskId": "task-11" - } - } - """.trimIndent() - - val request = verifyDeserialization(McpJson, json) - request.method shouldBe Method.Defined.TasksGet - request.taskId shouldBe "task-11" - request.meta.shouldBeNull() - } - - // ======================================================================== - // GetTaskResult - // ======================================================================== - - @Test - fun `should serialize GetTaskResult with all fields`() { - val result = GetTaskResult( - taskId = "task-20", - status = TaskStatus.Completed, - statusMessage = "Done", - createdAt = "2025-01-01T00:00:00Z", - lastUpdatedAt = "2025-01-01T00:05:00Z", - ttl = 300000, - pollInterval = 10000, - meta = buildJsonObject { put("server", "main") }, - ) - - verifySerialization( - result, - McpJson, - """ - { - "taskId": "task-20", - "status": "completed", - "statusMessage": "Done", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:05:00Z", - "ttl": 300000, - "pollInterval": 10000, - "_meta": { - "server": "main" - } - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize GetTaskResult and verify TaskFields contract`() { - val json = """ - { - "taskId": "task-21", - "status": "working", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:00:30Z" - } - """.trimIndent() - - val result = verifyDeserialization(McpJson, json) - result.shouldBeInstanceOf() - result.taskId shouldBe "task-21" - result.status shouldBe TaskStatus.Working - result.statusMessage.shouldBeNull() - result.ttl.shouldBeNull() - result.pollInterval.shouldBeNull() - result.meta.shouldBeNull() - } - - // ======================================================================== - // GetTaskPayloadRequest - // ======================================================================== - - @Test - fun `should serialize GetTaskPayloadRequest`() { - val request = GetTaskPayloadRequest( - GetTaskPayloadRequestParams(taskId = "task-30"), - ) - - verifySerialization( - request, - McpJson, - """ - { - "method": "tasks/result", - "params": { - "taskId": "task-30" - } - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize GetTaskPayloadRequest`() { - val json = """ - { - "method": "tasks/result", - "params": { - "taskId": "task-31" - } - } - """.trimIndent() - - val request = verifyDeserialization(McpJson, json) - request.method shouldBe Method.Defined.TasksResult - request.taskId shouldBe "task-31" - request.meta.shouldBeNull() - } - - // ======================================================================== - // GetTaskPayloadResult - // ======================================================================== - - @Test - fun `should serialize GetTaskPayloadResult with meta`() { - val result = GetTaskPayloadResult( - buildJsonObject { - put("_meta", buildJsonObject { put("origin", "task-30") }) - }, - ) - - verifySerialization( - result, - McpJson, - """ - { - "_meta": { - "origin": "task-30" - } - } - """.trimIndent(), - ) - } - - @Test - fun `should serialize GetTaskPayloadResult without meta`() { - val result = GetTaskPayloadResult(buildJsonObject { }) - - verifySerialization(result, McpJson, "{}") - } - - @Test - fun `should preserve arbitrary payload fields in GetTaskPayloadResult`() { - val json = """ - { - "_meta": { - "origin": "task-42" - }, - "content": [ - { - "type": "text", - "text": "hello" - } - ], - "isError": false - } - """.trimIndent() - - val result = verifyDeserialization(McpJson, json) - result.meta.shouldNotBeNull() - result.meta?.get("origin")?.jsonPrimitive?.content shouldBe "task-42" - result["content"].shouldNotBeNull() - result["isError"].shouldNotBeNull() - } - - // ======================================================================== - // ListTasksRequest - // ======================================================================== - - @Test - fun `should serialize ListTasksRequest without params`() { - val request = ListTasksRequest() - - verifySerialization( - request, - McpJson, - """ - { - "method": "tasks/list" - } - """.trimIndent(), - ) - } - - @Test - fun `should serialize ListTasksRequest with cursor`() { - val request = ListTasksRequest( - params = PaginatedRequestParams(cursor = "page-2"), - ) - - verifySerialization( - request, - McpJson, - """ - { - "method": "tasks/list", - "params": { - "cursor": "page-2" - } - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize ListTasksRequest`() { - val json = """ - { - "method": "tasks/list", - "params": { - "cursor": "next-page" - } - } - """.trimIndent() - - val request = verifyDeserialization(McpJson, json) - request.method shouldBe Method.Defined.TasksList - request.cursor shouldBe "next-page" - } - - // ======================================================================== - // ListTasksResult - // ======================================================================== - - @Test - fun `should serialize ListTasksResult with empty list`() { - val result = ListTasksResult(tasks = emptyList()) - - verifySerialization( - result, - McpJson, - """ - { - "tasks": [] - } - """.trimIndent(), - ) - } - - @Test - fun `should serialize ListTasksResult with tasks and pagination`() { - val result = ListTasksResult( - tasks = listOf( - Task( - taskId = "task-1", - status = TaskStatus.Working, - createdAt = "2025-01-01T00:00:00Z", - lastUpdatedAt = "2025-01-01T00:00:00Z", - ttl = null, - ), - Task( - taskId = "task-2", - status = TaskStatus.Completed, - createdAt = "2025-01-01T00:00:00Z", - lastUpdatedAt = "2025-01-01T00:01:00Z", - ttl = 60000, - ), - ), - nextCursor = "cursor-abc", - ) - - verifySerialization( - result, - McpJson, - """ - { - "tasks": [ - { - "taskId": "task-1", - "status": "working", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:00:00Z" - }, - { - "taskId": "task-2", - "status": "completed", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:01:00Z", - "ttl": 60000 - } - ], - "nextCursor": "cursor-abc" - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize ListTasksResult`() { - val json = """ - { - "tasks": [ - { - "taskId": "task-99", - "status": "cancelled", - "statusMessage": "User cancelled", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:03:00Z" - } - ] - } - """.trimIndent() - - val result = verifyDeserialization(McpJson, json) - result.tasks.size shouldBe 1 - result.tasks[0].taskId shouldBe "task-99" - result.tasks[0].status shouldBe TaskStatus.Cancelled - result.tasks[0].statusMessage shouldBe "User cancelled" - result.tasks[0].ttl.shouldBeNull() - result.nextCursor.shouldBeNull() - } - - // ======================================================================== - // CancelTaskRequest - // ======================================================================== - - @Test - fun `should serialize CancelTaskRequest`() { - val request = CancelTaskRequest( - CancelTaskRequestParams(taskId = "task-40"), - ) - - verifySerialization( - request, - McpJson, - """ - { - "method": "tasks/cancel", - "params": { - "taskId": "task-40" - } - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize CancelTaskRequest`() { - val json = """ - { - "method": "tasks/cancel", - "params": { - "taskId": "task-41" - } - } - """.trimIndent() - - val request = verifyDeserialization(McpJson, json) - request.method shouldBe Method.Defined.TasksCancel - request.taskId shouldBe "task-41" - request.meta.shouldBeNull() - } - - // ======================================================================== - // CancelTaskResult - // ======================================================================== - - @Test - fun `should serialize CancelTaskResult`() { - val result = CancelTaskResult( - taskId = "task-40", - status = TaskStatus.Cancelled, - statusMessage = "Cancelled by user", - createdAt = "2025-01-01T00:00:00Z", - lastUpdatedAt = "2025-01-01T00:02:00Z", - ttl = null, - ) - - verifySerialization( - result, - McpJson, - """ - { - "taskId": "task-40", - "status": "cancelled", - "statusMessage": "Cancelled by user", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:02:00Z" - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize CancelTaskResult and verify TaskFields contract`() { - val json = """ - { - "taskId": "task-42", - "status": "cancelled", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:04:00Z", - "ttl": 120000, - "pollInterval": 3000 - } - """.trimIndent() - - val result = verifyDeserialization(McpJson, json) - result.shouldBeInstanceOf() - result.taskId shouldBe "task-42" - result.status shouldBe TaskStatus.Cancelled - result.statusMessage.shouldBeNull() - result.ttl shouldBe 120000L - result.pollInterval shouldBe 3000L - } - - // ======================================================================== - // Deserialization with unknown fields (ignoreUnknownKeys) - // ======================================================================== - - @Test - fun `should deserialize Task ignoring unknown fields`() { - val json = """ - { - "taskId": "task-100", - "status": "working", - "createdAt": "2025-01-01T00:00:00Z", - "lastUpdatedAt": "2025-01-01T00:00:00Z", - "unknownField": "should be ignored" - } - """.trimIndent() - - val task = McpJson.decodeFromString(json) - task.taskId shouldBe "task-100" - task.status shouldBe TaskStatus.Working - task.ttl.shouldBeNull() - } - - // ======================================================================== - // RequestMeta.relatedTask accessor - // ======================================================================== - - @Test - fun `should read relatedTask from RequestMeta`() { - val meta = RequestMeta( - buildJsonObject { - put( - RELATED_TASK_META_KEY, - buildJsonObject { put("taskId", "task-50") }, - ) - }, - ) - - val related = meta.relatedTask - related.shouldNotBeNull() - related.taskId shouldBe "task-50" - } - - @Test - fun `should return null relatedTask when key is absent`() { - val meta = RequestMeta( - buildJsonObject { put("progressToken", "pt-1") }, - ) - - meta.relatedTask.shouldBeNull() - } - - @Test - fun `GetTaskResult cast to CancelTaskResult via generic does not throw due to type erasure`() { - val result: RequestResult = GetTaskResult( - taskId = "task-1", - status = TaskStatus.Cancelled, - createdAt = "2025-01-01T00:00:00Z", - lastUpdatedAt = "2025-01-01T00:01:00Z", - ttl = null, - ) - - // Simulates what Protocol.request() does: - // the cast goes through a generic type parameter, so JVM erases T to RequestResult - fun uncheckedCast(value: RequestResult): T { - @Suppress("UNCHECKED_CAST") - return value as T - } - - val cancelResult: CancelTaskResult = uncheckedCast(result) - - cancelResult.taskId shouldBe "task-1" - cancelResult.status shouldBe TaskStatus.Cancelled - } -} 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..a662e44 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 @@ -1,7 +1,6 @@ package io.modelcontextprotocol.kotlin.sdk.types -import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization -import io.modelcontextprotocol.kotlin.test.utils.verifySerialization +import io.kotest.assertions.json.shouldEqualJson import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.int @@ -22,18 +21,16 @@ class ToolsTest { inputSchema = ToolSchema(), ) - verifySerialization( - tool, - McpJson, - """ + val json = McpJson.encodeToString(tool) + + json shouldEqualJson """ { "name": "search", "inputSchema": { "type": "object" } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -77,10 +74,9 @@ class ToolsTest { meta = buildJsonObject { put("category", "search") }, ) - verifySerialization( - tool, - McpJson, - """ + val json = McpJson.encodeToString(tool) + + json shouldEqualJson """ { "name": "web-search", "inputSchema": { @@ -117,8 +113,7 @@ class ToolsTest { "category": "search" } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -144,7 +139,7 @@ class ToolsTest { } """.trimIndent() - val tool = verifyDeserialization(McpJson, json) + val tool = McpJson.decodeFromString(json) assertEquals("translate", tool.name) assertEquals("Translate Text", tool.annotations?.title) @@ -157,110 +152,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() - - verifySerialization(tool, McpJson, toolWithDefsJson()) - } - - @Test - fun `should deserialize ToolSchema with defs`() { - val json = $$""" - { - "name": "create-page", - "inputSchema": { - "type": "object", - "$defs": { - "parentRequest": { - "type": "object", - "properties": { - "page_id": { - "type": "string" - } - } - } - }, - "properties": { - "parent": { - "$ref": "#/$defs/parentRequest" - } - }, - "required": ["parent"] - } - } - """.trimIndent() - - val tool = verifyDeserialization(McpJson, json) - - val schema = tool.inputSchema - val defs = schema.defs - assertNotNull(defs) - val parentRequest = defs["parentRequest"]?.jsonObject - assertNotNull(parentRequest) - assertEquals("object", parentRequest["type"]?.jsonPrimitive?.content) - assertEquals( - $$"#/$defs/parentRequest", - schema.properties?.get("parent")?.jsonObject?.get($$"$ref")?.jsonPrimitive?.content, - ) - assertEquals(listOf("parent"), schema.required) - } - @Test fun `should serialize CallToolRequest with arguments`() { val request = CallToolRequest( @@ -273,10 +164,9 @@ class ToolsTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "tools/call", "params": { @@ -289,8 +179,7 @@ class ToolsTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -310,7 +199,7 @@ class ToolsTest { } """.trimIndent() - val request = verifyDeserialization(McpJson, json) + val request = McpJson.decodeFromString(json) assertEquals(Method.Defined.ToolsCall, request.method) val params = request.params @@ -319,57 +208,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( @@ -384,10 +222,9 @@ class ToolsTest { meta = buildJsonObject { put("elapsedMs", 1200) }, ) - verifySerialization( - result, - McpJson, - """ + val json = McpJson.encodeToString(result) + + json shouldEqualJson """ { "content": [ { @@ -406,8 +243,7 @@ class ToolsTest { "elapsedMs": 1200 } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -431,7 +267,7 @@ class ToolsTest { } """.trimIndent() - val result = verifyDeserialization(McpJson, json) + val result = McpJson.decodeFromString(json) assertEquals(true, result.isError) val text = assertIs(result.content.first()) @@ -451,10 +287,9 @@ class ToolsTest { ), ) - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "tools/list", "params": { @@ -464,23 +299,20 @@ class ToolsTest { } } } - """.trimIndent(), - ) + """.trimIndent() } @Test fun `should serialize ListToolsRequest without params`() { val request = ListToolsRequest() - verifySerialization( - request, - McpJson, - """ + val json = McpJson.encodeToString(request) + + json shouldEqualJson """ { "method": "tools/list" } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -494,10 +326,9 @@ class ToolsTest { meta = buildJsonObject { put("page", 1) }, ) - verifySerialization( - result, - McpJson, - """ + val json = McpJson.encodeToString(result) + + json shouldEqualJson """ { "tools": [ { @@ -518,8 +349,7 @@ class ToolsTest { "page": 1 } } - """.trimIndent(), - ) + """.trimIndent() } @Test @@ -542,7 +372,7 @@ class ToolsTest { } """.trimIndent() - val result = verifyDeserialization(McpJson, json) + val result = McpJson.decodeFromString(json) assertEquals("cursor-next", result.nextCursor) val tools = result.tools @@ -580,102 +410,6 @@ class ToolsTest { assertEquals(null, result.structuredContent) } - @Test - fun `should serialize ToolExecution with taskSupport`() { - val execution = ToolExecution(taskSupport = TaskSupport.Required) - - verifySerialization( - execution, - McpJson, - """ - { - "taskSupport": "required" - } - """.trimIndent(), - ) - } - - @Test - fun `should serialize ToolExecution without taskSupport`() { - val execution = ToolExecution() - - verifySerialization( - execution, - McpJson, - "{}", - ) - } - - @Test - fun `should deserialize ToolExecution with taskSupport`() { - val json = """ - { - "taskSupport": "optional" - } - """.trimIndent() - - val execution = verifyDeserialization(McpJson, json) - - assertEquals(TaskSupport.Optional, execution.taskSupport) - } - - @Test - fun `should deserialize ToolExecution without taskSupport`() { - val json = "{}" - - val execution = verifyDeserialization(McpJson, json) - - assertEquals(null, execution.taskSupport) - } - - @Test - fun `should serialize Tool with execution`() { - val tool = Tool( - name = "long-running-task", - inputSchema = ToolSchema(), - description = "A tool that supports task-augmented execution", - execution = ToolExecution(taskSupport = TaskSupport.Required), - ) - - verifySerialization( - tool, - McpJson, - """ - { - "name": "long-running-task", - "inputSchema": { - "type": "object" - }, - "description": "A tool that supports task-augmented execution", - "execution": { - "taskSupport": "required" - } - } - """.trimIndent(), - ) - } - - @Test - fun `should deserialize Tool with execution`() { - val json = """ - { - "name": "long-running-task", - "inputSchema": { - "type": "object" - }, - "execution": { - "taskSupport": "forbidden" - } - } - """.trimIndent() - - val tool = verifyDeserialization(McpJson, json) - - assertEquals("long-running-task", tool.name) - assertNotNull(tool.execution) - assertEquals(TaskSupport.Forbidden, tool.execution.taskSupport) - } - @Test fun `should serialize tool schema type even when defaults disabled`() { val tool = weatherTool() @@ -698,7 +432,7 @@ class ToolsTest { @Test fun `should deserialize complex tool definition`() { val expected = weatherTool() - val actual = verifyDeserialization(McpJson, weatherToolJson()) + val actual = McpJson.decodeFromString(weatherToolJson()) assertEquals(expected, actual) } @@ -786,65 +520,4 @@ class ToolsTest { } } """.trimIndent() - - private fun toolWithDefs(): Tool = Tool( - name = "create-page", - inputSchema = toolSchemaWithDefs(), - ) - - private fun toolSchemaWithDefs(): ToolSchema = ToolSchema( - properties = buildJsonObject { - put( - "parent", - buildJsonObject { - put($$"$ref", $$"#/$defs/parentRequest") - }, - ) - }, - required = listOf("parent"), - defs = buildJsonObject { - put( - "parentRequest", - buildJsonObject { - put("type", "object") - put( - "properties", - buildJsonObject { - put( - "page_id", - buildJsonObject { - put("type", "string") - }, - ) - }, - ) - }, - ) - }, - ) - - private fun toolWithDefsJson(): String = $$""" - { - "name": "create-page", - "inputSchema": { - "type": "object", - "$defs": { - "parentRequest": { - "type": "object", - "properties": { - "page_id": { - "type": "string" - } - } - } - }, - "properties": { - "parent": { - "$ref": "#/$defs/parentRequest" - } - }, - "required": ["parent"] - } - } - """.trimIndent() } 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 deleted file mode 100644 index 25d9ee6..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt +++ /dev/null @@ -1,183 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.types.dsl - -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.jsonPrimitive -import kotlinx.serialization.json.put -import kotlin.test.Test - -/** - * Tests for ClientCapabilities DSL builder. - * - * Verifies that capabilities can be constructed via DSL within InitializeRequest, - * covering minimal (empty), full (all fields), and variant patterns. - */ -@OptIn(ExperimentalMcpApi::class) -class CapabilitiesDslTest { - @Test - fun `capabilities should build minimal empty capabilities`() { - val request = buildInitializeRequest { - protocolVersion = "2024-11-05" - capabilities { } - info("Test", "1.0") - } - - request.params.capabilities.shouldNotBeNull { - sampling.shouldBeNull() - roots.shouldBeNull() - elicitation.shouldBeNull() - experimental.shouldBeNull() - extensions.shouldBeNull() - } - } - - @Test - fun `capabilities should build full with all fields and nested properties`() { - val request = buildInitializeRequest { - protocolVersion = "2024-11-05" - capabilities { - sampling(ClientCapabilities.Sampling(tools = EmptyJsonObject)) - roots(listChanged = true) - elicitation(ClientCapabilities.Elicitation(form = EmptyJsonObject, url = EmptyJsonObject)) - experimental { - put("customFeature", true) - put("version", "1.0") - put("beta", false) - put("maxConcurrency", 10) - } - extensions( - mapOf( - "io.modelcontextprotocol/ui" to buildJsonObject { - put("mimeTypes", "text/html") - }, - ), - ) - } - info("Test", "1.0") - } - - request.params.capabilities.shouldNotBeNull { - sampling shouldNotBeNull { - tools shouldBe EmptyJsonObject - } - roots shouldNotBeNull { - listChanged shouldBe true - } - elicitation shouldNotBeNull { - form shouldBe EmptyJsonObject - url shouldBe EmptyJsonObject - } - experimental shouldNotBeNull { - get("customFeature")?.jsonPrimitive?.content shouldBe "true" - get("version")?.jsonPrimitive?.content shouldBe "1.0" - 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" - } - } - } - } - - @Test - fun `capabilities should support roots variants`() { - // Test listChanged = true - val requestTrue = buildInitializeRequest { - protocolVersion = "2024-11-05" - capabilities { roots(listChanged = true) } - info("Test", "1.0") - } - requestTrue.params.capabilities.roots?.listChanged shouldBe true - - // Test listChanged = false - val requestFalse = buildInitializeRequest { - protocolVersion = "2024-11-05" - capabilities { roots(listChanged = false) } - info("Test", "1.0") - } - requestFalse.params.capabilities.roots?.listChanged shouldBe false - - // Test listChanged = null (not provided) - val requestNull = buildInitializeRequest { - protocolVersion = "2024-11-05" - capabilities { roots() } - info("Test", "1.0") - } - 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 - experimental { put("feature", "v1") } - experimental { put("feature", "v2") } // Should overwrite - } - info("Test", "1.0") - } - - request.params.capabilities.shouldNotBeNull { - sampling shouldNotBeNull { - tools shouldBe EmptyJsonObject - } - experimental shouldNotBeNull { - get("feature")?.jsonPrimitive?.content shouldBe "v2" - } - } - } -} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ContentDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ContentDslTest.kt deleted file mode 100644 index 4801d02..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ContentDslTest.kt +++ /dev/null @@ -1,385 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.types.dsl - -import io.kotest.assertions.throwables.shouldThrow -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.Annotations -import io.modelcontextprotocol.kotlin.sdk.types.AudioContent -import io.modelcontextprotocol.kotlin.sdk.types.ImageContent -import io.modelcontextprotocol.kotlin.sdk.types.Role -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.assistantAudio -import io.modelcontextprotocol.kotlin.sdk.types.assistantImage -import io.modelcontextprotocol.kotlin.sdk.types.buildCreateMessageRequest -import io.modelcontextprotocol.kotlin.sdk.types.userText -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put -import kotlin.test.Test - -/** - * Tests for MediaContent DSL builders (TextContent, ImageContent, AudioContent). - * - * Verifies content can be constructed via DSL within sampling messages, - * covering minimal (required fields), full (all fields), validation, and edge cases. - */ -@OptIn(ExperimentalMcpApi::class) -class ContentDslTest { - - // ======================================================================== - // TextContent Tests - // ======================================================================== - - @Test - fun `userText should build minimal with text only`() { - val request = buildCreateMessageRequest { - maxTokens = 100 - messages { - userText { - text = "Hello, world!" - } - } - } - - (request.params.messages[0].content.single() as TextContent).shouldNotBeNull { - text shouldBe "Hello, world!" - annotations.shouldBeNull() - meta.shouldBeNull() - } - } - - @Test - fun `userText should build full with all fields and nested meta`() { - val request = buildCreateMessageRequest { - maxTokens = 100 - messages { - userText { - text = "Analyze the quarterly sales report for Q4 2025" - annotations( - audience = listOf(Role.User, Role.Assistant), - priority = 0.85, - lastModified = "2025-02-17T10:30:00Z", - ) - meta { - put("source", "dashboard") - put("userId", "user-123") - put("sessionId", 42) - put("analyzed", true) - } - } - } - } - - (request.params.messages[0].content.single() as TextContent).shouldNotBeNull { - text shouldBe "Analyze the quarterly sales report for Q4 2025" - annotations shouldNotBeNull { - audience shouldBe listOf(Role.User, Role.Assistant) - priority shouldBe 0.85 - lastModified shouldBe "2025-02-17T10:30:00Z" - } - meta shouldNotBeNull { - get("source")?.jsonPrimitive?.content shouldBe "dashboard" - get("userId")?.jsonPrimitive?.content shouldBe "user-123" - get("sessionId")?.jsonPrimitive?.content shouldBe "42" - get("analyzed")?.jsonPrimitive?.content shouldBe "true" - } - } - } - - @Test - fun `userText should handle unicode and special characters`() { - val request = buildCreateMessageRequest { - maxTokens = 100 - messages { - userText { - text = "Hello 🌍! Ça va? Москва 北京 مرحبا" - } - } - } - - (request.params.messages[0].content.single() as TextContent).text shouldBe "Hello 🌍! Ça va? Москва 北京 مرحبا" - } - - @Test - fun `userText should handle empty string`() { - val request = buildCreateMessageRequest { - maxTokens = 100 - messages { - userText { - text = "" - } - } - } - - (request.params.messages[0].content.single() as TextContent).text shouldBe "" - } - - @Test - fun `userText should throw if text is missing`() { - shouldThrow { - buildCreateMessageRequest { - maxTokens = 100 - messages { - userText { } - } - } - } - } - - // ======================================================================== - // ImageContent Tests - // ======================================================================== - - @Test - fun `assistantImage should build minimal with data and mimeType`() { - val request = buildCreateMessageRequest { - maxTokens = 100 - messages { - assistantImage { - data = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" - mimeType = "image/png" - } - } - } - - (request.params.messages[0].content.single() as ImageContent).shouldNotBeNull { - data shouldBe - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" - mimeType shouldBe "image/png" - annotations.shouldBeNull() - meta.shouldBeNull() - } - } - - @Test - fun `assistantImage should build full with all fields and image metadata`() { - val request = buildCreateMessageRequest { - maxTokens = 100 - messages { - assistantImage { - data = "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFklEQVR42mNk+M9QzzCCgAEYjRZnRwEA" - mimeType = "image/jpeg" - annotations( - audience = listOf(Role.Assistant), - priority = 0.92, - lastModified = "2025-02-17T11:45:30Z", - ) - meta { - put("width", 1920) - put("height", 1080) - put("format", "jpeg") - put("compression", "lossy") - put("quality", 85) - } - } - } - } - - (request.params.messages[0].content.single() as ImageContent).shouldNotBeNull { - mimeType shouldBe "image/jpeg" - annotations shouldNotBeNull { - audience shouldBe listOf(Role.Assistant) - priority shouldBe 0.92 - lastModified shouldBe "2025-02-17T11:45:30Z" - } - meta shouldNotBeNull { - get("width")?.jsonPrimitive?.content shouldBe "1920" - get("height")?.jsonPrimitive?.content shouldBe "1080" - get("format")?.jsonPrimitive?.content shouldBe "jpeg" - get("compression")?.jsonPrimitive?.content shouldBe "lossy" - get("quality")?.jsonPrimitive?.content shouldBe "85" - } - } - } - - @Test - fun `assistantImage should accept various MIME types`() { - val mimeTypes = listOf("image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml") - - mimeTypes.forEach { mime -> - val request = buildCreateMessageRequest { - maxTokens = 100 - messages { - assistantImage { - data = "base64data" - mimeType = mime - } - } - } - (request.params.messages[0].content.single() as ImageContent).mimeType shouldBe mime - } - } - - @Test - fun `assistantImage should throw if data is missing`() { - shouldThrow { - buildCreateMessageRequest { - maxTokens = 100 - messages { - assistantImage { - mimeType = "image/png" - } - } - } - } - } - - @Test - fun `assistantImage should throw if mimeType is missing`() { - shouldThrow { - buildCreateMessageRequest { - maxTokens = 100 - messages { - assistantImage { - data = "base64data" - } - } - } - } - } - - // ======================================================================== - // AudioContent Tests - // ======================================================================== - - @Test - fun `assistantAudio should build minimal with data and mimeType`() { - val request = buildCreateMessageRequest { - maxTokens = 100 - messages { - assistantAudio { - data = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==" - mimeType = "audio/wav" - } - } - } - - (request.params.messages[0].content.single() as AudioContent).shouldNotBeNull { - data shouldBe "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==" - mimeType shouldBe "audio/wav" - annotations.shouldBeNull() - meta.shouldBeNull() - } - } - - @Test - fun `assistantAudio should build full with all fields and audio metadata`() { - val request = buildCreateMessageRequest { - maxTokens = 100 - messages { - assistantAudio { - data = "//uQxAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADIADMzMzMzMzMzMzMzMzMzMzMzMzMzMzM" - mimeType = "audio/mpeg" - annotations( - audience = listOf(Role.User), - priority = 0.75, - lastModified = "2025-02-17T12:00:00Z", - ) - meta { - put("duration", 180) - put("bitrate", 128) - put("codec", "mp3") - put("sampleRate", 44100) - put("channels", 2) - } - } - } - } - - (request.params.messages[0].content.single() as AudioContent).shouldNotBeNull { - mimeType shouldBe "audio/mpeg" - annotations shouldNotBeNull { - audience shouldBe listOf(Role.User) - priority shouldBe 0.75 - lastModified shouldBe "2025-02-17T12:00:00Z" - } - meta shouldNotBeNull { - get("duration")?.jsonPrimitive?.content shouldBe "180" - get("bitrate")?.jsonPrimitive?.content shouldBe "128" - get("codec")?.jsonPrimitive?.content shouldBe "mp3" - get("sampleRate")?.jsonPrimitive?.content shouldBe "44100" - get("channels")?.jsonPrimitive?.content shouldBe "2" - } - } - } - - @Test - fun `assistantAudio should throw if data is missing`() { - shouldThrow { - buildCreateMessageRequest { - maxTokens = 100 - messages { - assistantAudio { - mimeType = "audio/wav" - } - } - } - } - } - - @Test - fun `assistantAudio should throw if mimeType is missing`() { - shouldThrow { - buildCreateMessageRequest { - maxTokens = 100 - messages { - assistantAudio { - data = "base64audio" - } - } - } - } - } - - // ======================================================================== - // Annotations Edge Cases - // ======================================================================== - - @Test - fun `annotations should support boundary priority values`() { - // Priority = 0.0 (minimum) - val requestMin = buildCreateMessageRequest { - maxTokens = 100 - messages { - userText { - text = "Min priority" - annotations(priority = 0.0) - } - } - } - (requestMin.params.messages[0].content.single() as TextContent).annotations?.priority shouldBe 0.0 - - // Priority = 1.0 (maximum) - val requestMax = buildCreateMessageRequest { - maxTokens = 100 - messages { - userText { - text = "Max priority" - annotations(priority = 1.0) - } - } - } - (requestMax.params.messages[0].content.single() as TextContent).annotations?.priority shouldBe 1.0 - } - - @Test - fun `annotations should support direct object construction`() { - val request = buildCreateMessageRequest { - maxTokens = 100 - messages { - userText { - text = "With annotations object" - annotations(Annotations(priority = 0.5, audience = listOf(Role.User))) - } - } - } - - (request.params.messages[0].content.single() as TextContent).annotations shouldNotBeNull { - priority shouldBe 0.5 - audience shouldBe listOf(Role.User) - lastModified.shouldBeNull() - } - } -} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ElicitationDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ElicitationDslTest.kt index 4a34bab..e49e4d2 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ElicitationDslTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ElicitationDslTest.kt @@ -2,11 +2,8 @@ package io.modelcontextprotocol.kotlin.sdk.types.dsl import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe -import io.kotest.matchers.types.shouldBeInstanceOf import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi -import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.StringSchema import io.modelcontextprotocol.kotlin.sdk.types.buildElicitRequest import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -27,15 +24,14 @@ class ElicitationDslTest { } request.params.message shouldBe "Provide info" - val formParams = request.params.shouldBeInstanceOf() - formParams.requestedSchema.properties["email"].shouldBeInstanceOf() - formParams.requestedSchema.required shouldBe listOf("email") + request.params.requestedSchema.properties["email"] shouldBe buildJsonObject { put("type", "string") } + request.params.requestedSchema.required shouldBe listOf("email") } @Test fun `buildElicitRequest should support direct requestedSchema`() { val schema = ElicitRequestParams.RequestedSchema( - properties = mapOf("name" to StringSchema()), + properties = buildJsonObject { put("name", buildJsonObject { put("type", "string") }) }, required = listOf("name"), ) val request = buildElicitRequest { @@ -43,20 +39,19 @@ class ElicitationDslTest { requestedSchema(schema) } - val formParams = request.params.shouldBeInstanceOf() - formParams.requestedSchema shouldBe schema + request.params.requestedSchema shouldBe schema } @Test fun `ElicitRequestedSchemaBuilder should support direct properties assignment`() { + val props = buildJsonObject { put("key", "value") } val request = buildElicitRequest { message = "Test" requestedSchema { - properties(buildJsonObject { put("key", buildJsonObject { put("type", "string") }) }) + properties(props) } } - val formParams = request.params.shouldBeInstanceOf() - formParams.requestedSchema.properties["key"].shouldBeInstanceOf() + request.params.requestedSchema.properties shouldBe props } @Test 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/PingRequestDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/PingRequestDslTest.kt index 79cef06..5a219ba 100644 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/PingRequestDslTest.kt +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/PingRequestDslTest.kt @@ -49,16 +49,12 @@ class PingRequestDslTest { val requestInt = buildPingRequest { meta { progressToken(123) } } - requestInt.params?.meta?.json - ?.get("progressToken") - ?.jsonPrimitive?.int shouldBe 123 + requestInt.params?.meta?.json?.get("progressToken")?.jsonPrimitive?.int shouldBe 123 val requestLong = buildPingRequest { meta { progressToken(456L) } } - requestLong.params?.meta?.json - ?.get("progressToken") - ?.jsonPrimitive?.int shouldBe 456 + requestLong.params?.meta?.json?.get("progressToken")?.jsonPrimitive?.int shouldBe 456 } @Test diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/RequestDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/RequestDslTest.kt deleted file mode 100644 index ad9c719..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/RequestDslTest.kt +++ /dev/null @@ -1,256 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.types.dsl - -import io.kotest.matchers.collections.shouldContainAll -import io.kotest.matchers.nulls.shouldBeNull -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.kotest.matchers.types.shouldBeInstanceOf -import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi -import io.modelcontextprotocol.kotlin.sdk.types.Method -import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import io.modelcontextprotocol.kotlin.sdk.types.buildListToolsRequest -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.add -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long -import kotlinx.serialization.json.put -import kotlin.test.Test - -/** - * Tests for RequestMeta and PaginatedRequest DSL builders. - * - * Verifies meta and cursor can be constructed via DSL within paginated requests, - * covering minimal (no params), full (all field types), variants, and edge cases. - */ -@OptIn(ExperimentalMcpApi::class) -class RequestDslTest { - @Test - fun `request should build minimal without any params`() { - val request = buildListToolsRequest { } - - request.params.shouldBeNull() - request.method shouldBe Method.Defined.ToolsList - } - - @Test - fun `request should build full with cursor and meta containing all field types`() { - val request = buildListToolsRequest { - cursor = "next-page-eyJvZmZzZXQiOjEwMH0" - meta { - // ProgressToken - progressToken("progress-abc123") - - // String fields - put("requestId", "req-456") - put("source", "api-gateway") - - // Number fields - put("priority", 5) - put("retryCount", 3L) - put("timeout", 30000) - - // Boolean fields - put("urgent", true) - put("cached", false) - - // Null field - put("optional", null) - - // Nested JsonObject - putJsonObject("context") { - put("userId", "user-789") - put("sessionId", "sess-abc") - put("authenticated", true) - put("permissions", 7) - } - - // JsonArray - putJsonArray("tags") { - add("production") - add("critical") - add("monitored") - } - } - } - - request.params shouldNotBeNull { - cursor shouldBe "next-page-eyJvZmZzZXQiOjEwMH0" - meta shouldNotBeNull { - // ProgressToken - progressToken shouldNotBeNull { - shouldBeInstanceOf() - value shouldBe "progress-abc123" - } - - // String fields - get("requestId")?.jsonPrimitive?.content shouldBe "req-456" - get("source")?.jsonPrimitive?.content shouldBe "api-gateway" - - // Number fields - get("priority")?.jsonPrimitive?.int shouldBe 5 - get("retryCount")?.jsonPrimitive?.long shouldBe 3L - get("timeout")?.jsonPrimitive?.int shouldBe 30000 - - // Boolean fields - get("urgent")?.jsonPrimitive?.boolean shouldBe true - get("cached")?.jsonPrimitive?.boolean shouldBe false - - // Null field - get("optional") shouldBe JsonNull - - // Nested JsonObject - get("context") shouldNotBeNull { - jsonObject["userId"]?.jsonPrimitive?.content shouldBe "user-789" - jsonObject["sessionId"]?.jsonPrimitive?.content shouldBe "sess-abc" - jsonObject["authenticated"]?.jsonPrimitive?.boolean shouldBe true - jsonObject["permissions"]?.jsonPrimitive?.int shouldBe 7 - } - - // JsonArray - get("tags") shouldNotBeNull { - jsonArray.map { it.jsonPrimitive.content } shouldContainAll listOf( - "production", - "critical", - "monitored", - ) - } - } - } - } - - @Test - fun `meta progressToken should support String Int and Long types`() { - // String progressToken - val stringRequest = buildListToolsRequest { - meta { progressToken("token-abc") } - } - stringRequest.params?.meta?.progressToken shouldNotBeNull { - shouldBeInstanceOf() - value shouldBe "token-abc" - } - - // Int progressToken - val intRequest = buildListToolsRequest { - meta { progressToken(42) } - } - intRequest.params?.meta?.progressToken shouldNotBeNull { - shouldBeInstanceOf() - value shouldBe 42L - } - - // Long progressToken - val longRequest = buildListToolsRequest { - meta { progressToken(999L) } - } - longRequest.params?.meta?.progressToken shouldNotBeNull { - shouldBeInstanceOf() - value shouldBe 999L - } - } - - @Test - fun `meta should support custom fields without progressToken`() { - val request = buildListToolsRequest { - meta { - put("requestId", "req-123") - put("source", "cli") - // No progressToken - } - } - - request.params shouldNotBeNull { - meta shouldNotBeNull { - progressToken.shouldBeNull() - get("requestId")?.jsonPrimitive?.content shouldBe "req-123" - get("source")?.jsonPrimitive?.content shouldBe "cli" - } - } - } - - @Test - fun `cursor should work without meta`() { - val request = buildListToolsRequest { - cursor = "page-2-cursor" - } - - request.params shouldNotBeNull { - cursor shouldBe "page-2-cursor" - meta.shouldBeNull() - } - } - - @Test - fun `cursor should handle empty string`() { - val request = buildListToolsRequest { - cursor = "" - } - - request.params shouldNotBeNull { - cursor shouldBe "" - } - } - - @Test - fun `meta should handle special characters in keys`() { - val request = buildListToolsRequest { - meta { - put("key-with-dashes", "value1") - put("key.with.dots", "value2") - put("key_with_underscores", "value3") - put("keyWithCamelCase", "value4") - } - } - - request.params?.meta shouldNotBeNull { - get("key-with-dashes")?.jsonPrimitive?.content shouldBe "value1" - get("key.with.dots")?.jsonPrimitive?.content shouldBe "value2" - get("key_with_underscores")?.jsonPrimitive?.content shouldBe "value3" - get("keyWithCamelCase")?.jsonPrimitive?.content shouldBe "value4" - } - } - - @Test - fun `meta should overwrite when progressToken set multiple times`() { - val request = buildListToolsRequest { - meta { - progressToken("first") - progressToken(123) // Different type - progressToken("second") // Back to string, should be final - } - } - - request.params?.meta?.progressToken shouldNotBeNull { - shouldBeInstanceOf() - value shouldBe "second" - } - } - - @Test - fun `meta should overwrite when custom field set multiple times`() { - val request = buildListToolsRequest { - meta { - put("key", "first") - put("key", 123) // Different type - put("key", "third") // Back to string, should be final - } - } - - request.params?.meta shouldNotBeNull { - get("key")?.jsonPrimitive?.content shouldBe "third" - } - } - - @Test - fun `meta should handle very long cursor strings`() { - val longCursor = "x".repeat(1000) - val request = buildListToolsRequest { - cursor = longCursor - } - - request.params?.cursor shouldBe longCursor - } -} 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..5b60cfb 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 @@ -70,7 +70,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 +80,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 +101,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 +125,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/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherSecurityTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherSecurityTest.kt deleted file mode 100644 index fe3fca4..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherSecurityTest.kt +++ /dev/null @@ -1,221 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.utils - -import io.kotest.assertions.withClue -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNotBe -import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate -import kotlin.test.Test - -/** - * Security regression tests for [PathSegmentTemplateMatcher]. - * - * Every test **fails** when an attack succeeds — green = defended. - * - * Findings addressed: - * - F1: Exactly one percent-decode pass (no double-decoding). - * - F2: Variable values are attacker-controlled; handlers must validate them. - * - F3: URI segment depth cap prevents allocation amplification. - * - F4: Dot-segment normalization blocks traversal on JVM/JS (no-op on native/WASM). - * - F5: Double-slash — collapsed by JVM normalization or causes size mismatch on native/WASM. - */ -class PathSegmentTemplateMatcherSecurityTest { - - // region: F4: Dot-segment traversal - - /** - * URIs containing `..` or `.` path segments. - * - * On JVM/JS: [normalizeUri] resolves these to their canonical target path, so the - * traversal URI correctly routes to the same template the normalized path would match. - * On native/WASM: [normalizeUri] is a no-op — the extra segments produce a size mismatch - * and the match returns null (different reason, same safety outcome). - */ - private data class TraversalCase(val label: String, val template: String, val uri: String) - - private val traversalNoMatchCases = listOf( - TraversalCase( - label = "single .. traversal to reach private template", - template = "app://host/private/{name}", - uri = "app://host/public/../private/secret", - ), - TraversalCase( - label = "double .. traversal", - template = "app://host/admin/{name}", - uri = "app://host/a/b/../../admin/config", - ), - TraversalCase( - label = ". current-segment in path", - template = "app://host/users/{id}", - uri = "app://host/./users/42", - ), - ) - - @Test - fun `dot-segment traversal resolves to correct target without raw path markers in variables`() { - // On JVM/JS: normalizeUri() resolves `..` and `.` before segment matching. - // → The traversal URI correctly routes to the normalized target template. - // On native/WASM: no normalization — extra segments cause a size mismatch → null. - // In all cases, captured variable values must not contain raw traversal markers. - for ((label, template, uri) in traversalNoMatchCases) { - withClue("traversal '$label': uri='$uri', template='$template'") { - val result = matcher(template).match(uri) - result?.variables?.values?.forEach { value -> - withClue("variable value '$value' must not contain raw '..' or '.' traversal marker") { - value.contains("..") shouldBe false - value shouldNotBe "." - } - } - } - } - } - - @Test - fun `dot-segment URI matches the template it normalizes to`() { - // "public/../users/42" normalizes to "users/42" on JVM/JS, no-op on native/WASM. - // On JVM/JS this must match; the test is platform-conditional via expect/actual. - val result = matcher("users/{id}").match("users/./42") - // After normalization "users/./42" → "users/42" (JVM/JS) or stays 3 segments (native/WASM). - // Either null (native/WASM — 3 segments vs 2) or a valid match (JVM/JS) is acceptable. - // What must NOT happen is a wrong-template match. We check the value if it matches. - result?.variables?.get("id") shouldBe result?.let { "42" } - } - - // endregion - // region: F1: Single percent-decode pass (no double-decoding) - - /** - * %25 is the encoding of '%'. %252F = "%2F" after one decode pass — must NOT become "/". - * If two decode passes were applied, %252F → %2F → /, enabling path traversal. - */ - @Test - fun `double-encoded percent-sign is decoded only once`() { - val result = matcher("files/{path}").match("files/%252Fetc%252Fpasswd") - result.shouldNotBeNull { - // One decode pass: %25 → %, %2F → /... but only %25 is decoded first. - // %252F → %2F (the %25 decodes to %, leaving literal "%2F") - withClue("double-encoded %2F must not become a slash after one decode pass") { - variables["path"]?.contains('/') shouldBe false - } - } - } - - @Test - fun `percent-encoded slash percent-2F in variable is decoded to slash`() { - // %2F IS decoded (one pass) — the result is "/" in the variable value. - // This documents the known behavior: handlers receive decoded values and must - // validate that path separators are not present when constructing file paths. - val result = matcher("files/{path}").match("files/..%2Fetc%2Fpasswd") - result.shouldNotBeNull { - withClue("decoded %2F becomes a path separator — handler must validate") { - variables["path"] shouldBe "../etc/passwd" - } - } - } - - @Test - fun `null byte percent-00 is decoded and passed to handler`() { - // %00 decodes to the null character. Handlers using C-string APIs or file paths - // must reject values containing null bytes. - val result = matcher("items/{id}").match("items/foo%00bar") - result.shouldNotBeNull { - withClue("decoded null byte is present — handler must reject") { - variables["id"] shouldBe "foo\u0000bar" - } - } - } - - @Test - fun `pct-encoded identity bypass`() { - // %69 = 'i'. "adm%69n" decodes to "admin". If a handler compares the raw URI - // against a deny-list of "admin", it must use the decoded value. - val result = matcher("svc/{role}").match("svc/adm%69n") - result.shouldNotBeNull { - variables["role"] shouldBe "admin" - } - } - - // endregion - // region: F3: URI segment depth cap - - @Test - fun `URI exceeding MAX_DEPTH segments returns null`() { - // 51 slash-separated single-char segments → well over the depth cap of 50 - val deepUri = (1..51).joinToString("/") { "x" } - matcher("{a}").match(deepUri) shouldBe null - } - - @Test - fun `URI at exactly MAX_DEPTH segments is not rejected by depth cap`() { - // A URI with exactly 50 segments must be evaluated normally (still non-matching - // against a 1-segment template, but not rejected by the depth guard itself). - val atLimit = (1..50).joinToString("/") { "x" } - // Template has 1 segment, URI has 50 — size mismatch, not depth rejection. - // Both code paths return null; this test verifies no exception is thrown. - matcher("{id}").match(atLimit) shouldBe null - } - - @Test - fun `many-segment URI against many templates completes quickly`() { - val deepUri = (1..49).joinToString("/") { "seg$it" } - val matchers = (1..100).map { matcher("{v$it}") } - // Must not allocate or compute excessively — just verify it finishes. - matchers.forEach { it.match(deepUri) } - } - - // endregion - // region: F5: Double-slash does not produce spurious matches - - @Test - fun `double-slash in URI path is normalized on JVM or causes size mismatch elsewhere`() { - // On JVM: URI("users//42").normalize() collapses "//" to "/" → "users/42" → matches. - // On native/WASM: no normalization — split produces 3 segments vs 2 in template → null. - // Either outcome is safe: no empty-segment injection reaches the handler unchecked. - val result = matcher("users/{id}").match("users//42") - // If matched (JVM), the captured value must be the legitimate path segment. - result?.variables?.get("id") shouldBe result?.let { "42" } - } - - @Test - fun `double-slash in scheme authority path is normalized on JVM or causes size mismatch elsewhere`() { - // On JVM: URI("app://host//injected").normalize() collapses the path "//" to "/" - // → "app://host/injected" → matches "app://host/{id}". - // On native/WASM: no normalization — extra empty segment causes size mismatch → null. - val result = matcher("app://host/{id}").match("app://host//injected") - // If matched (JVM), the captured value must be the legitimate path segment. - result?.variables?.get("id") shouldBe result?.let { "injected" } - } - - // endregion - // region: Query string and fragment pass-through - - @Test - fun `query string and fragment are captured in variable values - handlers must validate`() { - // PathSegmentTemplateMatcher splits on '/' only and does not strip query - // strings or fragments. They are captured verbatim inside the variable value. - // Handlers MUST reject or strip '?' and '#' before using values in file paths, - // database queries, or any other security-sensitive operation. - - // Segment split of "api://host/foo?bar=baz" → ["api:", "", "host", "foo?bar=baz"] (4). - // Template "api://host/{id}" → ["api:", "", "host", "{id}"] (4). - // → Matches on all platforms; query string ends up in the variable value. - val queryResult = matcher("api://host/{id}").match("api://host/foo?bar=baz") - queryResult.shouldNotBeNull { - variables["id"] shouldBe "foo?bar=baz" - } - - // Fragment is also captured verbatim — same reasoning. - val fragmentResult = matcher("api://host/{id}").match("api://host/foo#section") - fragmentResult.shouldNotBeNull { - variables["id"] shouldBe "foo#section" - } - } - - // endregion - // region: Helpers - - private fun matcher(uriTemplate: String): PathSegmentTemplateMatcher = PathSegmentTemplateMatcher( - resourceTemplate = ResourceTemplate(uriTemplate, "Test"), - ) - // endregion -} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherTest.kt deleted file mode 100644 index 2f43b5e..0000000 --- a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/PathSegmentTemplateMatcherTest.kt +++ /dev/null @@ -1,236 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.utils - -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.matchers.maps.shouldBeEmpty -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate -import kotlin.test.Test - -class PathSegmentTemplateMatcherTest { - - // region: Construction - - @Test - fun `should throw on blank template`() { - shouldThrow { - matcher(" ") - } - } - - @Test - fun `should throw on empty variable name`() { - shouldThrow { - matcher("users/{}") - } - } - - @Test - fun `should throw when depth exceeds maxTemplateDepth`() { - val deep = "a/b/c/d/e/f/g/h/i/j/k" // 11 segments - shouldThrow { - matcher(deep, maxDepth = 10) - } - } - - @Test - fun `should accept template at exactly maxTemplateDepth`() { - val atLimit = "a/b/c/d/e/f/g/h/i/j" // 10 segments - matcher(atLimit, maxDepth = 10) // must not throw - } - - // endregion - // region: Basic matching - - @Test - fun `should return null for URI with fewer segments than template`() { - matcher("users/{id}/posts").match("users/42") shouldBe null - } - - @Test - fun `should return null for URI with more segments than template`() { - matcher("users/{id}").match("users/42/extra") shouldBe null - } - - @Test - fun `should match all-literal template`() { - val result = matcher("users/profile").match("users/profile") - result.shouldNotBeNull { - variables.shouldBeEmpty() - } - } - - @Test - fun `should return null when literal segment does not match`() { - matcher("users/profile").match("users/settings") shouldBe null - } - - @Test - fun `should extract single variable`() { - val result = matcher("users/{id}").match("users/42") - result.shouldNotBeNull { - variables["id"] shouldBe "42" - } - } - - @Test - fun `should extract multiple variables`() { - val result = matcher("users/{userId}/posts/{postId}").match("users/alice/posts/99") - result.shouldNotBeNull { - variables["userId"] shouldBe "alice" - variables["postId"] shouldBe "99" - } - } - - @Test - fun `should match template with scheme`() { - val result = matcher("test://items/{id}").match("test://items/42") - result.shouldNotBeNull { - variables["id"] shouldBe "42" - } - } - - // endregion - // region:Scoring - - @Test - fun `all-literal template scores higher than parameterized template for same URI`() { - val literal = matcher("users/profile").match("users/profile")!! - val parameterized = matcher("users/{id}").match("users/profile")!! - (literal.score > parameterized.score) shouldBe true - } - - @Test - fun `score increases with number of segments`() { - val short = matcher("a/b").match("a/b")!! - val long = matcher("a/b/c").match("a/b/c")!! - (long.score > short.score) shouldBe true - } - - @Test - fun `all-literal two-segment template score is 4`() { - // 2 literal segments × LITERAL_MATCH_SCORE(2) = 4 - matcher("users/profile").match("users/profile")!!.score shouldBe 4 - } - - @Test - fun `one-variable two-segment template score is 3`() { - // 1 literal × 2 + 1 variable × 1 = 3 - matcher("users/{id}").match("users/42")!!.score shouldBe 3 - } - - @Test - fun `all-variable template scores one per segment`() { - // 2 variables × VARIABLE_MATCH_SCORE(1) = 2 - matcher("{a}/{b}").match("x/y")!!.score shouldBe 2 - } - - // endregion - // region:URL decoding - - @Test - fun `should URL-decode percent-encoded variable value`() { - val result = matcher("search/{query}").match("search/hello%20world") - result.shouldNotBeNull { - variables["query"] shouldBe "hello world" - } - } - - @Test - fun `should URL-decode percent-encoded literal segment before comparing`() { - // %66 decodes to 'f', so "pro%66ile" == "profile" after decoding — it matches - matcher("users/profile").match("users/pro%66ile").shouldNotBeNull() - } - - // endregion - // region:Length guard - - @Test - fun `should return null when URI exceeds maxUrlLength`() { - val longUri = "a/" + "x".repeat(2048) - matcher("a/{id}", maxUrlLength = 2048).match(longUri) shouldBe null - } - - @Test - fun `should match URI at exactly maxUrlLength`() { - // URI of exactly maxUrlLength characters must be accepted - val uri = "a/" + "x".repeat(2046) // length = 2048 - matcher("a/{id}", maxUrlLength = 2048).match(uri).shouldNotBeNull() - } - - // endregion - // region:Edge cases - - @Test - fun `should match root-level single segment template`() { - val result = matcher("{id}").match("42") - result.shouldNotBeNull { - variables["id"] shouldBe "42" - } - } - - @Test - fun `should treat leading and trailing slashes as equivalent`() { - val result = matcher("/users/{id}/").match("/users/7/") - result.shouldNotBeNull { - variables["id"] shouldBe "7" - } - } - - @Test - fun `should capture empty string for single-segment variable when URI is empty`() { - // "".trim('/').split("/") == [""] — one segment — so {id} captures "" - val result = matcher("{id}").match("") - result.shouldNotBeNull { - variables["id"] shouldBe "" - } - } - - @Test - fun `factory creates matcher equal to direct construction`() { - val template = ResourceTemplate("items/{id}", "Items") - val fromFactory = PathSegmentTemplateMatcher.factory.create(template) - val direct = PathSegmentTemplateMatcher(template) - - val uriToMatch = "items/99" - fromFactory.match(uriToMatch) shouldBe direct.match(uriToMatch) - } - - // endregion - // region: MatchResult equality - - @Test - fun `MatchResult equals by value`() { - val a = MatchResult(mapOf("id" to "1"), score = 3) - val b = MatchResult(mapOf("id" to "1"), score = 3) - a shouldBe b - } - - @Test - fun `MatchResult not equal when score differs`() { - val a = MatchResult(mapOf("id" to "1"), score = 3) - val b = MatchResult(mapOf("id" to "1"), score = 2) - (a == b) shouldBe false - } - - @Test - fun `MatchResult not equal when variables differ`() { - val a = MatchResult(mapOf("id" to "1"), score = 3) - val b = MatchResult(mapOf("id" to "2"), score = 3) - (a == b) shouldBe false - } - - // endregion - // region: Helpers - - private fun matcher( - uriTemplate: String, - maxUrlLength: Int = 2048, - maxDepth: Int = 10, - ): PathSegmentTemplateMatcher = PathSegmentTemplateMatcher( - resourceTemplate = ResourceTemplate(uriTemplate, "Test"), - maxUriLength = maxUrlLength, - maxDepth = maxDepth, - ) - // endregion -} diff --git a/kotlin-sdk-core/src/jsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.js.kt b/kotlin-sdk-core/src/jsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.js.kt deleted file mode 100644 index fb8aaf4..0000000 --- a/kotlin-sdk-core/src/jsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.js.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.utils - -private external class URL(url: String) { - val href: String -} - -/** - * Normalizes [uri] via the browser/Node.js `URL` API, resolving dot segments and - * normalizing the scheme, host, and path. Returns [uri] unchanged if it cannot be - * parsed (e.g. relative URIs or schemes unsupported by the runtime). - */ -internal actual fun normalizeUri(uri: String): String = try { - URL(uri).href -} catch (_: Throwable) { - uri -} diff --git a/kotlin-sdk-core/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/sdk/internal/utils.jvm.kt b/kotlin-sdk-core/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/sdk/internal/utils.jvm.kt index 35a1e98..d00c1ab 100644 --- a/kotlin-sdk-core/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/sdk/internal/utils.jvm.kt +++ b/kotlin-sdk-core/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/sdk/internal/utils.jvm.kt @@ -3,6 +3,5 @@ package io.modelcontextprotocol.kotlin.sdk.internal import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -/** Platform-specific [CoroutineDispatcher] for I/O-bound operations. */ public actual val IODispatcher: CoroutineDispatcher get() = Dispatchers.IO diff --git a/kotlin-sdk-core/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.jvm.kt b/kotlin-sdk-core/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.jvm.kt deleted file mode 100644 index 0cde506..0000000 --- a/kotlin-sdk-core/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.jvm.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.utils - -import java.net.URI -import java.net.URISyntaxException - -/** - * Normalizes [uri] by resolving dot segments (e.g. `/a/b/../c` → `/a/c`) via [URI.normalize]. - * Returns [uri] unchanged if it is not a valid URI. - */ -internal actual fun normalizeUri(uri: String): String = try { - URI(uri).normalize().toString() -} catch (_: URISyntaxException) { - uri -} diff --git a/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransportTest.kt b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransportTest.kt deleted file mode 100644 index 1d144d8..0000000 --- a/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransportTest.kt +++ /dev/null @@ -1,498 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.shared - -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.matchers.shouldBe -import io.kotest.matchers.string.shouldContain -import io.mockk.spyk -import io.modelcontextprotocol.kotlin.sdk.InternalMcpApi -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 kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource -import org.junit.jupiter.params.provider.EnumSource -import org.junit.jupiter.params.provider.MethodSource -import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.coroutines.cancellation.CancellationException - -/** - * Unit tests for [AbstractClientTransport] state machine and lifecycle. - * - * Tests behavioral correctness of state transitions, lifecycle management, - * and message sending without relying on mocking frameworks. - */ -@OptIn(ExperimentalAtomicApi::class) -class AbstractClientTransportTest { - - companion object { - @JvmStatic - fun nonMcpExceptions() = listOf( - TestException("Generic failure"), - java.io.IOException("Network error"), - IllegalStateException("Invalid state"), - ) - } - - private lateinit var transport: TestClientTransport - - @BeforeEach - fun beforeEach() { - transport = spyk(TestClientTransport()) - } - - @Nested - @DisplayName("Lifecycle") - inner class LifecycleTests { - - @Test - fun `should start with NEW state`() { - transport.currentState shouldBe ClientTransportState.New - } - - @Test - fun `should transition to OPERATIONAL after successful start`() = runTest { - transport.start() - - transport.currentState shouldBe ClientTransportState.Operational - transport.initializeCalled shouldBe true - } - - @Test - fun `should call initialize during start`() = runTest { - transport.start() - - transport.initializeCalled shouldBe true - transport.currentState shouldBe ClientTransportState.Operational - } - - @Test - fun `should transition to INITIALIZATION_FAILED on start error`() = runTest { - transport.shouldFailInitialization = true - - shouldThrow { - transport.start() - } - - transport.currentState shouldBe ClientTransportState.InitializationFailed - transport.closeResourcesCalled shouldBe true - } - - @Test - fun `should call closeResources on initialization failure`() = runTest { - transport.shouldFailInitialization = true - - shouldThrow { - transport.start() - } - - transport.closeResourcesCalled shouldBe true - } - - @Test - fun `should reject starting twice`() = runTest { - transport.start() - - val exception = shouldThrow { - transport.start() - } - exception.message shouldContain "expected transport state New" - } - - @Test - fun `should transition to STOPPED after successful close`() = runTest { - transport.start() - transport.close() - - transport.currentState shouldBe ClientTransportState.Stopped - transport.closeResourcesCalled shouldBe true - } - - @Test - fun `should call closeResources during close`() = runTest { - transport.start() - - transport.close() - - transport.closeResourcesCalled shouldBe true - transport.currentState shouldBe ClientTransportState.Stopped - } - - @Test - fun `should be idempotent when closed multiple times`() = runTest { - // given - transport.start() - transport.close() - // when - transport.close() - transport.close() - - transport.currentState shouldBe ClientTransportState.Stopped - transport.closeResourcesCallCount shouldBe 1 - } - - @Test - fun `should call onClose callback exactly once on multiple close calls`() = runTest { - var onCloseCallCount = 0 - transport.onClose { onCloseCallCount++ } - - transport.start() - transport.close() - transport.close() - - onCloseCallCount shouldBe 1 - } - - @Test - fun `should transition to SHUTDOWN_FAILED on close error`() = runTest { - transport.shouldFailClose = true - transport.start() - - transport.close() - - transport.currentState shouldBe ClientTransportState.ShutdownFailed - } - - @Test - fun `should call onClose even when close fails`() = runTest { - // given - var onCloseCalled = false - transport.onClose { onCloseCalled = true } - transport.shouldFailClose = true - transport.start() - // when - transport.close() - // then - onCloseCalled shouldBe true - } - - @Test - fun `should propagate CancellationException on close`() = runTest { - // given - transport.shouldThrowCancellation = true - transport.start() - // when-then - shouldThrow { - transport.close() - } - - transport.currentState shouldBe ClientTransportState.ShutdownFailed - } - - @Test - fun `should call onClose even when CancellationException is thrown`() = runTest { - var onCloseCalled = false - transport.onClose { onCloseCalled = true } - transport.shouldThrowCancellation = true - transport.start() - - shouldThrow { - transport.close() - } - - onCloseCalled shouldBe true - } - } - - @Nested - @DisplayName("State Transitions") - inner class StateTransitionTests { - - @ParameterizedTest - @CsvSource( - "New, Initializing", - "New, Stopped", - "Initializing, Operational", - "Initializing, InitializationFailed", - "Operational, ShuttingDown", - "ShuttingDown, Stopped", - "ShuttingDown, ShutdownFailed", - ) - fun `should allow valid transitions`(fromName: String, toName: String) { - val from = ClientTransportState.valueOf(fromName) - val to = ClientTransportState.valueOf(toName) - - transport.forceState(from) - transport.testStateTransition(from, to) - - transport.currentState shouldBe to - } - - @ParameterizedTest - @CsvSource( - // From New: only Initializing is valid - "New, Operational", - "New, InitializationFailed", - "New, ShuttingDown", - "New, ShutdownFailed", - // From Initializing: only Operational or InitializationFailed are valid - "Initializing, New", - "Initializing, Initializing", - "Initializing, ShuttingDown", - "Initializing, Stopped", - "Initializing, ShutdownFailed", - // From Operational: only ShuttingDown is valid - "Operational, New", - "Operational, Initializing", - "Operational, InitializationFailed", - "Operational, Operational", - "Operational, Stopped", - "Operational, ShutdownFailed", - // From ShuttingDown: only Stopped or ShutdownFailed are valid - "ShuttingDown, New", - "ShuttingDown, Initializing", - "ShuttingDown, InitializationFailed", - "ShuttingDown, Operational", - "ShuttingDown, ShuttingDown", - // From terminal states: no transitions allowed - "InitializationFailed, New", - "InitializationFailed, Operational", - "Stopped, New", - "Stopped, Operational", - "ShutdownFailed, New", - "ShutdownFailed, Operational", - ) - fun `should reject invalid transitions`(fromName: String, toName: String) { - val from = ClientTransportState.valueOf(fromName) - val to = ClientTransportState.valueOf(toName) - - transport.forceState(from) - - val exception = shouldThrow { - transport.testStateTransition(from, to) - } - exception.message shouldContain "Invalid transition: $from → $to" - } - } - - @Nested - @DisplayName("Send Operations") - inner class SendTests { - - @Test - fun `should send message successfully when OPERATIONAL`() = runTest { - val message = PingRequest().toJSON() - - transport.start() - transport.send(message) - - transport.sentMessages shouldBe listOf(message) - } - - @Test - fun `should pass options to performSend`() = runTest { - val message = PingRequest().toJSON() - val options = TransportSendOptions() - - transport.start() - transport.send(message, options) - - transport.sentMessages shouldBe listOf(message) - transport.lastSendOptions shouldBe options - } - - @Test - fun `should throw when sending before start`() = runTest { - val exception = shouldThrow { - transport.send(PingRequest().toJSON()) - } - - exception.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - exception.message shouldContain "Transport is not ready" - } - - @Test - fun `should throw when sending after close`() = runTest { - transport.start() - transport.close() - - val exception = shouldThrow { - transport.send(PingRequest().toJSON()) - } - - exception.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - exception.message shouldContain "Transport is not ready" - } - - @ParameterizedTest - @EnumSource( - value = ClientTransportState::class, - names = ["Operational"], - mode = EnumSource.Mode.EXCLUDE, - ) - fun `should throw when sending in non-OPERATIONAL state`(state: ClientTransportState) = runTest { - transport.forceState(state) - - val exception = shouldThrow { - transport.send(PingRequest().toJSON()) - } - - exception.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - exception.message shouldContain "Transport is not ready" - transport.sentMessages.isEmpty() shouldBe true - } - - @Test - fun `should call onError when performSend throws McpException`() = runTest { - var errorCallCount = 0 - var capturedError: Throwable? = null - transport.onError { error -> - errorCallCount++ - capturedError = error - } - transport.shouldFailSend = true - transport.sendException = McpException(RPCError.ErrorCode.INTERNAL_ERROR, "Send failed") - - transport.start() - val exception = shouldThrow { - transport.send(PingRequest().toJSON()) - } - - errorCallCount shouldBe 1 - capturedError shouldBe transport.sendException - exception shouldBe transport.sendException - } - - @ParameterizedTest - @MethodSource("io.modelcontextprotocol.kotlin.sdk.shared.AbstractClientTransportTest#nonMcpExceptions") - fun `should call onError when performSend throws non-MCP exception`(testException: Throwable) = runTest { - var errorCallCount = 0 - var capturedError: Throwable? = null - transport.onError { error -> - errorCallCount++ - capturedError = error - } - transport.shouldFailSend = true - transport.sendException = testException - - transport.start() - val exception = shouldThrow { - transport.send(PingRequest().toJSON()) - } - - errorCallCount shouldBe 1 - capturedError shouldBe testException - exception.code shouldBe RPCError.ErrorCode.INTERNAL_ERROR - exception.cause shouldBe testException - } - - @Test - fun `should NOT call onError when performSend throws CancellationException`() = runTest { - var errorCallCount = 0 - transport.onError { errorCallCount++ } - transport.shouldFailSend = true - transport.sendException = CancellationException("Operation cancelled") - - transport.start() - shouldThrow { - transport.send(PingRequest().toJSON()) - } - - errorCallCount shouldBe 0 - } - } - - @Nested - @DisplayName("Idempotency") - inner class IdempotencyTests { - - @Test - fun `should be idempotent when closing from NEW state`() = runTest { - transport.close() - - transport.currentState shouldBe ClientTransportState.Stopped - transport.closeResourcesCalled shouldBe false - } - - @Test - fun `should be idempotent when closing from INITIALIZATION_FAILED state`() = runTest { - transport.shouldFailInitialization = true - shouldThrow { transport.start() } - - transport.close() - - transport.currentState shouldBe ClientTransportState.InitializationFailed - // closeResources was called during a failed start, but not during this close - transport.closeResourcesCallCount shouldBe 1 - } - - @ParameterizedTest - @EnumSource( - value = ClientTransportState::class, - names = ["ShuttingDown", "ShutdownFailed", "Stopped"], - ) - fun `should be idempotent when closing from terminal or shutdown states`(state: ClientTransportState) = - runTest { - transport.forceState(state) - val initialCloseCount = transport.closeResourcesCallCount - - transport.close() - - transport.currentState shouldBe state - transport.closeResourcesCallCount shouldBe initialCloseCount - } - } - - /** - * Simple test implementation of [AbstractClientTransport] for behavioral testing. - * Tracks method calls without mocking frameworks. - */ - @OptIn(InternalMcpApi::class) - private class TestClientTransport : AbstractClientTransport() { - override val logger: KLogger = KotlinLogging.logger {} - val sentMessages = mutableListOf() - var lastSendOptions: TransportSendOptions? = null - var initializeCalled = false - var closeResourcesCalled = false - var closeResourcesCallCount = 0 - var shouldFailInitialization = false - var shouldFailClose = false - var shouldThrowCancellation = false - var shouldFailSend = false - var sendException: Throwable? = null - - val currentState: ClientTransportState - get() = state - - override suspend fun initialize() { - initializeCalled = true - if (shouldFailInitialization) { - throw TestException("Initialization failed") - } - } - - override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) { - if (shouldFailSend) { - throw sendException ?: TestException("Send failed") - } - sentMessages.add(message) - lastSendOptions = options - } - - override suspend fun closeResources() { - closeResourcesCalled = true - closeResourcesCallCount++ - - when { - shouldThrowCancellation -> throw CancellationException("Test cancellation") - shouldFailClose -> throw TestException("Close failed") - } - } - - fun testStateTransition(from: ClientTransportState, to: ClientTransportState) { - stateTransition(from, to) - } - - fun forceState(newState: ClientTransportState) = updateState(newState) - } - - private class TestException(message: String) : Exception(message) -} 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-core/src/nativeMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.native.kt b/kotlin-sdk-core/src/nativeMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.native.kt deleted file mode 100644 index 623fdfc..0000000 --- a/kotlin-sdk-core/src/nativeMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.native.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.utils -/** No platform-native URI normalizer is available on native targets; [uri] is returned unchanged. */ -internal actual fun normalizeUri(uri: String): String = uri diff --git a/kotlin-sdk-core/src/wasmJsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.wasmJs.kt b/kotlin-sdk-core/src/wasmJsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.wasmJs.kt deleted file mode 100644 index 7ad7bce..0000000 --- a/kotlin-sdk-core/src/wasmJsMain/kotlin/io/modelcontextprotocol/kotlin/sdk/utils/UriUtils.wasmJs.kt +++ /dev/null @@ -1,4 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.utils - -/** No platform-native URI normalizer is available on WASM targets; [uri] is returned unchanged. */ -internal actual fun normalizeUri(uri: String): String = uri diff --git a/kotlin-sdk-server/Module.md b/kotlin-sdk-server/Module.md index 176a398..94ae71e 100644 --- a/kotlin-sdk-server/Module.md +++ b/kotlin-sdk-server/Module.md @@ -1,125 +1,14 @@ # Module kotlin-sdk-server -`kotlin-sdk-server` contains the server-side building blocks for Model Context Protocol (MCP) applications written in -Kotlin Multiplatform. It pairs the protocol types from `kotlin-sdk-core` with transport implementations and utilities -that make it straightforward to expose tools, prompts, and resources to MCP clients. +The server module provides core server-side functionality for the Model Context Protocol (MCP) Kotlin SDK. +This module includes essential parts for writing MCP server applications. -## What the module provides +## Features -- **Server runtime**: The `Server` class coordinates sessions, initialization flow, and capability enforcement. It - tracks active sessions and cleans them up when transports close. -- **Feature registries**: Helpers to register and remove tools, prompts, resources, and resource templates. Registry - listeners emit list-changed notifications when the capability is enabled. -- **Transports**: Ready-to-use transports for STDIO (CLI-style hosting), Ktor Server-Sent Events (SSE) with POST - back-channel, and Ktor WebSocket. All share the common `Transport` abstraction from the SDK. -- **Ktor integration**: Extension functions (`Route.mcp`, `Route.mcpWebSocket`, and `Application` variants) that - plug MCP into an existing Ktor server with minimal boilerplate. -- **Notifications**: Built-in support for resource subscription, resource-updated events, and feature list change - notifications when capabilities are set accordingly. +* Server-side request handling +* Model context management +* Protocol implementation +* Integration utilities -## Typical server setup - -1. Declare capabilities your server supports using `ServerOptions`. Capabilities drive which handlers are installed and - whether list-change notifications are emitted. -2. Register features: tools, prompts, resources, and resource templates. Handlers run in suspending context and can be - removed or replaced at runtime. -3. Attach a transport and open a session via `createSession(transport)`. Multiple sessions can be active simultaneously. -4. Close the server to tear down transports and unsubscribe sessions. - -### Minimal STDIO server - -```kotlin -val server = Server( - serverInfo = Implementation(name = "demo-server", version = "1.0.0"), - options = ServerOptions( - capabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = true) - ) - ) -) { - addTool( - name = "hello", - description = "Greets the caller by name" - ) { request -> - val name = request.params.arguments?.get("name")?.jsonPrimitive?.content ?: "there" - CallToolResult( - content = listOf(TextContent("Hello, $name!")), - isError = false, - ) - } -} - -val transport = StdioServerTransport( - System.`in`.asSource().buffered(), - System.out.asSink().buffered() -) -server.createSession(transport) -``` - -### Hosting with Ktor (SSE) - -```kotlin -fun Application.module() { - mcp { - Server( - serverInfo = Implementation("ktor-sse", "1.0.0"), - options = ServerOptions( - ServerCapabilities( - prompts = ServerCapabilities.Prompts(listChanged = true), - resources = ServerCapabilities.Resources(listChanged = true, subscribe = true), - ) - ) - ) { - addPrompt( - name = "welcome", - description = "Explain how to use the server" - ) { - GetPromptResult( - description = "Welcome to the MCP server", - messages = listOf(CreateMessageResult.Message(TextContent("Try listing tools first"))) - ) - } - } - } -} -``` - -The SSE route exposes a streaming GET endpoint and a POST endpoint for client messages. Session affinity is maintained -by a `sessionId` query parameter managed by the SDK. - -### Hosting with Ktor (WebSocket) - -```kotlin -fun Application.module() { - mcpWebSocket { - Server( - serverInfo = Implementation("ktor-ws", "1.0.0"), - options = ServerOptions(ServerCapabilities(tools = ServerCapabilities.Tools())) - ) - } -} -``` - -`mcpWebSocket` installs Ktor WebSockets and wires message handling over a single bidirectional connection. - -## Feature registration details - -- **Tools**: `addTool` accepts a `Tool` descriptor or inline parameters plus a suspend handler. `removeTool` and - `addTools/removeTools` support dynamic updates. -- **Prompts**: `addPrompt` wires a provider that returns `GetPromptResult` and optional arguments metadata. -- **Resources**: `addResource` exposes static or generated content via `ReadResourceResult`; `addResources` - batch-registers. Templates and roots are also supported for structured discovery. -- **Instructions**: Provide static text or a function via `instructionsProvider` to send per-session usage guidance - during initialization. - -## Lifecycle and diagnostics - -- Sessions emit callbacks via `onConnect` and `onClose`. Deprecated `onInitialized` exists for backward compatibility; - prefer session-level hooks. -- The server enforces declared capabilities; attempting to register an unsupported feature throws with a descriptive - message. -- Logging uses `KotlinLogging`; avoid logging secrets. To inspect traffic, run the server with DEBUG level enabled. - -Use this module whenever you need to expose MCP features from a Kotlin service—whether embedding in a CLI via STDIO, -serving from Ktor over SSE/WebSocket, or composing transports for custom environments. Public APIs are explicit to keep -server-facing contracts stable for consumers. +For detailed usage instructions and examples, please refer to +the [documentation](https://github.com/modelcontextprotocol/kotlin-sdk/blob/main/README.md). diff --git a/kotlin-sdk-server/api/kotlin-sdk-server.api b/kotlin-sdk-server/api/kotlin-sdk-server.api index d8e4c7a..53c971b 100644 --- a/kotlin-sdk-server/api/kotlin-sdk-server.api +++ b/kotlin-sdk-server/api/kotlin-sdk-server.api @@ -1,106 +1,51 @@ -public abstract interface class io/modelcontextprotocol/kotlin/sdk/server/ClientConnection { - public abstract fun createElicitation (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun createElicitation (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun createElicitation (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun createElicitation$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun createElicitation$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun createElicitation$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public abstract fun createMessage (Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun createMessage$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public abstract fun getSessionId ()Ljava/lang/String; - public abstract fun listRoots (Lio/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun listRoots$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Lio/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public abstract fun notification (Lio/modelcontextprotocol/kotlin/sdk/types/ServerNotification;Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun notification$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Lio/modelcontextprotocol/kotlin/sdk/types/ServerNotification;Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public abstract fun ping (Lio/modelcontextprotocol/kotlin/sdk/types/PingRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - 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 abstract fun sendElicitationComplete (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun sendLoggingMessage (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingMessageNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun sendPromptListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun sendResourceListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun sendResourceUpdated (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceUpdatedNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun sendToolListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public final class io/modelcontextprotocol/kotlin/sdk/server/ClientConnection$DefaultImpls { - public static synthetic fun createElicitation$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun createElicitation$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun createElicitation$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun createMessage$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun listRoots$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Lio/modelcontextprotocol/kotlin/sdk/types/ListRootsRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun notification$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Lio/modelcontextprotocol/kotlin/sdk/types/ServerNotification;Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - 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 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 mcp (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function1;)V + public static final fun mcp (Lio/ktor/server/routing/Routing;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public static final fun mcp (Lio/ktor/server/routing/Routing;Lkotlin/jvm/functions/Function1;)V } public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt : io/modelcontextprotocol/kotlin/sdk/server/Feature { - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/Prompt;Lkotlin/jvm/functions/Function3;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/Prompt;Lkotlin/jvm/functions/Function2;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/Prompt; - public final fun component2 ()Lkotlin/jvm/functions/Function3; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/Prompt;Lkotlin/jvm/functions/Function3;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt;Lio/modelcontextprotocol/kotlin/sdk/types/Prompt;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt; + public final fun component2 ()Lkotlin/jvm/functions/Function2; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/Prompt;Lkotlin/jvm/functions/Function2;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt;Lio/modelcontextprotocol/kotlin/sdk/types/Prompt;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredPrompt; public fun equals (Ljava/lang/Object;)Z public fun getKey ()Ljava/lang/String; - public final fun getMessageProvider ()Lkotlin/jvm/functions/Function3; + public final fun getMessageProvider ()Lkotlin/jvm/functions/Function2; public final fun getPrompt ()Lio/modelcontextprotocol/kotlin/sdk/types/Prompt; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredResource : io/modelcontextprotocol/kotlin/sdk/server/Feature { - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/Resource;Lkotlin/jvm/functions/Function3;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/Resource;Lkotlin/jvm/functions/Function2;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/Resource; - public final fun component2 ()Lkotlin/jvm/functions/Function3; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/Resource;Lkotlin/jvm/functions/Function3;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredResource; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredResource;Lio/modelcontextprotocol/kotlin/sdk/types/Resource;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredResource; + public final fun component2 ()Lkotlin/jvm/functions/Function2; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/Resource;Lkotlin/jvm/functions/Function2;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredResource; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredResource;Lio/modelcontextprotocol/kotlin/sdk/types/Resource;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredResource; public fun equals (Ljava/lang/Object;)Z public fun getKey ()Ljava/lang/String; - public final fun getReadHandler ()Lkotlin/jvm/functions/Function3; + public final fun getReadHandler ()Lkotlin/jvm/functions/Function2; public final fun getResource ()Lio/modelcontextprotocol/kotlin/sdk/types/Resource; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class io/modelcontextprotocol/kotlin/sdk/server/RegisteredTool : io/modelcontextprotocol/kotlin/sdk/server/Feature { - public fun (Lio/modelcontextprotocol/kotlin/sdk/types/Tool;Lkotlin/jvm/functions/Function3;)V + public fun (Lio/modelcontextprotocol/kotlin/sdk/types/Tool;Lkotlin/jvm/functions/Function2;)V public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/types/Tool; - public final fun component2 ()Lkotlin/jvm/functions/Function3; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/Tool;Lkotlin/jvm/functions/Function3;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredTool; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredTool;Lio/modelcontextprotocol/kotlin/sdk/types/Tool;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredTool; + public final fun component2 ()Lkotlin/jvm/functions/Function2; + public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/types/Tool;Lkotlin/jvm/functions/Function2;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredTool; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredTool;Lio/modelcontextprotocol/kotlin/sdk/types/Tool;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/server/RegisteredTool; public fun equals (Ljava/lang/Object;)Z - public final fun getHandler ()Lkotlin/jvm/functions/Function3; + public final fun getHandler ()Lkotlin/jvm/functions/Function2; public fun getKey ()Ljava/lang/String; public final fun getTool ()Lio/modelcontextprotocol/kotlin/sdk/types/Tool; public fun hashCode ()I @@ -112,33 +57,27 @@ public class io/modelcontextprotocol/kotlin/sdk/server/Server { public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/Implementation;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lio/modelcontextprotocol/kotlin/sdk/types/Implementation;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/types/Implementation;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun addPrompt (Lio/modelcontextprotocol/kotlin/sdk/types/Prompt;Lkotlin/jvm/functions/Function3;)V - public final fun addPrompt (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/functions/Function3;)V - public static synthetic fun addPrompt$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)V + public final fun addPrompt (Lio/modelcontextprotocol/kotlin/sdk/types/Prompt;Lkotlin/jvm/functions/Function2;)V + public final fun addPrompt (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/functions/Function2;)V + public static synthetic fun addPrompt$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public final fun addPrompts (Ljava/util/List;)V - public final fun addResource (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;)V - public static synthetic fun addResource$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)V - public final fun addResourceTemplate (Lio/modelcontextprotocol/kotlin/sdk/types/ResourceTemplate;Lkotlin/jvm/functions/Function4;)V - public final fun addResourceTemplate (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function4;)V - public static synthetic fun addResourceTemplate$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function4;ILjava/lang/Object;)V + public final fun addResource (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V + public static synthetic fun addResource$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public final fun addResources (Ljava/util/List;)V - public final fun addTool (Lio/modelcontextprotocol/kotlin/sdk/types/Tool;Lkotlin/jvm/functions/Function3;)V - public final fun addTool (Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/functions/Function3;)V - public static synthetic fun addTool$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Lio/modelcontextprotocol/kotlin/sdk/types/ToolExecution;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)V + public final fun addTool (Lio/modelcontextprotocol/kotlin/sdk/types/Tool;Lkotlin/jvm/functions/Function2;)V + public final fun addTool (Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/functions/Function2;)V + public static synthetic fun addTool$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema;Lio/modelcontextprotocol/kotlin/sdk/types/ToolAnnotations;Lkotlinx/serialization/json/JsonObject;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public final fun addTools (Ljava/util/List;)V - public final fun clientConnection (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection; public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun connect (Lio/modelcontextprotocol/kotlin/sdk/shared/Transport;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun createElicitation (Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun createElicitation (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun createElicitation$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun createElicitation$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun createMessage (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun createMessage$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun createSession (Lio/modelcontextprotocol/kotlin/sdk/shared/Transport;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; protected final fun getInstructionsProvider ()Lkotlin/jvm/functions/Function0; protected final fun getOptions ()Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions; public final fun getPrompts ()Ljava/util/Map; - public final fun getResourceTemplates ()Ljava/util/List; public final fun getResources ()Ljava/util/Map; protected final fun getServerInfo ()Lio/modelcontextprotocol/kotlin/sdk/types/Implementation; public final fun getSessions ()Ljava/util/Map; @@ -147,17 +86,16 @@ public class io/modelcontextprotocol/kotlin/sdk/server/Server { public static synthetic fun listRoots$default (Lio/modelcontextprotocol/kotlin/sdk/server/Server;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun onClose (Lkotlin/jvm/functions/Function0;)V public final fun onConnect (Lkotlin/jvm/functions/Function0;)V + public final fun onInitialized (Lkotlin/jvm/functions/Function0;)V public final fun ping (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun removeNotificationHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public final fun removeNotificationHandler (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public final fun removePrompt (Ljava/lang/String;)Z public final fun removePrompts (Ljava/util/List;)I public final fun removeResource (Ljava/lang/String;)Z - public final fun removeResourceTemplate (Ljava/lang/String;)Z public final fun removeResources (Ljava/util/List;)I public final fun removeTool (Ljava/lang/String;)Z public final fun removeTools (Ljava/util/List;)I - public final fun sendElicitationComplete (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun sendLoggingMessage (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/LoggingMessageNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun sendPromptListChanged (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun sendResourceListChanged (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -168,13 +106,9 @@ public class io/modelcontextprotocol/kotlin/sdk/server/Server { } public final class io/modelcontextprotocol/kotlin/sdk/server/ServerOptions : io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions { - 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 final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities; - public final fun getResourceTemplateMatcherFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory; } public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelcontextprotocol/kotlin/sdk/shared/Protocol { @@ -183,9 +117,7 @@ public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelc protected fun assertNotificationCapability (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public fun assertRequestHandlerCapability (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V public final fun createElicitation (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun createElicitation (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun createElicitation$default (Lio/modelcontextprotocol/kotlin/sdk/server/ServerSession;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun createElicitation$default (Lio/modelcontextprotocol/kotlin/sdk/server/ServerSession;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun createMessage (Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun createMessage$default (Lio/modelcontextprotocol/kotlin/sdk/server/ServerSession;Lio/modelcontextprotocol/kotlin/sdk/types/CreateMessageRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public fun equals (Ljava/lang/Object;)Z @@ -200,9 +132,7 @@ public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelc public fun onClose ()V public final fun onClose (Lkotlin/jvm/functions/Function0;)V public final fun onInitialized (Lkotlin/jvm/functions/Function0;)V - protected fun onInitializedNotification ()V public final fun ping (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun sendElicitationComplete (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun sendLoggingMessage (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingMessageNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun sendPromptListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun sendResourceListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -211,8 +141,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,26 +152,14 @@ 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 - public fun (Lio/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport$Configuration;)V public fun (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Ljava/lang/Long;)V public synthetic fun (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -259,25 +176,14 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServe public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } -public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport$Configuration { - public synthetic fun (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/time/Duration;JILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/time/Duration;JLkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getAllowedHosts ()Ljava/util/List; - public final fun getAllowedOrigins ()Ljava/util/List; - public final fun getEnableDnsRebindingProtection ()Z - public final fun getEnableJsonResponse ()Z - public final fun getEventStore ()Lio/modelcontextprotocol/kotlin/sdk/server/EventStore; - public final fun getMaxRequestBodySize ()J - public final fun getRetryInterval-FghU774 ()Lkotlin/time/Duration; -} - public final class io/modelcontextprotocol/kotlin/sdk/server/WebSocketMcpKtorServerExtensionsKt { public static final fun mcpWebSocket (Lio/ktor/server/application/Application;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V public static final fun mcpWebSocket (Lio/ktor/server/application/Application;Lkotlin/jvm/functions/Function0;)V public static final fun mcpWebSocket (Lio/ktor/server/routing/Route;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function2;)V public static final fun mcpWebSocket (Lio/ktor/server/routing/Route;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function2;)V - public static final fun mcpWebSocket (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V public static final fun mcpWebSocket (Lio/ktor/server/routing/Route;Lkotlin/jvm/functions/Function0;)V + public static final fun mcpWebSocket (Lio/ktor/server/routing/Routing;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V + public static final fun mcpWebSocket (Lio/ktor/server/routing/Routing;Lkotlin/jvm/functions/Function0;)V public static synthetic fun mcpWebSocket$default (Lio/ktor/server/routing/Route;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public static synthetic fun mcpWebSocket$default (Lio/ktor/server/routing/Route;Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/server/ServerOptions;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public static final fun mcpWebSocketTransport (Lio/ktor/server/routing/Route;Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V diff --git a/kotlin-sdk-server/build.gradle.kts b/kotlin-sdk-server/build.gradle.kts index 193eb90..e51a643 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) @@ -13,9 +12,7 @@ kotlin { api(project(":kotlin-sdk-core")) api(libs.ktor.server.core) api(libs.ktor.server.sse) - implementation(libs.ktor.server.content.negotiation) implementation(libs.ktor.server.websockets) - implementation(libs.ktor.serialization) implementation(libs.kotlin.logging) } } @@ -23,24 +20,20 @@ kotlin { commonTest { dependencies { implementation(kotlin("test")) - implementation(libs.kotest.assertions.core) - implementation(libs.kotest.assertions.json) implementation(libs.kotlinx.coroutines.test) - implementation(project(":test-utils")) } } jvmTest { dependencies { - implementation(libs.junit.jupiter.params) - implementation(libs.kotest.assertions.ktor) - implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.logging) - implementation(libs.ktor.serialization) implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization) implementation(libs.ktor.server.test.host) - - implementation(libs.logback.classic) + implementation(libs.kotest.assertions.core) + implementation(libs.kotest.assertions.json) + runtimeOnly(libs.slf4j.simple) } } } diff --git a/kotlin-sdk-server/detekt-baseline-main.xml b/kotlin-sdk-server/detekt-baseline-main.xml deleted file mode 100644 index aad11fa..0000000 --- a/kotlin-sdk-server/detekt-baseline-main.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - UnsafeCallOnNullableType:StreamableHttpServerTransport.kt:StreamableHttpServerTransport$responseRequestId!! - - 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 deleted file mode 100644 index 21e0925..0000000 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt +++ /dev/null @@ -1,347 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.github.oshai.kotlinlogging.KotlinLogging -import io.modelcontextprotocol.kotlin.sdk.shared.RequestOptions -import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest -import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult -import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequest -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.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 -import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import io.modelcontextprotocol.kotlin.sdk.types.RequestResult -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 {} - -/** - * Represents a connection interface between a server and a client, enabling - * communication through notifications, requests, and other operations. - * This interface defines various methods to facilitate the interaction. - */ -public interface ClientConnection { - - /** - * A unique identifier identifying the current session. - * Has no inherent meaning. - */ - public val sessionId: String - - /** - * Sends a server-side notification to the client. - * - * @param notification The notification to be sent to the client. This typically represents - * a specific event or update from the server. - * @param relatedRequestId An optional identifier linking this notification to a prior request. - * Useful for correlating notifications with originating requests. - */ - public suspend fun notification(notification: ServerNotification, relatedRequestId: RequestId? = null) - - /** - * Sends a ping request to the client to check connectivity. - * - * @param request Optional request parameters for the ping operation. - * @param options Optional request options, such as timeout or progress callback settings. - * @return The result of the ping request. - * @throws IllegalStateException If for some reason the method is not supported or the connection is closed. - */ - public suspend fun ping(request: PingRequest = PingRequest(), options: RequestOptions? = null): EmptyResult - - /** - * Creates a message using the server's sampling capability. - * - * @param request The parameters for creating a message. - * @param options Optional request options. - * @return The created message result. - * @throws IllegalStateException If the server does not support sampling or if the request fails. - */ - public suspend fun createMessage( - request: CreateMessageRequest, - options: RequestOptions? = null, - ): CreateMessageResult - - /** - * Lists the available "roots" from the client's perspective (if supported). - * - * Roots define the boundaries of where servers can operate within the filesystem, - * allowing them to understand which directories and files they have access to - * - * @param request Optional request parameters containing metadata for the list roots operation. - * @param options Optional request options, such as timeout or progress callback settings. - * @return The list of roots. - * @throws IllegalStateException If the server or client does not support roots. - */ - public suspend fun listRoots( - request: ListRootsRequest = ListRootsRequest(), - options: RequestOptions? = null, - ): ListRootsResult - - /** - * 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. - * 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, - requestedSchema: ElicitRequestParams.RequestedSchema, - options: RequestOptions? = null, - ): ElicitResult - - /** - * Sends a URL mode elicitation request to the client, directing the user - * to an external URL for out-of-band interactions. - * - * @param message The message explaining why the interaction is needed. - * @param elicitationId A unique identifier for the elicitation. - * @param url The URL that the user should navigate to. - * @param options Optional request options. - * @return The result of the elicitation request. - * @throws IllegalStateException If the server or client does not support elicitation. - */ - public suspend fun createElicitation( - message: String, - elicitationId: String, - url: String, - options: RequestOptions? = null, - ): ElicitResult - - /** - * 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 - - /** - * Sends a logging message notification to the client. - * Messages are filtered based on the current logging level set by the client. - * If no logging level is set, all messages are sent. - * - * @param notification The logging message notification. - */ - public suspend fun sendLoggingMessage(notification: LoggingMessageNotification) - - /** - * Sends a resource-updated notification to the client, indicating that a specific resource has changed. - * - * @param notification Details of the updated resource. - */ - public suspend fun sendResourceUpdated(notification: ResourceUpdatedNotification) - - /** - * Sends a notification to the client indicating that the list of resources has changed. - */ - public suspend fun sendResourceListChanged() - - /** - * Sends a notification to the client indicating that the list of tools has changed. - */ - public suspend fun sendToolListChanged() - - /** - * Sends a notification to the client indicating that the list of prompts has changed. - */ - public suspend fun sendPromptListChanged() - - /** - * Sends a notification to the client indicating that an out-of-band elicitation has completed. - * - * @param notification Details of the completed elicitation. - */ - public suspend fun sendElicitationComplete(notification: ElicitationCompleteNotification) -} - -internal class ClientConnectionImpl(private val session: ServerSession) : ClientConnection { - - override val sessionId: String get() = session.sessionId - - private suspend fun request(request: Request, options: RequestOptions? = null): T { - logger.trace { "Sending request to client for session $sessionId: $request" } - return session.request(request, options) - } - - override suspend fun notification(notification: ServerNotification, relatedRequestId: RequestId?) { - if (notification is LoggingMessageNotification && !isMessageAccepted(notification.params.level)) { - logger.trace { "Filtering out logging message with level ${notification.params.level}" } - return - } - logger.trace { "Sending notification to client for session $sessionId: $notification" } - session.notification(notification, relatedRequestId) - } - - override suspend fun ping(request: PingRequest, options: RequestOptions?): EmptyResult { - logger.trace { "Sending ping request: $request" } - return request(request, options) - } - - 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." - } - } - - 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) - } - - override suspend fun listRoots(request: ListRootsRequest, options: RequestOptions?): ListRootsResult { - logger.debug { "Listing roots" } - return request(request, options) - } - - 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 -> - "Creating form elicitation with message length=${params.message.length}, " + - "schema properties count=${params.requestedSchema.properties.size}" - - is ElicitRequestURLParams -> - "Creating URL elicitation with message length=${params.message.length}, " + - "elicitationId=${params.elicitationId}" - } - } - 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 - } - - override suspend fun createElicitation( - message: String, - requestedSchema: ElicitRequestParams.RequestedSchema, - options: RequestOptions?, - ): ElicitResult = createElicitation( - request = ElicitRequest(params = ElicitRequestFormParams(message = message, requestedSchema = requestedSchema)), - options = options, - ) - - override suspend fun createElicitation( - message: String, - elicitationId: String, - url: String, - options: RequestOptions?, - ): ElicitResult = createElicitation( - request = ElicitRequest( - params = ElicitRequestURLParams(message = message, elicitationId = elicitationId, url = url), - ), - options = options, - ) - - override suspend fun sendLoggingMessage(notification: LoggingMessageNotification) { - notification(notification) - } - - override suspend fun sendResourceUpdated(notification: ResourceUpdatedNotification) { - logger.debug { "Sending resource updated notification for: ${notification.params.uri}" } - notification(notification) - } - - override suspend fun sendResourceListChanged() { - logger.debug { "Sending resource list changed notification" } - notification(ResourceListChangedNotification()) - } - - override suspend fun sendToolListChanged() { - logger.debug { "Sending tool list changed notification" } - notification(ToolListChangedNotification()) - } - - override suspend fun sendPromptListChanged() { - logger.debug { "Sending prompt list changed notification" } - notification(PromptListChangedNotification()) - } - - 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) - } - - /** - * Determines whether a message with the specified logging level is accepted - * based on the current logging level of the session. - * - * @param level The logging level of the message to be evaluated. - * @return True if the message is accepted (i.e., its level meets or exceeds - * the current session's logging level), or if no current logging - * level is set. Otherwise, false. - */ - private fun isMessageAccepted(level: LoggingLevel): Boolean { - val current = session.currentLoggingLevel.value ?: return true // If no level is set, don't filter - - return level >= current - } -} 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..78bed9c 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 @@ -8,9 +8,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.Prompt import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult import io.modelcontextprotocol.kotlin.sdk.types.Resource -import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate import io.modelcontextprotocol.kotlin.sdk.types.Tool -import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcher internal typealias FeatureKey = String @@ -27,10 +25,7 @@ internal interface Feature { * @property tool The tool definition. * @property handler A suspend function to handle the tool call requests. */ -public data class RegisteredTool( - val tool: Tool, - val handler: suspend ClientConnection.(CallToolRequest) -> CallToolResult, -) : Feature { +public data class RegisteredTool(val tool: Tool, val handler: suspend (CallToolRequest) -> CallToolResult) : Feature { override val key: String = tool.name } @@ -42,7 +37,7 @@ public data class RegisteredTool( */ public data class RegisteredPrompt( val prompt: Prompt, - val messageProvider: suspend ClientConnection.(GetPromptRequest) -> GetPromptResult, + val messageProvider: suspend (GetPromptRequest) -> GetPromptResult, ) : Feature { override val key: String = prompt.name } @@ -55,23 +50,7 @@ public data class RegisteredPrompt( */ public data class RegisteredResource( val resource: Resource, - val readHandler: suspend ClientConnection.(ReadResourceRequest) -> ReadResourceResult, + val readHandler: suspend (ReadResourceRequest) -> ReadResourceResult, ) : Feature { override val key: String = resource.uri } - -/** - * 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. - */ -internal class RegisteredResourceTemplate( - val resourceTemplate: ResourceTemplate, - val matcher: ResourceTemplateMatcher, - val readHandler: suspend ClientConnection.(ReadResourceRequest, Map) -> ReadResourceResult, -) : Feature { - override val key: String = resourceTemplate.uriTemplate -} 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..2a2f950 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,380 +1,86 @@ 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.Routing import io.ktor.server.routing.RoutingContext -import io.ktor.server.routing.delete -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 +import kotlinx.atomicfu.AtomicRef +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.update +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.toPersistentMap import kotlinx.coroutines.awaitCancellation private val logger = KotlinLogging.logger {} -/** - * Registers MCP over [Server-Sent Events (SSE) Transport](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) - * at the specified [path] on this [Route]. - * - * **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 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, -) { - route(path) { - mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block) +internal class SseTransportManager(transports: Map = emptyMap()) { + private val transports: AtomicRef> = atomic(transports.toPersistentMap()) + + fun getTransport(sessionId: String): SseServerTransport? = transports.value[sessionId] + + fun addTransport(transport: SseServerTransport) { + transports.update { it.put(transport.sessionId, transport) } + } + + fun removeTransport(sessionId: String) { + transports.update { it.remove(sessionId) } } } -/** - * Registers MCP over [Server-Sent Events (SSE) Transport](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) - * endpoints on this [Route]. - * - * **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, -) { - try { - plugin(SSE) - } catch (e: MissingApplicationPluginException) { - throw IllegalStateException( - "The SSE plugin must be installed before registering MCP routes. " + - "Add `install(SSE)` to your application configuration, " + - "or use Application.mcp() which installs it automatically.", - e, - ) +public fun Routing.mcp(path: String, block: ServerSSESession.() -> Server) { + route(path) { + mcp(block) } +} - installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins) - - val transportManager = TransportManager() +/* +* Configures the Ktor Application to handle Model Context Protocol (MCP) over Server-Sent Events (SSE). +*/ +@KtorDsl +public fun Routing.mcp(block: ServerSSESession.() -> Server) { + val sseTransportManager = SseTransportManager() sse { - mcpSseEndpoint("", transportManager, maxRequestBodySize, block) + mcpSseEndpoint("", sseTransportManager, block) } post { - mcpPostEndpoint(transportManager) + mcpPostEndpoint(sseTransportManager) } } -/** - * Configures the Ktor Application to handle Model Context Protocol (MCP) - * over [Server-Sent Events (SSE) Transport](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) - * and sets up routing with the provided configuration block. - * - * 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, -) { - installMcpContentNegotiation() +public fun Application.mcp(block: ServerSSESession.() -> Server) { 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() - install(SSE) - - val transportManager = TransportManager() - - 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) - } - - post { - val transport = streamableTransport( - transportManager = transportManager, - configuration = configuration, - block = block, - ) ?: return@post - - transport.handleRequest(null, call) - } - - delete { - val transport = existingStreamableTransport(call, transportManager) ?: return@delete - transport.handleRequest(null, call) - } - } - } -} - -/** - * Configures the Ktor Application to handle Model Context Protocol (MCP) - * over [Streamable HTTP Transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) - * - * Sets up SSE, HTTP POST, and DELETE endpoints at the specified [path]. - * 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] 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 eventStore An optional [EventStore] instance to enable resumable event stream functionality. - * Allows storing and replaying events. - * @param sseHeartbeatConfig The heartbeat configuration option for SSE connections. `null` means no heartbeat is sent. - * @param block factory block with access to the [RoutingContext] (for reading request headers) - * that creates and returns the [Server] to handle the connection. - */ -@KtorDsl -public fun Application.mcpStreamableHttp( - path: String = "/mcp", - enableDnsRebindingProtection: Boolean = true, - allowedHosts: List? = null, - allowedOrigins: List? = null, - eventStore: EventStore? = null, - sseHeartbeatConfig: (Heartbeat.() -> Unit)? = null, - block: RoutingContext.() -> Server, -) { - mcpStreamableHttp( - path = path, - enableDnsRebindingProtection = enableDnsRebindingProtection, - allowedHosts = allowedHosts, - allowedOrigins = allowedOrigins, - configuration = StreamableHttpServerTransport.Configuration( - 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() - - routing { - route(path) { - installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins) - - post { - mcpStatelessStreamableHttpEndpoint( - configuration = configuration, - block = block, - ) - } - get { - call.rejectUnsupportedMethod() - } - delete { - call.rejectUnsupportedMethod() - } - } - } -} - -/** - * Configures the Ktor Application to handle Model Context Protocol (MCP) - * 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. - * - * Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation] - * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson]. - * - * @param path The URL path where the server listens for incoming JSON-RPC requests. Defaults to "/mcp". - * @param enableDnsRebindingProtection Determines whether DNS rebinding protection is enabled. Defaults to `true`. - * @param allowedHosts A list of allowed hostnames. If `null` and DNS rebinding protection is enabled, - * defaults to `localhost`, `127.0.0.1`, `[::1]`. - * @param allowedOrigins A list of allowed `Origin` header values, compared by hostname only - * (scheme and port are ignored). Requests without an `Origin` header are allowed. - * If `null`, origin validation is disabled. - * @param block factory block with access to the [RoutingContext] (for reading request headers) - * that creates and returns the [Server] to handle the connection. - */ -@KtorDsl -public fun Application.mcpStatelessStreamableHttp( - path: String = "/mcp", - enableDnsRebindingProtection: Boolean = true, - allowedHosts: List? = null, - allowedOrigins: List? = null, - block: RoutingContext.() -> Server, -) { - mcpStatelessStreamableHttp( - path = path, - enableDnsRebindingProtection = enableDnsRebindingProtection, - allowedHosts = allowedHosts, - allowedOrigins = allowedOrigins, - configuration = StreamableHttpServerTransport.Configuration( - 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( +internal suspend fun ServerSSESession.mcpSseEndpoint( postEndpoint: String, - transportManager: TransportManager, - maxRequestBodySize: Long, + sseTransportManager: SseTransportManager, block: ServerSSESession.() -> Server, ) { - val transport = mcpSseTransport(postEndpoint, transportManager, maxRequestBodySize) + val transport = mcpSseTransport(postEndpoint, sseTransportManager) val server = block() server.onClose { logger.info { "Server connection closed for sessionId: ${transport.sessionId}" } - transportManager.removeTransport(transport.sessionId) + sseTransportManager.removeTransport(transport.sessionId) } server.createSession(transport) @@ -384,43 +90,18 @@ private suspend fun ServerSSESession.mcpSseEndpoint( awaitCancellation() } -private fun ServerSSESession.mcpSseTransport( +internal fun ServerSSESession.mcpSseTransport( postEndpoint: String, - transportManager: TransportManager, - maxRequestBodySize: Long, + sseTransportManager: SseTransportManager, ): SseServerTransport { - val transport = SseServerTransport(postEndpoint, this, maxRequestBodySize) - transportManager.addTransport(transport.sessionId, transport) + val transport = SseServerTransport(postEndpoint, this) + sseTransportManager.addTransport(transport) logger.info { "New SSE connection established and stored with sessionId: ${transport.sessionId}" } return transport } -private suspend fun RoutingContext.mcpStatelessStreamableHttpEndpoint( - configuration: StreamableHttpServerTransport.Configuration, - block: RoutingContext.() -> Server, -) { - val transport = StreamableHttpServerTransport( - configuration, - ).also { it.setSessionIdGenerator(null) } - - logger.info { "New stateless StreamableHttp connection established without sessionId" } - - val server = block() - val session = 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" } - } -} - -private suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportManager) { +internal suspend fun RoutingContext.mcpPostEndpoint(sseTransportManager: SseTransportManager) { val sessionId: String = call.request.queryParameters["sessionId"] ?: run { call.respond(HttpStatusCode.BadRequest, "sessionId query parameter is not provided") return @@ -428,7 +109,7 @@ private suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportMa logger.debug { "Received message for sessionId: $sessionId" } - val transport = transportManager.getTransport(sessionId) + val transport = sseTransportManager.getTransport(sessionId) if (transport == null) { logger.warn { "Session not found for sessionId: $sessionId" } call.respond(HttpStatusCode.NotFound, "Session not found") @@ -438,86 +119,3 @@ private suspend fun RoutingContext.mcpPostEndpoint(transportManager: TransportMa transport.handlePostMessage(call) 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( - call: ApplicationCall, - transportManager: TransportManager, -): StreamableHttpServerTransport? { - val sessionId = call.request.sessionId() - if (sessionId.isNullOrEmpty()) { - call.reject( - HttpStatusCode.BadRequest, - RPCError.ErrorCode.CONNECTION_CLOSED, - "Bad Request: No valid session ID provided", - ) - return null - } - - val transport = transportManager.getTransport(sessionId) - return if (transport == null) { - call.reject( - HttpStatusCode.NotFound, - RPCError.ErrorCode.CONNECTION_CLOSED, - "Session not found", - ) - null - } else { - transport - } -} - -private suspend fun RoutingContext.streamableTransport( - transportManager: TransportManager, - configuration: StreamableHttpServerTransport.Configuration, - block: RoutingContext.() -> Server, -): StreamableHttpServerTransport? { - val sessionId = call.request.sessionId() - if (sessionId != null) { - val transport = transportManager.getTransport(sessionId) - return transport ?: existingStreamableTransport(call, transportManager) - } - - val transport = StreamableHttpServerTransport(configuration) - - transport.setOnSessionInitialized { initializedSessionId -> - transportManager.addTransport(initializedSessionId, transport) - logger.info { "New StreamableHttp connection established and stored with sessionId: $initializedSessionId" } - } - - transport.setOnSessionClosed { closedSession -> - transportManager.removeTransport(closedSession) - logger.info { "Closed StreamableHttp connection and removed sessionId: $closedSession" } - } - - val server = block() - server.onClose { - transport.sessionId?.let { transportManager.removeTransport(it) } - logger.info { "Server connection closed for sessionId: ${transport.sessionId}" } - } - server.createSession(transport) - - 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/KtorServerHelpers.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServerHelpers.kt deleted file mode 100644 index 42093c8..0000000 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServerHelpers.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.github.oshai.kotlinlogging.KotlinLogging -import io.ktor.serialization.kotlinx.json.json -import io.ktor.server.application.Application -import io.ktor.server.application.install -import io.ktor.server.application.pluginOrNull -import io.ktor.server.plugins.contentnegotiation.ContentNegotiation -import io.ktor.util.AttributeKey -import io.modelcontextprotocol.kotlin.sdk.types.McpJson - -private val logger = KotlinLogging.logger {} - -private val McpContentNegotiationInstalled = AttributeKey("McpContentNegotiationInstalled") - -/** - * Installs [ContentNegotiation] configured with [McpJson]. - * - * MCP requires specific JSON settings (`explicitNulls = false`, `encodeDefaults = true`, - * `classDiscriminatorMode = NONE`). Using the default `json()` instead of `json(McpJson)` - * causes serialization failures at runtime (e.g., explicit null fields in JSON-RPC responses). - * - * If [ContentNegotiation] was already installed by user code, a warning is logged because - * Ktor's public API does not expose the Json configuration for verification. - * - * This function is idempotent: subsequent calls on the same [Application] return immediately - * without re-installing the plugin or logging additional warnings. - */ -internal fun Application.installMcpContentNegotiation() { - if (attributes.getOrNull(McpContentNegotiationInstalled) != null) return - attributes.put(McpContentNegotiationInstalled, Unit) - - if (pluginOrNull(ContentNegotiation) != null) { - // ContentNegotiation was already installed by user code. - logger.warn { - "ContentNegotiation is already installed. " + - "MCP requires json(McpJson) for correct JSON-RPC serialization " + - "(explicitNulls = false, encodeDefaults = true). " + - "Ensure your ContentNegotiation configuration includes json(McpJson)." - } - } else { - install(ContentNegotiation) { - json(McpJson) - } - } -} 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..1b33f39 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 @@ -21,29 +22,19 @@ import kotlin.uuid.Uuid internal const val SESSION_ID_PARAM = "sessionId" +@Deprecated("Use SseServerTransport instead", ReplaceWith("SseServerTransport"), DeprecationLevel.ERROR) +public typealias SSEServerTransport = SseServerTransport + /** * Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests. * * 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. */ @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() @@ -70,7 +61,7 @@ public class SseServerTransport( if (it != null && it !is CancellationException) { _onError.invoke(it) } else { - invokeOnCloseCallback() + _onClose.invoke() } } } @@ -85,7 +76,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 +84,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 +93,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 +109,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 @@ -137,7 +117,7 @@ public class SseServerTransport( override suspend fun close() { session.close() - invokeOnCloseCallback() + _onClose.invoke() } override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) { 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..ef52d72 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 @@ -11,7 +10,6 @@ import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams 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.GetPromptRequest @@ -27,38 +25,24 @@ 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.LoggingMessageNotification -import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.Method import io.modelcontextprotocol.kotlin.sdk.types.Notification import io.modelcontextprotocol.kotlin.sdk.types.Prompt import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument -import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult import io.modelcontextprotocol.kotlin.sdk.types.Resource -import io.modelcontextprotocol.kotlin.sdk.types.ResourceTemplate import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest import io.modelcontextprotocol.kotlin.sdk.types.TextContent import io.modelcontextprotocol.kotlin.sdk.types.Tool 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 private val logger = KotlinLogging.logger {} @@ -67,27 +51,10 @@ 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 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]. + * @property enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with clients. */ -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, -) { - @JvmOverloads - public constructor( - capabilities: ServerCapabilities, - enforceStrictCapabilities: Boolean = true, - ) : this(capabilities, enforceStrictCapabilities, PathSegmentTemplateMatcher.factory) -} +public class ServerOptions(public val capabilities: ServerCapabilities, enforceStrictCapabilities: Boolean = true) : + ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) /** * An MCP server is responsible for storing features and handling new connections. @@ -101,12 +68,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, @@ -128,8 +96,13 @@ public open class Server( block: Server.() -> Unit = {}, ) : this(serverInfo, options, { instructions }, block) + @Suppress("ktlint:standard:backing-property-naming") + private var _onInitialized: (() -> Unit) = {} + + @Suppress("ktlint:standard:backing-property-naming") private var _onConnect: (() -> Unit) = {} + @Suppress("ktlint:standard:backing-property-naming") private var _onClose: () -> Unit = {} @OptIn(ExperimentalTime::class) @@ -155,11 +128,6 @@ public open class Server( addListener(notificationService.resourceUpdatedListener) } } - private val resourceTemplateRegistry = FeatureRegistry("ResourceTemplate").apply { - if (options.capabilities.resources?.listChanged == true) { - addListener(notificationService.resourceListChangedListener) - } - } /** * Provides a snapshot of all sessions currently registered in the server @@ -185,17 +153,10 @@ public open class Server( public val resources: Map get() = resourceRegistry.values - /** - * Provides a snapshot of all resource templates currently registered in the server. - */ - public val resourceTemplates: List - get() = resourceTemplateRegistry.values.values.map { it.resourceTemplate } - init { block(this) } - /** Closes this server, shutting down the notification service and all active sessions. */ public suspend fun close() { logger.debug { "Closing MCP server" } notificationService.close() @@ -206,6 +167,20 @@ public open class Server( _onClose() } + /** + * Starts a new server session with the given transport and initializes + * internal request handlers based on the server's capabilities. + * + * @param transport The transport layer to connect the session with. + * @return The initialized and connected server session. + */ + @Deprecated( + "Use createSession(transport) instead.", + ReplaceWith("createSession(transport)"), + DeprecationLevel.WARNING, + ) + public suspend fun connect(transport: Transport): ServerSession = createSession(transport) + /** * Starts a new server session with the given transport and initializes * internal request handlers based on the server's capabilities. @@ -222,7 +197,7 @@ public open class Server( handleListTools() } session.setRequestHandler(Method.Defined.ToolsCall) { request, _ -> - handleCallTool(session, request) + handleCallTool(request) } } @@ -232,7 +207,7 @@ public open class Server( handleListPrompts() } session.setRequestHandler(Method.Defined.PromptsGet) { request, _ -> - handleGetPrompt(session, request) + handleGetPrompt(request) } } @@ -242,7 +217,7 @@ public open class Server( handleListResources() } session.setRequestHandler(Method.Defined.ResourcesRead) { request, _ -> - handleReadResource(session, request) + handleReadResource(request) } session.setRequestHandler(Method.Defined.ResourcesTemplatesList) { _, _ -> handleListResourceTemplates() @@ -289,6 +264,22 @@ public open class Server( } } + /** + * Registers a callback to be invoked when the server has completed initialization. + */ + @Deprecated( + "Initialization moved to ServerSession, use ServerSession.onInitialized instead.", + ReplaceWith("ServerSession.onInitialized"), + DeprecationLevel.WARNING, + ) + public fun onInitialized(block: () -> Unit) { + val old = _onInitialized + _onInitialized = { + old() + block() + } + } + /** * Registers a callback to be invoked when the server connection is closing. */ @@ -307,12 +298,12 @@ 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. */ - public fun addTool(tool: Tool, handler: suspend ClientConnection.(CallToolRequest) -> CallToolResult) { - checkNotNull(options.capabilities.tools) { + public fun addTool(tool: Tool, handler: suspend (CallToolRequest) -> CallToolResult) { + 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." } - warnIfInvalidToolName(tool.name) + toolRegistry.add(RegisteredTool(tool, handler)) } @@ -320,16 +311,16 @@ 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. * @param meta Optional metadata as a [JsonObject]. * @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, @@ -337,9 +328,8 @@ public open class Server( title: String? = null, outputSchema: ToolSchema? = null, toolAnnotations: ToolAnnotations? = null, - execution: ToolExecution? = null, meta: JsonObject? = null, - handler: suspend ClientConnection.(CallToolRequest) -> CallToolResult, + handler: suspend (CallToolRequest) -> CallToolResult, ) { val tool = Tool( name = name, @@ -348,7 +338,6 @@ public open class Server( description = description, title = title, annotations = toolAnnotations, - execution = execution, meta = meta, ) addTool(tool, handler) @@ -361,11 +350,10 @@ public open class Server( * @throws IllegalStateException If the server does not support tools. */ public fun addTools(toolsToAdd: List) { - checkNotNull(options.capabilities.tools) { + check(options.capabilities.tools != null) { 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) } @@ -377,7 +365,7 @@ public open class Server( * @throws IllegalStateException If the server does not support tools. */ public fun removeTool(name: String): Boolean { - checkNotNull(options.capabilities.tools) { + check(options.capabilities.tools != null) { logger.error { "Failed to remove tool '$name': Server does not support tools capability" } "Server does not support tools capability." } @@ -392,7 +380,7 @@ public open class Server( * @throws IllegalStateException If the server does not support tools. */ public fun removeTools(toolNames: List): Int { - checkNotNull(options.capabilities.tools) { + check(options.capabilities.tools != null) { logger.error { "Failed to remove tools: Server does not support tools capability" } "Server does not support tools capability." } @@ -408,11 +396,8 @@ public open class Server( * @param promptProvider A suspend function that returns the prompt content when requested by the client. * @throws IllegalStateException If the server does not support prompts. */ - public fun addPrompt( - prompt: Prompt, - promptProvider: suspend ClientConnection.(GetPromptRequest) -> GetPromptResult, - ) { - checkNotNull(options.capabilities.prompts) { + public fun addPrompt(prompt: Prompt, promptProvider: suspend (GetPromptRequest) -> GetPromptResult) { + 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." } @@ -432,7 +417,7 @@ public open class Server( name: String, description: String? = null, arguments: List? = null, - promptProvider: suspend ClientConnection.(GetPromptRequest) -> GetPromptResult, + promptProvider: suspend (GetPromptRequest) -> GetPromptResult, ) { val prompt = Prompt(name = name, description = description, arguments = arguments) addPrompt(prompt, promptProvider) @@ -445,7 +430,7 @@ public open class Server( * @throws IllegalStateException If the server does not support prompts. */ public fun addPrompts(promptsToAdd: List) { - checkNotNull(options.capabilities.prompts) { + check(options.capabilities.prompts != null) { logger.error { "Failed to add prompts: Server does not support prompts capability" } "Server does not support prompts capability." } @@ -460,7 +445,7 @@ public open class Server( * @throws IllegalStateException If the server does not support prompts. */ public fun removePrompt(name: String): Boolean { - checkNotNull(options.capabilities.prompts) { + check(options.capabilities.prompts != null) { logger.error { "Failed to remove prompt '$name': Server does not support prompts capability" } "Server does not support prompts capability." } @@ -476,7 +461,7 @@ public open class Server( * @throws IllegalStateException If the server does not support prompts. */ public fun removePrompts(promptNames: List): Int { - checkNotNull(options.capabilities.prompts) { + check(options.capabilities.prompts != null) { logger.error { "Failed to remove prompts: Server does not support prompts capability" } "Server does not support prompts capability." } @@ -499,9 +484,9 @@ public open class Server( name: String, description: String, mimeType: String = "text/html", - readHandler: suspend ClientConnection.(ReadResourceRequest) -> ReadResourceResult, + readHandler: suspend (ReadResourceRequest) -> ReadResourceResult, ) { - checkNotNull(options.capabilities.resources) { + check(options.capabilities.resources != null) { logger.error { "Failed to add resource '$name': Server does not support resources capability" } "Server does not support resources capability." } @@ -516,7 +501,7 @@ public open class Server( * @throws IllegalStateException If the server does not support resources. */ public fun addResources(resourcesToAdd: List) { - checkNotNull(options.capabilities.resources) { + check(options.capabilities.resources != null) { logger.error { "Failed to add resources: Server does not support resources capability" } "Server does not support resources capability." } @@ -531,7 +516,7 @@ public open class Server( * @throws IllegalStateException If the server does not support resources. */ public fun removeResource(uri: String): Boolean { - checkNotNull(options.capabilities.resources) { + check(options.capabilities.resources != null) { logger.error { "Failed to remove resource '$uri': Server does not support resources capability" } "Server does not support resources capability." } @@ -546,74 +531,13 @@ public open class Server( * @throws IllegalStateException If the server does not support resources. */ public fun removeResources(uris: List): Int { - checkNotNull(options.capabilities.resources) { + check(options.capabilities.resources != null) { logger.error { "Failed to remove resources: Server does not support resources capability" } "Server does not support resources capability." } return resourceRegistry.removeAll(uris) } - /** - * Registers a resource template. Clients can discover it via `resources/templates/list` - * and read matching URIs via `resources/read`. - * - * @param template The [ResourceTemplate] describing the URI template pattern. - * @param readHandler A suspend function invoked when a client reads a URI that matches - * the template. The second parameter contains the URI variables extracted from the match. - * @throws IllegalStateException If the server does not support resources. - */ - public fun addResourceTemplate( - template: ResourceTemplate, - readHandler: suspend ClientConnection.(ReadResourceRequest, Map) -> ReadResourceResult, - ) { - checkNotNull(options.capabilities.resources) { - "Server does not support resources capability." - } - val matcher = options.resourceTemplateMatcherFactory.create(template) - resourceTemplateRegistry.add( - RegisteredResourceTemplate( - template, - matcher, - readHandler = readHandler, - ), - ) - } - - /** - * Registers a resource template by constructing a [ResourceTemplate] from given parameters. - * - * @param uriTemplate The RFC 6570 URI template string (e.g. `"file:///{path}"`). - * @param name A human-readable name for the template. - * @param description A human-readable description of the resource template. - * @param mimeType The MIME type of resource content served by this template. - * @param readHandler A suspend function invoked when a client reads a URI that matches - * the template. The second parameter contains the URI variables extracted from the match. - * @throws IllegalStateException If the server does not support resources. - */ - public fun addResourceTemplate( - uriTemplate: String, - name: String, - description: String? = null, - mimeType: String? = null, - readHandler: suspend ClientConnection.(ReadResourceRequest, Map) -> ReadResourceResult, - ) { - addResourceTemplate(ResourceTemplate(uriTemplate, name, description, mimeType), readHandler) - } - - /** - * Removes a resource template by its URI template string. - * - * @param uriTemplate The URI template string identifying the template to remove. - * @return True if the template was removed, false if it was not found. - * @throws IllegalStateException If the server does not support resources. - */ - public fun removeResourceTemplate(uriTemplate: String): Boolean { - checkNotNull(options.capabilities.resources) { - "Server does not support resources capability." - } - return resourceTemplateRegistry.remove(uriTemplate) - } - // --- Internal Handlers --- private fun handleSubscribeResources(session: ServerSession, request: SubscribeRequest) { if (options.capabilities.resources?.subscribe ?: false) { @@ -638,7 +562,7 @@ public open class Server( return ListToolsResult(tools = toolList, nextCursor = null) } - private suspend fun handleCallTool(session: ServerSession, request: CallToolRequest): CallToolResult { + private suspend fun handleCallTool(request: CallToolRequest): CallToolResult { val requestParams = request.params logger.debug { "Handling tool call request for tool: ${requestParams.name}" } @@ -651,17 +575,13 @@ public open class Server( ) } + @Suppress("TooGenericExceptionCaught") // Execute the tool handler and catch any errors return try { logger.trace { "Executing tool ${requestParams.name} with input: ${requestParams.arguments}" } - tool.run { - session.clientConnection.handler(request) - } + tool.handler(request) } 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( @@ -676,20 +596,15 @@ public open class Server( return ListPromptsResult(prompts = prompts.values.map { it.prompt }) } - private suspend fun handleGetPrompt(session: ServerSession, request: GetPromptRequest): GetPromptResult { + private suspend fun handleGetPrompt(request: GetPromptRequest): GetPromptResult { val requestParams = request.params logger.debug { "Handling get prompt request for: ${requestParams.name}" } 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) - } + return prompt.messageProvider(request) } private fun handleListResources(): ListResourcesResult { @@ -697,55 +612,34 @@ public open class Server( return ListResourcesResult(resources = resources.values.map { it.resource }) } - private suspend fun handleReadResource(session: ServerSession, request: ReadResourceRequest): ReadResourceResult { - val uri = request.params.uri - logger.debug { "Handling read resource request for: $uri" } - - // Priority 1: exact URI match - resourceRegistry.get(uri)?.let { resource -> - return resource.run { session.clientConnection.readHandler(request) } - } - - // Priority 2: most-specific matching template. - // Selection: highest score wins; on tie, fewest variables wins; on tie, registration order wins. - val (template, matchResult) = resourceTemplateRegistry.values.values - .mapNotNull { tmpl -> tmpl.matcher.match(uri)?.let { tmpl to it } } - .maxWithOrNull( - compareBy> { (_, result) -> result.score } - .thenByDescending { (_, result) -> result.variables.size }, - ) + private suspend fun handleReadResource(request: ReadResourceRequest): ReadResourceResult { + val requestParams = request.params + logger.debug { "Handling read resource request for: ${requestParams.uri}" } + val resource = resourceRegistry.get(requestParams.uri) ?: run { - logger.error { "Resource not found: $uri" } - throw McpException( - code = RPCError.ErrorCode.RESOURCE_NOT_FOUND, - message = "Resource not found", - data = buildJsonObject { put("uri", uri) }, - ) + logger.error { "Resource not found: ${requestParams.uri}" } + throw IllegalArgumentException("Resource not found: ${requestParams.uri}") } - - logger.debug { "Matched resource template '${template.key}' for URI: $uri" } - return template.run { session.clientConnection.readHandler(request, matchResult.variables) } + return resource.readHandler(request) } private fun handleListResourceTemplates(): ListResourceTemplatesResult { - logger.debug { "Handling list resource templates request" } - return ListResourceTemplatesResult(resourceTemplateRegistry.values.values.map { it.resourceTemplate }) + // If you have resource templates, return them here. For now, return empty. + return ListResourceTemplatesResult(listOf()) } - // Start the ServerSession / ClientConnection redirection section - - /** Returns the [ClientConnection] for the session identified by [sessionId]. */ - public fun clientConnection(sessionId: String): ClientConnection = - sessionRegistry.getSession(sessionId).clientConnection + // Start the ServerSession redirection section /** - * Triggers [ClientConnection.ping] request for session by provided [sessionId]. + * Triggers [ServerSession.ping] request for session by provided [sessionId]. * @param sessionId The session ID to ping */ - public suspend fun ping(sessionId: String): EmptyResult = clientConnection(sessionId).ping() + public suspend fun ping(sessionId: String): EmptyResult = with(sessionRegistry.getSession(sessionId)) { + ping() + } /** - * Triggers [ClientConnection.createMessage] request for session by provided [sessionId]. + * Triggers [ServerSession.createMessage] request for session by provided [sessionId]. * * @param sessionId The session ID to create a message. * @param params The parameters for creating a message. @@ -757,10 +651,12 @@ public open class Server( sessionId: String, params: CreateMessageRequest, options: RequestOptions? = null, - ): CreateMessageResult = clientConnection(sessionId).createMessage(params, options) + ): CreateMessageResult = with(sessionRegistry.getSession(sessionId)) { + request(params, options) + } /** - * Triggers [ClientConnection.listRoots] request for session by provided [sessionId]. + * Triggers [ServerSession.listRoots] request for session by provided [sessionId]. * * @param sessionId The session ID to list roots for. * @param params JSON parameters for the request, usually empty. @@ -772,10 +668,12 @@ public open class Server( sessionId: String, params: JsonObject = EmptyJsonObject, options: RequestOptions? = null, - ): ListRootsResult = sessionRegistry.getSession(sessionId).listRoots(params, options) + ): ListRootsResult = with(sessionRegistry.getSession(sessionId)) { + listRoots(params, options) + } /** - * Triggers [ClientConnection.createElicitation] request for session by provided [sessionId]. + * Triggers [ServerSession.createElicitation] request for session by provided [sessionId]. * * @param sessionId The session ID to create elicitation for. * @param message The elicitation message. @@ -789,111 +687,81 @@ public open class Server( message: String, requestedSchema: ElicitRequestParams.RequestedSchema, options: RequestOptions? = null, - ): ElicitResult = clientConnection(sessionId).createElicitation( - message = message, - requestedSchema = requestedSchema, - options = options, - ) + ): ElicitResult = with(sessionRegistry.getSession(sessionId)) { + createElicitation(message, requestedSchema, options) + } /** - * Triggers URL mode [ClientConnection.createElicitation] request for session by provided [sessionId]. - * - * @param sessionId The session ID to create elicitation for. - * @param message The message explaining why the interaction is needed. - * @param elicitationId A unique identifier for the elicitation. - * @param url The URL that the user should navigate to. - * @param options Optional request options. - * @return The created elicitation result. - * @throws IllegalStateException If the server does not support elicitation or if the request fails. - */ - public suspend fun createElicitation( - sessionId: String, - message: String, - elicitationId: String, - url: String, - options: RequestOptions? = null, - ): ElicitResult = clientConnection(sessionId).createElicitation( - message = message, - elicitationId = elicitationId, - url = url, - options = options, - ) - - /** - * Triggers [ClientConnection.sendLoggingMessage] for session by provided [sessionId]. + * Triggers [ServerSession.sendLoggingMessage] for session by provided [sessionId]. * * @param sessionId The session ID to send the logging message to. * @param notification The logging message notification. */ public suspend fun sendLoggingMessage(sessionId: String, notification: LoggingMessageNotification) { - clientConnection(sessionId).sendLoggingMessage(notification) + with(sessionRegistry.getSession(sessionId)) { + sendLoggingMessage(notification) + } } /** - * Triggers [ClientConnection.sendResourceUpdated] for session by provided [sessionId]. + * Triggers [ServerSession.sendResourceUpdated] for session by provided [sessionId]. * * @param sessionId The session ID to send the resource updated notification to. * @param notification Details of the updated resource. */ public suspend fun sendResourceUpdated(sessionId: String, notification: ResourceUpdatedNotification) { - clientConnection(sessionId).sendResourceUpdated(notification) + with(sessionRegistry.getSession(sessionId)) { + sendResourceUpdated(notification) + } } /** - * Triggers [ClientConnection.sendResourceListChanged] for session by provided [sessionId]. + * Triggers [ServerSession.sendResourceListChanged] for session by provided [sessionId]. * * @param sessionId The session ID to send the resource list changed notification to. */ public suspend fun sendResourceListChanged(sessionId: String) { - clientConnection(sessionId).sendResourceListChanged() + with(sessionRegistry.getSession(sessionId)) { + sendResourceListChanged() + } } /** - * Triggers [ClientConnection.sendToolListChanged] for session by provided [sessionId]. + * Triggers [ServerSession.sendToolListChanged] for session by provided [sessionId]. * * @param sessionId The session ID to send the tool list changed notification to. */ public suspend fun sendToolListChanged(sessionId: String) { - clientConnection(sessionId).sendToolListChanged() + with(sessionRegistry.getSession(sessionId)) { + sendToolListChanged() + } } /** - * Triggers [ClientConnection.sendPromptListChanged] for session by provided [sessionId]. + * Triggers [ServerSession.sendPromptListChanged] for session by provided [sessionId]. * * @param sessionId The session ID to send the prompt list changed notification to. */ public suspend fun sendPromptListChanged(sessionId: String) { - clientConnection(sessionId).sendPromptListChanged() + with(sessionRegistry.getSession(sessionId)) { + sendPromptListChanged() + } } - - /** - * Triggers [ClientConnection.sendElicitationComplete] for session by provided [sessionId]. - * - * @param sessionId The session ID to send the elicitation complete notification to. - * @param notification Details of the completed elicitation. - */ - public suspend fun sendElicitationComplete(sessionId: String, notification: ElicitationCompleteNotification) { - clientConnection(sessionId).sendElicitationComplete(notification) - } - // End the ServerSession / ClientConnection redirection section + // End the ServerSession redirection section // Start the notification handling section - - /** Registers a notification [handler] for the given [method] on all active sessions. */ public fun setNotificationHandler(method: Method, handler: (notification: T) -> Deferred) { sessions.forEach { (_, session) -> session.setNotificationHandler(method, handler) } } - /** Removes the notification handler for the given [method] from all active sessions. */ public fun removeNotificationHandler(method: Method) { sessions.forEach { (_, session) -> session.removeNotificationHandler(method) } } - /** Registers a notification [handler] for the given [method] on a specific session. */ public fun setNotificationHandler( sessionId: String, method: Method, @@ -902,7 +770,6 @@ public open class Server( sessionRegistry.getSessionOrNull(sessionId)?.setNotificationHandler(method, handler) } - /** Removes the notification handler for the given [method] from a specific session. */ public fun removeNotificationHandler(sessionId: String, method: Method) { sessionRegistry.getSessionOrNull(sessionId)?.removeNotificationHandler(method) } 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..e65db2f 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 @@ -7,9 +7,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.BaseRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult +import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequest import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams 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.Implementation @@ -21,14 +21,16 @@ 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.Method import io.modelcontextprotocol.kotlin.sdk.types.Method.Defined -import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.PingRequest +import io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.RequestMeta +import io.modelcontextprotocol.kotlin.sdk.types.ResourceListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification import io.modelcontextprotocol.kotlin.sdk.types.SUPPORTED_PROTOCOL_VERSIONS import io.modelcontextprotocol.kotlin.sdk.types.SetLevelRequest +import io.modelcontextprotocol.kotlin.sdk.types.ToolListChangedNotification import kotlinx.atomicfu.AtomicRef import kotlinx.atomicfu.atomic import kotlinx.coroutines.CompletableDeferred @@ -39,43 +41,35 @@ import kotlin.uuid.Uuid private val logger = KotlinLogging.logger {} /** - * Represents an active server-side session in the Model Context Protocol. - * - * 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 + * Represents a server session. */ +@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() + @Suppress("ktlint:standard:backing-property-naming") private var _onInitialized: (() -> Unit) = {} + @Suppress("ktlint:standard:backing-property-naming") private var _onClose: () -> Unit = {} - private val _clientCapabilities: AtomicRef = atomic(null) - private val _clientVersion: AtomicRef = atomic(null) + /** + * The client's reported capabilities after initialization. + */ + public var clientCapabilities: ClientCapabilities? = null + private set - /** Capabilities reported by the client during initialization, or `null` before the handshake completes. */ - public val clientCapabilities: ClientCapabilities? get() = _clientCapabilities.value - - /** Client implementation information reported during initialization, or `null` before the handshake completes. */ - public val clientVersion: Implementation? get() = _clientVersion.value + /** + * The client's version information after initialization. + */ + public var clientVersion: Implementation? = null + private set /** * The capabilities supported by the server, related to the session. @@ -86,7 +80,7 @@ public open class ServerSession( * The current logging level set by the client. * When null, all messages are sent (no filtering). */ - internal val currentLoggingLevel: AtomicRef = atomic(null) + private val currentLoggingLevel: AtomicRef = atomic(null) init { // Core protocol handlers @@ -108,18 +102,8 @@ 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 @@ -148,6 +132,114 @@ public open class ServerSession( _onClose() } + /** + * Sends a ping request to the client to check connectivity. + * + * @return The result of the ping request. + * @throws IllegalStateException If for some reason the method is not supported or the connection is closed. + */ + public suspend fun ping(): EmptyResult = request(PingRequest()) + + /** + * Creates a message using the server's sampling capability. + * + * @param params The parameters for creating a message. + * @param options Optional request options. + * @return The created message result. + * @throws IllegalStateException If the server does not support sampling or if the request fails. + */ + public suspend fun createMessage( + params: CreateMessageRequest, + options: RequestOptions? = null, + ): CreateMessageResult { + logger.debug { + "Creating message with ${params.params.messages.size} messages, maxTokens=${params.params.maxTokens}, temperature=${params.params.temperature}, systemPrompt=${if (params.params.systemPrompt != null) "present" else "absent"}" + } + logger.trace { "Full createMessage params: $params" } + return request(params, options) + } + + /** + * Lists the available "roots" from the client's perspective (if supported). + * + * @param params JSON parameters for the request, usually empty. + * @param options Optional request options. + * @return The list of roots. + * @throws IllegalStateException If the server or client does not support roots. + */ + public suspend fun listRoots( + params: JsonObject = EmptyJsonObject, + options: RequestOptions? = null, + ): ListRootsResult { + logger.debug { "Listing roots with params: $params" } + return request(ListRootsRequest(BaseRequestParams(RequestMeta(params))), options) + } + + public suspend fun createElicitation( + message: String, + requestedSchema: ElicitRequestParams.RequestedSchema, + options: RequestOptions? = null, + ): ElicitResult { + logger.debug { + "Creating elicitation with message length=${message.length}, " + + "schema properties count=${requestedSchema.properties.size}" + } + logger.trace { "Full elicitation message: $message, requestedSchema: $requestedSchema" } + return request(ElicitRequest(ElicitRequestParams(message, requestedSchema)), options) + } + + /** + * Sends a logging message notification to the client. + * Messages are filtered based on the current logging level set by the client. + * If no logging level is set, all messages are sent. + * + * @param notification The logging message notification. + */ + public suspend fun sendLoggingMessage(notification: LoggingMessageNotification) { + if (serverCapabilities.logging != null) { + if (isMessageAccepted(notification.params.level)) { + logger.trace { "Sending logging message: ${notification.params.data}" } + notification(notification) + } else { + logger.trace { "Filtering out logging message with level ${notification.params.level}" } + } + } + } + + /** + * Sends a resource-updated notification to the client, indicating that a specific resource has changed. + * + * @param notification Details of the updated resource. + */ + public suspend fun sendResourceUpdated(notification: ResourceUpdatedNotification) { + logger.debug { "Sending resource updated notification for: ${notification.params.uri}" } + notification(notification) + } + + /** + * Sends a notification to the client indicating that the list of resources has changed. + */ + public suspend fun sendResourceListChanged() { + logger.debug { "Sending resource list changed notification" } + notification(ResourceListChangedNotification()) + } + + /** + * Sends a notification to the client indicating that the list of tools has changed. + */ + public suspend fun sendToolListChanged() { + logger.debug { "Sending tool list changed notification" } + notification(ToolListChangedNotification()) + } + + /** + * Sends a notification to the client indicating that the list of prompts has changed. + */ + public suspend fun sendPromptListChanged() { + logger.debug { "Sending prompt list changed notification" } + notification(PromptListChangedNotification()) + } + /** * Asserts that the client supports the capability required for the given [method]. * @@ -162,30 +254,24 @@ public open class ServerSession( Defined.SamplingCreateMessage -> { if (clientCapabilities?.sampling == null) { logger.error { "Client capability assertion failed: sampling not supported" } - error("Client does not support sampling (required for ${method.value})") + throw IllegalStateException("Client does not support sampling (required for ${method.value})") } } Defined.RootsList -> { if (clientCapabilities?.roots == null) { logger.error { "Client capability assertion failed: listing roots not supported" } - error("Client does not support listing roots (required for ${method.value})") + throw IllegalStateException("Client does not support listing roots (required for ${method.value})") } } Defined.ElicitationCreate -> { if (clientCapabilities?.elicitation == null) { logger.error { "Client capability assertion failed: elicitation not supported" } - error("Client does not support elicitation (required for ${method.value})") + throw IllegalStateException("Client does not support elicitation (required for ${method.value})") } } - Defined.TasksGet, - Defined.TasksResult, - Defined.TasksList, - Defined.TasksCancel, - -> assertTasksCapabilityForMethod(method) - Defined.Ping -> { // No specific capability required } @@ -196,28 +282,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. * @@ -231,7 +295,7 @@ public open class ServerSession( Defined.NotificationsMessage -> { if (serverCapabilities.logging == null) { logger.error { "Server capability assertion failed: logging not supported" } - error("Server does not support logging (required for ${method.value})") + throw IllegalStateException("Server does not support logging (required for ${method.value})") } } @@ -239,7 +303,7 @@ public open class ServerSession( Defined.NotificationsResourcesListChanged, -> { if (serverCapabilities.resources == null) { - error( + throw IllegalStateException( "Server does not support notifying about resources (required for ${method.value})", ) } @@ -247,7 +311,7 @@ public open class ServerSession( Defined.NotificationsToolsListChanged -> { if (serverCapabilities.tools == null) { - error( + throw IllegalStateException( "Server does not support notifying of tool list changes (required for ${method.value})", ) } @@ -255,18 +319,12 @@ public open class ServerSession( Defined.NotificationsPromptsListChanged -> { if (serverCapabilities.prompts == null) { - error( + throw IllegalStateException( "Server does not support notifying of prompt list changes (required for ${method.value})", ) } } - Defined.NotificationsTasksStatus -> { - if (serverCapabilities.tasks == null) { - error("Server does not support tasks (required for ${method.value})") - } - } - Defined.NotificationsCancelled, Defined.NotificationsProgress, -> { @@ -292,14 +350,14 @@ public open class ServerSession( Defined.SamplingCreateMessage -> { if (serverCapabilities.experimental?.get("sampling") == null) { logger.error { "Server capability assertion failed: sampling not supported" } - error("Server does not support sampling (required for $method)") + throw IllegalStateException("Server does not support sampling (required for $method)") } } Defined.LoggingSetLevel -> { if (serverCapabilities.logging == null) { logger.error { "Server does not support logging (required for $method)" } - error("Server does not support logging (required for $method)") + throw IllegalStateException("Server does not support logging (required for $method)") } } @@ -307,7 +365,7 @@ public open class ServerSession( Defined.PromptsList, -> { if (serverCapabilities.prompts == null) { - error("Server does not support prompts (required for $method)") + throw IllegalStateException("Server does not support prompts (required for $method)") } } @@ -318,7 +376,7 @@ public open class ServerSession( Defined.ResourcesUnsubscribe, -> { if (serverCapabilities.resources == null) { - error("Server does not support resources (required for $method)") + throw IllegalStateException("Server does not support resources (required for $method)") } } @@ -326,7 +384,7 @@ public open class ServerSession( Defined.ToolsList, -> { if (serverCapabilities.tools == null) { - error("Server does not support tools (required for $method)") + throw IllegalStateException("Server does not support tools (required for $method)") } } @@ -340,24 +398,17 @@ public open class ServerSession( } } - private fun handleInitialize(request: InitializeRequest): InitializeResult { - if (!_clientCapabilities.compareAndSet(null, request.params.capabilities)) { - throw McpException( - code = RPCError.ErrorCode.INVALID_REQUEST, - message = "Server already initialized", - ) - } - + private suspend fun handleInitialize(request: InitializeRequest): InitializeResult { logger.debug { "Handling initialization request from client" } - _clientVersion.value = request.params.clientInfo + clientCapabilities = request.params.capabilities + clientVersion = request.params.clientInfo val requestedVersion = request.params.protocolVersion val protocolVersion = if (SUPPORTED_PROTOCOL_VERSIONS.contains(requestedVersion)) { requestedVersion } else { logger.warn { - "Client requested unsupported protocol version $requestedVersion, " + - "falling back to $LATEST_PROTOCOL_VERSION" + "Client requested unsupported protocol version $requestedVersion, falling back to $LATEST_PROTOCOL_VERSION" } LATEST_PROTOCOL_VERSION } @@ -370,6 +421,26 @@ public open class ServerSession( ) } + /** + * Checks if a message with the given level should be ignored based on the current logging level. + * + * @param level The level of the message to check. + * @return true if the message should be ignored (filtered out), false otherwise. + */ + private fun isMessageIgnored(level: LoggingLevel): Boolean { + val current = currentLoggingLevel.value ?: return false // If no level is set, don't filter + + return level < current + } + + /** + * Checks if a message with the given level should be accepted based on the current logging level. + * + * @param level The level of the message to check. + * @return true if the message should be accepted (not filtered out), false otherwise. + */ + private fun isMessageAccepted(level: LoggingLevel): Boolean = !isMessageIgnored(level) + override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is ServerSession) return false @@ -377,125 +448,4 @@ public open class ServerSession( } override fun hashCode(): Int = sessionId.hashCode() - - // Start the ClientConnection redirection section - - /** - * Sends a ping request to the client to check connectivity. - * - * @return The result of the ping request. - * @throws IllegalStateException If for some reason the method is not supported or the connection is closed. - */ - public suspend fun ping(): EmptyResult = clientConnection.ping() - - /** - * Creates a message using the server's sampling capability. - * - * @param params The parameters for creating a message. - * @param options Optional request options. - * @return The created message result. - * @throws IllegalStateException If the server does not support sampling or if the request fails. - */ - public suspend fun createMessage( - params: CreateMessageRequest, - options: RequestOptions? = null, - ): CreateMessageResult = clientConnection.createMessage(params, options) - - /** - * Lists the available "roots" from the client's perspective (if supported). - * - * @param params JSON parameters for the request, usually empty. - * @param options Optional request options. - * @return The list of roots. - * @throws IllegalStateException If the server or client does not support roots. - */ - public suspend fun listRoots( - params: JsonObject = EmptyJsonObject, - options: RequestOptions? = null, - ): ListRootsResult { - logger.debug { "Listing roots with params: $params" } - return request(ListRootsRequest(BaseRequestParams(RequestMeta(params))), options) - } - - /** - * 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. - * 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, - requestedSchema: ElicitRequestParams.RequestedSchema, - options: RequestOptions? = null, - ): ElicitResult = clientConnection.createElicitation(message, requestedSchema, options) - - /** - * Sends a URL mode elicitation request to the client, directing the user - * to an external URL for out-of-band interactions. - * - * @param message The message explaining why the interaction is needed. - * @param elicitationId A unique identifier for the elicitation. - * @param url The URL that the user should navigate to. - * @param options Optional request options. - * @return The result of the elicitation request. - * @throws IllegalStateException If the server or client does not support elicitation. - */ - public suspend fun createElicitation( - message: String, - elicitationId: String, - url: String, - options: RequestOptions? = null, - ): ElicitResult = clientConnection.createElicitation(message, elicitationId, url, options) - - /** - * Sends a logging message notification to the client. - * Messages are filtered based on the current logging level set by the client. - * If no logging level is set, all messages are sent. - * - * @param notification The logging message notification. - */ - public suspend fun sendLoggingMessage(notification: LoggingMessageNotification): Unit = - clientConnection.sendLoggingMessage(notification) - - /** - * Sends a resource-updated notification to the client, indicating that a specific resource has changed. - * - * @param notification Details of the updated resource. - */ - public suspend fun sendResourceUpdated(notification: ResourceUpdatedNotification): Unit = - clientConnection.sendResourceUpdated(notification) - - /** - * Sends a notification to the client indicating that the list of resources has changed. - */ - public suspend fun sendResourceListChanged(): Unit = clientConnection.sendResourceListChanged() - - /** - * Sends a notification to the client indicating that the list of tools has changed. - */ - public suspend fun sendToolListChanged(): Unit = clientConnection.sendToolListChanged() - - /** - * Sends a notification to the client indicating that the list of prompts has changed. - */ - public suspend fun sendPromptListChanged(): Unit = clientConnection.sendPromptListChanged() - - /** - * Sends a notification to the client indicating that an out-of-band elicitation has completed. - * - * @param notification Details of the completed elicitation. - */ - public suspend fun sendElicitationComplete(notification: ElicitationCompleteNotification): Unit = - clientConnection.sendElicitationComplete(notification) - // End the ClientConnection redirection section } 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..092d0a6 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,15 @@ 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 +22,131 @@ 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 - -private const val READ_BUFFER_SIZE = 8192L +import kotlin.coroutines.CoroutineContext /** - * 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: - * - * - **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() - * ``` + * Reads from System.in and writes to System.out. */ @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 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 outputWriter = 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 - - readerJob = resolvedScope.launch( - ioDispatcher + CoroutineName("StdioServerTransport.reader"), - ) { readerPump() } - processorJob = resolvedScope.launch( - handlerDispatcher + CoroutineName("StdioServerTransport.processor"), - start = CoroutineStart.UNDISPATCHED, - ) { processorPump() } - writerJob = resolvedScope.launch( - ioDispatcher + CoroutineName("StdioServerTransport.writer"), - start = CoroutineStart.UNDISPATCHED, - ) { writerPump() } - } finally { - setupComplete.complete(Unit) - } - } - - private 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) + // Launch a coroutine to read from stdin + readingJob = scope.launch { + val buf = Buffer() + try { + while (isActive) { + val bytesRead = inputStream.readAtMostTo(buf, 8192) + if (bytesRead == -1L) { + // EOF reached + break + } + if (bytesRead > 0) { + val chunk = buf.readByteArray() + readChannel.send(chunk) } } + } catch (e: Throwable) { + logger.error(e) { "Error reading from stdin" } + _onError.invoke(e) + } finally { + // Reached EOF or error, close connection + close() + } + } + + // Launch a coroutine to process messages from readChannel + scope.launch { + try { + for (chunk in readChannel) { + readBuffer.append(chunk) + processReadBuffer() + } + } catch (e: Throwable) { + _onError.invoke(e) + } + } + + // Launch a coroutine to handle message sending + sendingJob = scope.launch { + try { + for (message in writeChannel) { + val json = serializeMessage(message) + outputWriter.writeString(json) + outputWriter.flush() + } + } catch (e: Throwable) { + logger.error(e) { "Error writing to stdout" } + _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 suspend fun processReadBuffer() { + while (true) { + val message = try { + readBuffer.readMessage() + } catch (e: Throwable) { + _onError.invoke(e) + null + } + + if (message == null) break + // Async invocation broke delivery order + try { + _onMessage.invoke(message) + } catch (e: Throwable) { + _onError.invoke(e) } - } 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() readBuffer.clear() - if (ownsScope) { - effectiveScope?.coroutineContext?.get(Job)?.cancel() - } - invokeOnCloseCallback() + + runCatching { + outputWriter.flush() + outputWriter.close() + }.onFailure { logger.warn(it) { "Failed to close stdout" } } + + _onClose.invoke() } } - /** - * 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..56bcead 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,63 +1,52 @@ 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 import io.modelcontextprotocol.kotlin.sdk.types.Method 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 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 MIN_PRIMING_EVENT_PROTOCOL_VERSION = "2025-11-25" +private const val MAXIMUM_MESSAGE_SIZE = 4 * 1024 * 1024 // 4 MB /** * A holder for an active request call. - * If [StreamableHttpServerTransport.Configuration.enableJsonResponse] is true, the session is null. - * Otherwise, the session is not null. + * If enableJsonResponse is true, session is null. + * Otherwise, session is not null. */ private data class SessionContext(val session: ServerSSESession?, val call: ApplicationCall) @@ -76,96 +65,29 @@ private data class SessionContext(val session: ServerSSESession?, val call: Appl * - No Session ID is included in any responses * - No session validation is performed * - * @param configuration Transport configuration. See [Configuration] for available options. + * @param enableJsonResponse If true, the server will return JSON responses instead of starting an SSE stream. + * This can be useful for simple request/response scenarios without streaming. + * Default is false (SSE streams are preferred). + * @param enableDnsRebindingProtection Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). + * Default is false for backwards compatibility. + * @param allowedHosts List of allowed host header values for DNS rebinding protection. + * If not specified, host validation is disabled. + * @param allowedOrigins List of allowed origin header values for DNS rebinding protection. + * If not specified, origin validation is disabled. + * @param eventStore Event store for resumability support + * If provided, resumability will be enabled, allowing clients to reconnect and resume messages + * @param retryIntervalMillis Retry interval (in milliseconds) advertised via SSE priming events to hint the client when to reconnect. + * Applies only when an [eventStore] is configured. Defaults to `null` (no retry hint). */ @OptIn(ExperimentalUuidApi::class, ExperimentalAtomicApi::class) -public class StreamableHttpServerTransport(private val configuration: Configuration) : AbstractTransport() { - - @Deprecated("Use default constructor with explicit Configuration()") - public constructor() : this(configuration = Configuration()) - - /** - * Secondary constructor for `StreamableHttpServerTransport` that simplifies initialization by directly taking the - * configurable parameters without requiring a `Configuration` instance. - * - * @param enableJsonResponse Determines whether the server should return JSON responses. - * Defaults to `false`. - * @param enableDnsRebindingProtection Enables DNS rebinding protection. - * Defaults to `false`. - * @param allowedHosts A list of hosts allowed for server communication. - * Defaults to `null`, allowing all hosts. - * @param allowedOrigins A list of allowed origins for CORS (Cross-Origin Resource Sharing). - * Defaults to `null`, allowing all origins. - * @param eventStore The `EventStore` instance for handling resumable events. - * Defaults to `null`, disabling resumability. - * @param retryIntervalMillis Retry interval in milliseconds for event handling or reconnection attempts. - * Defaults to `null`. - */ - @Deprecated( - "Use constructor with Configuration: StreamableHttpServerTransport(Configuration(enableJsonResponse = ...))", - replaceWith = ReplaceWith( - "StreamableHttpServerTransport(Configuration(enableJsonResponse = enableJsonResponse, enableDnsRebindingProtection = enableDnsRebindingProtection, allowedHosts = allowedHosts, allowedOrigins = allowedOrigins, eventStore = eventStore, retryIntervalMillis = retryIntervalMillis))", - ), - ) - public constructor( - enableJsonResponse: Boolean = false, - enableDnsRebindingProtection: Boolean = false, - allowedHosts: List? = null, - allowedOrigins: List? = null, - eventStore: EventStore? = null, - retryIntervalMillis: Long? = null, - ) : this( - Configuration( - enableJsonResponse = enableJsonResponse, - enableDnsRebindingProtection = enableDnsRebindingProtection, - allowedHosts = allowedHosts, - allowedOrigins = allowedOrigins, - eventStore = eventStore, - retryInterval = retryIntervalMillis?.milliseconds, - ), - ) - - /** - * Configuration options for [StreamableHttpServerTransport]. - * - * @property enableJsonResponse when `true`, returns direct JSON responses instead of SSE streams - * @property enableDnsRebindingProtection enables DNS rebinding protection - * @property allowedHosts list of hosts allowed for server communication, or `null` to allow all - * @property allowedOrigins list of allowed CORS origins, or `null` to allow all - * @property eventStore store for resumable events, or `null` to disable resumability - * @property retryInterval retry interval for SSE reconnection attempts - * @property maxRequestBodySize Maximum allowed size (in bytes) for incoming request bodies. - * Defaults to 4 MB (4,194,304 bytes). - */ - 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, - public val maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, - ) { - init { - require(maxRequestBodySize > 0) { - "maxRequestBodySize must be greater than 0" - } - } - } - - /** Session identifier assigned after initialization in stateful mode, or `null` in stateless mode. */ +public class StreamableHttpServerTransport( + private val enableJsonResponse: Boolean = false, + private val enableDnsRebindingProtection: Boolean = false, + private val allowedHosts: List? = null, + private val allowedOrigins: List? = null, + private val eventStore: EventStore? = null, + private val retryIntervalMillis: Long? = null, +) : AbstractTransport() { public var sessionId: String? = null private set @@ -180,20 +102,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() @@ -237,8 +145,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat override suspend fun start() { check(started.compareAndSet(expectedValue = false, newValue = true)) { - "StreamableHttpServerTransport already started! If using Server class, " + - "note that connect() calls start() automatically." + "StreamableHttpServerTransport already started! If using Server class, note that connect() calls start() automatically." } } @@ -260,148 +167,66 @@ 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) { + if (!enableJsonResponse) { activeStream?.let { stream -> emitOnStream(streamId, stream.session, message) } - if (!isTerminated) return } + val isTerminated = message is JSONRPCResponse || message is JSONRPCError + if (!isTerminated) 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 - } + if (enableJsonResponse) { + activeStream.call.response.header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + sessionId?.let { activeStream.call.response.header(MCP_SESSION_ID_HEADER, it) } val responses = relatedIds.mapNotNull { requestToResponseMapping[it] } - 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() + _onClose() } } /** - * 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,18 +250,11 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } /** - * Handles POST requests containing JSON-RPC messages. - * - * In JSON-response mode this suspends until a response has been produced for every request in - * the POST body, then completes the HTTP exchange with the collected JSON (a single object for - * one request, an array for a batch). Bodies without requests are acknowledged with 202 - * immediately. In SSE mode the responses are emitted onto the per-request SSE stream instead. + * Handles POST requests containing JSON-RPC messages */ public suspend fun handlePostRequest(session: ServerSSESession?, call: ApplicationCall) { try { - if (!configuration.enableJsonResponse && session == null) { - error("Server session can't be null for SSE responses") - } + if (!enableJsonResponse && session == null) error("Server session can't be null with json response") val acceptHeader = call.request.header(HttpHeaders.Accept) val isAcceptEventStream = acceptHeader.accepts(ContentType.Text.EventStream) @@ -461,10 +279,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 +289,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 +308,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) { + if (!enableJsonResponse) { call.appendSseHeaders() flushSse(session) // flush headers immediately - maybeSendPrimingEvent(streamId, session, clientProtocolVersion) - } - - val pendingPost = if (configuration.enableJsonResponse) { - CompletableDeferred>() - } else { - null + maybeSendPrimingEvent(streamId, session) } 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, @@ -582,11 +342,15 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - /** Handles an HTTP GET request by establishing a standalone SSE stream for server-initiated notifications. */ 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 - // for server-to-client notifications when enableJsonResponse = true. + if (enableJsonResponse) { + call.reject( + HttpStatusCode.MethodNotAllowed, + RPCError.ErrorCode.CONNECTION_CLOSED, + "Method not allowed.", + ) + return + } val sseSession = session ?: error("Server session can't be null for streaming GET requests") val acceptHeader = call.request.header(HttpHeaders.Accept) @@ -601,55 +365,36 @@ public class StreamableHttpServerTransport(private val configuration: Configurat if (!validateSession(call) || !validateProtocolVersion(call)) return - configuration.eventStore?.let { store -> + eventStore?.let { store -> call.request.header(MCP_RESUMPTION_TOKEN_HEADER)?.let { lastEventId -> replayEvents(store, lastEventId, sseSession) return } } - // 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 } - 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) + + call.appendSseHeaders() + flushSse(sseSession) // flush headers immediately + streamsMapping[STANDALONE_SSE_STREAM_ID] = SessionContext(sseSession, call) + maybeSendPrimingEvent(STANDALONE_SSE_STREAM_ID, sseSession) + sseSession.coroutineContext.job.invokeOnCompletion { + streamsMapping.remove(STANDALONE_SSE_STREAM_ID) } } - /** Handles an HTTP DELETE request by closing the session and the transport. */ public suspend fun handleDeleteRequest(call: ApplicationCall) { if (!validateSession(call) || !validateProtocolVersion(call)) return sessionId?.let { onSessionClosed?.invoke(it) } close() - call.respond(HttpStatusCode.OK) + call.respondNullable(status = HttpStatusCode.OK, message = null) } /** @@ -657,18 +402,16 @@ public class StreamableHttpServerTransport(private val configuration: Configurat * Useful for implementing polling behavior for long-running operations. */ public suspend fun closeSseStream(requestId: RequestId) { - if (configuration.enableJsonResponse) return + if (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) } } @@ -708,8 +451,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 +461,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 +472,12 @@ 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 { + private suspend fun validateSession(call: ApplicationCall): Boolean { if (sessionIdGenerator == null) return true if (!initialized.load()) { @@ -746,40 +485,49 @@ 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 else -> { call.reject( HttpStatusCode.NotFound, - REQUEST_TIMEOUT, + -32001, "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 +539,6 @@ public class StreamableHttpServerTransport(private val configuration: Configurat ", ", ) })", - id, ) false } @@ -801,26 +548,22 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } private fun validateHeaders(call: ApplicationCall): String? { - if (!configuration.enableDnsRebindingProtection) return null + if (!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() - } + allowedHosts?.let { hosts -> + 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" } } - configuration.allowedOrigins?.let { origins -> + allowedOrigins?.let { origins -> 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" } } @@ -831,21 +574,28 @@ public class StreamableHttpServerTransport(private val configuration: Configurat private suspend fun flushSse(session: ServerSSESession?) { try { session?.send(data = "") - } catch (e: CancellationException) { - throw e } catch (e: Exception) { _onError(e) } } private suspend fun parseBody(call: ApplicationCall): List? { - val body = try { - call.receiveTextWithLimit(configuration.maxRequestBodySize) - } catch (e: RequestBodyTooLargeException) { + val contentLength = call.request.header(HttpHeaders.ContentLength)?.toIntOrNull() ?: 0 + if (contentLength > MAXIMUM_MESSAGE_SIZE) { 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 ${MAXIMUM_MESSAGE_SIZE / (1024 * 1024)} MB", + ) + return null + } + + val body = call.receiveText() + if (body.length > MAXIMUM_MESSAGE_SIZE) { + call.reject( + HttpStatusCode.PayloadTooLarge, + RPCError.ErrorCode.INVALID_REQUEST, + "Invalid Request: message size exceeds maximum of ${MAXIMUM_MESSAGE_SIZE / (1024 * 1024)} MB", ) return null } @@ -866,64 +616,24 @@ 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) + val eventId = 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) } } - private suspend fun maybeSendPrimingEvent( - streamId: String, - session: ServerSSESession?, - clientProtocolVersion: String, - ) { - val store = configuration.eventStore - if (store == null || session == null) return - // Priming events have empty data which older clients cannot handle. - // Only send priming events to clients with protocol version >= 2025-11-25 - // which includes the fix for handling empty SSE data. - if (clientProtocolVersion < MIN_PRIMING_EVENT_PROTOCOL_VERSION) return + private suspend fun maybeSendPrimingEvent(streamId: String, session: ServerSSESession?) { + val store = eventStore ?: return + val sseSession = session ?: return try { val primingEventId = store.storeEvent(streamId, JSONRPCEmptyMessage) - session.send( - id = primingEventId, - retry = configuration.retryInterval?.inWholeMilliseconds, - data = "", - ) - } catch (e: CancellationException) { - throw e + sseSession.send(id = primingEventId, retry = retryIntervalMillis, data = "") } catch (e: Exception) { _onError(e) } @@ -938,13 +648,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/TransportManager.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/TransportManager.kt deleted file mode 100644 index e02931f..0000000 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/TransportManager.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport -import kotlinx.atomicfu.AtomicRef -import kotlinx.atomicfu.atomic -import kotlinx.atomicfu.update -import kotlinx.collections.immutable.PersistentMap -import kotlinx.collections.immutable.toPersistentMap - -internal class TransportManager { - private val transports: AtomicRef> = atomic(emptyMap().toPersistentMap()) - - fun getTransport(sessionId: String): T? = transports.value[sessionId] - - fun addTransport(sessionId: String, transport: T) { - transports.update { it.put(sessionId, transport) } - } - - fun removeTransport(sessionId: String) { - transports.update { it.remove(sessionId) } - } -} 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..b4d8849 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 @@ -4,6 +4,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.routing.Route +import io.ktor.server.routing.Routing import io.ktor.server.routing.routing import io.ktor.server.websocket.WebSocketServerSession import io.ktor.server.websocket.WebSockets @@ -22,7 +23,7 @@ private val logger = KotlinLogging.logger {} * Configures the Ktor Application to handle Model Context Protocol (MCP) over WebSocket. */ @KtorDsl -public fun Route.mcpWebSocket(block: () -> Server) { +public fun Routing.mcpWebSocket(block: () -> Server) { webSocket { mcpWebSocketEndpoint(block) } @@ -32,7 +33,7 @@ public fun Route.mcpWebSocket(block: () -> Server) { * Configures the Ktor Application to handle Model Context Protocol (MCP) over WebSocket. */ @KtorDsl -public fun Route.mcpWebSocket(path: String, block: () -> Server) { +public fun Routing.mcpWebSocket(path: String, block: () -> Server) { webSocket(path) { mcpWebSocketEndpoint(block) } @@ -86,7 +87,7 @@ private fun createMcpTransport(webSocketSession: WebSocketServerSession): WebSoc */ @Deprecated( "Use mcpWebSocket with a lambda that returns a Server instance instead", - ReplaceWith(""), + ReplaceWith("Routing.mcpWebSocket"), DeprecationLevel.WARNING, ) public fun Route.mcpWebSocket(options: ServerOptions? = null, handler: suspend Server.() -> Unit = {}) { @@ -95,6 +96,17 @@ public fun Route.mcpWebSocket(options: ServerOptions? = null, handler: suspend S } } +@Deprecated( + "Use mcpWebSocket with a lambda that returns a Server instance instead", + ReplaceWith("Routing.mcpWebSocket"), + DeprecationLevel.WARNING, +) +public fun Route.mcpWebSocket(block: () -> Server) { + webSocket { + block().createSession(createMcpTransport(this)) + } +} + /** * Registers a WebSocket route at the specified [path] that establishes an MCP server session. * @@ -104,7 +116,7 @@ public fun Route.mcpWebSocket(options: ServerOptions? = null, handler: suspend S */ @Deprecated( "Use mcpWebSocket with a path and a lambda that returns a Server instance instead", - ReplaceWith(""), + ReplaceWith("Routing.mcpWebSocket"), DeprecationLevel.WARNING, ) public fun Route.mcpWebSocket(path: String, options: ServerOptions? = null, handler: suspend Server.() -> Unit = {}) { @@ -120,7 +132,7 @@ public fun Route.mcpWebSocket(path: String, options: ServerOptions? = null, hand */ @Deprecated( "Use mcpWebSocket with a lambda that returns a Server instance instead", - ReplaceWith(""), + ReplaceWith("Routing.mcpWebSocket"), DeprecationLevel.WARNING, ) public fun Route.mcpWebSocketTransport(handler: suspend WebSocketMcpServerTransport.() -> Unit = {}) { @@ -152,7 +164,12 @@ public fun Route.mcpWebSocketTransport(path: String, handler: suspend WebSocketM } } -private suspend fun createMcpServer( +@Deprecated( + "Use mcpWebSocket with a lambda that returns a Server instance instead", + ReplaceWith("mcpWebSocket"), + DeprecationLevel.WARNING, +) +private suspend fun Route.createMcpServer( session: WebSocketServerSession, options: ServerOptions?, handler: suspend Server.() -> Unit, 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 deleted file mode 100644 index 7639bc6..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractKtorExtensionsTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.assertions.ktor.client.shouldHaveContentType -import io.kotest.assertions.ktor.client.shouldHaveStatus -import io.ktor.client.HttpClient -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.HttpStatusCode -import io.ktor.http.contentType -import io.ktor.utils.io.readUTF8Line -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities - -abstract class AbstractKtorExtensionsTest { - - protected val sseContentType = ContentType("text", "event-stream") - - protected fun testServer() = Server( - serverInfo = Implementation(name = "test-server", version = "1.0.0"), - options = ServerOptions(capabilities = ServerCapabilities()), - ) - - /** - * Asserts that both MCP transport endpoints are registered at [path]: - * - GET returns 200 with `text/event-stream` content type (SSE endpoint) - * - POST with a valid MCP payload and session returns 202 Accepted - * - POST without a sessionId returns 400 Bad Request - */ - protected suspend fun HttpClient.assertMcpEndpointsAt(path: String) { - prepareGet(path).execute { response -> - response.shouldHaveStatus(HttpStatusCode.OK) - response.shouldHaveContentType(sseContentType) - - // Extract sessionId from the SSE "endpoint" event - val channel = response.bodyAsChannel() - var eventName: String? = null - var sessionId: String? = null - - while (sessionId == null && !channel.isClosedForRead) { - val line = channel.readUTF8Line() ?: break - when { - line.startsWith("event:") -> eventName = line.substringAfter("event:").trim() - - line.startsWith("data:") && eventName == "endpoint" -> { - val data = line.substringAfter("data:").trim() - sessionId = data.substringAfter("sessionId=").ifEmpty { null } - } - } - } - - requireNotNull(sessionId) { "sessionId not found in SSE endpoint event" } - - // POST a valid JSON-RPC ping while the SSE connection is alive - val postResponse = post("$path?sessionId=$sessionId") { - contentType(ContentType.Application.Json) - setBody("""{"jsonrpc":"2.0","id":1,"method":"ping"}""") - } - postResponse.shouldHaveStatus(HttpStatusCode.Accepted) - } - - // POST without sessionId returns 400 Bad Request - post(path).shouldHaveStatus(HttpStatusCode.BadRequest) - } -} 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 deleted file mode 100644 index 9888867..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorApplicationExtensionsTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.assertions.ktor.client.shouldHaveStatus -import io.kotest.matchers.shouldBe -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText -import io.ktor.http.HttpStatusCode -import io.ktor.server.response.respondText -import io.ktor.server.routing.get -import io.ktor.server.routing.routing -import io.ktor.server.testing.testApplication -import kotlin.test.Test - -/** - * Integration tests for Ktor Application.mcp() extension. - * - * Verifies that Application.mcp() installs SSE automatically and registers - * MCP endpoints at the application root, without requiring explicit install(SSE). - */ -class KtorApplicationExtensionsTest : AbstractKtorExtensionsTest() { - - /** - * Verifies that Application.mcp() does not interfere with other routes - * added to the same application. - */ - @Test - fun `Application mcp should install SSE and coexist with other routes`() = testApplication { - application { - mcp(enableDnsRebindingProtection = false) { testServer() } - - routing { - get("/health") { call.respondText("healthy") } - } - } - - val healthResponse = client.get("/health") - healthResponse.shouldHaveStatus(HttpStatusCode.OK) - healthResponse.bodyAsText() shouldBe "healthy" - - client.assertMcpEndpointsAt("/") - } -} 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 deleted file mode 100644 index d6b95b4..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorRouteExtensionsTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.assertions.ktor.client.shouldHaveStatus -import io.kotest.matchers.shouldBe -import io.kotest.matchers.string.shouldContain -import io.ktor.client.request.get -import io.ktor.client.request.post -import io.ktor.client.statement.bodyAsText -import io.ktor.http.HttpStatusCode -import io.ktor.server.application.install -import io.ktor.server.response.respondText -import io.ktor.server.routing.get -import io.ktor.server.routing.route -import io.ktor.server.routing.routing -import io.ktor.server.sse.SSE -import io.ktor.server.testing.testApplication -import kotlin.test.Test -import kotlin.test.assertFailsWith - -/** - * Integration tests for Ktor Route.mcp() extensions. - * - * Verifies issue #237: Route.mcp() should work on subpaths and with middleware. - * The key issue was that Routing.mcp() registered at top-level, preventing use on subpaths. - * Now Route.mcp() allows registration on any route path. - */ -class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() { - - /** - * Verifies that Route.mcp() throws immediately at route registration time - * when the SSE plugin has not been installed, rather than failing silently on the first request. - */ - @Test - fun `Route mcp should throw at registration time if SSE plugin is not installed`() { - val exception = assertFailsWith { - testApplication { - application { - // Intentionally omit install(SSE) - routing { - mcp { testServer() } - } - } - client.get("/") - } - } - exception.message shouldContain "SSE" - exception.message shouldContain "install" - } - - /** - * Verifies that Route.mcp() registers SSE and POST endpoints on the subpath, - * not at root, and does not disturb sibling routes. - */ - @Test - fun `Route mcp should register SSE and POST endpoints at the given subpath`() = testApplication { - application { - install(SSE) - - routing { - get("/") { call.respondText("root") } - - route("/api/mcp") { - get("/test") { call.respondText("test-endpoint") } - mcp(enableDnsRebindingProtection = false) { testServer() } - } - } - } - - // Sibling routes are unaffected - val rootResponse = client.get("/") - rootResponse.shouldHaveStatus(HttpStatusCode.OK) - rootResponse.bodyAsText() shouldBe "root" - - val testResponse = client.get("/api/mcp/test") - testResponse.shouldHaveStatus(HttpStatusCode.OK) - testResponse.bodyAsText() shouldBe "test-endpoint" - - client.assertMcpEndpointsAt("/api/mcp") - } - - /** - * Verifies that Route.mcp() registers endpoints at the correct fully-qualified - * path when nested inside multiple route() calls. - */ - @Test - fun `Route mcp should register endpoints at the full nested path`() = testApplication { - application { - install(SSE) - - routing { - route("/v1") { - route("/services") { - route("/mcp") { - mcp(enableDnsRebindingProtection = false) { testServer() } - } - } - } - } - } - - client.assertMcpEndpointsAt("/v1/services/mcp") - } - - /** - * Verifies that mcp(path) registers SSE and POST endpoints at the sub-path - * relative to the enclosing route, not at the root. - */ - @Test - fun `Route mcp with path parameter should register endpoints at the resolved path`() = testApplication { - application { - install(SSE) - - routing { - route("/api") { - mcp("/mcp-endpoint", enableDnsRebindingProtection = false) { testServer() } - } - } - } - - client.assertMcpEndpointsAt("/api/mcp-endpoint") - - // Root /api is not an MCP endpoint - client.post("/api").shouldHaveStatus(HttpStatusCode.NotFound) - } -} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/LogCapture.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/LogCapture.kt deleted file mode 100644 index dcf3ad2..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/LogCapture.kt +++ /dev/null @@ -1,35 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import ch.qos.logback.classic.Logger -import ch.qos.logback.classic.spi.ILoggingEvent -import ch.qos.logback.core.read.ListAppender -import org.slf4j.LoggerFactory - -/** - * Captures log events from the given logger (and its children) using a Logback [ListAppender]. - * - * Usage: - * ``` - * LogCapture("io.modelcontextprotocol.kotlin.sdk.server").use { logs -> - * // … code that emits log messages … - * logs.messages.shouldExist { "expected text" in it } - * } - * ``` - */ -internal class LogCapture(loggerName: String) : AutoCloseable { - private val logger: Logger = LoggerFactory.getLogger(loggerName) as Logger - private val appender: ListAppender = ListAppender() - - init { - appender.start() - logger.addAppender(appender) - } - - val messages: List - get() = appender.list.map { it.formattedMessage } - - override fun close() { - logger.detachAppender(appender) - appender.stop() - } -} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaStdioServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaStdioServerTransportTest.kt new file mode 100644 index 0000000..32e25ba --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/OldSchemaStdioServerTransportTest.kt @@ -0,0 +1,140 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.InitializedNotification +import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.PingRequest +import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer +import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage +import io.modelcontextprotocol.kotlin.sdk.toJSON +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered +import java.io.ByteArrayOutputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OldSchemaStdioServerTransportTest { + private lateinit var input: PipedInputStream + private lateinit var inputWriter: PipedOutputStream + private lateinit var outputBuffer: ReadBuffer + private lateinit var output: ByteArrayOutputStream + + // We'll store the wrapped streams that meet the constructor requirements + private lateinit var bufferedInput: Source + private lateinit var printOutput: Sink + + @BeforeTest + fun setUp() { + // Simulate an input stream that we can push data into using inputWriter. + input = PipedInputStream() + inputWriter = PipedOutputStream(input) + + outputBuffer = ReadBuffer() + + // A custom ByteArrayOutputStream that appends all written data into outputBuffer + output = object : ByteArrayOutputStream() { + override fun write(b: ByteArray, off: Int, len: Int) { + super.write(b, off, len) + outputBuffer.append(b.copyOfRange(off, off + len)) + } + } + + bufferedInput = input.asSource().buffered() + + printOutput = output.asSink().buffered() + } + + @Test + fun `should start then close cleanly`() { + runBlocking { + val server = StdioServerTransport(bufferedInput, printOutput) + server.onError { error -> + throw error + } + + var didClose = false + server.onClose { + didClose = true + } + + server.start() + assertFalse(didClose, "Should not have closed yet") + + server.close() + assertTrue(didClose, "Should have closed after calling close()") + } + } + + @Test + fun `should not read until started`() = runTest { + val server = StdioServerTransport(bufferedInput, printOutput) + server.onError { error -> + throw error + } + + var didRead = false + val readMessage = CompletableDeferred() + + server.onMessage { message -> + didRead = true + readMessage.complete(message) + } + + val message = PingRequest().toJSON() + + // Push a message before the server started + val serialized = serializeMessage(message) + inputWriter.write(serialized) + inputWriter.flush() + + assertFalse(didRead, "Should not have read message before start") + + server.start() + val received = readMessage.await() + assertEquals(message, received) + } + + @Test + fun `should read multiple messages`() = runTest { + val server = StdioServerTransport(bufferedInput, printOutput) + server.onError { error -> + throw error + } + + val messages = listOf( + PingRequest().toJSON(), + InitializedNotification().toJSON(), + ) + + val readMessages = mutableListOf() + val finished = CompletableDeferred() + + server.onMessage { message -> + readMessages.add(message) + if (message == messages[1]) { + finished.complete(Unit) + } + } + + // Push both messages before starting the server + for (m in messages) { + inputWriter.write(serializeMessage(m)) + } + inputWriter.flush() + + server.start() + finished.await() + + assertEquals(messages, readMessages) + } +} 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..ff46263 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 @@ -1,59 +1,28 @@ package io.modelcontextprotocol.kotlin.sdk.server -import io.kotest.assertions.nondeterministic.eventually -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 +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import kotlinx.io.Sink import kotlinx.io.Source import kotlinx.io.asSink import kotlinx.io.asSource import kotlinx.io.buffered -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource 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.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals 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) class StdioServerTransportTest { private lateinit var input: PipedInputStream private lateinit var inputWriter: PipedOutputStream @@ -64,7 +33,7 @@ class StdioServerTransportTest { private lateinit var bufferedInput: Source private lateinit var printOutput: Sink - @BeforeEach + @BeforeTest fun setUp() { // Simulate an input stream that we can push data into using inputWriter. input = PipedInputStream() @@ -81,37 +50,34 @@ class StdioServerTransportTest { } bufferedInput = input.asSource().buffered() + printOutput = output.asSink().buffered() } @Test - fun `should be safe to close before start`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - server.close() // initialized guard makes this a no-op; must not throw + fun `should start then close cleanly`() { + runBlocking { + val server = StdioServerTransport(bufferedInput, printOutput) + server.onError { error -> + throw error + } + + var didClose = false + server.onClose { + didClose = true + } + + server.start() + assertFalse(didClose, "Should not have closed yet") + + server.close() + assertTrue(didClose, "Should have closed after calling close()") + } } @Test - fun `should start then close cleanly`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - server.onError { error -> - throw error - } - - var didClose = false - server.onClose { - didClose = true - } - - server.start() - assertFalse(didClose, "Should not have closed yet") - - server.close() - assertTrue(didClose, "Should have closed after calling close()") - } - - @Test - fun `should not read until started`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) + fun `should not read until started`() = runTest { + val server = StdioServerTransport(bufferedInput, printOutput) server.onError { error -> throw error } @@ -135,12 +101,12 @@ class StdioServerTransportTest { server.start() val received = readMessage.await() - received shouldBe message + assertEquals(message, received) } @Test - fun `should read multiple messages`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) + fun `should read multiple messages`() = runTest { + val server = StdioServerTransport(bufferedInput, printOutput) server.onError { error -> throw error } @@ -169,495 +135,10 @@ class StdioServerTransportTest { server.start() finished.await() - readMessages shouldBe messages - } - - // region: Exception handling - - @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 capturedError = CompletableDeferred() - server.onError { capturedError.complete(it) } - server.onMessage {} - - server.start() - - capturedError.await() shouldBe throwable - server.close() - } - - @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 capturedError = CompletableDeferred() - server.onError { capturedError.complete(it) } - server.onMessage {} - - server.start() - server.send(PingRequest().toJSON()) - - capturedError.await() shouldBe throwable - server.close() - } - - @Test - fun `should call onClose when input EOF is reached`(): Unit = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - val didClose = CompletableDeferred() - server.onError { throw it } - server.onClose { didClose.complete(Unit) } - server.onMessage {} - - server.start() - inputWriter.close() // signal EOF to the reading loop - - eventually(2.seconds) { - didClose.isCompleted shouldBe true - } - } - - @Test - fun `should throw when starting twice`(): Unit = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - server.onMessage {} - server.start() - withClue("Server should not start twice") { - shouldThrow { - server.start() - } - } - 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 capturedErrors = mutableListOf() - val receivedMessages = mutableListOf() - val secondMessageProcessed = CompletableDeferred() - - val message1 = PingRequest().toJSON() - val message2 = InitializedNotification().toJSON() - - server.onError { capturedErrors.add(it) } - server.onMessage { message -> - if (message == message1) { - throw throwable - } else { - receivedMessages.add(message) - secondMessageProcessed.complete(Unit) - } - } - - server.start() - - inputWriter.write(serializeMessage(message1)) - inputWriter.write(serializeMessage(message2)) - inputWriter.flush() - - secondMessageProcessed.await() - - capturedErrors shouldContain throwable - receivedMessages shouldBe listOf(message2) - server.close() - } - - @Test - fun `should not invoke onError for CancellationException in handler`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - val capturedError = CompletableDeferred() - server.onError { capturedError.complete(it) } - - server.onMessage { throw CancellationException("cancelled") } - server.start() - - inputWriter.write(serializeMessage(PingRequest().toJSON())) - inputWriter.flush() - - // We expect onError NOT to be called. - // We wait a bit to make sure it's not called, then close. - try { - withTimeout(1.seconds) { - capturedError.await() - } - fail("Should not have captured an error for CancellationException") - } catch (_: TimeoutCancellationException) { - // Success - timeout reached without error captured - } finally { - server.close() - } - } - - // endregion - - @Test - fun `should continue receiving valid messages after malformed JSON is skipped`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - val received = CompletableDeferred() - // ReadBuffer silently skips unparseable lines — no onError callback expected - server.onError {} - server.onMessage { received.complete(it) } - - val validMessage = PingRequest().toJSON() - inputWriter.write("not-valid-json\n".toByteArray()) - inputWriter.write(" \t \r \n".toByteArray()) // blank line - inputWriter.write(serializeMessage(validMessage).toByteArray()) - inputWriter.flush() - - server.start() - - received.await() shouldBe validMessage - server.close() - } - - @Test - fun `should throw McpException when send is called before start`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - val ex = shouldThrow { - server.send(PingRequest().toJSON()) - } - ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - } - - @Test - fun `should throw McpException when send is called after close`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - server.onError {} - server.onMessage {} - server.start() - server.close() - - val ex = shouldThrow { - server.send(PingRequest().toJSON()) - } - ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - } - - @Test - fun `should drain in-flight outbound messages on graceful close`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - server.onError {} - server.onMessage {} - server.start() - - val numMessages = 50 - repeat(numMessages) { - server.send(PingRequest().toJSON()) - } - server.close() - - val outputLines = String(output.toByteArray()).lines().count { it.isNotBlank() } - outputLines shouldBe numMessages - } - - @Test - fun `should fail-fast when reader throws non-EOF IOException`() = runIntegrationTest { - val ioError = IOException("transient stream failure") - val server = StdioServerTransport(input = FaultyRawSource(ioError).buffered(), output = printOutput) - val errorCaptured = CompletableDeferred() - val closeCaptured = CompletableDeferred() - server.onError { errorCaptured.complete(it) } - server.onClose { closeCaptured.complete(Unit) } - server.onMessage {} - - server.start() - - errorCaptured.await() shouldBe ioError - closeCaptured.await() - } - - @Test - fun `should close input source on natural EOF`() = runIntegrationTest { - val inputClosed = CompletableDeferred() - val eofSource = object : RawSource { - override fun readAtMostTo(sink: Buffer, byteCount: Long): Long = -1L - override fun close() { - inputClosed.complete(Unit) - } - } - val server = StdioServerTransport(input = eofSource.buffered(), output = printOutput) - val onCloseFired = CompletableDeferred() - server.onError {} - server.onMessage {} - server.onClose { onCloseFired.complete(Unit) } - - server.start() - - inputClosed.await() - onCloseFired.await() - } - - // region: concurrency - - @Test - fun `should be safe under concurrent start and close`() = runIntegrationTest(timeout = 30.seconds) { - val iterations = 2000 - repeat(iterations) { iteration -> - val server = StdioServerTransport(input = Buffer(), output = Buffer()) - server.onError {} - server.onMessage {} - - coroutineScope { - launch(Dispatchers.Default) { - try { - server.start() - } catch (_: IllegalStateException) { - } - } - launch(Dispatchers.Default) { - server.close() - } - } - - withClue("iteration $iteration: send() after race should fail with CONNECTION_CLOSED") { - val ex = shouldThrow { - server.send(PingRequest().toJSON()) - } - ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - } - } - } - - @Test - fun `should suspend send under back-pressure`() = runIntegrationTest(timeout = 30.seconds) { - val unblock = CountDownLatch(1) - val blockingSink = object : RawSink { - override fun write(source: Buffer, byteCount: Long) { - source.clear() - unblock.await() - } - - override fun flush() { - // noop - } - - override fun close() { - unblock.countDown() - } - } - val server = StdioServerTransport(input = bufferedInput, output = blockingSink.buffered()) - server.onError {} - server.onMessage {} - server.start() - - val maxAttempts = 256 - var suspendedAt = -1 - for (i in 0 until maxAttempts) { - val ok = withTimeoutOrNull(100.milliseconds) { server.send(PingRequest().toJSON()) } - if (ok == null) { - suspendedAt = i - break - } - } - assertTrue( - suspendedAt in 0 until maxAttempts, - "send() should have suspended within $maxAttempts attempts, got suspendedAt=$suspendedAt", - ) - // Unblock writer so close() can drain. - unblock.countDown() - server.close() - } - - @Test - fun `should not hang close when handler suspends indefinitely`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - val handlerEntered = CompletableDeferred() - val neverComplete = CompletableDeferred() - server.onError {} - server.onMessage { - handlerEntered.complete(Unit) - neverComplete.await() - } - server.start() - - inputWriter.write(serializeMessage(PingRequest().toJSON())) - inputWriter.flush() - - handlerEntered.await() - server.close() - } - - @Test - fun `should not hang close when onMessage was never registered`() = runIntegrationTest { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) - server.onError {} - server.start() - - inputWriter.write(serializeMessage(PingRequest().toJSON())) - inputWriter.flush() - - delay(200.milliseconds) - - server.close() - } - - // endregion - - // region: scope and dispatcher knobs - - @Test - fun `should honor externally provided CoroutineScope`() = runIntegrationTest { - val externalScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - val server = StdioServerTransport(input = bufferedInput, output = printOutput) { - scope = externalScope - } - val closeCaptured = CompletableDeferred() - server.onError {} - server.onClose { closeCaptured.complete(Unit) } - server.onMessage {} - - server.start() - externalScope.cancel() - - closeCaptured.await() - - val ex = shouldThrow { - server.send(PingRequest().toJSON()) - } - ex.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED - } - - @Test - fun `should use ioDispatcher for reader and writer`() = runIntegrationTest { - val threadName = "stdio-test-io-thread" - val executor = Executors.newFixedThreadPool(2) { r -> Thread(r, threadName) } - try { - val readerThread = CompletableDeferred() - val writerThread = CompletableDeferred() - val unblockReader = CountDownLatch(1) - val recordingInput = object : RawSource { - override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { - if (!readerThread.isCompleted) { - readerThread.complete(Thread.currentThread().name) - } - unblockReader.await() - return -1L - } - - override fun close() { - unblockReader.countDown() - } - } - val recordingOutput = object : RawSink { - override fun write(source: Buffer, byteCount: Long) { - if (!writerThread.isCompleted) { - writerThread.complete(Thread.currentThread().name) - } - source.clear() - } - - override fun flush() { - // noop - } - - override fun close() { - // noop - } - } - val server = StdioServerTransport( - input = recordingInput.buffered(), - output = recordingOutput.buffered(), - ) { - ioDispatcher = executor.asCoroutineDispatcher() - } - server.onError {} - server.onMessage {} - server.start() - - readerThread.await() shouldContain threadName - server.send(PingRequest().toJSON()) - writerThread.await() shouldContain threadName - - server.close() - } finally { - executor.shutdownNow() - } - } - - @Test - fun `should use handlerDispatcher for message handling`() = runIntegrationTest { - val threadName = "stdio-test-handler-thread" - val executor = Executors.newSingleThreadExecutor { r -> Thread(r, threadName) } - try { - val server = StdioServerTransport(input = bufferedInput, output = printOutput) { - handlerDispatcher = executor.asCoroutineDispatcher() - } - val observedThreadName = CompletableDeferred() - server.onError {} - server.onMessage { - observedThreadName.complete(Thread.currentThread().name) - } - server.start() - - inputWriter.write(serializeMessage(PingRequest().toJSON())) - inputWriter.flush() - - observedThreadName.await() shouldContain threadName - server.close() - } finally { - executor.shutdownNow() - } - } - - // endregion - - private fun inputErrors() = listOf( - IOException("simulated read failure"), - RuntimeException("unexpected read exception"), - OutOfMemoryError("unexpected read error"), - ) - - private fun outputErrors() = listOf( - IOException("simulated write failure"), - RuntimeException("unexpected write exception"), - OutOfMemoryError("unexpected write error"), - ) - - private fun handlerErrors() = listOf( - RuntimeException("handler failure"), - IOException("handler IO failure"), - OutOfMemoryError("handler error"), - ) - - /** A [RawSource] that immediately throws [throwable] on every read attempt. */ - private class FaultyRawSource(private val throwable: Throwable) : RawSource { - override fun readAtMostTo(sink: Buffer, byteCount: Long): Long = throw throwable - override fun close() { - // noop - } - } - - /** A [RawSink] that throws [throwable] on every [write] call. */ - private class FaultyRawSink(private val throwable: Throwable) : RawSink { - override fun write(source: Buffer, byteCount: Long): Unit = throw throwable - override fun flush() { - // noop - } - - override fun close() { - // noop - } + assertEquals(messages, readMessages) } } -private fun PipedOutputStream.write(s: String) { +fun PipedOutputStream.write(s: String) { write(s.toByteArray()) } 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/StreamableHttpServerConfigurationTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerConfigurationTest.kt deleted file mode 100644 index 1efe2ab..0000000 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerConfigurationTest.kt +++ /dev/null @@ -1,103 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.server - -import io.kotest.matchers.collections.shouldExist -import io.kotest.matchers.collections.shouldHaveSize -import io.ktor.client.request.get -import io.ktor.serialization.kotlinx.json.json -import io.ktor.server.application.install -import io.ktor.server.testing.testApplication -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import kotlin.test.Test -import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation - -private const val MCP_SERVER_PACKAGE = "io.modelcontextprotocol.kotlin.sdk.server" - -class StreamableHttpServerConfigurationTest { - - private fun testServer(): Server = Server( - Implementation("test-server", "1.0"), - ServerOptions(capabilities = ServerCapabilities()), - ) - - @Test - fun `mcpStreamableHttp warns when ContentNegotiation is pre-installed`() { - LogCapture(MCP_SERVER_PACKAGE).use { logs -> - testApplication { - application { - install(ServerContentNegotiation) { - json() // Pre-install with default (wrong) config - } - mcpStreamableHttp { testServer() } - } - client.get("/mcp") - } - logs.messages.shouldExist { "ContentNegotiation is already installed" in it } - } - } - - @Test - fun `mcpStatelessStreamableHttp warns when ContentNegotiation is pre-installed`() { - LogCapture(MCP_SERVER_PACKAGE).use { logs -> - testApplication { - application { - install(ServerContentNegotiation) { - json() // Pre-install with default (wrong) config - } - mcpStatelessStreamableHttp { testServer() } - } - client.get("/mcp") - } - logs.messages.shouldExist { "ContentNegotiation is already installed" in it } - } - } - - @Test - fun `mcp warns when ContentNegotiation is pre-installed`() { - LogCapture(MCP_SERVER_PACKAGE).use { logs -> - testApplication { - application { - install(ServerContentNegotiation) { - json() // Pre-install with default (wrong) config - } - mcp { testServer() } - } - client.get("/sse") - } - logs.messages.shouldExist { "ContentNegotiation is already installed" in it } - } - } - - @Test - fun `installMcpContentNegotiation is idempotent`() { - LogCapture(MCP_SERVER_PACKAGE).use { logs -> - testApplication { - application { - installMcpContentNegotiation() - installMcpContentNegotiation() - } - client.get("/") - } - logs.messages.filter { "ContentNegotiation is already installed" in it } - .shouldHaveSize(0) - } - } - - @Test - fun `installMcpContentNegotiation warns exactly once when ContentNegotiation is pre-installed`() { - LogCapture(MCP_SERVER_PACKAGE).use { logs -> - testApplication { - application { - install(ServerContentNegotiation) { - json() - } - installMcpContentNegotiation() - installMcpContentNegotiation() - } - client.get("/") - } - logs.messages.filter { "ContentNegotiation is already installed" in it } - .shouldHaveSize(1) - } - } -} 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..968c057 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 @@ -1,46 +1,32 @@ package io.modelcontextprotocol.kotlin.sdk.server -import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.collections.shouldContain 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,82 +35,22 @@ import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult import io.modelcontextprotocol.kotlin.sdk.types.McpJson import io.modelcontextprotocol.kotlin.sdk.types.Method -import io.modelcontextprotocol.kotlin.sdk.types.RPCError import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.Tool import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.toJSON -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withTimeout import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put -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 class StreamableHttpServerTransportTest { - companion object { - @JvmStatic - @MethodSource - fun invalidPayloads() = listOf( - "", - " ", - " \n \t ", - null, - "lolol", - ) - - private val sizeTestPayload = "x".repeat(64) - - @JvmStatic - fun maxBodySizeTestCases(): List = listOf( - Arguments.of(sizeTestPayload.length.toLong() - 1, HttpStatusCode.PayloadTooLarge), - 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" @Test @@ -153,90 +79,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,203 +114,7 @@ class StreamableHttpServerTransportTest { } @Test - fun `second initialization request returns JSON-RPC error with request id`() = 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 firstResponse = client.post(path) { - addStreamableHeaders() - setBody(payload) - } - - 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 - } - - @Test - fun `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 initResponse = client.post(path) { - addStreamableHeaders() - setBody(buildInitializeRequestPayload()) - } - - initResponse.status shouldBe HttpStatusCode.OK - val sessionId = initResponse.headers[MCP_SESSION_ID_HEADER] - assertNotNull(sessionId) - - val response = client.post(path) { - addStreamableHeaders() - header("mcp-session-id", sessionId) - header("mcp-protocol-version", "1900-01-01") - setBody( - encodeMessages( - listOf( - JSONRPCRequest( - id = RequestId("test-1"), - method = Method.Defined.ToolsList.value, - ), - ), - ), - ) - } - - 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 - fun `notifications only payload is accepted`() = testApplication { + fun `notifications only payload responds with 202 Accepted`() = testApplication { configTestServer() val client = createTestClient() @@ -556,609 +202,15 @@ class StreamableHttpServerTransportTest { assertEquals(HttpStatusCode.OK, response.status) - val responses = response.body>() - val results = responses.map { it.result } - results.shouldContainAll(firstResult, secondResult) - - // Check responses' order - - // TODO Uncomment when fixed https://github.com/modelcontextprotocol/kotlin-sdk/issues/548 - /*assertEquals(listOf(firstRequest.id, secondRequest.id), responses.map { it.id }) - val firstMeta = (responses[0] as ListToolsResult).meta - val secondMeta = (responses[1] as ListResourcesResult).meta - assertEquals("first", firstMeta?.get("label")?.jsonPrimitive?.content) - 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 { - 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 response = client.post(path) { - addStreamableHeaders() - setBody(payload) - } - - response.status shouldBe HttpStatusCode.BadRequest - } - - @Test - fun `POST with payload at max size is accepted`() = 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 maxSizePayload = "x".repeat(4 * 1024 * 1024) - - val response = client.post(path) { - addStreamableHeaders() - setBody(maxSizePayload) - } - - response.status shouldBe HttpStatusCode.BadRequest - } - - @Test - fun `POST with oversized body returns an 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 oversizedPayload = "x".repeat(4 * 1024 * 1024 + 1) - - val response = client.post(path) { - addStreamableHeaders() - setBody(oversizedPayload) - } - - 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`( - maxSize: Long, - expectedStatus: HttpStatusCode, - ) = testApplication { - configTestServer() - - val client = createTestClient() - - val transport = StreamableHttpServerTransport( - StreamableHttpServerTransport.Configuration( - enableJsonResponse = true, - maxRequestBodySize = maxSize, - ), - ) - transport.onMessage { message -> - if (message is JSONRPCRequest) { - transport.send(JSONRPCResponse(message.id, EmptyResult())) - } - } - - configureTransportEndpoint(transport) - - val response = client.post(path) { - addStreamableHeaders() - setBody(sizeTestPayload) - } - - response.status shouldBe expectedStatus - } - - @Test - fun `Configuration with negative maxRequestBodySize throws IllegalArgumentException`() { - assertFailsWith { - StreamableHttpServerTransport.Configuration(maxRequestBodySize = -1) - } - } - - @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 - } + val responses = response.body>().map { it.result } + responses shouldContain (firstResult) + responses shouldContain (secondResult) + // TODO(check order) +// assertEquals(listOf(firstRequest.id, secondRequest.id), responses.map { it.id }) +// val firstMeta = (responses[0].result as EmptyResult).meta +// val secondMeta = (responses[1].result as EmptyResult).meta +// assertEquals("first", firstMeta?.get("label")?.jsonPrimitive?.content) +// assertEquals("second", secondMeta?.get("label")?.jsonPrimitive?.content) } private fun ApplicationTestBuilder.configureTransportEndpoint(transport: StreamableHttpServerTransport) { @@ -1215,12 +267,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-server/src/jvmTest/resources/logback-test.xml b/kotlin-sdk-server/src/jvmTest/resources/logback-test.xml deleted file mode 100644 index aa33478..0000000 --- a/kotlin-sdk-server/src/jvmTest/resources/logback-test.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - System.err - - [%thread] %level %logger - %msg%n - - - - - - - - - - diff --git a/kotlin-sdk-testing/src/jvmTest/resources/simplelogger.properties b/kotlin-sdk-server/src/jvmTest/resources/simplelogger.properties similarity index 87% rename from kotlin-sdk-testing/src/jvmTest/resources/simplelogger.properties rename to kotlin-sdk-server/src/jvmTest/resources/simplelogger.properties index 5679351..95fb646 100644 --- a/kotlin-sdk-testing/src/jvmTest/resources/simplelogger.properties +++ b/kotlin-sdk-server/src/jvmTest/resources/simplelogger.properties @@ -4,4 +4,5 @@ org.slf4j.simpleLogger.showThreadName=true org.slf4j.simpleLogger.showDateTime=false # Log level for specific packages or classes +org.slf4j.simpleLogger.log.io.ktor.server=DEBUG org.slf4j.simpleLogger.log.io.modelcontextprotocol=DEBUG diff --git a/kotlin-sdk-testing/Module.md b/kotlin-sdk-testing/Module.md deleted file mode 100644 index 9291de5..0000000 --- a/kotlin-sdk-testing/Module.md +++ /dev/null @@ -1,67 +0,0 @@ -# Module kotlin-sdk-testing - -`kotlin-sdk-testing` provides testing utilities for MCP client-server interactions. It eliminates the need for external -processes or network connections by offering coroutine-safe in-memory transport implementations that connect clients and servers directly. - -## Typical usage - -1. Create linked transport pair with `ChannelTransport.createLinkedPair()`. -2. Connect client and server concurrently using the paired transports. -3. Configure server request handlers. -4. Execute client operations and verify results. - -### Complete test example - -```kotlin -@OptIn(ExperimentalMcpApi::class) -@Test -fun testClientServerCommunication(): Unit = runBlocking { - val server = Server( - Implementation(name = "test server", version = "1.0"), - ServerOptions(capabilities = ServerCapabilities(resources = ServerCapabilities.Resources())), - ) - - val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair() - - val client = Client(clientInfo = Implementation(name = "test client", version = "1.0")) - - 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, - capabilities = ServerCapabilities( - resources = ServerCapabilities.Resources(null, null), - tools = ServerCapabilities.Tools(null), - ), - serverInfo = Implementation(name = "test", version = "1.0"), - ) - } - - serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ -> - ListResourcesResult( - resources = listOf(Resource(uri = "/foo/bar", name = "foo-bar-resource")), - ) - } - - // Test client operations - client.listResources() shouldNotBeNull { - this.resources shouldHaveSize 1 - } - - // Clean up resources - client.close() - server.close() -} -``` - -**Note**: In production tests, wrap client/server operations in try-finally blocks to ensure proper resource cleanup even if assertions fail. - -Use this module for unit and integration testing of MCP implementations without managing external server processes, -network ports, or I/O streams. The channel-based transport ensures tests run instantly with full isolation between test -cases. diff --git a/kotlin-sdk-testing/api/kotlin-sdk-testing.api b/kotlin-sdk-testing/api/kotlin-sdk-testing.api deleted file mode 100644 index 4a942a4..0000000 --- a/kotlin-sdk-testing/api/kotlin-sdk-testing.api +++ /dev/null @@ -1,26 +0,0 @@ -public final class io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport { - public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$Companion; - public fun (Lkotlinx/coroutines/channels/Channel;Lkotlinx/coroutines/CoroutineDispatcher;)V - public synthetic fun (Lkotlinx/coroutines/channels/Channel;Lkotlinx/coroutines/CoroutineDispatcher;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lkotlinx/coroutines/channels/SendChannel;Lkotlinx/coroutines/channels/ReceiveChannel;Lkotlinx/coroutines/CoroutineDispatcher;)V - public synthetic fun (Lkotlinx/coroutines/channels/SendChannel;Lkotlinx/coroutines/channels/ReceiveChannel;Lkotlinx/coroutines/CoroutineDispatcher;ILkotlin/jvm/internal/DefaultConstructorMarker;)V -} - -public final class io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$Companion { - public final fun createLinkedPair (ILkotlinx/coroutines/CoroutineDispatcher;)Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$LinkedTransports; - public static synthetic fun createLinkedPair$default (Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$Companion;ILkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$LinkedTransports; -} - -public final class io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$LinkedTransports { - public fun (Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;)V - public final fun component1 ()Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport; - public final fun component2 ()Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport; - public final fun copy (Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;)Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$LinkedTransports; - public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$LinkedTransports;Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport$LinkedTransports; - public fun equals (Ljava/lang/Object;)Z - public final fun getClientTransport ()Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport; - public final fun getServerTransport ()Lio/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - diff --git a/kotlin-sdk-testing/build.gradle.kts b/kotlin-sdk-testing/build.gradle.kts deleted file mode 100644 index bbb0388..0000000 --- a/kotlin-sdk-testing/build.gradle.kts +++ /dev/null @@ -1,52 +0,0 @@ -@file:OptIn(ExperimentalWasmDsl::class) - -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) -} - -kotlin { - - iosArm64() - iosX64() - iosSimulatorArm64() - watchosArm64() - watchosSimulatorArm64() - tvosArm64() - tvosSimulatorArm64() - js { - browser() - nodejs() - } - wasmJs { - browser() - nodejs() - } - - sourceSets { - commonMain { - dependencies { - api(project(":kotlin-sdk-core")) - implementation(libs.kotlin.logging) - } - } - - commonTest { - dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.kotest.assertions.core) - } - } - jvmTest { - dependencies { - runtimeOnly(libs.slf4j.simple) - } - } - } -} 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 deleted file mode 100644 index ae5f487..0000000 --- a/kotlin-sdk-testing/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransport.kt +++ /dev/null @@ -1,182 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.testing - -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi -import io.modelcontextprotocol.kotlin.sdk.shared.AbstractClientTransport -import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.launch -import kotlinx.coroutines.yield - -/** - * A transport implementation that uses Kotlin Coroutines Channels for asynchronous - * 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. - * @param dispatcher The coroutine dispatcher for the event loop. Defaults to [Dispatchers.Default]. - */ -@ExperimentalMcpApi -public class ChannelTransport( - private val sendChannel: SendChannel, - private val receiveChannel: ReceiveChannel, - dispatcher: CoroutineDispatcher = Dispatchers.Default, -) : AbstractClientTransport() { - - override val logger: KLogger = KotlinLogging.logger {} - - private val scope = CoroutineScope(SupervisorJob() + dispatcher) - - /** - * Creates a `ChannelTransport` instance using a single channel for both sending and receiving messages. - * - * This constructor simplifies the use of `ChannelTransport` in scenarios where a single channel - * is enough for bidirectional communication. A default channel with unlimited capacity is used - * if no channel is provided. - * - * @param channel The `Channel` of `JSONRPCMessage` instances, used for both sending and receiving. - * Defaults to a channel with unlimited capacity. - * @param dispatcher The coroutine dispatcher for the event loop. Defaults to [Dispatchers.Default]. - */ - public constructor( - channel: Channel = Channel(UNLIMITED), - dispatcher: CoroutineDispatcher = Dispatchers.Default, - ) : this(channel, channel, dispatcher) - - /** - * Represents a pair of interconnected [ChannelTransport]s for bidirectional communication. - * - * Messages sent via one transport are received by the other. This is useful for - * setting up communication between a client and server without requiring external networking. - * - * @property clientTransport The transport intended for use on the client-side. - * @property serverTransport The transport intended for use on the server-side. - */ - public data class LinkedTransports(val clientTransport: ChannelTransport, val serverTransport: ChannelTransport) - - public companion object { - - /** - * 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) - * ``` - * - * @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( - capacity: Int = 256, - dispatcher: CoroutineDispatcher = Dispatchers.Default, - ): LinkedTransports { - val sendChannel = Channel(capacity) - val receiveChannel = Channel(capacity) - val clientTransport = ChannelTransport(sendChannel, receiveChannel, dispatcher) - val serverTransport = ChannelTransport(receiveChannel, sendChannel, dispatcher) - return LinkedTransports(clientTransport = clientTransport, serverTransport = serverTransport) - } - } - - /** - * Starts the transport, allowing it to begin processing messages. - * - * Waits for the event loop to start before returning, ensuring the transport is - * fully initialized and ready to send/receive messages. - * - * Processes messages until the channel closes or cancellation occurs. - * Exceptions from message handlers are reported via onError but do not stop processing. - */ - override suspend fun initialize() { - logger.info { "ChannelTransport starting message processing" } - val started = CompletableDeferred() - scope.launch(CoroutineName("ChannelTransport#${hashCode()}-event-loop")) { - try { - // Signal that event loop has started, then yield to ensure we're ready - started.complete(Unit) - yield() - - for (message in receiveChannel) { - logger.debug { "Received message: ${message::class.simpleName}" } - - try { - _onMessage.invoke(message) - logger.trace { "Message processed successfully: ${message::class.simpleName}" } - } catch (e: CancellationException) { - // Let cancellation propagate immediately - logger.debug { "Cancellation requested during message processing" } - throw e - } catch (e: Exception) { - // Report other errors but continue processing - logger.warn(e) { "Error processing message: ${message::class.simpleName}" } - _onError.invoke(e) - } - } - logger.info { "ChannelTransport stopped: receive channel closed" } - } catch (e: Exception) { - // Only complete exceptionally if not already completed - if (!started.isCompleted) { - started.completeExceptionally(e) - } - throw e - } finally { - close() - } - } - // Wait for the event loop to start - started.await() - } - - /** - * Sends a JSON-RPC message through the transport. - * - * @param message The JSON-RPC message to send. This can be a request or a response, - * conforming to the JSON-RPC 2.0 specification. - * @param options Optional transport-specific options that provide additional context or behavior - * for the message transmission, such as associating the message with a specific - * incoming request or handling resumption tokens. - */ - override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) { - logger.debug { "Sending message: ${message::class.simpleName}" } - sendChannel.send(message) - logger.trace { "Message sent successfully: ${message::class.simpleName}" } - } - - /** - * Closes the transport, preventing further message transmission and processing. - */ - override suspend fun closeResources() { - logger.info { "Closing ChannelTransport" } - sendChannel.close() - if (receiveChannel !== sendChannel) { - logger.debug { "Cancelling separate receive channel" } - receiveChannel.cancel() - } - scope.cancel() - scope.coroutineContext[Job]?.join() // Wait for cleanup - logger.info { "ChannelTransport closed" } - } -} 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 deleted file mode 100644 index 4aae932..0000000 --- a/kotlin-sdk-testing/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/testing/ChannelTransportTest.kt +++ /dev/null @@ -1,211 +0,0 @@ -package io.modelcontextprotocol.kotlin.sdk.testing - -import io.kotest.assertions.nondeterministic.eventually -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.shouldBe -import io.kotest.matchers.types.shouldBeInstanceOf -import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest -import io.modelcontextprotocol.kotlin.sdk.types.RequestId -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedSendChannelException -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.time.Duration.Companion.seconds - -@OptIn(ExperimentalMcpApi::class) -class ChannelTransportTest { - - @Test - fun `send writes to sendChannel`() = runTest { - val sendChannel = Channel(Channel.UNLIMITED) - val transport = ChannelTransport(sendChannel, Channel()) - - transport.start() - - val message = JSONRPCRequest(RequestId.NumberId(42), "test") - transport.send(message) - - sendChannel.receive() shouldBe message - } - - @Test - fun `start reads from receiveChannel and invokes onMessage`() = runTest { - val receiveChannel = Channel(Channel.UNLIMITED) - val transport = ChannelTransport(Channel(), receiveChannel) - - val messagesProcessed = CompletableDeferred() - val received = mutableListOf() - transport.onMessage { msg -> - received.add(msg) - if (received.size == 2) { - messagesProcessed.complete(Unit) - } - } - - transport.start() - - val msg1 = JSONRPCRequest(RequestId.NumberId(1), "method1") - val msg2 = JSONRPCRequest(RequestId.NumberId(2), "method2") - receiveChannel.send(msg1) - receiveChannel.send(msg2) - - // Wait for messages to be processed - messagesProcessed.await() - - received.shouldContainExactly(msg1, msg2) - } - - @Test - fun `start completes when channel closes`() = runTest { - val receiveChannel = Channel(Channel.UNLIMITED) - val transport = ChannelTransport(Channel(), receiveChannel) - - val messageProcessed = CompletableDeferred() - val received = mutableListOf() - transport.onMessage { - received.add(it) - messageProcessed.complete(Unit) - } - - transport.start() - - val msg = JSONRPCRequest(RequestId.NumberId(1), "method1") - receiveChannel.send(msg) - receiveChannel.close() // Close the channel - - // Wait for a message to be processed - messageProcessed.await() - - received.shouldContainExactly(msg) - } - - @Test - fun `close is idempotent`() = runTest { - val transport = ChannelTransport() - - var closeCount = 0 - transport.onClose { closeCount++ } - - transport.start() - - transport.close() - transport.close() - - closeCount shouldBe 1 - } - - @Test - fun `callbacks chain not replace`() = runTest { - val receiveChannel = Channel(Channel.UNLIMITED) - val transport = ChannelTransport(Channel(), receiveChannel) - - val messageProcessed = CompletableDeferred() - val calls = mutableListOf() - transport.onMessage { - calls.add("first") - } - transport.onMessage { - calls.add("second") - messageProcessed.complete(Unit) - } - - val startJob = backgroundScope.launch { transport.start() } - - receiveChannel.send(JSONRPCRequest(RequestId.NumberId(1), "test")) - messageProcessed.await() - - calls.shouldContainExactly("first", "second") - startJob.cancelAndJoin() - } - - @Test - fun `secondary constructor uses single channel`() = runTest { - val channel = Channel(Channel.UNLIMITED) - val transport = ChannelTransport(channel) - - val messageProcessed = CompletableDeferred() - val received = mutableListOf() - transport.onMessage { msg -> - received.add(msg) - messageProcessed.complete(Unit) - } - - transport.start() - - val message = JSONRPCRequest(RequestId.NumberId(1), "test") - transport.send(message) - messageProcessed.await() - - eventually(2.seconds) { - received.shouldContainExactly(message) - } - } - - @Test - fun `send to closed channel triggers error`() = runTest { - val sendChannel = Channel(Channel.UNLIMITED) - val transport = ChannelTransport(sendChannel, Channel()) - - transport.start() - - var errorCaught = false - transport.onError { - it.shouldBeInstanceOf() - errorCaught = true - } - sendChannel.close() - - try { - transport.send(JSONRPCRequest(RequestId.NumberId(1), "method")) - } catch (e: Exception) { - // send() wraps ClosedSendChannelException in McpException - e.shouldBeInstanceOf() - } - - eventually(2.seconds) { - errorCaught shouldBe true - } - } - - @Test - fun `exceptions in message handler do not stop processing`() = runTest { - val receiveChannel = Channel(Channel.UNLIMITED) - val transport = ChannelTransport(Channel(), receiveChannel) - - val allProcessed = CompletableDeferred() - val received = mutableListOf() - val errors = mutableListOf() - - transport.onError { errors.add(it) } - transport.onMessage { msg -> - val id = ((msg as JSONRPCRequest).id as RequestId.NumberId).value.toInt() - received.add(id) - if (id == 2) { - throw RuntimeException("Error processing message 2") - } - if (received.size == 4) { - allProcessed.complete(Unit) - } - } - - transport.start() - - // Send 4 messages, second one will throw - receiveChannel.send(JSONRPCRequest(RequestId.NumberId(1), "m1")) - receiveChannel.send(JSONRPCRequest(RequestId.NumberId(2), "m2")) - receiveChannel.send(JSONRPCRequest(RequestId.NumberId(3), "m3")) - receiveChannel.send(JSONRPCRequest(RequestId.NumberId(4), "m4")) - - allProcessed.await() - - // All messages should be processed despite error in message 2 - received.shouldContainExactly(1, 2, 3, 4) - errors.size shouldBe 1 - errors[0].message shouldBe "Error processing message 2" - } -} diff --git a/kotlin-sdk-testing/src/jvmTest/resources/junit-platform.properties b/kotlin-sdk-testing/src/jvmTest/resources/junit-platform.properties deleted file mode 100644 index f14d100..0000000 --- a/kotlin-sdk-testing/src/jvmTest/resources/junit-platform.properties +++ /dev/null @@ -1,5 +0,0 @@ -## https://docs.junit.org/5.3.0-M1/user-guide/index.html#writing-tests-parallel-execution -junit.jupiter.execution.parallel.enabled=true -junit.jupiter.execution.parallel.config.strategy=dynamic -junit.jupiter.execution.parallel.mode.default=concurrent -junit.jupiter.execution.parallel.mode.classes.default=concurrent 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 deleted file mode 100644 index f831a74..0000000 --- a/samples/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Kotlin MCP SDK Samples - -Runnable projects demonstrating MCP server and client implementations with the -[Kotlin MCP SDK](https://github.com/modelcontextprotocol/kotlin-sdk). -For background on the protocol itself, see the [MCP documentation](https://modelcontextprotocol.io/introduction). - -## Overview - -| 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 | -| [notebooks](./notebooks) | Client (Notebook) | Streamable HTTP | Tool discovery & invocation | - -## Getting Started - -- **Building a server?** Start with [simple-streamable-server](./simple-streamable-server) — it - uses the recommended Streamable HTTP transport and covers tools, resources, prompts, and logging. -- **Building a client?** Open the [notebooks](./notebooks) sample for a step-by-step walkthrough, - or see [kotlin-mcp-client](./kotlin-mcp-client) for a full CLI client with Anthropic API - integration. - -## Samples - -### Simple Streamable HTTP Server - -A minimal Streamable HTTP server with optional Bearer token authentication. Demonstrates tools -(`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 -different transport modes side by side. -[Read more →](./kotlin-mcp-server) - -### Weather STDIO Server - -A focused STDIO server that exposes weather forecast and alert tools backed by the weather.gov API. -Includes Claude Desktop integration instructions. -[Read more →](./weather-stdio-server) - -### Kotlin MCP Client - -An interactive CLI client that connects to any MCP server over STDIO and routes queries through -Anthropic's Claude API, bridging MCP tools with LLM conversations. -[Read more →](./kotlin-mcp-client) - -### MCP Client Notebook - -A Kotlin notebook that connects to a remote MCP server via Streamable HTTP and demonstrates ping, -tool listing, and tool invocation — all in an interactive cell-by-cell format. -[Read more →](./notebooks) diff --git a/samples/kotlin-mcp-client/README.md b/samples/kotlin-mcp-client/README.md index b10f925..fd9e143 100644 --- a/samples/kotlin-mcp-client/README.md +++ b/samples/kotlin-mcp-client/README.md @@ -1,50 +1,69 @@ # Kotlin MCP Client -An interactive CLI client that connects to any MCP server over STDIO and pipes queries through -Anthropic's Claude API. +This project demonstrates how to build a Model Context Protocol (MCP) client in Kotlin that interacts with an MCP server +via a STDIO transport layer while leveraging Anthropic's API for natural language processing. The client uses the MCP +Kotlin SDK to communicate with an MCP server that exposes various tools, and it uses Anthropic's API to process user +queries and integrate tool responses into the conversation. -## Overview - -This sample demonstrates a complete MCP client workflow: launching an MCP server as a subprocess, -discovering its tools, converting them to Anthropic's tool format, and running an interactive chat -loop where Claude can call server tools on behalf of the user. +For more information about the MCP SDK and protocol, please refer to +the [MCP documentation](https://modelcontextprotocol.io/introduction). ## Prerequisites -- JDK 17+ -- An `ANTHROPIC_API_KEY` environment variable set with a valid Anthropic API key -- An MCP server script to connect to (`.js`, `.py`, or `.jar`) +- **Java 17 or later** +- **Gradle** (or the Gradle wrapper provided with the project) +- An Anthropic API key set in your environment variable `ANTHROPIC_API_KEY` +- Basic understanding of MCP concepts and Kotlin programming -## Build & Run +## Overview -Run the client, passing the path to an MCP server: +The client application performs the following tasks: + +- **Connecting to an MCP server** — + launches an MCP server process (implemented in JavaScript, Python, or Java) using STDIO transport. + It connects to the server, retrieves available tools, and converts them to Anthropic’s tool format. +- **Processing queries** — + accepts user queries, sends them to Anthropic’s API along with the registered tools, and handles responses. + If the response indicates a tool should be called, it invokes the corresponding MCP tool and continues the + conversation based on the tool’s result. +- **Interactive chat loop** — + runs an interactive command-line loop, allowing users to continuously submit queries and receive responses. + +## Building and Running + +Use the Gradle wrapper to build the application. In a terminal, run: ```shell -# Connect to a JVM server -./gradlew run --args="path/to/server.jar" +./gradlew clean build +``` -# Connect to a Python server -./gradlew run --args="path/to/server.py" +To run the client, execute the jar file and provide the path to your MCP server script. -# Connect to a Node.js server -./gradlew run --args="path/to/build/index.js" +To run the client with any MCP server: + +```shell +java -jar build/libs/.jar path/to/server.jar # jvm server +java -jar build/libs/.jar path/to/server.py # python server +java -jar build/libs/.jar path/to/build/index.js # node server ``` > [!NOTE] -> The client uses STDIO transport, so it launches the MCP server as a subprocess. +> The client uses STDIO transport, so it launches the MCP server as a separate process. > Ensure the server script is executable and is a valid `.js`, `.py`, or `.jar` file. -## MCP Capabilities +## Configuration for Anthropic -From the **client** perspective, this sample demonstrates: +Ensure your Anthropic API key is available in your environment: -- **Tool discovery** — lists tools from the connected server and converts them to Anthropic's tool - format. -- **Tool invocation** — when Claude's response requests a tool call, the client invokes the - corresponding MCP tool and feeds the result back into the conversation. +```shell +export ANTHROPIC_API_KEY=your_anthropic_api_key_here +``` + +The client uses `AnthropicOkHttpClient.fromEnv()` to automatically load the API key from `ANTHROPIC_API_KEY` and +`ANTHROPIC_AUTH_TOKEN` environment variables. ## Additional Resources -- [MCP Specification](https://modelcontextprotocol.io/specification/latest) +- [MCP Specification](https://spec.modelcontextprotocol.io/) - [Kotlin MCP SDK](https://github.com/modelcontextprotocol/kotlin-sdk) -- [Anthropic Java SDK](https://github.com/anthropics/anthropic-sdk-java) +- [Anthropic Java SDK](https://github.com/anthropics/anthropic-sdk-java/tree/main) diff --git a/samples/kotlin-mcp-client/gradle/libs.versions.toml b/samples/kotlin-mcp-client/gradle/libs.versions.toml index b58eef2..fa8b4e9 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.9.0" +kotlin = "2.2.21" +ktor = "3.2.3" +mcp-kotlin = "0.8.1" +shadow = "9.2.2" +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..f8e1ee3 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..bad7c24 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.2.0-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..adff685 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/HEAD/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..c4bdd3a 100644 --- a/samples/kotlin-mcp-client/gradlew.bat +++ b/samples/kotlin-mcp-client/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/samples/kotlin-mcp-client/src/main/kotlin/io/modelcontextprotocol/sample/client/MCPClient.kt b/samples/kotlin-mcp-client/src/main/kotlin/io/modelcontextprotocol/sample/client/MCPClient.kt index 23fdfcc..8360316 100644 --- a/samples/kotlin-mcp-client/src/main/kotlin/io/modelcontextprotocol/sample/client/MCPClient.kt +++ b/samples/kotlin-mcp-client/src/main/kotlin/io/modelcontextprotocol/sample/client/MCPClient.kt @@ -2,11 +2,10 @@ package io.modelcontextprotocol.sample.client import com.anthropic.client.okhttp.AnthropicOkHttpClient import com.anthropic.core.JsonValue -import com.anthropic.models.messages.ContentBlockParam import com.anthropic.models.messages.MessageCreateParams import com.anthropic.models.messages.MessageParam +import com.anthropic.models.messages.Model import com.anthropic.models.messages.Tool -import com.anthropic.models.messages.ToolResultBlockParam import com.anthropic.models.messages.ToolUnion import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.ObjectMapper @@ -15,28 +14,24 @@ import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield import kotlinx.io.asSink import kotlinx.io.asSource import kotlinx.io.buffered import kotlinx.serialization.json.JsonObject -import java.util.concurrent.TimeUnit +import kotlin.jvm.optionals.getOrNull -private const val MODEL = "claude-sonnet-4-20250514" -private const val MAX_TOKENS = 1024L - -class MCPClient(apiKey: String) : AutoCloseable { - private val anthropic = AnthropicOkHttpClient.builder() - .apiKey(apiKey) - .build() +class MCPClient : AutoCloseable { + // Configures using the `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` environment variables + private val anthropic = AnthropicOkHttpClient.fromEnv() // Initialize MCP client private val mcp: Client = Client(clientInfo = Implementation(name = "mcp-client-cli", version = "1.0.0")) - // Server process reference for cleanup - private var serverProcess: Process? = null + private val messageParamsBuilder: MessageCreateParams.Builder = MessageCreateParams.builder() + .model(Model.CLAUDE_4_SONNET_20250514) + .maxTokens(1024) // List of tools offered by the server private lateinit var tools: List @@ -49,52 +44,52 @@ class MCPClient(apiKey: String) : AutoCloseable { // Connect to the server using the path to the server suspend fun connectToServer(serverScriptPath: String) { - // Build the command based on the file extension of the server script - val command = buildList { - when (serverScriptPath.substringAfterLast(".")) { - "js" -> add("node") - "py" -> add(if (System.getProperty("os.name").lowercase().contains("win")) "python" else "python3") - "jar" -> addAll(listOf("java", "-jar")) - else -> throw IllegalArgumentException("Server script must be a .js, .py or .jar file") + try { + // Build the command based on the file extension of the server script + val command = buildList { + when (serverScriptPath.substringAfterLast(".")) { + "js" -> add("node") + "py" -> add(if (System.getProperty("os.name").lowercase().contains("win")) "python" else "python3") + "jar" -> addAll(listOf("java", "-jar")) + else -> throw IllegalArgumentException("Server script must be a .js, .py or .jar file") + } + add(serverScriptPath) } - add(serverScriptPath) - } - // Start the server process - val process = withContext(Dispatchers.IO) { - ProcessBuilder(command) - .redirectError(ProcessBuilder.Redirect.INHERIT) - .start() - } - serverProcess = process + // Start the server process + val process = ProcessBuilder(command).start() - // Setup I/O transport using the process streams - val transport = StdioClientTransport( - input = process.inputStream.asSource().buffered(), - output = process.outputStream.asSink().buffered(), - ) - - // Connect the MCP client to the server using the transport - mcp.connect(transport) - - // Request the list of available tools from the server - val toolsResult = mcp.listTools() - tools = toolsResult.tools.map { tool -> - ToolUnion.ofTool( - Tool.builder() - .name(tool.name) - .description(tool.description ?: "") - .inputSchema( - Tool.InputSchema.builder() - .type(JsonValue.from(tool.inputSchema.type)) - .properties(tool.inputSchema.properties?.toJsonValue() ?: EmptyJsonObject.toJsonValue()) - .putAdditionalProperty("required", JsonValue.from(tool.inputSchema.required)) - .build(), - ) - .build(), + // Setup I/O transport using the process streams + val transport = StdioClientTransport( + input = process.inputStream.asSource().buffered(), + output = process.outputStream.asSink().buffered(), ) + + // Connect the MCP client to the server using the transport + mcp.connect(transport) + + // Request the list of available tools from the server + val toolsResult = mcp.listTools() + tools = toolsResult.tools.map { tool -> + ToolUnion.ofTool( + Tool.builder() + .name(tool.name) + .description(tool.description ?: "") + .inputSchema( + Tool.InputSchema.builder() + .type(JsonValue.from(tool.inputSchema.type)) + .properties(tool.inputSchema.properties?.toJsonValue() ?: EmptyJsonObject.toJsonValue()) + .putAdditionalProperty("required", JsonValue.from(tool.inputSchema.required)) + .build(), + ) + .build(), + ) + } + println("Connected to server with tools: ${tools.joinToString(", ") { it.tool().get().name() }}") + } catch (e: Exception) { + println("Failed to connect to MCP server: $e") + throw e } - println("Connected to server with tools: ${tools.joinToString(", ") { it.tool().get().name() }}") } // Process a user query and return a string response @@ -109,29 +104,23 @@ class MCPClient(apiKey: String) : AutoCloseable { // Send the query to the Anthropic model and get the response val response = anthropic.messages().create( - MessageCreateParams.builder() - .model(MODEL) - .maxTokens(MAX_TOKENS) + messageParamsBuilder .messages(messages) .tools(tools) .build(), ) val finalText = mutableListOf() - val toolResults = mutableListOf() - response.content().forEach { content -> when { // Append text outputs from the response - content.isText() -> finalText.add(content.text().get().text()) + content.isText() -> finalText.add(content.text().getOrNull()?.text() ?: "") // If the response indicates a tool use, process it further content.isToolUse() -> { - val toolUse = content.toolUse().get() - val toolName = toolUse.name() - val toolUseId = toolUse.id() + val toolName = content.toolUse().get().name() val toolArgs = - toolUse._input().convert(object : TypeReference>() {}) + content.toolUse().get()._input().convert(object : TypeReference>() {}) // Call the tool with provided arguments val result = mcp.callTool( @@ -140,55 +129,37 @@ class MCPClient(apiKey: String) : AutoCloseable { ) finalText.add("[Calling tool $toolName with args $toolArgs]") - // Build tool_result content block with tool_use_id - val toolResultContent = result.content - .filterIsInstance() - .joinToString("\n") { it.text } + // Add the tool result message to the conversation + messages.add( + MessageParam.builder() + .role(MessageParam.Role.USER) + .content( + """ + "type": "tool_result", + "tool_name": $toolName, + "result": ${ + result.content.joinToString("\n") { + (it as TextContent).text + } + } + """.trimIndent(), + ) + .build(), + ) - val toolResultBlock = ToolResultBlockParam.builder() - .toolUseId(toolUseId) - .content(toolResultContent) - .apply { if (result.isError == true) isError(true) } - .build() + // Retrieve an updated response after tool execution + val aiResponse = anthropic.messages().create( + messageParamsBuilder + .messages(messages) + .build(), + ) - toolResults.add(ContentBlockParam.ofToolResult(toolResultBlock)) + // Append the updated response to final text + finalText.add(aiResponse.content().first().text().getOrNull()?.text() ?: "") } } } - // If there were tool calls, send tool results back and get final response - if (toolResults.isNotEmpty()) { - // Add the full assistant response (includes tool_use blocks) - messages.add( - MessageParam.builder() - .role(MessageParam.Role.ASSISTANT) - .contentOfBlockParams(response.content().map { it.toParam() }) - .build(), - ) - - // Add user message with tool results - messages.add( - MessageParam.builder() - .role(MessageParam.Role.USER) - .contentOfBlockParams(toolResults) - .build(), - ) - - // Retrieve an updated response after tool execution (without tools) - val aiResponse = anthropic.messages().create( - MessageCreateParams.builder() - .model(MODEL) - .maxTokens(MAX_TOKENS) - .messages(messages) - .build(), - ) - - // Append the updated response to final text - aiResponse.content() - .filter { it.isText() } - .forEach { finalText.add(it.text().get().text()) } - } - return finalText.joinToString("\n", prefix = "", postfix = "") } @@ -200,27 +171,17 @@ class MCPClient(apiKey: String) : AutoCloseable { while (true) { print("\nQuery: ") val message = readlnOrNull() ?: break - if (message.trim().lowercase() == "quit") break - - try { - val response = processQuery(message) - println("\n$response") - } catch (e: Exception) { - println("\nError: ${e.message}") - } + if (message.lowercase() == "quit") break + val response = processQuery(message) + println("\n$response") + yield() } } override fun close() { runBlocking { mcp.close() + anthropic.close() } - serverProcess?.let { process -> - process.destroy() - if (!process.waitFor(5, TimeUnit.SECONDS)) { - process.destroyForcibly() - } - } - anthropic.close() } } diff --git a/samples/kotlin-mcp-client/src/main/kotlin/io/modelcontextprotocol/sample/client/main.kt b/samples/kotlin-mcp-client/src/main/kotlin/io/modelcontextprotocol/sample/client/main.kt index 001a255..19a3102 100644 --- a/samples/kotlin-mcp-client/src/main/kotlin/io/modelcontextprotocol/sample/client/main.kt +++ b/samples/kotlin-mcp-client/src/main/kotlin/io/modelcontextprotocol/sample/client/main.kt @@ -3,14 +3,13 @@ package io.modelcontextprotocol.sample.client import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { - require(args.isNotEmpty()) { "Usage: java -jar " } - - val apiKey = System.getenv("ANTHROPIC_API_KEY") - require(!apiKey.isNullOrBlank()) { "ANTHROPIC_API_KEY environment variable is not set" } - - val client = MCPClient(apiKey) + require(args.isNotEmpty()) { + "Usage: java -jar /build/libs/kotlin-mcp-client-0.1.0-all.jar " + } + val serverPath = args.first() + val client = MCPClient() client.use { - client.connectToServer(args.first()) + client.connectToServer(serverPath) client.chatLoop() } } diff --git a/samples/kotlin-mcp-server/README.md b/samples/kotlin-mcp-server/README.md index 82dc5e7..fa56b66 100644 --- a/samples/kotlin-mcp-server/README.md +++ b/samples/kotlin-mcp-server/README.md @@ -1,85 +1,93 @@ -# Kotlin MCP Server +# MCP Kotlin Server Sample -A sample MCP server demonstrating multiple transport modes: STDIO, SSE (plain), and SSE (Ktor -plugin). +A sample implementation of an MCP (Model Context Protocol) server in Kotlin that demonstrates different server +configurations and transport methods. -> **Note:** The SSE transport modes are provided for backward compatibility. For new projects, -> consider using the [simple-streamable-server](../simple-streamable-server) sample, which uses the -> recommended Streamable HTTP transport. +## Features -## Overview +- Multiple server operation modes: + - Standard I/O server + - SSE (Server-Sent Events) server with plain configuration + - SSE server using Ktor plugin +- Built-in capabilities for: + - Prompts management + - Resources handling + - Tools integration -This sample registers a prompt, a tool, and a resource, then lets you choose how to expose them. -STDIO mode is the default and is best for process-based clients. The two SSE modes show how to -serve MCP over HTTP using either a manual Ktor routing setup or the built-in `mcp { }` Ktor plugin. +## Getting Started -## Prerequisites +### Running the Server -- JDK 17+ -- [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) (optional, for testing) +The server defaults [STDIO transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio). -## Build & Run +You can customize the behavior using command-line arguments. +Logs are printed to [./build/stdout.log](./build/stdout.log) -### STDIO mode (default) +#### Standard I/O mode (STDIO): + +```bash +./gradlew clean build +``` +Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector) +to connect to MCP via STDIO (Click the "▶️ Connect" button): ```shell -./gradlew run +npx @modelcontextprotocol/inspector --config mcp-inspector-config.json --server stdio-server ``` -Or explicitly: +#### SSE with plain configuration: -```shell -./gradlew run --args="--stdio" -``` +**NB!: 🐞 This configuration may not work ATM** -Connect with the MCP Inspector: - -```shell -npx @modelcontextprotocol/inspector --config samples/kotlin-mcp-server/mcp-inspector-config.json --server stdio-server -``` - -### SSE with Ktor plugin - -```shell -./gradlew run --args="--sse-server-ktor 3002" -``` - -Connect with the MCP Inspector: - -```shell -npx @modelcontextprotocol/inspector --config samples/kotlin-mcp-server/mcp-inspector-config.json --server sse-ktor-server -``` - -### SSE with plain configuration - -> **Known issue:** This mode may not work correctly at this time. - -```shell +```bash ./gradlew run --args="--sse-server 3001" ``` - -Connect with the MCP Inspector: - +or ```shell -npx @modelcontextprotocol/inspector --config samples/kotlin-mcp-server/mcp-inspector-config.json --server sse-server +./gradlew clean build +java -jar ./build/libs/kotlin-mcp-server-0.1.0-all.jar --sse-server 3001 ``` -## MCP Capabilities +Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector) +to connect to `http://localhost:3002/` via SSE Transport (Click the "▶️ Connect" button): +```shell +npx @modelcontextprotocol/inspector --config mcp-inspector-config.json --server sse-server +``` -### Tools +#### SSE with Ktor plugin: -| Name | Description | -|-------------------|------------------------------------------------------| -| `kotlin-sdk-tool` | A test tool that returns a "Hello, world!" greeting. | +```bash +./gradlew run --args="--sse-server-ktor 3002" +``` +or +```shell +./gradlew clean build +java -jar ./build/libs/kotlin-mcp-server-0.1.0-all.jar --sse-server-ktor 3002 +``` -### Prompts +Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector) +to connect to `http://localhost:3002/` via SSE transport (Click the "▶️ Connect" button): +```shell +npx @modelcontextprotocol/inspector --config mcp-inspector-config.json --server sse-ktor-server +``` -| Name | Description | -|--------------------|------------------------------------------------------------------------------------| -| `Kotlin Developer` | Generates a prompt to develop a small Kotlin application for a given project name. | +## Server Capabilities -### Resources +- **Prompts**: Supports prompt management with list change notifications +- **Resources**: Includes subscription support and list change notifications +- **Tools**: Supports tool management with list change notifications -| Name | URI | Description | -|--------------|-----------------------|---------------------------------------------------------| -| `Web Search` | `https://search.com/` | A placeholder resource demonstrating resource handling. | +## Implementation Details + +The server is implemented using: +- Ktor for HTTP server functionality (SSE modes) +- Kotlin coroutines for asynchronous operations +- SSE for real-time communication in web contexts +- Standard I/O for command-line interface and process-based communication + +## Example Capabilities + +The sample server demonstrates: +- **Prompt**: "Kotlin Developer" - helps develop small Kotlin applications with a configurable project name +- **Tool**: "kotlin-sdk-tool" - a simple test tool that returns a greeting +- **Resource**: "Web Search" - a placeholder resource demonstrating resource handling diff --git a/samples/kotlin-mcp-server/gradle/libs.versions.toml b/samples/kotlin-mcp-server/gradle/libs.versions.toml index d46c2f1..0ce6d58 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.2.3" +mcp-kotlin = "0.8.1" +slf4j = "2.0.17" +shadow = "9.2.2" [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..f8e1ee3 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..bad7c24 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.2.0-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..adff685 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/HEAD/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..c4bdd3a 100644 --- a/samples/kotlin-mcp-server/gradlew.bat +++ b/samples/kotlin-mcp-server/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/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/notebooks/McpClient.ipynb b/samples/notebooks/McpClient.ipynb index 54a0953..583039d 100644 --- a/samples/notebooks/McpClient.ipynb +++ b/samples/notebooks/McpClient.ipynb @@ -18,65 +18,52 @@ }, { "metadata": { - "executionRelatedData": { - "compiledClasses": [ - "Line_3_jupyter", - "Line_7_jupyter", - "Line_10_jupyter", - "Line_5_jupyter", - "Line_8_jupyter", - "Line_6_jupyter", - "Line_11_jupyter", - "Line_4_jupyter", - "Line_9_jupyter", - "Line_18_jupyter", - "Line_19_jupyter" - ] - }, "ExecuteTime": { - "end_time": "2026-03-30T10:42:19.114283Z", - "start_time": "2026-03-30T10:42:19.068020Z" + "end_time": "2025-12-04T16:08:57.281786Z", + "start_time": "2025-12-04T16:08:57.126434Z" } }, "cell_type": "code", "source": [ "%use coroutines (1.10.2)\n", - "%use ktor-client (0.3.0-98)\n", + "%use ktor-client (0.3.0-5)\n", "\n", "USE {\n", " dependencies(\n", - " \"io.modelcontextprotocol:kotlin-sdk-client-jvm:0.10.0\",\n", + " \"io.modelcontextprotocol:kotlin-sdk-client-jvm:0.8.1\",\n", " )\n", "}" ], "outputs": [], - "execution_count": 8 + "execution_count": 9 }, { "metadata": { - "executionRelatedData": { - "compiledClasses": [ - "Line_12_jupyter", - "Line_20_jupyter" - ] - }, "ExecuteTime": { - "end_time": "2026-03-30T10:42:19.131015Z", - "start_time": "2026-03-30T10:42:19.115475Z" + "end_time": "2025-12-04T16:08:57.298752Z", + "start_time": "2025-12-04T16:08:57.285099Z" } }, "cell_type": "code", "source": [ - "import io.ktor.client.HttpClient\n", - "import io.ktor.client.plugins.logging.*\n", - "import io.ktor.client.plugins.sse.SSE\n", - "import io.modelcontextprotocol.kotlin.sdk.client.Client\n", - "import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport\n", + "import kotlinx.coroutines.runBlocking\n", + "import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject\n", "import io.modelcontextprotocol.kotlin.sdk.types.Implementation\n", - "import io.modelcontextprotocol.kotlin.sdk.types.TextContent" + "import io.modelcontextprotocol.kotlin.sdk.types.TextContent\n", + "import io.modelcontextprotocol.kotlin.sdk.client.Client\n", + "import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions\n", + "import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport\n", + "import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport\n", + "import io.ktor.client.HttpClient\n", + "import io.ktor.client.call.body\n", + "import io.ktor.client.request.get\n", + "import kotlinx.serialization.Serializable\n", + "import kotlinx.serialization.json.JsonObject\n", + "import io.ktor.client.plugins.logging.*\n", + "import io.ktor.client.plugins.sse.SSE" ], "outputs": [], - "execution_count": 9 + "execution_count": 10 }, { "metadata": {}, @@ -88,15 +75,9 @@ }, { "metadata": { - "executionRelatedData": { - "compiledClasses": [ - "Line_13_jupyter", - "Line_21_jupyter" - ] - }, "ExecuteTime": { - "end_time": "2026-03-30T10:42:19.189250Z", - "start_time": "2026-03-30T10:42:19.131969Z" + "end_time": "2025-12-04T16:08:57.346926Z", + "start_time": "2025-12-04T16:08:57.301809Z" } }, "cell_type": "code", @@ -107,15 +88,14 @@ " level = LogLevel.ALL\n", " logger = object : Logger {\n", " override fun log(message: String) {\n", - " val messageType = if (message.contains(\"REQUEST\")) \"Request\" else \"Response\"\n", - " DISPLAY(HTML(\"
HTTP $messageType
$message
\"))\n", + " DISPLAY(HTML(\"
HTTP Log
$message
\"))\n", " }\n", " }\n", " }\n", "}\n" ], "outputs": [], - "execution_count": 10 + "execution_count": 11 }, { "metadata": {}, @@ -124,15 +104,9 @@ }, { "metadata": { - "executionRelatedData": { - "compiledClasses": [ - "Line_14_jupyter", - "Line_22_jupyter" - ] - }, "ExecuteTime": { - "end_time": "2026-03-30T10:42:19.216018Z", - "start_time": "2026-03-30T10:42:19.190636Z" + "end_time": "2025-12-04T16:08:57.375128Z", + "start_time": "2025-12-04T16:08:57.350611Z" } }, "cell_type": "code", @@ -141,11 +115,12 @@ " clientInfo = Implementation(\n", " name = \"my-client\",\n", " version = \"1.0.0\"\n", - " )\n", + " ),\n", + " options = ClientOptions(),\n", ")" ], "outputs": [], - "execution_count": 11 + "execution_count": 12 }, { "metadata": {}, @@ -154,15 +129,9 @@ }, { "metadata": { - "executionRelatedData": { - "compiledClasses": [ - "Line_15_jupyter", - "Line_23_jupyter" - ] - }, "ExecuteTime": { - "end_time": "2026-03-30T10:42:19.748081Z", - "start_time": "2026-03-30T10:42:19.216824Z" + "end_time": "2025-12-04T16:08:58.199710Z", + "start_time": "2025-12-04T16:08:57.379661Z" } }, "cell_type": "code", @@ -183,7 +152,7 @@ { "data": { "text/html": [ - "
HTTP Request
REQUEST: https://learn.microsoft.com/api/mcp\n",
+       "
HTTP Log
REQUEST: https://learn.microsoft.com/api/mcp\n",
        "METHOD: POST\n",
        "COMMON HEADERS\n",
        "-> Accept: application/json, text/event-stream\n",
@@ -193,7 +162,7 @@
        "-> Content-Type: application/json\n",
        "BODY Content-Type: application/json\n",
        "BODY START\n",
-       "{\"id\":\"8461d63b3d784df183832d109b688608\",\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{},\"clientInfo\":{\"name\":\"my-client\",\"version\":\"1.0.0\"}},\"jsonrpc\":\"2.0\"}\n",
+       "{\"id\":\"26884bad1c9b4ea0b9c4470f893b60d3\",\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"my-client\",\"version\":\"1.0.0\"}},\"jsonrpc\":\"2.0\"}\n",
        "BODY END
" ] }, @@ -206,31 +175,31 @@ { "data": { "text/html": [ - "
HTTP Response
RESPONSE: 200 OK\n",
+       "
HTTP Log
RESPONSE: 200 OK\n",
        "METHOD: POST\n",
        "FROM: https://learn.microsoft.com/api/mcp\n",
        "COMMON HEADERS\n",
-       "-> Akamai-Cache-Status: Miss from child, NotCacheable from parent\n",
+       "-> Akamai-Cache-Status: Miss from child, Miss from parent\n",
        "-> Cache-Control: no-cache, no-store\n",
        "-> Connection: keep-alive\n",
        "-> Content-Encoding: identity\n",
-       "-> Content-Length: 1983\n",
+       "-> Content-Length: 1947\n",
        "-> Content-Type: text/event-stream\n",
-       "-> Date: Mon, 30 Mar 2026 10:42:19 GMT\n",
-       "-> Expires: Mon, 30 Mar 2026 10:42:19 GMT\n",
+       "-> Date: Thu, 04 Dec 2025 16:08:57 GMT\n",
+       "-> Expires: Thu, 04 Dec 2025 16:08:57 GMT\n",
+       "-> Mcp-Session-Id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIn0sInVzZXJJZENsYWltIjpudWxsfQ\n",
        "-> Request-Context: appId=cid-v1:ccd19013-8f20-4877-8d3f-84a0107e7119\n",
        "-> Set-Cookie: ASLBSA=0003562b174eb06eed9ae0ec705e26d2c9aec6332388d120314730a5c2da183d42c8; Path=/; Secure; HttpOnly;; ASLBSACORS=0003562b174eb06eed9ae0ec705e26d2c9aec6332388d120314730a5c2da183d42c8; SameSite=none; Path=/; Secure; HttpOnly;\n",
        "-> Strict-Transport-Security: max-age=31536000; includeSubDomains; preload\n",
        "-> X-Content-Type-Options: nosniff\n",
        "-> X-Powered-By: ASP.NET\n",
-       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIiwiaWNvbnMiOm51bGwsIndlYnNpdGVVcmwiOm51bGx9LCJ1c2VySWRDbGFpbSI6IjQ4NjFlZWMxLWRlNGUtNGZhMi1hMjA4LWJkNzNhNDdkYmE3OCJ9\n",
        "-> nel: {\"report_to\":\"network-errors\",\"max_age\":604800,\"success_fraction\":0.01,\"failure_fraction\":1.0}\n",
        "-> report-to: {\"group\":\"network-errors\",\"max_age\":604800,\"endpoints\":[{\"url\":\"https://mdec.nelreports.net/api/report?cat=mdocs\"}]}\n",
-       "-> x-azure-ref: 20260330T104219Z-157fb874dd7qc6ndhC1SVG8pe00000000740000000000xwt\n",
+       "-> x-azure-ref: 20251204T160857Z-r1cfc47d796grb7bhC1STOs2rc0000002u20000000002xwb\n",
        "BODY Content-Type: text/event-stream\n",
        "BODY START\n",
        "event: message\n",
-       "data: {\"result\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{\"logging\":{},\"prompts\":{\"listChanged\":true},\"resources\":{\"listChanged\":true},\"tools\":{\"listChanged\":true}},\"serverInfo\":{\"name\":\"Microsoft Learn MCP Server\",\"version\":\"1.0.0\"},\"instructions\":\"# Microsoft Learn MCP Server\\r\\n\\r\\nThis server gives structured access to official Microsoft and Azure documentation via three tools:\\r\\n\\r\\n## Tools\\r\\n\\r\\n### microsoft_docs_search\\r\\nSearch official documentation and return up to 10 concise, high-quality content chunks (max 500 tokens each), including title, URL, and excerpt.\\r\\n\\r\\n- Use first to get a quick, reliable overview\\r\\n- Ideal for grounding answers in Microsoft knowledge\\r\\n\\r\\n### microsoft_code_sample_search\\r\\nSearch for code snippets and examples in official Microsoft Learn documentation and return up to 20 relevant, high-quality code samples.\\r\\n\\r\\n- Use when you need to provide sample Microsoft/Azure related code in your answers.  \\r\\n- Ideal for generating code snippets or practical implementation examples.  \\r\\n- Optional parameter \\u0060language\\u0060 can filter results. \\r\\n\\r\\n### microsoft_docs_fetch\\r\\nFetch and convert full Microsoft documentation pages to markdown.\\r\\n\\r\\n- Use after search when you need full content from a specific URL\\r\\n- Required for detailed tutorials, troubleshooting, prerequisites, code samples, or when search results are incomplete or outdated\\r\\n\\r\\n## Workflow\\r\\n\\r\\n1. Use \\u0060microsoft_docs_search\\u0060 to find relevant documents.\\r\\n2. If you need code examples or practical snippets, use \\u0060microsoft_code_sample_search\\u0060.\\r\\n3. If deeper or complete information is needed, use \\u0060microsoft_docs_fetch\\u0060.\\r\\n\\r\\n**Search gives breadth. Code Sample Search gives practical examples. Fetch gives depth.**\\r\\n\\r\\nAll content comes from Microsoft Learn or official sources, returned in clean markdown format.\"},\"id\":\"8461d63b3d784df183832d109b688608\",\"jsonrpc\":\"2.0\"}\n",
+       "data: {\"result\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{\"logging\":{},\"prompts\":{},\"resources\":{},\"tools\":{\"listChanged\":true}},\"serverInfo\":{\"name\":\"Microsoft Learn MCP Server\",\"version\":\"1.0.0\"},\"instructions\":\"# Microsoft Learn MCP Server\\r\\n\\r\\nThis server gives structured access to official Microsoft and Azure documentation via three tools:\\r\\n\\r\\n## Tools\\r\\n\\r\\n### microsoft_docs_search\\r\\nSearch official documentation and return up to 10 concise, high-quality content chunks (max 500 tokens each), including title, URL, and excerpt.\\r\\n\\r\\n- Use first to get a quick, reliable overview\\r\\n- Ideal for grounding answers in Microsoft knowledge\\r\\n\\r\\n### microsoft_code_sample_search\\r\\nSearch for code snippets and examples in official Microsoft Learn documentation and return up to 20 relevant, high-quality code samples.\\r\\n\\r\\n- Use when you need to provide sample Microsoft/Azure related code in your answers.  \\r\\n- Ideal for generating code snippets or practical implementation examples.  \\r\\n- Optional parameter \\u0060language\\u0060 can filter results. \\r\\n\\r\\n### microsoft_docs_fetch\\r\\nFetch and convert full Microsoft documentation pages to markdown.\\r\\n\\r\\n- Use after search when you need full content from a specific URL\\r\\n- Required for detailed tutorials, troubleshooting, prerequisites, code samples, or when search results are incomplete or outdated\\r\\n\\r\\n## Workflow\\r\\n\\r\\n1. Use \\u0060microsoft_docs_search\\u0060 to find relevant documents.\\r\\n2. If you need code examples or practical snippets, use \\u0060microsoft_code_sample_search\\u0060.\\r\\n3. If deeper or complete information is needed, use \\u0060microsoft_docs_fetch\\u0060.\\r\\n\\r\\n**Search gives breadth. Code Sample Search gives practical examples. Fetch gives depth.**\\r\\n\\r\\nAll content comes from Microsoft Learn or official sources, returned in clean markdown format.\"},\"id\":\"26884bad1c9b4ea0b9c4470f893b60d3\",\"jsonrpc\":\"2.0\"}\n",
        "\n",
        "\n",
        "BODY END
" @@ -245,12 +214,12 @@ { "data": { "text/html": [ - "
HTTP Request
REQUEST: https://learn.microsoft.com/api/mcp\n",
+       "
HTTP Log
REQUEST: https://learn.microsoft.com/api/mcp\n",
        "METHOD: POST\n",
        "COMMON HEADERS\n",
        "-> Accept: application/json, text/event-stream\n",
        "-> Accept-Charset: UTF-8\n",
-       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIiwiaWNvbnMiOm51bGwsIndlYnNpdGVVcmwiOm51bGx9LCJ1c2VySWRDbGFpbSI6IjQ4NjFlZWMxLWRlNGUtNGZhMi1hMjA4LWJkNzNhNDdkYmE3OCJ9\n",
+       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIn0sInVzZXJJZENsYWltIjpudWxsfQ\n",
        "CONTENT HEADERS\n",
        "-> Content-Length: 54\n",
        "-> Content-Type: application/json\n",
@@ -269,26 +238,26 @@
     {
      "data": {
       "text/html": [
-       "
HTTP Response
RESPONSE: 202 Accepted\n",
+       "
HTTP Log
RESPONSE: 202 Accepted\n",
        "METHOD: POST\n",
        "FROM: https://learn.microsoft.com/api/mcp\n",
        "COMMON HEADERS\n",
-       "-> Akamai-Cache-Status: Miss from child, NotCacheable from parent\n",
+       "-> Akamai-Cache-Status: Miss from child, Miss from parent\n",
        "-> Cache-Control: no-cache, no-store\n",
        "-> Connection: keep-alive\n",
        "-> Content-Encoding: identity\n",
        "-> Content-Length: 0\n",
-       "-> Date: Mon, 30 Mar 2026 10:42:19 GMT\n",
-       "-> Expires: Mon, 30 Mar 2026 10:42:19 GMT\n",
+       "-> Date: Thu, 04 Dec 2025 16:08:57 GMT\n",
+       "-> Expires: Thu, 04 Dec 2025 16:08:57 GMT\n",
+       "-> Mcp-Session-Id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIn0sInVzZXJJZENsYWltIjpudWxsfQ\n",
        "-> Request-Context: appId=cid-v1:ccd19013-8f20-4877-8d3f-84a0107e7119\n",
        "-> Set-Cookie: ASLBSA=0003562b174eb06eed9ae0ec705e26d2c9aec6332388d120314730a5c2da183d42c8; Path=/; Secure; HttpOnly;; ASLBSACORS=0003562b174eb06eed9ae0ec705e26d2c9aec6332388d120314730a5c2da183d42c8; SameSite=none; Path=/; Secure; HttpOnly;\n",
        "-> Strict-Transport-Security: max-age=31536000; includeSubDomains; preload\n",
        "-> X-Content-Type-Options: nosniff\n",
        "-> X-Powered-By: ASP.NET\n",
-       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIiwiaWNvbnMiOm51bGwsIndlYnNpdGVVcmwiOm51bGx9LCJ1c2VySWRDbGFpbSI6IjQ4NjFlZWMxLWRlNGUtNGZhMi1hMjA4LWJkNzNhNDdkYmE3OCJ9\n",
        "-> nel: {\"report_to\":\"network-errors\",\"max_age\":604800,\"success_fraction\":0.01,\"failure_fraction\":1.0}\n",
        "-> report-to: {\"group\":\"network-errors\",\"max_age\":604800,\"endpoints\":[{\"url\":\"https://mdec.nelreports.net/api/report?cat=mdocs\"}]}\n",
-       "-> x-azure-ref: 20260330T104219Z-157fb874dd7dr7drhC1SVGcxmc0000000pkg000000005v30\n",
+       "-> x-azure-ref: 20251204T160857Z-r1cfc47d796r8qqwhC1STO91g00000001p6g000000002rdg\n",
        "BODY Content-Type: null\n",
        "BODY START\n",
        "\n",
@@ -301,22 +270,15 @@
       "display_id": null
      }
     },
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "✅ Server version: Implementation(name=Microsoft Learn MCP Server, version=1.0.0, title=null, websiteUrl=null, icons=null)\n"
-     ]
-    },
     {
      "data": {
       "text/html": [
-       "
HTTP Request
REQUEST: https://learn.microsoft.com/api/mcp\n",
+       "
HTTP Log
REQUEST: https://learn.microsoft.com/api/mcp\n",
        "METHOD: GET\n",
        "COMMON HEADERS\n",
        "-> Accept: application/json\n",
        "-> Accept-Charset: UTF-8\n",
-       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIiwiaWNvbnMiOm51bGwsIndlYnNpdGVVcmwiOm51bGx9LCJ1c2VySWRDbGFpbSI6IjQ4NjFlZWMxLWRlNGUtNGZhMi1hMjA4LWJkNzNhNDdkYmE3OCJ9\n",
+       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIn0sInVzZXJJZENsYWltIjpudWxsfQ\n",
        "CONTENT HEADERS\n",
        "-> Content-Length: 0\n",
        "-> Accept: text/event-stream\n",
@@ -337,225 +299,263 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "✅ Server capabilities: ServerCapabilities(tools=Tools(listChanged=true), resources=Resources(listChanged=true, subscribe=null), prompts=Prompts(listChanged=true), logging={}, completions=null, experimental=null)\n"
-     ]
-    }
-   ],
-   "execution_count": 12
-  },
-  {
-   "metadata": {},
-   "cell_type": "markdown",
-   "source": "Let's get a list of available tools:"
-  },
-  {
-   "metadata": {
-    "executionRelatedData": {
-     "compiledClasses": [
-      "Line_16_jupyter",
-      "Line_24_jupyter"
-     ]
-    },
-    "ExecuteTime": {
-     "end_time": "2026-03-30T10:42:20.410862Z",
-     "start_time": "2026-03-30T10:42:19.750112Z"
-    }
-   },
-   "cell_type": "code",
-   "source": [
-    "runBlocking {\n",
-    "    val listToolsResult = mcpClient.listTools()\n",
-    "\n",
-    "    for (tool in listToolsResult.tools) {\n",
-    "        println(\n",
-    "            \"\"\"\n",
-    "            |🔨Title: ${tool.title} (${tool.name})\n",
-    "            |\n",
-    "            |Description: ${tool.description}\n",
-    "            |\n",
-    "            |Input schema: ${tool.inputSchema}\n",
-    "            |Output schema: ${tool.outputSchema}\n",
-    "            |Icons: ${tool.icons?.joinToString(\", \")}\n",
-    "            |Annotations: ${tool.annotations}\n",
-    "            |Execution: ${tool.execution}\n",
-    "            |\n",
-    "            \"\"\".trimMargin()\n",
-    "        )\n",
-    "    }\n",
-    "}\n"
-   ],
-   "outputs": [
-    {
-     "data": {
-      "text/html": [
-       "
HTTP Request
REQUEST: https://learn.microsoft.com/api/mcp\n",
-       "METHOD: POST\n",
-       "COMMON HEADERS\n",
-       "-> Accept: application/json, text/event-stream\n",
-       "-> Accept-Charset: UTF-8\n",
-       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIiwiaWNvbnMiOm51bGwsIndlYnNpdGVVcmwiOm51bGx9LCJ1c2VySWRDbGFpbSI6IjQ4NjFlZWMxLWRlNGUtNGZhMi1hMjA4LWJkNzNhNDdkYmE3OCJ9\n",
-       "CONTENT HEADERS\n",
-       "-> Content-Length: 79\n",
-       "-> Content-Type: application/json\n",
-       "BODY Content-Type: application/json\n",
-       "BODY START\n",
-       "{\"id\":\"b9c6596da2514e04b08cbaa1defb300b\",\"method\":\"tools/list\",\"jsonrpc\":\"2.0\"}\n",
-       "BODY END
" - ] - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - }, - { - "data": { - "text/html": [ - "
HTTP Response
RESPONSE https://learn.microsoft.com/api/mcp failed with exception: io.ktor.client.plugins.sse.SSEClientException: Expected status code 200 but was 405
" - ] - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - }, - { - "data": { - "text/html": [ - "
HTTP Response
RESPONSE: 405 Method Not Allowed\n",
-       "METHOD: GET\n",
-       "FROM: https://learn.microsoft.com/api/mcp\n",
-       "COMMON HEADERS\n",
-       "-> Akamai-Cache-Status: Miss from child, NotCacheable from parent\n",
-       "-> Cache-Control: max-age=0\n",
-       "-> Connection: keep-alive\n",
-       "-> Content-Length: 215\n",
-       "-> Date: Mon, 30 Mar 2026 10:42:19 GMT\n",
-       "-> Expires: Mon, 30 Mar 2026 10:42:19 GMT\n",
-       "-> Request-Context: appId=cid-v1:ccd19013-8f20-4877-8d3f-84a0107e7119\n",
-       "-> Strict-Transport-Security: max-age=31536000; includeSubDomains; preload\n",
-       "-> X-Content-Type-Options: nosniff\n",
-       "-> X-Powered-By: ASP.NET\n",
-       "-> nel: {\"report_to\":\"network-errors\",\"max_age\":604800,\"success_fraction\":0.01,\"failure_fraction\":1.0}\n",
-       "-> report-to: {\"group\":\"network-errors\",\"max_age\":604800,\"endpoints\":[{\"url\":\"https://mdec.nelreports.net/api/report?cat=mdocs\"}]}\n",
-       "-> x-azure-ref: 20260330T104219Z-157fb874dd7qc6ndhC1SVG8pe00000000740000000000xxc
" - ] - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - }, - { - "data": { - "text/html": [ - "
HTTP Response
RESPONSE: 200 OK\n",
-       "METHOD: POST\n",
-       "FROM: https://learn.microsoft.com/api/mcp\n",
-       "COMMON HEADERS\n",
-       "-> Akamai-Cache-Status: Miss from child, NotCacheable from parent\n",
-       "-> Cache-Control: no-cache, no-store\n",
-       "-> Connection: keep-alive\n",
-       "-> Content-Encoding: identity\n",
-       "-> Content-Length: 4983\n",
-       "-> Content-Type: text/event-stream\n",
-       "-> Date: Mon, 30 Mar 2026 10:42:20 GMT\n",
-       "-> Expires: Mon, 30 Mar 2026 10:42:20 GMT\n",
-       "-> Request-Context: appId=cid-v1:ccd19013-8f20-4877-8d3f-84a0107e7119\n",
-       "-> Set-Cookie: ASLBSA=0003562b174eb06eed9ae0ec705e26d2c9aec6332388d120314730a5c2da183d42c8; Path=/; Secure; HttpOnly;; ASLBSACORS=0003562b174eb06eed9ae0ec705e26d2c9aec6332388d120314730a5c2da183d42c8; SameSite=none; Path=/; Secure; HttpOnly;\n",
-       "-> Strict-Transport-Security: max-age=31536000; includeSubDomains; preload\n",
-       "-> X-Content-Type-Options: nosniff\n",
-       "-> X-Powered-By: ASP.NET\n",
-       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIiwiaWNvbnMiOm51bGwsIndlYnNpdGVVcmwiOm51bGx9LCJ1c2VySWRDbGFpbSI6IjQ4NjFlZWMxLWRlNGUtNGZhMi1hMjA4LWJkNzNhNDdkYmE3OCJ9\n",
-       "-> nel: {\"report_to\":\"network-errors\",\"max_age\":604800,\"success_fraction\":0.01,\"failure_fraction\":1.0}\n",
-       "-> report-to: {\"group\":\"network-errors\",\"max_age\":604800,\"endpoints\":[{\"url\":\"https://mdec.nelreports.net/api/report?cat=mdocs\"}]}\n",
-       "-> x-azure-ref: 20260330T104219Z-157fb874dd7dr7drhC1SVGcxmc0000000pkg000000005v38\n",
-       "BODY Content-Type: text/event-stream\n",
-       "BODY START\n",
-       "event: message\n",
-       "data: {\"result\":{\"tools\":[{\"name\":\"microsoft_docs_search\",\"title\":\"Microsoft Docs Search\",\"description\":\"Search official Microsoft/Azure documentation to find the most relevant and trustworthy content for a user\\u0027s query. This tool returns up to 10 high-quality content chunks (each max 500 tokens), extracted from Microsoft Learn and other official sources. Each result includes the article title, URL, and a self-contained content excerpt optimized for fast retrieval and reasoning. Always use this tool to quickly ground your answers in accurate, first-party Microsoft/Azure knowledge.\\n\\n## Follow-up Pattern\\nTo ensure completeness, use microsoft_docs_fetch when high-value pages are identified by search. The fetch tool complements search by providing the full detail. This is a required step for comprehensive results.\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"query\":{\"description\":\"a query or topic about Microsoft/Azure products, services, platforms, developer tools, frameworks, or APIs\",\"type\":\"string\",\"default\":null}}},\"outputSchema\":{\"type\":\"object\",\"properties\":{\"results\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":[\"string\",\"null\"]},\"title\":{\"type\":\"string\"},\"content\":{\"type\":[\"string\",\"null\"]},\"contentUrl\":{\"type\":\"string\"},\"content_omitted\":{\"type\":\"boolean\"},\"extensionData\":{\"type\":[\"object\",\"null\"]}}}}}},\"annotations\":{\"title\":\"Microsoft Docs Search\",\"destructiveHint\":false,\"idempotentHint\":true,\"readOnlyHint\":true}},{\"name\":\"microsoft_code_sample_search\",\"title\":\"Microsoft Code Sample Search\",\"description\":\"Search for code snippets and examples in official Microsoft Learn documentation. This tool retrieves relevant code samples from Microsoft documentation pages providing developers with practical implementation examples and best practices for Microsoft/Azure products and services related coding tasks. This tool will help you use the **LATEST OFFICIAL** code snippets to empower coding capabilities.\\n\\n## When to Use This Tool\\n- When you are going to provide sample Microsoft/Azure related code snippets in your answers.\\n- When you are **generating any Microsoft/Azure related code**.\\n\\n## Usage Pattern\\nInput a descriptive query, or SDK/class/method name to retrieve related code samples. The optional parameter \\u0060language\\u0060 can help to filter results.\\n\\nEligible values for \\u0060language\\u0060 parameter include: csharp javascript typescript python powershell azurecli al sql java kusto cpp go rust ruby php\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"query\":{\"description\":\"a descriptive query, SDK name, method name or code snippet related to Microsoft/Azure products, services, platforms, developer tools, frameworks, APIs or SDKs\",\"type\":\"string\"},\"language\":{\"description\":\"Optional parameter specifying the programming language of code snippets to retrieve. Can significantly improve search quality if provided. Eligible values: csharp javascript typescript python powershell azurecli al sql java kusto cpp go rust ruby php\",\"type\":\"string\",\"default\":null}},\"required\":[\"query\"]},\"outputSchema\":{\"type\":\"object\",\"properties\":{\"results\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"description\":{\"type\":\"string\"},\"codeSnippet\":{\"type\":[\"string\",\"null\"]},\"link\":{\"type\":\"string\"},\"language\":{\"type\":\"string\"},\"content_omitted\":{\"type\":\"boolean\"}}}}}},\"annotations\":{\"title\":\"Microsoft Code Sample Search\",\"destructiveHint\":false,\"idempotentHint\":true,\"readOnlyHint\":true}},{\"name\":\"microsoft_docs_fetch\",\"title\":\"Microsoft Docs Fetch\",\"description\":\"Fetch and convert a Microsoft Learn documentation webpage to markdown format. This tool retrieves the latest complete content of Microsoft documentation webpages including Azure, .NET, Microsoft 365, and other Microsoft technologies.\\n\\n## When to Use This Tool\\n- When search results provide incomplete information or truncated content\\n- When you need complete step-by-step procedures or tutorials\\n- When you need troubleshooting sections, prerequisites, or detailed explanations\\n- When search results reference a specific page that seems highly relevant\\n- For comprehensive guides that require full context\\n\\n## Usage Pattern\\nUse this tool AFTER microsoft_docs_search when you identify specific high-value pages that need complete content. The search tool gives you an overview; this tool gives you the complete picture.\\n\\n## URL Requirements\\n- The URL must be a valid HTML documentation webpage from the microsoft.com domain\\n- Binary files (PDF, DOCX, images, etc.) are not supported\\n\\n## Output Format\\nmarkdown with headings, code blocks, tables, and links preserved.\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"url\":{\"description\":\"URL of the Microsoft documentation page to read\",\"type\":\"string\"}},\"required\":[\"url\"]},\"annotations\":{\"title\":\"Microsoft Docs Fetch\",\"destructiveHint\":false,\"idempotentHint\":true,\"readOnlyHint\":true}}]},\"id\":\"b9c6596da2514e04b08cbaa1defb300b\",\"jsonrpc\":\"2.0\"}\n",
-       "\n",
-       "\n",
-       "BODY END
" - ] - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "🔨Title: Microsoft Docs Search (microsoft_docs_search)\n", - "\n", - "Description: Search official Microsoft/Azure documentation to find the most relevant and trustworthy content for a user's query. This tool returns up to 10 high-quality content chunks (each max 500 tokens), extracted from Microsoft Learn and other official sources. Each result includes the article title, URL, and a self-contained content excerpt optimized for fast retrieval and reasoning. Always use this tool to quickly ground your answers in accurate, first-party Microsoft/Azure knowledge.\n", - "\n", - "## Follow-up Pattern\n", - "To ensure completeness, use microsoft_docs_fetch when high-value pages are identified by search. The fetch tool complements search by providing the full detail. This is a required step for comprehensive results.\n", - "\n", - "Input schema: ToolSchema(properties={\"query\":{\"description\":\"a query or topic about Microsoft/Azure products, services, platforms, developer tools, frameworks, or APIs\",\"type\":\"string\",\"default\":null}}, required=null, defs=null)\n", - "Output schema: ToolSchema(properties={\"results\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":[\"string\",\"null\"]},\"title\":{\"type\":\"string\"},\"content\":{\"type\":[\"string\",\"null\"]},\"contentUrl\":{\"type\":\"string\"},\"content_omitted\":{\"type\":\"boolean\"},\"extensionData\":{\"type\":[\"object\",\"null\"]}}}}}, required=null, defs=null)\n", - "Icons: null\n", - "Annotations: ToolAnnotations(title=Microsoft Docs Search, readOnlyHint=true, destructiveHint=false, idempotentHint=true, openWorldHint=null)\n", - "Execution: null\n", - "\n", - "🔨Title: Microsoft Code Sample Search (microsoft_code_sample_search)\n", - "\n", - "Description: Search for code snippets and examples in official Microsoft Learn documentation. This tool retrieves relevant code samples from Microsoft documentation pages providing developers with practical implementation examples and best practices for Microsoft/Azure products and services related coding tasks. This tool will help you use the **LATEST OFFICIAL** code snippets to empower coding capabilities.\n", - "\n", - "## When to Use This Tool\n", - "- When you are going to provide sample Microsoft/Azure related code snippets in your answers.\n", - "- When you are **generating any Microsoft/Azure related code**.\n", - "\n", - "## Usage Pattern\n", - "Input a descriptive query, or SDK/class/method name to retrieve related code samples. The optional parameter `language` can help to filter results.\n", - "\n", - "Eligible values for `language` parameter include: csharp javascript typescript python powershell azurecli al sql java kusto cpp go rust ruby php\n", - "\n", - "Input schema: ToolSchema(properties={\"query\":{\"description\":\"a descriptive query, SDK name, method name or code snippet related to Microsoft/Azure products, services, platforms, developer tools, frameworks, APIs or SDKs\",\"type\":\"string\"},\"language\":{\"description\":\"Optional parameter specifying the programming language of code snippets to retrieve. Can significantly improve search quality if provided. Eligible values: csharp javascript typescript python powershell azurecli al sql java kusto cpp go rust ruby php\",\"type\":\"string\",\"default\":null}}, required=[query], defs=null)\n", - "Output schema: ToolSchema(properties={\"results\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"description\":{\"type\":\"string\"},\"codeSnippet\":{\"type\":[\"string\",\"null\"]},\"link\":{\"type\":\"string\"},\"language\":{\"type\":\"string\"},\"content_omitted\":{\"type\":\"boolean\"}}}}}, required=null, defs=null)\n", - "Icons: null\n", - "Annotations: ToolAnnotations(title=Microsoft Code Sample Search, readOnlyHint=true, destructiveHint=false, idempotentHint=true, openWorldHint=null)\n", - "Execution: null\n", - "\n", - "🔨Title: Microsoft Docs Fetch (microsoft_docs_fetch)\n", - "\n", - "Description: Fetch and convert a Microsoft Learn documentation webpage to markdown format. This tool retrieves the latest complete content of Microsoft documentation webpages including Azure, .NET, Microsoft 365, and other Microsoft technologies.\n", - "\n", - "## When to Use This Tool\n", - "- When search results provide incomplete information or truncated content\n", - "- When you need complete step-by-step procedures or tutorials\n", - "- When you need troubleshooting sections, prerequisites, or detailed explanations\n", - "- When search results reference a specific page that seems highly relevant\n", - "- For comprehensive guides that require full context\n", - "\n", - "## Usage Pattern\n", - "Use this tool AFTER microsoft_docs_search when you identify specific high-value pages that need complete content. The search tool gives you an overview; this tool gives you the complete picture.\n", - "\n", - "## URL Requirements\n", - "- The URL must be a valid HTML documentation webpage from the microsoft.com domain\n", - "- Binary files (PDF, DOCX, images, etc.) are not supported\n", - "\n", - "## Output Format\n", - "markdown with headings, code blocks, tables, and links preserved.\n", - "\n", - "Input schema: ToolSchema(properties={\"url\":{\"description\":\"URL of the Microsoft documentation page to read\",\"type\":\"string\"}}, required=[url], defs=null)\n", - "Output schema: null\n", - "Icons: null\n", - "Annotations: ToolAnnotations(title=Microsoft Docs Fetch, readOnlyHint=true, destructiveHint=false, idempotentHint=true, openWorldHint=null)\n", - "Execution: null\n", - "\n" + "✅ Server version: Implementation(name=Microsoft Learn MCP Server, version=1.0.0, title=null, websiteUrl=null, icons=null)\n", + "✅ Server capabilities: ServerCapabilities(tools=Tools(listChanged=true), resources=Resources(listChanged=null, subscribe=null), prompts=Prompts(listChanged=null), logging={}, completions=null, experimental=null)\n" ] } ], "execution_count": 13 }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Now we can send ping request to MCP server:" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-04T16:08:58.507109Z", + "start_time": "2025-12-04T16:08:58.206412Z" + } + }, + "cell_type": "code", + "source": [ + "runBlocking {\n", + " mcpClient.ping()\n", + "}" + ], + "outputs": [ + { + "data": { + "text/html": [ + "
HTTP Log
REQUEST: https://learn.microsoft.com/api/mcp\n",
+       "METHOD: POST\n",
+       "COMMON HEADERS\n",
+       "-> Accept: application/json, text/event-stream\n",
+       "-> Accept-Charset: UTF-8\n",
+       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIn0sInVzZXJJZENsYWltIjpudWxsfQ\n",
+       "CONTENT HEADERS\n",
+       "-> Content-Length: 73\n",
+       "-> Content-Type: application/json\n",
+       "BODY Content-Type: application/json\n",
+       "BODY START\n",
+       "{\"id\":\"caf63f9816a44766a926944329e987d5\",\"method\":\"ping\",\"jsonrpc\":\"2.0\"}\n",
+       "BODY END
" + ] + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + }, + { + "data": { + "text/html": [ + "
HTTP Log
RESPONSE: 200 OK\n",
+       "METHOD: POST\n",
+       "FROM: https://learn.microsoft.com/api/mcp\n",
+       "COMMON HEADERS\n",
+       "-> Akamai-Cache-Status: Miss from child, Miss from parent\n",
+       "-> Cache-Control: no-cache, no-store\n",
+       "-> Connection: keep-alive\n",
+       "-> Content-Encoding: identity\n",
+       "-> Content-Length: 92\n",
+       "-> Content-Type: text/event-stream\n",
+       "-> Date: Thu, 04 Dec 2025 16:08:58 GMT\n",
+       "-> Expires: Thu, 04 Dec 2025 16:08:58 GMT\n",
+       "-> Mcp-Session-Id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIn0sInVzZXJJZENsYWltIjpudWxsfQ\n",
+       "-> Request-Context: appId=cid-v1:ccd19013-8f20-4877-8d3f-84a0107e7119\n",
+       "-> Set-Cookie: ASLBSA=0003562b174eb06eed9ae0ec705e26d2c9aec6332388d120314730a5c2da183d42c8; Path=/; Secure; HttpOnly;; ASLBSACORS=0003562b174eb06eed9ae0ec705e26d2c9aec6332388d120314730a5c2da183d42c8; SameSite=none; Path=/; Secure; HttpOnly;\n",
+       "-> Strict-Transport-Security: max-age=31536000; includeSubDomains; preload\n",
+       "-> X-Content-Type-Options: nosniff\n",
+       "-> X-Powered-By: ASP.NET\n",
+       "-> nel: {\"report_to\":\"network-errors\",\"max_age\":604800,\"success_fraction\":0.01,\"failure_fraction\":1.0}\n",
+       "-> report-to: {\"group\":\"network-errors\",\"max_age\":604800,\"endpoints\":[{\"url\":\"https://mdec.nelreports.net/api/report?cat=mdocs\"}]}\n",
+       "-> x-azure-ref: 20251204T160858Z-r195dfd6579k9c28hC1STOn4n80000000nk0000000006rvx\n",
+       "BODY Content-Type: text/event-stream\n",
+       "BODY START\n",
+       "event: message\n",
+       "data: {\"result\":{},\"id\":\"caf63f9816a44766a926944329e987d5\",\"jsonrpc\":\"2.0\"}\n",
+       "\n",
+       "\n",
+       "BODY END
" + ] + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + }, + { + "data": { + "text/plain": [ + "EmptyResult(meta=null)" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 14 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Let's get a list of available tools:" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-12-04T16:08:58.818668Z", + "start_time": "2025-12-04T16:08:58.530569Z" + } + }, + "cell_type": "code", + "source": [ + "runBlocking {\n", + " val listToolsResult = mcpClient.listTools()\n", + "\n", + " for (tool in listToolsResult.tools) {\n", + " DISPLAY(tool)\n", + " }\n", + "}\n" + ], + "outputs": [ + { + "data": { + "text/html": [ + "
HTTP Log
REQUEST: https://learn.microsoft.com/api/mcp\n",
+       "METHOD: POST\n",
+       "COMMON HEADERS\n",
+       "-> Accept: application/json, text/event-stream\n",
+       "-> Accept-Charset: UTF-8\n",
+       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIn0sInVzZXJJZENsYWltIjpudWxsfQ\n",
+       "CONTENT HEADERS\n",
+       "-> Content-Length: 79\n",
+       "-> Content-Type: application/json\n",
+       "BODY Content-Type: application/json\n",
+       "BODY START\n",
+       "{\"id\":\"1c46ee9521954378958971e267d543fc\",\"method\":\"tools/list\",\"jsonrpc\":\"2.0\"}\n",
+       "BODY END
" + ] + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + }, + { + "data": { + "text/html": [ + "
HTTP Log
RESPONSE: 200 OK\n",
+       "METHOD: POST\n",
+       "FROM: https://learn.microsoft.com/api/mcp\n",
+       "COMMON HEADERS\n",
+       "-> Akamai-Cache-Status: Miss from child, Miss from parent\n",
+       "-> Cache-Control: no-cache, no-store\n",
+       "-> Connection: keep-alive\n",
+       "-> Content-Encoding: identity\n",
+       "-> Content-Length: 4495\n",
+       "-> Content-Type: text/event-stream\n",
+       "-> Date: Thu, 04 Dec 2025 16:08:58 GMT\n",
+       "-> Expires: Thu, 04 Dec 2025 16:08:58 GMT\n",
+       "-> Mcp-Session-Id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIn0sInVzZXJJZENsYWltIjpudWxsfQ\n",
+       "-> Request-Context: appId=cid-v1:ccd19013-8f20-4877-8d3f-84a0107e7119\n",
+       "-> Set-Cookie: ASLBSA=0003562b174eb06eed9ae0ec705e26d2c9aec6332388d120314730a5c2da183d42c8; Path=/; Secure; HttpOnly;; ASLBSACORS=0003562b174eb06eed9ae0ec705e26d2c9aec6332388d120314730a5c2da183d42c8; SameSite=none; Path=/; Secure; HttpOnly;\n",
+       "-> Strict-Transport-Security: max-age=31536000; includeSubDomains; preload\n",
+       "-> X-Content-Type-Options: nosniff\n",
+       "-> X-Powered-By: ASP.NET\n",
+       "-> nel: {\"report_to\":\"network-errors\",\"max_age\":604800,\"success_fraction\":0.01,\"failure_fraction\":1.0}\n",
+       "-> report-to: {\"group\":\"network-errors\",\"max_age\":604800,\"endpoints\":[{\"url\":\"https://mdec.nelreports.net/api/report?cat=mdocs\"}]}\n",
+       "-> x-azure-ref: 20251204T160858Z-r195dfd6579k9c28hC1STOn4n80000000nk0000000006rw8\n",
+       "BODY Content-Type: text/event-stream\n",
+       "BODY START\n",
+       "event: message\n",
+       "data: {\"result\":{\"tools\":[{\"name\":\"microsoft_docs_search\",\"title\":\"Microsoft Docs Search\",\"description\":\"Search official Microsoft/Azure documentation to find the most relevant and trustworthy content for a user\\u0027s query. This tool returns up to 10 high-quality content chunks (each max 500 tokens), extracted from Microsoft Learn and other official sources. Each result includes the article title, URL, and a self-contained content excerpt optimized for fast retrieval and reasoning. Always use this tool to quickly ground your answers in accurate, first-party Microsoft/Azure knowledge.\\n\\nThe \\u0060question\\u0060 parameter is no longer used, use \\u0060query\\u0060 instead.\\n\\n## Follow-up Pattern\\nTo ensure completeness, use microsoft_docs_fetch when high-value pages are identified by search. The fetch tool complements search by providing the full detail. This is a required step for comprehensive results.\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"query\":{\"description\":\"a query or topic about Microsoft/Azure products, services, platforms, developer tools, frameworks, or APIs\",\"type\":\"string\",\"default\":null},\"question\":{\"description\":\"this parameter is no longer used, use query instead.\",\"type\":\"string\",\"default\":null}}},\"annotations\":{\"title\":\"Microsoft Docs Search\",\"destructiveHint\":false,\"idempotentHint\":true,\"readOnlyHint\":true}},{\"name\":\"microsoft_code_sample_search\",\"title\":\"Microsoft Code Sample Search\",\"description\":\"Search for code snippets and examples in official Microsoft Learn documentation. This tool retrieves relevant code samples from Microsoft documentation pages providing developers with practical implementation examples and best practices for Microsoft/Azure products and services related coding tasks. This tool will help you use the **LATEST OFFICIAL** code snippets to empower coding capabilities.\\n\\n## When to Use This Tool\\n- When you are going to provide sample Microsoft/Azure related code snippets in your answers.\\n- When you are **generating any Microsoft/Azure related code**.\\n\\n## Usage Pattern\\nInput a descriptive query, or SDK/class/method name to retrieve related code samples. The optional parameter \\u0060language\\u0060 can help to filter results.\\n\\nEligible values for \\u0060language\\u0060 parameter include: csharp javascript typescript python powershell azurecli al sql java kusto cpp go rust ruby php\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"query\":{\"description\":\"a descriptive query, SDK name, method name or code snippet related to Microsoft/Azure products, services, platforms, developer tools, frameworks, APIs or SDKs\",\"type\":\"string\"},\"language\":{\"description\":\"Optional parameter specifying the programming language of code snippets to retrieve. Can significantly improve search quality if provided. Eligible values: csharp javascript typescript python powershell azurecli al sql java kusto cpp go rust ruby php\",\"type\":\"string\",\"default\":null}},\"required\":[\"query\"]},\"annotations\":{\"title\":\"Microsoft Code Sample Search\",\"destructiveHint\":false,\"idempotentHint\":true,\"readOnlyHint\":true}},{\"name\":\"microsoft_docs_fetch\",\"title\":\"Microsoft Docs Fetch\",\"description\":\"Fetch and convert a Microsoft Learn documentation page to markdown format. This tool retrieves the latest complete content of Microsoft documentation pages including Azure, .NET, Microsoft 365, and other Microsoft technologies.\\n\\n## When to Use This Tool\\n- When search results provide incomplete information or truncated content\\n- When you need complete step-by-step procedures or tutorials\\n- When you need troubleshooting sections, prerequisites, or detailed explanations\\n- When search results reference a specific page that seems highly relevant\\n- For comprehensive guides that require full context\\n\\n## Usage Pattern\\nUse this tool AFTER microsoft_docs_search when you identify specific high-value pages that need complete content. The search tool gives you an overview; this tool gives you the complete picture.\\n\\n## URL Requirements\\n- The URL must be a valid link from the microsoft.com domain.\\n\\n## Output Format\\nmarkdown with headings, code blocks, tables, and links preserved.\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"url\":{\"description\":\"URL of the Microsoft documentation page to read\",\"type\":\"string\"}},\"required\":[\"url\"]},\"annotations\":{\"title\":\"Microsoft Docs Fetch\",\"destructiveHint\":false,\"idempotentHint\":true,\"readOnlyHint\":true}}]},\"id\":\"1c46ee9521954378958971e267d543fc\",\"jsonrpc\":\"2.0\"}\n",
+       "\n",
+       "\n",
+       "BODY END
" + ] + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + }, + { + "data": { + "text/plain": [ + "Tool(name=microsoft_docs_search, inputSchema=ToolSchema(properties={\"query\":{\"description\":\"a query or topic about Microsoft/Azure products, services, platforms, developer tools, frameworks, or APIs\",\"type\":\"string\",\"default\":null},\"question\":{\"description\":\"this parameter is no longer used, use query instead.\",\"type\":\"string\",\"default\":null}}, required=null), description=Search official Microsoft/Azure documentation to find the most relevant and trustworthy content for a user's query. This tool returns up to 10 high-quality content chunks (each max 500 tokens), extracted from Microsoft Learn and other official sources. Each result includes the article title, URL, and a self-contained content excerpt optimized for fast retrieval and reasoning. Always use this tool to quickly ground your answers in accurate, first-party Microsoft/Azure knowledge.\n", + "\n", + "The `question` parameter is no longer used, use `query` instead.\n", + "\n", + "## Follow-up Pattern\n", + "To ensure completeness, use microsoft_docs_fetch when high-value pages are identified by search. The fetch tool complements search by providing the full detail. This is a required step for comprehensive results., outputSchema=null, title=Microsoft Docs Search, annotations=ToolAnnotations(title=Microsoft Docs Search, readOnlyHint=true, destructiveHint=false, idempotentHint=true, openWorldHint=null), icons=null, meta=null)" + ] + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + }, + { + "data": { + "text/plain": [ + "Tool(name=microsoft_code_sample_search, inputSchema=ToolSchema(properties={\"query\":{\"description\":\"a descriptive query, SDK name, method name or code snippet related to Microsoft/Azure products, services, platforms, developer tools, frameworks, APIs or SDKs\",\"type\":\"string\"},\"language\":{\"description\":\"Optional parameter specifying the programming language of code snippets to retrieve. Can significantly improve search quality if provided. Eligible values: csharp javascript typescript python powershell azurecli al sql java kusto cpp go rust ruby php\",\"type\":\"string\",\"default\":null}}, required=[query]), description=Search for code snippets and examples in official Microsoft Learn documentation. This tool retrieves relevant code samples from Microsoft documentation pages providing developers with practical implementation examples and best practices for Microsoft/Azure products and services related coding tasks. This tool will help you use the **LATEST OFFICIAL** code snippets to empower coding capabilities.\n", + "\n", + "## When to Use This Tool\n", + "- When you are going to provide sample Microsoft/Azure related code snippets in your answers.\n", + "- When you are **generating any Microsoft/Azure related code**.\n", + "\n", + "## Usage Pattern\n", + "Input a descriptive query, or SDK/class/method name to retrieve related code samples. The optional parameter `language` can help to filter results.\n", + "\n", + "Eligible values for `language` parameter include: csharp javascript typescript python powershell azurecli al sql java kusto cpp go rust ruby php, outputSchema=null, title=Microsoft Code Sample Search, annotations=ToolAnnotations(title=Microsoft Code Sample Search, readOnlyHint=true, destructiveHint=false, idempotentHint=true, openWorldHint=null), icons=null, meta=null)" + ] + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + }, + { + "data": { + "text/plain": [ + "Tool(name=microsoft_docs_fetch, inputSchema=ToolSchema(properties={\"url\":{\"description\":\"URL of the Microsoft documentation page to read\",\"type\":\"string\"}}, required=[url]), description=Fetch and convert a Microsoft Learn documentation page to markdown format. This tool retrieves the latest complete content of Microsoft documentation pages including Azure, .NET, Microsoft 365, and other Microsoft technologies.\n", + "\n", + "## When to Use This Tool\n", + "- When search results provide incomplete information or truncated content\n", + "- When you need complete step-by-step procedures or tutorials\n", + "- When you need troubleshooting sections, prerequisites, or detailed explanations\n", + "- When search results reference a specific page that seems highly relevant\n", + "- For comprehensive guides that require full context\n", + "\n", + "## Usage Pattern\n", + "Use this tool AFTER microsoft_docs_search when you identify specific high-value pages that need complete content. The search tool gives you an overview; this tool gives you the complete picture.\n", + "\n", + "## URL Requirements\n", + "- The URL must be a valid link from the microsoft.com domain.\n", + "\n", + "## Output Format\n", + "markdown with headings, code blocks, tables, and links preserved., outputSchema=null, title=Microsoft Docs Fetch, annotations=ToolAnnotations(title=Microsoft Docs Fetch, readOnlyHint=true, destructiveHint=false, idempotentHint=true, openWorldHint=null), icons=null, meta=null)" + ] + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } + ], + "execution_count": 15 + }, { "metadata": {}, "cell_type": "markdown", @@ -563,15 +563,9 @@ }, { "metadata": { - "executionRelatedData": { - "compiledClasses": [ - "Line_17_jupyter", - "Line_25_jupyter" - ] - }, "ExecuteTime": { - "end_time": "2026-03-30T10:42:21.227094Z", - "start_time": "2026-03-30T10:42:20.419561Z" + "end_time": "2025-12-04T16:08:59.912312Z", + "start_time": "2025-12-04T16:08:58.839853Z" } }, "cell_type": "code", @@ -581,19 +575,9 @@ " name = \"microsoft_docs_search\",\n", " arguments = mapOf(\"query\" to \"Linux\")\n", " )\n", - " for (content in toolCallResult.content) {\n", - " (content as? TextContent).let {\n", - " Json.parseToJsonElement(it?.text.orEmpty()).jsonObject.get(\"results\")?.jsonArray?.forEach { result ->\n", - " val item = result as JsonObject\n", - " println(\n", - " \"\"\"\n", - " |Title: ${item[\"title\"]}\n", - " |\n", - " |Content: ${item[\"content\"]}\n", - " |Content URL: ${item[\"contentUrl\"]}\n", - " \"\"\".trimMargin()\n", - " )\n", - " }\n", + " toolCallResult?.let {\n", + " for (content in it.content) {\n", + " println(content)\n", " }\n", " }\n", "}\n" @@ -602,18 +586,18 @@ { "data": { "text/html": [ - "
HTTP Request
REQUEST: https://learn.microsoft.com/api/mcp\n",
+       "
HTTP Log
REQUEST: https://learn.microsoft.com/api/mcp\n",
        "METHOD: POST\n",
        "COMMON HEADERS\n",
        "-> Accept: application/json, text/event-stream\n",
        "-> Accept-Charset: UTF-8\n",
-       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIiwiaWNvbnMiOm51bGwsIndlYnNpdGVVcmwiOm51bGx9LCJ1c2VySWRDbGFpbSI6IjQ4NjFlZWMxLWRlNGUtNGZhMi1hMjA4LWJkNzNhNDdkYmE3OCJ9\n",
+       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIn0sInVzZXJJZENsYWltIjpudWxsfQ\n",
        "CONTENT HEADERS\n",
        "-> Content-Length: 162\n",
        "-> Content-Type: application/json\n",
        "BODY Content-Type: application/json\n",
        "BODY START\n",
-       "{\"id\":\"07e26d19ee6248d98f6dc039bf4fc589\",\"method\":\"tools/call\",\"params\":{\"name\":\"microsoft_docs_search\",\"arguments\":{\"query\":\"Linux\"},\"_meta\":{}},\"jsonrpc\":\"2.0\"}\n",
+       "{\"id\":\"c4d77709f5314c9e9f097f08dd756438\",\"method\":\"tools/call\",\"params\":{\"name\":\"microsoft_docs_search\",\"arguments\":{\"query\":\"Linux\"},\"_meta\":{}},\"jsonrpc\":\"2.0\"}\n",
        "BODY END
" ] }, @@ -626,31 +610,31 @@ { "data": { "text/html": [ - "
HTTP Response
RESPONSE: 200 OK\n",
+       "
HTTP Log
RESPONSE: 200 OK\n",
        "METHOD: POST\n",
        "FROM: https://learn.microsoft.com/api/mcp\n",
        "COMMON HEADERS\n",
-       "-> Akamai-Cache-Status: Miss from child, NotCacheable from parent\n",
+       "-> Akamai-Cache-Status: Miss from child, Miss from parent\n",
        "-> Cache-Control: no-cache, no-store\n",
        "-> Connection: keep-alive\n",
        "-> Content-Encoding: identity\n",
-       "-> Content-Length: 49801\n",
+       "-> Content-Length: 24086\n",
        "-> Content-Type: text/event-stream\n",
-       "-> Date: Mon, 30 Mar 2026 10:42:21 GMT\n",
-       "-> Expires: Mon, 30 Mar 2026 10:42:21 GMT\n",
+       "-> Date: Thu, 04 Dec 2025 16:08:59 GMT\n",
+       "-> Expires: Thu, 04 Dec 2025 16:08:59 GMT\n",
+       "-> Mcp-Session-Id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIn0sInVzZXJJZENsYWltIjpudWxsfQ\n",
        "-> Request-Context: appId=cid-v1:ccd19013-8f20-4877-8d3f-84a0107e7119\n",
        "-> Set-Cookie: ASLBSA=0003562b174eb06eed9ae0ec705e26d2c9aec6332388d120314730a5c2da183d42c8; Path=/; Secure; HttpOnly;; ASLBSACORS=0003562b174eb06eed9ae0ec705e26d2c9aec6332388d120314730a5c2da183d42c8; SameSite=none; Path=/; Secure; HttpOnly;\n",
        "-> Strict-Transport-Security: max-age=31536000; includeSubDomains; preload\n",
        "-> X-Content-Type-Options: nosniff\n",
        "-> X-Powered-By: ASP.NET\n",
-       "-> mcp-session-id: eyJjbGllbnRJbmZvIjp7Im5hbWUiOiJteS1jbGllbnQiLCJ0aXRsZSI6bnVsbCwidmVyc2lvbiI6IjEuMC4wIiwiaWNvbnMiOm51bGwsIndlYnNpdGVVcmwiOm51bGx9LCJ1c2VySWRDbGFpbSI6IjQ4NjFlZWMxLWRlNGUtNGZhMi1hMjA4LWJkNzNhNDdkYmE3OCJ9\n",
        "-> nel: {\"report_to\":\"network-errors\",\"max_age\":604800,\"success_fraction\":0.01,\"failure_fraction\":1.0}\n",
        "-> report-to: {\"group\":\"network-errors\",\"max_age\":604800,\"endpoints\":[{\"url\":\"https://mdec.nelreports.net/api/report?cat=mdocs\"}]}\n",
-       "-> x-azure-ref: 20260330T104220Z-157fb874dd7dr7drhC1SVGcxmc0000000pkg000000005v3x\n",
+       "-> x-azure-ref: 20251204T160859Z-r195dfd6579k9c28hC1STOn4n80000000nk0000000006rws\n",
        "BODY Content-Type: text/event-stream\n",
        "BODY START\n",
        "event: message\n",
-       "data: {\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\u0022results\\u0022:[{\\u0022title\\u0022:\\u0022How to download and install Linux\\u0022,\\u0022content\\u0022:\\u0022# How to download and install Linux\\\\n## Step 2 - Choose a Linux distribution\\\\nThe different versions of Linux are called \\\\u0022distributions\\\\u0022 (sometimes shortened to \\\\u201Cdistros\\\\u201D). So there isn\\\\u2019t actually a way to just \\\\u201Cinstall Linux\\\\u201D, you first need to choose which distribution that uses the Linux kernel you want to download and install. There are over 600 active Linux distributions, such as Ubuntu, Debian, Kali Linux, openSUSE, and more. There are a number of factors to consider in choosing a distribution, such as:\\\\n### Required Linux experience\\\\nIf you\\\\u2019re new to Linux, you may want to start with Ubuntu. This is one of the most common starting places for Linux users due to the level of support and ease of use. Ubuntu offers a desktop-version and a server-version depending on how you plan to use it. The specific tasks that the operating system will be responsible for may help you determine which distribution to use. Arch Linux is a popular choice for those who want a highly customizable, do-it-yourself approach that is still stable and well-supported by a large user base. It is a much more complicated place to start, but can help you to get a better understanding of how Linux works due to the amount of custom configuration.\\\\n### System requirements\\\\nA minimal, or \\\\u0022lightweight\\\\u0022, distribution means a small footprint, with lower memory and processing speed requirements. Alpine Linux is a popular option where users will find most features disabled or not installed by default, but still has everything you need for a containerized app. Security needs: Some Linux users may have specific security concerns to consider. Kali Linux is an example of a distro designed for security tasks, such as Penetration Testing, Security Research, Computer Forensics and Reverse Engineering.\\\\n### Business and Enterprise requirements\\\\nBusiness, corporate, or academic environments may have unique concerns from consumer environments. For example Red Hat Enterprise Linux (RHEL), SUSE Linux Enterprise Server (SLES) and Oracle Linux offer 24/7 global support and subscription-based service. CentOS is another popular enterprise distro that is community-supported but still compatible with RHEL.\\\\n### Community support\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/linux/install#step-2---choose-a-linux-distribution\\u0022},{\\u0022title\\u0022:\\u0022Part 1.2 - Basic operations on Linux\\u0022,\\u0022content\\u0022:\\u0022# Part 1.2 - Basic operations on Linux\\\\n## Run commands by using the \\\\u0022sudo\\\\u0022 prefix\\\\nInstead of becoming the root account user in your session, you can run commands as root by using the \\\\u0060sudo \\\\u003Ccommand\\\\u003E\\\\u0060 format. This approach is safer because it will run the given command only as a superuser.\\\\nFor example, if you run \\\\u0060restart\\\\u0060 while you\\\\u0027re not elevated to superuser status, the result is as follows.\\\\n![Screenshot of restart command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/restart-command.png)\\\\nTo run this command as a superuser instead, you can either change the session context to root (by running \\\\u0060sudo su\\\\u0060) or add the \\\\u0060sudo\\\\u0060 prefix, as follows:\\\\n\\\\u0060\\\\u0060\\\\u0060bash\\\\nsudo reboot\\\\n\\\\u0060\\\\u0060\\\\u0060\\\\nNow, the restart operation runs as expected by the virtual machine.\\\\n![Screenshot of reboot command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/reboot-command.png)\\\\n## Package managers\\\\nPackage managers are used to install, upgrade, and remove applications in Linux. There are several different package managers, such as DPKG (Debian Package Management System), RPM (Red Hat Package Manager), and Zypper for SUSE Package managers themselves are low-level. Therefore, it might be difficult to manage software directly by using the package manager. Fortunately, there are wrappers for these tools that make it easier to install and uninstall software. For example, [yum](https://wikipedia.org/wiki/Yum_%28software%29) is a wrapper around RPM (Red Hat Package Manager) and [APT](https://wikipedia.org/wiki/APT_%28software%29) is a wrapper around DPKG (Debian Package Management System).\\\\nThis tutorial assumes that you\\\\u0027re running the [Ubuntu package management system](https://ubuntu.com/server/docs/package-management). This system is derived from the same system that\\\\u0027s used by the Debian GNU/Linux distribution. Therefore, we can use APT to install the software.\\\\nDepending on your Linux choice, you might have to use other tools. For example, you can use yum to install or uninstall software if you\\\\u0027re running Red Hat.\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-by-using-the-sudo-prefix\\u0022},{\\u0022title\\u0022:\\u0022Part 1.2 - Basic operations on Linux\\u0022,\\u0022content\\u0022:\\u0022# Part 1.2 - Basic operations on Linux\\\\n## Run commands as a superuser\\\\nThe superuser is the most privileged user account. It has root (unrestricted) access to all files and folders, and complete control over the operation of the computer. The system administrator uses this account for system maintenance.\\\\nOn UNIX-like systems, the conventional name of the superuser is \\\\u0022root\\\\u0022. The root user can do tasks that are otherwise restricted for standard users, including the following ones:\\\\n1. Changing system directories\\\\n2. Changing user privileges\\\\n3. Changing file ownership\\\\n4. System-related tasks such as restarts\\\\n5. Installation of certain applications\\\\nThere are several ways to run commands as the root account user. One is to use the \\\\u0060sudo su\\\\u0060 command. In this command, \\\\u0060sudo\\\\u0060 is short for \\\\u0060superuser do\\\\u0060, and \\\\u0060su\\\\u0060 is an abbreviation for \\\\u0022switch user.\\\\u0022 You can also use the \\\\u0060su\\\\u0060 command to switch between standard users.\\\\nImportant\\\\nAfter you become the root account user, everything you do runs in root context. Therefore, you should act carefully because the root account has access to the entire system.\\\\nTo demonstrate a permissions issue, create a text file in a special folder that\\\\u0027s named */etc*.\\\\nThe */etc/* folder is where system configuration files are located. This folder protects its contents from being changed by a standard user.\\\\nRun the following command:\\\\n\\\\u0060\\\\u0060\\\\u0060bash\\\\necho hello world \\\\u003E /etc/helloworld.txt\\\\n\\\\u0060\\\\u0060\\\\u0060\\\\nThe \\\\u0060echo\\\\u0060 command writes all the text that follows it to the output. The angle bracket (\\\\u0060\\\\u003E\\\\u0060) tells the system to send the output to the */etc/helloworld.txt* file instead of the console. This behavior is similar to how Windows works.\\\\nNote\\\\nLinux is case-sensitive: *Helloworld.txt*, *helloworld.txt* and *helloworld.Txt* are different files.\\\\nYou don\\\\u0027t have to provide a file name extension. The name \\\\u0022helloworld\\\\u0022 by itself is perfectly valid. (The \\\\u0022txt\\\\u0022 extension is used here only as an example.)\\\\nWhen you run this command, you receive a **permission denied** error message.\\\\n![Screenshot of permission denied message.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/permission-denied-message.png)\\\\nThe operation fails because the */etc* folder is special folder that a standard user can\\\\u0027t change. Verify the user by running the \\\\u0060id\\\\u0060 command.\\\\n![Screenshot of I D command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/id-command.png)\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-as-a-superuser\\u0022},{\\u0022title\\u0022:\\u0022How to download and install Linux\\u0022,\\u0022content\\u0022:\\u0022# How to download and install Linux\\\\n## Step 1 - Choose a method to install Linux\\\\nThe choice of which method to use for installing Linux depends on your needs and preferences.\\\\n1. **New to using Linux?** We recommend starting with Windows Subsystem for Linux (WSL) as it\\\\u0027s the easiest way to get up and running.\\\\n2. **Work in a business environment with more complex needs related to scale or security?** We recommend running Linux as a Virtual Machine (VM) in the cloud and checking out the support that Azure has to offer. This also applies if you want to run Linux as a server.\\\\n3. **Only want to run Linux as your primary operating system?** If you are good with a slightly more complicated install process and don\\\\u0027t need access to Windows tools (like Outlook, Teams, Word, PowerPoint, etc), you can run Linux on bare metal to access the full potential of your hardware without any overhead from virtualization or emulation.\\\\nLearn more about these options below.\\\\n### Windows Subsystem for Linux (WSL)\\\\nAvailable to those with a Windows machine, this is the most simple way to install Linux. Just run the Linux install command: \\\\u0060wsl --install\\\\u0060 to install the Ubuntu distribution.\\\\nThis method uses virtualization to integrate the Windows operating system (OS) with the Linux OS (running on an actual Linux kernel). You can add as many different Linux distributions as you please and run your favorite Linux tools, including GUI apps, alongside your favorite Windows tools. You can also mix and match Bash and PowerShell commands in the same command line. A Linux file system hosts your project files, while a separate Windows file systems hosts any developer tools, such as VS Code, that you\\\\u0027d like to work on the files with. This method of running Linux is highly efficient and performant. Once you choose a Linux distribution or opt to use the default Ubuntu distro, follow the instructions to Use the install Linux command with Windows Subsystem for Linux or learn more in the [WSL documentation](https://learn.microsoft.com/en-us/windows/wsl).\\\\n![WSL install command screenshot](https://learn.microsoft.com/linux/images/wsl-install-command-screenshot.png)\\\\n### Create a Virtual Machine (VM)\\\\nAvailable to everyone regardless if you\\\\u0027re using a Windows or MacOS-based machine, but will require either an Azure account, if creating a VM in the cloud, or virtualization software, if creating a VM directly on your machine (locally).\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/linux/install#step-1---choose-a-method-to-install-linux\\u0022},{\\u0022title\\u0022:\\u0022How to download and install Linux\\u0022,\\u0022content\\u0022:\\u0022# How to download and install Linux\\\\nLinux is an operating system, similar to Windows, but with many different versions due to the nature of being open source and fully customizable. To install Linux, you must choose an install method and choose a Linux distribution.\\\\nTo install Linux:\\\\n1. Choose an install method: Windows Subsystem for Linux (WSL), Bare metal Linux; or create a Virtual Machine (VM) to run Linux locally or in the cloud.\\\\n2. Choose a Linux distribution: Ubuntu, Debian, Kali Linux, openSUSE, etc.\\\\n3. Follow the steps for your preferred install method:\\\\n3.1. Use the install Linux command with Windows Subsystem for Linux (WSL)\\\\n3.2. Create a Linux Virtual Machine (VM) in the cloud\\\\n3.3. Create a Linux Virtual Machine (VM) on your local machine\\\\n3.4. Create a bootable USB to install bare-metal Linux\\\\n4. After installing Linux: Get familiar with your distribution\\\\u0027s package manager, update and upgrade the packages available, and get familiar with the other [Linux resources at Microsoft](https://learn.microsoft.com/linux/), such as training courses, Linux-versions of popular tools, news, and Open Source events.\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/linux/install\\u0022},{\\u0022title\\u0022:\\u0022New to Linux resources for SQL users\\u0022,\\u0022content\\u0022:\\u0022# New to Linux resources for SQL users\\\\n**Applies to:**![](https://learn.microsoft.com/sql/includes/media/yes-icon.svg?view=sql-server-ver17)[SQL Server](https://learn.microsoft.com/sql/sql-server/sql-docs-navigation-guide?view=sql-server-ver17#applies-to) on Linux\\\\nThis article provides a learning path through an introduction to Linux concepts. Use the sections in the article as a guided learning path to familiarize yourself with the Linux environment.\\\\nThis list isn\\\\u0027t comprehensive but instead provides the bare minimum you need to help you administer and navigate your SQL Server on Linux environment. See the [full list of tutorials](https://www.linux.org/forums/linux-beginner-tutorials.123/) to learn more.\\\\n## What is Linux?\\\\nThe [What is Linux](https://www.linux.org/threads/what-is-linux.4106/) module introduces the history of Linux as an operating system. The module explains *the Kernel* and where Linux is today. This tutorial serves as an introduction to Linux, helping you get started.\\\\n## Select a distribution\\\\nAfter you know the history of Linux, decide which [distribution of Linux](https://www.linux.org/threads/selecting-a-linux-distribution.4117/) best suits your business needs.\\\\nSQL Server 2025 (17.x) is [supported](https://learn.microsoft.com/sql/linux/sql-server-linux-release-notes-2025?view=sql-server-ver17#supported-platforms) on Red Hat Enterprise Linux (RHEL) and Ubuntu.\\\\nSQL Server 2022 (16.x) and earlier versions are [also supported](https://learn.microsoft.com/sql/linux/sql-server-linux-release-notes-2022?view=sql-server-ver17#supported-platforms) on SUSE Linux Enterprise Server (SLES).\\\\n## Get around directories\\\\nAfter you choose the appropriate distribution of Linux, familiarize yourself with [getting around Linux directories](https://www.linux.org/threads/getting-around-in-linux-directories.4120/).\\\\nThis module helps you answer the following questions:\\\\n1. How to navigate between different files\\\\n2. How to identify a file from a directory\\\\n3. Identify different directories\\\\n## Install new software\\\\nNext, learn how to [install new software](https://www.linux.org/threads/installing-new-software-debian-red-hat-slackware.4119/) on your new Linux operating system. This module covers installing new software on the Debian and Red Hat Linux operating systems.\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/sql/linux/new-to-linux-resources?view=sql-server-ver17\\u0022},{\\u0022title\\u0022:\\u0022How to download and install Linux\\u0022,\\u0022content\\u0022:\\u0022# How to download and install Linux\\\\n## Step 3 - Follow install method instructions\\\\n### Install Linux with Windows Subsystem for Linux\\\\nTo install Linux on a Windows PC, use the install Linux command.\\\\n1. Open PowerShell or Windows Command Prompt in *administrator mode* by right-clicking and selecting \\\\u0022Run as administrator\\\\u0022.\\\\n2. Enter the Linux install command: \\\\u0060wsl --install\\\\u0060.\\\\n3. Restart your machine.\\\\n![WSL Linux Install List Command screenshot](https://learn.microsoft.com/linux/images/wsl-install-list-screenshot.png)\\\\n#### Install Linux command options\\\\nBy default, the install command will use the Ubuntu distribution of Linux. You can see the other distributions that are available in the Microsoft Store by entering the command: \\\\u0060wsl -l -o\\\\u0060. If you\\\\u0027d like to install a distribution other than Ubuntu, just add \\\\u0060-d\\\\u0060 to the command with the name of the distribution you\\\\u0027d like to install:\\\\n\\\\u0060\\\\u0060\\\\u0060powershell\\\\nwsl --install -d \\\\u003CDistro Name\\\\u003E\\\\n\\\\u0060\\\\u0060\\\\u0060\\\\nIf the distribution you want to install isn\\\\u0027t available in the Microsoft Store, see the Windows Subsystem for Linux (WSL) documentation on how to [Import any Linux distribution to use with WSL](https://learn.microsoft.com/en-us/windows/wsl/use-custom-distro).\\\\nFor more information on how to use VS Code, Git, databases, Docker, GUI apps, GPU acceleration, NodesJS, USB devices, or mount a disk, build a custom distribution, manage disk space, or set up WSL for your enterprise business, see the [WSL documentation](https://learn.microsoft.com/en-us/windows/wsl/).\\\\n### Create a Linux Virtual Machine in the cloud\\\\nTo create a Linux Virtual Machine (VM) that is hosted in the cloud on Azure, log-in to the Azure Portal and select the \\\\u201CCreate a Virtual Machine\\\\u201D service. Then follow the instructions for creating a user name, project, instance, SSH key, ports, and copying the IP address.\\\\nAzure supports a variety of options for setting up Linux VMs in the cloud. For a quickstart guide on how to set up a Linux VM, choose your preferred method:\\\\n1. [Azure Portal](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-portal?tabs=ubuntu\\\\u0026source=docs)\\\\n2. [Azure CLI](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-cli)\\\\n3. [Azure PowerShell](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-powershell)\\\\n4. [Terraform](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-terraform)\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/linux/install#step-3---follow-install-method-instructions\\u0022},{\\u0022title\\u0022:\\u0022How to download and install Linux\\u0022,\\u0022content\\u0022:\\u0022# How to download and install Linux\\\\n## Step 2 - Choose a Linux distribution\\\\nA large user community that consistently contributes to the distribution, fixing bugs, adding features, helping with questions, etc. is another good consideration. Ubuntu is one of the most popular distos. openSUSE has been around longer than most other distributions and offers in addition to the stable version (Leap) a rolling version (Tumbleweed). Many other distros rapidly grow popular as the needs and preferences of the user community change. The Linux.org site has resources to help you assess what\\\\u0027s available, well-supported, or growing in popularity.\\\\n![Screenshot of the list of distributions on Linux.org](https://learn.microsoft.com/linux/images/linux-distributions-list-screenshot.png)\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/linux/install#step-2---choose-a-linux-distribution\\u0022},{\\u0022title\\u0022:\\u0022Part 1.2 - Basic operations on Linux\\u0022,\\u0022content\\u0022:\\u0022# Part 1.2 - Basic operations on Linux\\\\n## Run commands as a superuser\\\\nTo become the root account user, run the \\\\u0060sudo su\\\\u0060 command.\\\\n![Screenshot of sudo su command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/sudo-su-command.png)\\\\nYou should notice two things: When you become root, the dollar sign character (\\\\u0060$\\\\u0060) becomes a pound sign character (\\\\u0060#\\\\u0060). Also, the username is changed to **root**. If you run the same \\\\u0060id\\\\u0060 command again, you see that the user and group ID of root are **0**.\\\\n![Screenshot of root command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/root-command.png)\\\\nImportant\\\\nNow you have elevated yourself to the \\\\u0022superuser\\\\u0022 role in your session, you have complete access to the system. Keep in mind that this is a potentially dangerous situation, and you should exercise caution as you continue.\\\\nNow, run the same command one more time:\\\\n\\\\u0060\\\\u0060\\\\u0060bash\\\\necho hello world \\\\u003E /etc/helloworld.txt\\\\n\\\\u0060\\\\u0060\\\\u0060\\\\nThis time, you don\\\\u0027t receive any error message. To verify if the file is created, run the following command:\\\\n\\\\u0060\\\\u0060\\\\u0060bash\\\\nll /etc/hello*\\\\n\\\\u0060\\\\u0060\\\\u0060\\\\nThe \\\\u0060ll\\\\u0060 command lists the files and subfolders that are in a specified folder. In this example, the \\\\u0060/etc/hello*\\\\u0060 parameter value limits the command output to the files or folders whose name begins in \\\\u0060hello\\\\u0060.\\\\n![Screenshot of hello command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/hello-command.png)\\\\nThe output shows that the file was created. What if we want to examine the content of the file? The \\\\u0060cat\\\\u0060 command helps here. To see the content, run \\\\u0060cat /etc/helloworld.txt\\\\u0060.\\\\n![Screenshot of cat command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/cat-command.png)\\\\nThe \\\\u0060cat\\\\u0060 command reads files sequentially, and writes their content to the standard output. Therefore, it writes \\\\u0022hello world\\\\u0022 in the console.\\\\nTo prevent mistakes that could damage the system while you\\\\u0027re elevated to root status, it\\\\u0027s a good practice to exit the root session and return to your standard user session to avoid some dangerous operations. To do this, run \\\\u0060exit\\\\u0060. You can see that the pound sign reverts to a dollar sign, and the username is displayed as your standard user.\\\\n![Screenshot of exit command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/exit-command.png)\\\\nFor additional practice, run the same \\\\u0060ll\\\\u0060 and \\\\u0060cat\\\\u0060 commands within your standard user context. You\\\\u0027ll see that you can\\\\u0027t create the file in the */etc/* folder, but can you list the file and read the contents.\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-as-a-superuser\\u0022},{\\u0022title\\u0022:\\u0022Part 1.2 - Basic operations on Linux\\u0022,\\u0022content\\u0022:\\u0022# Part 1.2 - Basic operations on Linux\\\\n## Remove packages\\\\nFinding the file doesn\\\\u0027t involve package managers. Instead, you\\\\u0027ll run a standard search for files in Linux. There are several ways to do so. One of the most common search commands is \\\\u0060find\\\\u0060. However, this command is also confusing to use, so we won\\\\u0027t discuss it here. Instead, we\\\\u0027ll use \\\\u0060whereis\\\\u0060.\\\\nNote\\\\nLinux is very well documented. Nearly every command has a useful help page available. Use the [man](https://www.geeksforgeeks.org/man-command-in-linux-with-examples/) and [info](https://www.geeksforgeeks.org/info-command-in-linux-with-examples/) commands to show the Help pages in Linux. For example, to learn more on your own about the \\\\u0060find\\\\u0060 command, run \\\\u0060man find\\\\u0060 or \\\\u0060info find\\\\u0060.\\\\nAccording to the Help page for \\\\u0060whereis\\\\u0060, the definition is as follows:\\\\n\\\\u0060whereis\\\\u0060 locates the binary, source, and manual files for the specified command names.\\\\nIf you run \\\\u0060whereis apache2\\\\u0060, you should find the */etc/apache2* installation.\\\\n![Screenshot of whereis apache2 command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/whereis-apache2-command.png)\\\\nRemember that the */etc/* directory is where the system configuration files are located. To learn whether \\\\u0022apache2\\\\u0022is a file or folder, run \\\\u0060ll /etc/apache2\\\\u0060. As you can see, this is a folder, and it contains the apache2 configuration files.\\\\n![Screenshot of ll etc apache2 command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/ll-etc-apache2-command.png)\\\\nYou expect \\\\u0060apache2\\\\u0060 to contain these files and folders because you used the \\\\u0060apt remove\\\\u0060 command that doesn\\\\u0027t delete the configuration files.\\\\nAs final step, run the \\\\u0060apt purge\\\\u0060 command to see its effect. You can run a purge to clean up the installation folder even after you remove the package. The following screenshot shows that \\\\u0060whereis\\\\u0060 didn\\\\u0027t find anything. This is because the \\\\u0060purge\\\\u0060 command removed the configuration files together with the binaries.\\\\n![Screenshot of apt purge command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/apt-purge-command.png)\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#remove-packages\\u0022}]}\"}],\"structuredContent\":{\"results\":[{\"title\":\"How to download and install Linux\",\"content\":\"# How to download and install Linux\\n## Step 2 - Choose a Linux distribution\\nThe different versions of Linux are called \\u0022distributions\\u0022 (sometimes shortened to \\u201Cdistros\\u201D). So there isn\\u2019t actually a way to just \\u201Cinstall Linux\\u201D, you first need to choose which distribution that uses the Linux kernel you want to download and install. There are over 600 active Linux distributions, such as Ubuntu, Debian, Kali Linux, openSUSE, and more. There are a number of factors to consider in choosing a distribution, such as:\\n### Required Linux experience\\nIf you\\u2019re new to Linux, you may want to start with Ubuntu. This is one of the most common starting places for Linux users due to the level of support and ease of use. Ubuntu offers a desktop-version and a server-version depending on how you plan to use it. The specific tasks that the operating system will be responsible for may help you determine which distribution to use. Arch Linux is a popular choice for those who want a highly customizable, do-it-yourself approach that is still stable and well-supported by a large user base. It is a much more complicated place to start, but can help you to get a better understanding of how Linux works due to the amount of custom configuration.\\n### System requirements\\nA minimal, or \\u0022lightweight\\u0022, distribution means a small footprint, with lower memory and processing speed requirements. Alpine Linux is a popular option where users will find most features disabled or not installed by default, but still has everything you need for a containerized app. Security needs: Some Linux users may have specific security concerns to consider. Kali Linux is an example of a distro designed for security tasks, such as Penetration Testing, Security Research, Computer Forensics and Reverse Engineering.\\n### Business and Enterprise requirements\\nBusiness, corporate, or academic environments may have unique concerns from consumer environments. For example Red Hat Enterprise Linux (RHEL), SUSE Linux Enterprise Server (SLES) and Oracle Linux offer 24/7 global support and subscription-based service. CentOS is another popular enterprise distro that is community-supported but still compatible with RHEL.\\n### Community support\",\"contentUrl\":\"https://learn.microsoft.com/linux/install#step-2---choose-a-linux-distribution\"},{\"title\":\"Part 1.2 - Basic operations on Linux\",\"content\":\"# Part 1.2 - Basic operations on Linux\\n## Run commands by using the \\u0022sudo\\u0022 prefix\\nInstead of becoming the root account user in your session, you can run commands as root by using the \\u0060sudo \\u003Ccommand\\u003E\\u0060 format. This approach is safer because it will run the given command only as a superuser.\\nFor example, if you run \\u0060restart\\u0060 while you\\u0027re not elevated to superuser status, the result is as follows.\\n![Screenshot of restart command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/restart-command.png)\\nTo run this command as a superuser instead, you can either change the session context to root (by running \\u0060sudo su\\u0060) or add the \\u0060sudo\\u0060 prefix, as follows:\\n\\u0060\\u0060\\u0060bash\\nsudo reboot\\n\\u0060\\u0060\\u0060\\nNow, the restart operation runs as expected by the virtual machine.\\n![Screenshot of reboot command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/reboot-command.png)\\n## Package managers\\nPackage managers are used to install, upgrade, and remove applications in Linux. There are several different package managers, such as DPKG (Debian Package Management System), RPM (Red Hat Package Manager), and Zypper for SUSE Package managers themselves are low-level. Therefore, it might be difficult to manage software directly by using the package manager. Fortunately, there are wrappers for these tools that make it easier to install and uninstall software. For example, [yum](https://wikipedia.org/wiki/Yum_%28software%29) is a wrapper around RPM (Red Hat Package Manager) and [APT](https://wikipedia.org/wiki/APT_%28software%29) is a wrapper around DPKG (Debian Package Management System).\\nThis tutorial assumes that you\\u0027re running the [Ubuntu package management system](https://ubuntu.com/server/docs/package-management). This system is derived from the same system that\\u0027s used by the Debian GNU/Linux distribution. Therefore, we can use APT to install the software.\\nDepending on your Linux choice, you might have to use other tools. For example, you can use yum to install or uninstall software if you\\u0027re running Red Hat.\",\"contentUrl\":\"https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-by-using-the-sudo-prefix\"},{\"title\":\"Part 1.2 - Basic operations on Linux\",\"content\":\"# Part 1.2 - Basic operations on Linux\\n## Run commands as a superuser\\nThe superuser is the most privileged user account. It has root (unrestricted) access to all files and folders, and complete control over the operation of the computer. The system administrator uses this account for system maintenance.\\nOn UNIX-like systems, the conventional name of the superuser is \\u0022root\\u0022. The root user can do tasks that are otherwise restricted for standard users, including the following ones:\\n1. Changing system directories\\n2. Changing user privileges\\n3. Changing file ownership\\n4. System-related tasks such as restarts\\n5. Installation of certain applications\\nThere are several ways to run commands as the root account user. One is to use the \\u0060sudo su\\u0060 command. In this command, \\u0060sudo\\u0060 is short for \\u0060superuser do\\u0060, and \\u0060su\\u0060 is an abbreviation for \\u0022switch user.\\u0022 You can also use the \\u0060su\\u0060 command to switch between standard users.\\nImportant\\nAfter you become the root account user, everything you do runs in root context. Therefore, you should act carefully because the root account has access to the entire system.\\nTo demonstrate a permissions issue, create a text file in a special folder that\\u0027s named */etc*.\\nThe */etc/* folder is where system configuration files are located. This folder protects its contents from being changed by a standard user.\\nRun the following command:\\n\\u0060\\u0060\\u0060bash\\necho hello world \\u003E /etc/helloworld.txt\\n\\u0060\\u0060\\u0060\\nThe \\u0060echo\\u0060 command writes all the text that follows it to the output. The angle bracket (\\u0060\\u003E\\u0060) tells the system to send the output to the */etc/helloworld.txt* file instead of the console. This behavior is similar to how Windows works.\\nNote\\nLinux is case-sensitive: *Helloworld.txt*, *helloworld.txt* and *helloworld.Txt* are different files.\\nYou don\\u0027t have to provide a file name extension. The name \\u0022helloworld\\u0022 by itself is perfectly valid. (The \\u0022txt\\u0022 extension is used here only as an example.)\\nWhen you run this command, you receive a **permission denied** error message.\\n![Screenshot of permission denied message.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/permission-denied-message.png)\\nThe operation fails because the */etc* folder is special folder that a standard user can\\u0027t change. Verify the user by running the \\u0060id\\u0060 command.\\n![Screenshot of I D command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/id-command.png)\",\"contentUrl\":\"https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-as-a-superuser\"},{\"title\":\"How to download and install Linux\",\"content\":\"# How to download and install Linux\\n## Step 1 - Choose a method to install Linux\\nThe choice of which method to use for installing Linux depends on your needs and preferences.\\n1. **New to using Linux?** We recommend starting with Windows Subsystem for Linux (WSL) as it\\u0027s the easiest way to get up and running.\\n2. **Work in a business environment with more complex needs related to scale or security?** We recommend running Linux as a Virtual Machine (VM) in the cloud and checking out the support that Azure has to offer. This also applies if you want to run Linux as a server.\\n3. **Only want to run Linux as your primary operating system?** If you are good with a slightly more complicated install process and don\\u0027t need access to Windows tools (like Outlook, Teams, Word, PowerPoint, etc), you can run Linux on bare metal to access the full potential of your hardware without any overhead from virtualization or emulation.\\nLearn more about these options below.\\n### Windows Subsystem for Linux (WSL)\\nAvailable to those with a Windows machine, this is the most simple way to install Linux. Just run the Linux install command: \\u0060wsl --install\\u0060 to install the Ubuntu distribution.\\nThis method uses virtualization to integrate the Windows operating system (OS) with the Linux OS (running on an actual Linux kernel). You can add as many different Linux distributions as you please and run your favorite Linux tools, including GUI apps, alongside your favorite Windows tools. You can also mix and match Bash and PowerShell commands in the same command line. A Linux file system hosts your project files, while a separate Windows file systems hosts any developer tools, such as VS Code, that you\\u0027d like to work on the files with. This method of running Linux is highly efficient and performant. Once you choose a Linux distribution or opt to use the default Ubuntu distro, follow the instructions to Use the install Linux command with Windows Subsystem for Linux or learn more in the [WSL documentation](https://learn.microsoft.com/en-us/windows/wsl).\\n![WSL install command screenshot](https://learn.microsoft.com/linux/images/wsl-install-command-screenshot.png)\\n### Create a Virtual Machine (VM)\\nAvailable to everyone regardless if you\\u0027re using a Windows or MacOS-based machine, but will require either an Azure account, if creating a VM in the cloud, or virtualization software, if creating a VM directly on your machine (locally).\",\"contentUrl\":\"https://learn.microsoft.com/linux/install#step-1---choose-a-method-to-install-linux\"},{\"title\":\"How to download and install Linux\",\"content\":\"# How to download and install Linux\\nLinux is an operating system, similar to Windows, but with many different versions due to the nature of being open source and fully customizable. To install Linux, you must choose an install method and choose a Linux distribution.\\nTo install Linux:\\n1. Choose an install method: Windows Subsystem for Linux (WSL), Bare metal Linux; or create a Virtual Machine (VM) to run Linux locally or in the cloud.\\n2. Choose a Linux distribution: Ubuntu, Debian, Kali Linux, openSUSE, etc.\\n3. Follow the steps for your preferred install method:\\n3.1. Use the install Linux command with Windows Subsystem for Linux (WSL)\\n3.2. Create a Linux Virtual Machine (VM) in the cloud\\n3.3. Create a Linux Virtual Machine (VM) on your local machine\\n3.4. Create a bootable USB to install bare-metal Linux\\n4. After installing Linux: Get familiar with your distribution\\u0027s package manager, update and upgrade the packages available, and get familiar with the other [Linux resources at Microsoft](https://learn.microsoft.com/linux/), such as training courses, Linux-versions of popular tools, news, and Open Source events.\",\"contentUrl\":\"https://learn.microsoft.com/linux/install\"},{\"title\":\"New to Linux resources for SQL users\",\"content\":\"# New to Linux resources for SQL users\\n**Applies to:**![](https://learn.microsoft.com/sql/includes/media/yes-icon.svg?view=sql-server-ver17)[SQL Server](https://learn.microsoft.com/sql/sql-server/sql-docs-navigation-guide?view=sql-server-ver17#applies-to) on Linux\\nThis article provides a learning path through an introduction to Linux concepts. Use the sections in the article as a guided learning path to familiarize yourself with the Linux environment.\\nThis list isn\\u0027t comprehensive but instead provides the bare minimum you need to help you administer and navigate your SQL Server on Linux environment. See the [full list of tutorials](https://www.linux.org/forums/linux-beginner-tutorials.123/) to learn more.\\n## What is Linux?\\nThe [What is Linux](https://www.linux.org/threads/what-is-linux.4106/) module introduces the history of Linux as an operating system. The module explains *the Kernel* and where Linux is today. This tutorial serves as an introduction to Linux, helping you get started.\\n## Select a distribution\\nAfter you know the history of Linux, decide which [distribution of Linux](https://www.linux.org/threads/selecting-a-linux-distribution.4117/) best suits your business needs.\\nSQL Server 2025 (17.x) is [supported](https://learn.microsoft.com/sql/linux/sql-server-linux-release-notes-2025?view=sql-server-ver17#supported-platforms) on Red Hat Enterprise Linux (RHEL) and Ubuntu.\\nSQL Server 2022 (16.x) and earlier versions are [also supported](https://learn.microsoft.com/sql/linux/sql-server-linux-release-notes-2022?view=sql-server-ver17#supported-platforms) on SUSE Linux Enterprise Server (SLES).\\n## Get around directories\\nAfter you choose the appropriate distribution of Linux, familiarize yourself with [getting around Linux directories](https://www.linux.org/threads/getting-around-in-linux-directories.4120/).\\nThis module helps you answer the following questions:\\n1. How to navigate between different files\\n2. How to identify a file from a directory\\n3. Identify different directories\\n## Install new software\\nNext, learn how to [install new software](https://www.linux.org/threads/installing-new-software-debian-red-hat-slackware.4119/) on your new Linux operating system. This module covers installing new software on the Debian and Red Hat Linux operating systems.\",\"contentUrl\":\"https://learn.microsoft.com/sql/linux/new-to-linux-resources?view=sql-server-ver17\"},{\"title\":\"How to download and install Linux\",\"content\":\"# How to download and install Linux\\n## Step 3 - Follow install method instructions\\n### Install Linux with Windows Subsystem for Linux\\nTo install Linux on a Windows PC, use the install Linux command.\\n1. Open PowerShell or Windows Command Prompt in *administrator mode* by right-clicking and selecting \\u0022Run as administrator\\u0022.\\n2. Enter the Linux install command: \\u0060wsl --install\\u0060.\\n3. Restart your machine.\\n![WSL Linux Install List Command screenshot](https://learn.microsoft.com/linux/images/wsl-install-list-screenshot.png)\\n#### Install Linux command options\\nBy default, the install command will use the Ubuntu distribution of Linux. You can see the other distributions that are available in the Microsoft Store by entering the command: \\u0060wsl -l -o\\u0060. If you\\u0027d like to install a distribution other than Ubuntu, just add \\u0060-d\\u0060 to the command with the name of the distribution you\\u0027d like to install:\\n\\u0060\\u0060\\u0060powershell\\nwsl --install -d \\u003CDistro Name\\u003E\\n\\u0060\\u0060\\u0060\\nIf the distribution you want to install isn\\u0027t available in the Microsoft Store, see the Windows Subsystem for Linux (WSL) documentation on how to [Import any Linux distribution to use with WSL](https://learn.microsoft.com/en-us/windows/wsl/use-custom-distro).\\nFor more information on how to use VS Code, Git, databases, Docker, GUI apps, GPU acceleration, NodesJS, USB devices, or mount a disk, build a custom distribution, manage disk space, or set up WSL for your enterprise business, see the [WSL documentation](https://learn.microsoft.com/en-us/windows/wsl/).\\n### Create a Linux Virtual Machine in the cloud\\nTo create a Linux Virtual Machine (VM) that is hosted in the cloud on Azure, log-in to the Azure Portal and select the \\u201CCreate a Virtual Machine\\u201D service. Then follow the instructions for creating a user name, project, instance, SSH key, ports, and copying the IP address.\\nAzure supports a variety of options for setting up Linux VMs in the cloud. For a quickstart guide on how to set up a Linux VM, choose your preferred method:\\n1. [Azure Portal](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-portal?tabs=ubuntu\\u0026source=docs)\\n2. [Azure CLI](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-cli)\\n3. [Azure PowerShell](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-powershell)\\n4. [Terraform](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-terraform)\",\"contentUrl\":\"https://learn.microsoft.com/linux/install#step-3---follow-install-method-instructions\"},{\"title\":\"How to download and install Linux\",\"content\":\"# How to download and install Linux\\n## Step 2 - Choose a Linux distribution\\nA large user community that consistently contributes to the distribution, fixing bugs, adding features, helping with questions, etc. is another good consideration. Ubuntu is one of the most popular distos. openSUSE has been around longer than most other distributions and offers in addition to the stable version (Leap) a rolling version (Tumbleweed). Many other distros rapidly grow popular as the needs and preferences of the user community change. The Linux.org site has resources to help you assess what\\u0027s available, well-supported, or growing in popularity.\\n![Screenshot of the list of distributions on Linux.org](https://learn.microsoft.com/linux/images/linux-distributions-list-screenshot.png)\",\"contentUrl\":\"https://learn.microsoft.com/linux/install#step-2---choose-a-linux-distribution\"},{\"title\":\"Part 1.2 - Basic operations on Linux\",\"content\":\"# Part 1.2 - Basic operations on Linux\\n## Run commands as a superuser\\nTo become the root account user, run the \\u0060sudo su\\u0060 command.\\n![Screenshot of sudo su command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/sudo-su-command.png)\\nYou should notice two things: When you become root, the dollar sign character (\\u0060$\\u0060) becomes a pound sign character (\\u0060#\\u0060). Also, the username is changed to **root**. If you run the same \\u0060id\\u0060 command again, you see that the user and group ID of root are **0**.\\n![Screenshot of root command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/root-command.png)\\nImportant\\nNow you have elevated yourself to the \\u0022superuser\\u0022 role in your session, you have complete access to the system. Keep in mind that this is a potentially dangerous situation, and you should exercise caution as you continue.\\nNow, run the same command one more time:\\n\\u0060\\u0060\\u0060bash\\necho hello world \\u003E /etc/helloworld.txt\\n\\u0060\\u0060\\u0060\\nThis time, you don\\u0027t receive any error message. To verify if the file is created, run the following command:\\n\\u0060\\u0060\\u0060bash\\nll /etc/hello*\\n\\u0060\\u0060\\u0060\\nThe \\u0060ll\\u0060 command lists the files and subfolders that are in a specified folder. In this example, the \\u0060/etc/hello*\\u0060 parameter value limits the command output to the files or folders whose name begins in \\u0060hello\\u0060.\\n![Screenshot of hello command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/hello-command.png)\\nThe output shows that the file was created. What if we want to examine the content of the file? The \\u0060cat\\u0060 command helps here. To see the content, run \\u0060cat /etc/helloworld.txt\\u0060.\\n![Screenshot of cat command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/cat-command.png)\\nThe \\u0060cat\\u0060 command reads files sequentially, and writes their content to the standard output. Therefore, it writes \\u0022hello world\\u0022 in the console.\\nTo prevent mistakes that could damage the system while you\\u0027re elevated to root status, it\\u0027s a good practice to exit the root session and return to your standard user session to avoid some dangerous operations. To do this, run \\u0060exit\\u0060. You can see that the pound sign reverts to a dollar sign, and the username is displayed as your standard user.\\n![Screenshot of exit command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/exit-command.png)\\nFor additional practice, run the same \\u0060ll\\u0060 and \\u0060cat\\u0060 commands within your standard user context. You\\u0027ll see that you can\\u0027t create the file in the */etc/* folder, but can you list the file and read the contents.\",\"contentUrl\":\"https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-as-a-superuser\"},{\"title\":\"Part 1.2 - Basic operations on Linux\",\"content\":\"# Part 1.2 - Basic operations on Linux\\n## Remove packages\\nFinding the file doesn\\u0027t involve package managers. Instead, you\\u0027ll run a standard search for files in Linux. There are several ways to do so. One of the most common search commands is \\u0060find\\u0060. However, this command is also confusing to use, so we won\\u0027t discuss it here. Instead, we\\u0027ll use \\u0060whereis\\u0060.\\nNote\\nLinux is very well documented. Nearly every command has a useful help page available. Use the [man](https://www.geeksforgeeks.org/man-command-in-linux-with-examples/) and [info](https://www.geeksforgeeks.org/info-command-in-linux-with-examples/) commands to show the Help pages in Linux. For example, to learn more on your own about the \\u0060find\\u0060 command, run \\u0060man find\\u0060 or \\u0060info find\\u0060.\\nAccording to the Help page for \\u0060whereis\\u0060, the definition is as follows:\\n\\u0060whereis\\u0060 locates the binary, source, and manual files for the specified command names.\\nIf you run \\u0060whereis apache2\\u0060, you should find the */etc/apache2* installation.\\n![Screenshot of whereis apache2 command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/whereis-apache2-command.png)\\nRemember that the */etc/* directory is where the system configuration files are located. To learn whether \\u0022apache2\\u0022is a file or folder, run \\u0060ll /etc/apache2\\u0060. As you can see, this is a folder, and it contains the apache2 configuration files.\\n![Screenshot of ll etc apache2 command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/ll-etc-apache2-command.png)\\nYou expect \\u0060apache2\\u0060 to contain these files and folders because you used the \\u0060apt remove\\u0060 command that doesn\\u0027t delete the configuration files.\\nAs final step, run the \\u0060apt purge\\u0060 command to see its effect. You can run a purge to clean up the installation folder even after you remove the package. The following screenshot shows that \\u0060whereis\\u0060 didn\\u0027t find anything. This is because the \\u0060purge\\u0060 command removed the configuration files together with the binaries.\\n![Screenshot of apt purge command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/apt-purge-command.png)\",\"contentUrl\":\"https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#remove-packages\"}]}},\"id\":\"07e26d19ee6248d98f6dc039bf4fc589\",\"jsonrpc\":\"2.0\"}\n",
+       "data: {\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"[{\\u0022title\\u0022:\\u0022How to download and install Linux\\u0022,\\u0022content\\u0022:\\u0022# How to download and install Linux\\\\n## Step 2 - Choose a Linux distribution\\\\nThe different versions of Linux are called \\\\u0022distributions\\\\u0022 (sometimes shortened to \\\\u201Cdistros\\\\u201D). So there isn\\\\u2019t actually a way to just \\\\u201Cinstall Linux\\\\u201D, you first need to choose which distribution that uses the Linux kernel you want to download and install. There are over 600 active Linux distributions, such as Ubuntu, Debian, Kali Linux, openSUSE, and more. There are a number of factors to consider in choosing a distribution, such as:\\\\n### Required Linux experience\\\\nIf you\\\\u2019re new to Linux, you may want to start with Ubuntu. This is one of the most common starting places for Linux users due to the level of support and ease of use. Ubuntu offers a desktop-version and a server-version depending on how you plan to use it. The specific tasks that the operating system will be responsible for may help you determine which distribution to use. Arch Linux is a popular choice for those who want a highly customizable, do-it-yourself approach that is still stable and well-supported by a large user base. It is a much more complicated place to start, but can help you to get a better understanding of how Linux works due to the amount of custom configuration.\\\\n### System requirements\\\\nA minimal, or \\\\u0022lightweight\\\\u0022, distribution means a small footprint, with lower memory and processing speed requirements. Alpine Linux is a popular option where users will find most features disabled or not installed by default, but still has everything you need for a containerized app. Security needs: Some Linux users may have specific security concerns to consider. Kali Linux is an example of a distro designed for security tasks, such as Penetration Testing, Security Research, Computer Forensics and Reverse Engineering.\\\\n### Business and Enterprise requirements\\\\nBusiness, corporate, or academic environments may have unique concerns from consumer environments. For example Red Hat Enterprise Linux (RHEL), SUSE Linux Enterprise Server (SLES) and Oracle Linux offer 24/7 global support and subscription-based service. CentOS is another popular enterprise distro that is community-supported but still compatible with RHEL.\\\\n### Community support\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/en-us/linux/install#step-2---choose-a-linux-distribution\\u0022},{\\u0022title\\u0022:\\u0022New to Linux resources for SQL users\\u0022,\\u0022content\\u0022:\\u0022# New to Linux resources for SQL users\\\\n**Applies to:**[SQL Server](https://learn.microsoft.com/en-us/sql/sql-server/sql-docs-navigation-guide?view=sql-server-ver17#applies-to) - Linux\\\\nThis article is intended to provide a learning path through an introduction of Linux concepts. Use the sections in the article as a guided learning path to familiarize yourself with the Linux environment.\\\\nThis isn\\\\u0027t intended to be a comprehensive list but instead provides the bare minimum you need to help you administer and navigate your SQL Server on Linux environment. See the [full list of tutorials](https://www.linux.org/forums/linux-beginner-tutorials.123/) to learn more.\\\\n## What is Linux?\\\\nThe [What is Linux](https://www.linux.org/threads/what-is-linux.4106/) module introduces the history of Linux as an operating system. The module explains *the Kernel* and where Linux is today. This tutorial serves as an introduction to Linux, helping you get started.\\\\n## Select a distribution\\\\nAfter you know the history of Linux, decide which [distribution of Linux](https://www.linux.org/threads/selecting-a-linux-distribution.4117/) best suits your business needs. [SQL Server is supported](https://learn.microsoft.com/en-us/sql/linux/sql-server-linux-release-notes-2019?view=sql-server-ver17#supported-platforms) on various distributions such as Red Hat Enterprise Linux (RHEL), SUSE Linux Enterprise Server (SLES), and Ubuntu.\\\\n## Get around directories\\\\nAfter you have chosen the appropriate distribution of Linux, familiarize yourself with [getting around Linux directories](https://www.linux.org/threads/getting-around-in-linux-directories.4120/).\\\\nThis module will help you answer the following questions:\\\\n1. How to navigate between different files\\\\n2. How to identify a file from a directory\\\\n3. Identify different directories\\\\n## Install new software\\\\nNext, learn how to [install new software](https://www.linux.org/threads/installing-new-software-debian-red-hat-slackware.4119/) on your new Linux operating system. This module details installing new software to the Debian and Red Hat Linux operating systems.\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/en-us/sql/linux/new-to-linux-resources?view=sql-server-ver17\\u0022},{\\u0022title\\u0022:\\u0022Part 1.2 - Basic operations on Linux\\u0022,\\u0022content\\u0022:\\u0022# Part 1.2 - Basic operations on Linux\\\\n## Run commands by using the \\\\u0022sudo\\\\u0022 prefix\\\\nInstead of becoming the root account user in your session, you can run commands as root by using the \\\\u0060sudo \\\\u003Ccommand\\\\u003E\\\\u0060 format. This approach is safer because it will run the given command only as a superuser.\\\\nFor example, if you run \\\\u0060restart\\\\u0060 while you\\\\u0027re not elevated to superuser status, the result is as follows.\\\\n![Screenshot of restart command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/restart-command.png)\\\\nTo run this command as a superuser instead, you can either change the session context to root (by running \\\\u0060sudo su\\\\u0060) or add the \\\\u0060sudo\\\\u0060 prefix, as follows:\\\\n\\\\u0060\\\\u0060\\\\u0060bash\\\\nsudo reboot\\\\n\\\\u0060\\\\u0060\\\\u0060\\\\nNow, the restart operation runs as expected by the virtual machine.\\\\n![Screenshot of reboot command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/reboot-command.png)\\\\n## Package managers\\\\nPackage managers are used to install, upgrade, and remove applications in Linux. There are several different package managers, such as DPKG (Debian Package Management System), RPM (Red Hat Package Manager), and Zypper for SUSE Package managers themselves are low-level. Therefore, it might be difficult to manage software directly by using the package manager. Fortunately, there are wrappers for these tools that make it easier to install and uninstall software. For example, [yum](https://wikipedia.org/wiki/Yum_%28software%29) is a wrapper around RPM (Red Hat Package Manager) and [APT](https://wikipedia.org/wiki/APT_%28software%29) is a wrapper around DPKG (Debian Package Management System).\\\\nThis tutorial assumes that you\\\\u0027re running the [Ubuntu package management system](https://ubuntu.com/server/docs/package-management). This system is derived from the same system that\\\\u0027s used by the Debian GNU/Linux distribution. Therefore, we can use APT to install the software.\\\\nDepending on your Linux choice, you might have to use other tools. For example, you can use yum to install or uninstall software if you\\\\u0027re running Red Hat.\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-by-using-the-sudo-prefix\\u0022},{\\u0022title\\u0022:\\u0022Part 1.2 - Basic operations on Linux\\u0022,\\u0022content\\u0022:\\u0022# Part 1.2 - Basic operations on Linux\\\\n## Run commands as a superuser\\\\nThe superuser is the most privileged user account. It has root (unrestricted) access to all files and folders, and complete control over the operation of the computer. The system administrator uses this account for system maintenance.\\\\nOn UNIX-like systems, the conventional name of the superuser is \\\\u0022root\\\\u0022. The root user can do tasks that are otherwise restricted for standard users, including the following ones:\\\\n1. Changing system directories\\\\n2. Changing user privileges\\\\n3. Changing file ownership\\\\n4. System-related tasks such as restarts\\\\n5. Installation of certain applications\\\\nThere are several ways to run commands as the root account user. One is to use the \\\\u0060sudo su\\\\u0060 command. In this command, \\\\u0060sudo\\\\u0060 is short for \\\\u0060superuser do\\\\u0060, and \\\\u0060su\\\\u0060 is an abbreviation for \\\\u0022switch user.\\\\u0022 You can also use the \\\\u0060su\\\\u0060 command to switch between standard users.\\\\nImportant\\\\nAfter you become the root account user, everything you do runs in root context. Therefore, you should act carefully because the root account has access to the entire system.\\\\nTo demonstrate a permissions issue, create a text file in a special folder that\\\\u0027s named */etc*.\\\\nThe */etc/* folder is where system configuration files are located. This folder protects its contents from being changed by a standard user.\\\\nRun the following command:\\\\n\\\\u0060\\\\u0060\\\\u0060bash\\\\necho hello world \\\\u003E /etc/helloworld.txt\\\\n\\\\u0060\\\\u0060\\\\u0060\\\\nThe \\\\u0060echo\\\\u0060 command writes all the text that follows it to the output. The angle bracket (\\\\u0060\\\\u003E\\\\u0060) tells the system to send the output to the */etc/helloworld.txt* file instead of the console. This behavior is similar to how Windows works.\\\\nNote\\\\nLinux is case-sensitive: *Helloworld.txt*, *helloworld.txt* and *helloworld.Txt* are different files.\\\\nYou don\\\\u0027t have to provide a file name extension. The name \\\\u0022helloworld\\\\u0022 by itself is perfectly valid. (The \\\\u0022txt\\\\u0022 extension is used here only as an example.)\\\\nWhen you run this command, you receive a **permission denied** error message.\\\\n![Screenshot of permission denied message.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/permission-denied-message.png)\\\\nThe operation fails because the */etc* folder is special folder that a standard user can\\\\u0027t change. Verify the user by running the \\\\u0060id\\\\u0060 command.\\\\n![Screenshot of I D command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/id-command.png)\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-as-a-superuser\\u0022},{\\u0022title\\u0022:\\u0022How to download and install Linux\\u0022,\\u0022content\\u0022:\\u0022# How to download and install Linux\\\\n## Step 1 - Choose a method to install Linux\\\\nThe choice of which method to use for installing Linux depends on your needs and preferences.\\\\n1. **New to using Linux?** We recommend starting with Windows Subsystem for Linux (WSL) as it\\\\u0027s the easiest way to get up and running.\\\\n2. **Work in a business environment with more complex needs related to scale or security?** We recommend running Linux as a Virtual Machine (VM) in the cloud and checking out the support that Azure has to offer. This also applies if you want to run Linux as a server.\\\\n3. **Only want to run Linux as your primary operating system?** If you are good with a slightly more complicated install process and don\\\\u0027t need access to Windows tools (like Outlook, Teams, Word, PowerPoint, etc), you can run Linux on bare metal to access the full potential of your hardware without any overhead from virtualization or emulation.\\\\nLearn more about these options below.\\\\n### Windows Subsystem for Linux (WSL)\\\\nAvailable to those with a Windows machine, this is the most simple way to install Linux. Just run the Linux install command: \\\\u0060wsl --install\\\\u0060 to install the Ubuntu distribution.\\\\nThis method uses virtualization to integrate the Windows operating system (OS) with the Linux OS (running on an actual Linux kernel). You can add as many different Linux distributions as you please and run your favorite Linux tools, including GUI apps, alongside your favorite Windows tools. You can also mix and match Bash and PowerShell commands in the same command line. A Linux file system hosts your project files, while a separate Windows file systems hosts any developer tools, such as VS Code, that you\\\\u0027d like to work on the files with. This method of running Linux is highly efficient and performant. Once you choose a Linux distribution or opt to use the default Ubuntu distro, follow the instructions to Use the install Linux command with Windows Subsystem for Linux or learn more in the [WSL documentation](https://learn.microsoft.com/en-us/windows/wsl).\\\\n![WSL install command screenshot](https://learn.microsoft.com/en-us/linux/images/wsl-install-command-screenshot.png)\\\\n### Create a Virtual Machine (VM)\\\\nAvailable to everyone regardless if you\\\\u0027re using a Windows or MacOS-based machine, but will require either an Azure account, if creating a VM in the cloud, or virtualization software, if creating a VM directly on your machine (locally).\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/en-us/linux/install#step-1---choose-a-method-to-install-linux\\u0022},{\\u0022title\\u0022:\\u0022How to download and install Linux\\u0022,\\u0022content\\u0022:\\u0022# How to download and install Linux\\\\nLinux is an operating system, similar to Windows, but with many different versions due to the nature of being open source and fully customizable. To install Linux, you must choose an install method and choose a Linux distribution.\\\\nTo install Linux:\\\\n1. Choose an install method: Windows Subsystem for Linux (WSL), Bare metal Linux; or create a Virtual Machine (VM) to run Linux locally or in the cloud.\\\\n2. Choose a Linux distribution: Ubuntu, Debian, Kali Linux, openSUSE, etc.\\\\n3. Follow the steps for your preferred install method:\\\\n3.1. Use the install Linux command with Windows Subsystem for Linux (WSL)\\\\n3.2. Create a Linux Virtual Machine (VM) in the cloud\\\\n3.3. Create a Linux Virtual Machine (VM) on your local machine\\\\n3.4. Create a bootable USB to install bare-metal Linux\\\\n4. After installing Linux: Get familiar with your distribution\\\\u0027s package manager, update and upgrade the packages available, and get familiar with the other [Linux resources at Microsoft](https://learn.microsoft.com/en-us/linux/), such as training courses, Linux-versions of popular tools, news, and Open Source events.\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/en-us/linux/install\\u0022},{\\u0022title\\u0022:\\u0022Guide for changing your dev environment from Mac to Windows\\u0022,\\u0022content\\u0022:\\u0022# Guide for changing your dev environment from Mac to Windows\\\\n## Command-line shells and terminals\\\\nYou can now install Windows Subsystem for Linux (WSL) to support running a Linux shell within Windows. This means that you can run **bash**, with whichever specific Linux distribution you choose, integrated right inside Windows. Using WSL provides the kind of environment most familiar to Mac users. For example, you use **ls** to list the files in a current directory, not **dir** as you would with the traditional Windows Cmd Shell. To learn about installing and using WSL, see the [Windows Subsystem for Linux Installation Guide](https://learn.microsoft.com/en-us/windows/wsl/install). Linux distributions that you can install on Windows with WSL include:\\\\n1. [Ubuntu 20.04 LTS](https://www.microsoft.com/store/apps/9n6svws3rx71)\\\\n2. [Kali Linux](https://www.microsoft.com/store/apps/9PKR34TNCV07)\\\\n3. [Debian GNU/Linux](https://www.microsoft.com/store/apps/9MSVKQC78PK6)\\\\n4. [openSUSE Leap 15.1](https://www.microsoft.com/store/apps/9NJFZK00FGKV)\\\\n5. [SUSE Linux Enterprise Server 15 SP1](https://www.microsoft.com/store/apps/9PN498VPMF3Z)\\\\nJust to name a few. Find more in the [WSL install docs](https://learn.microsoft.com/en-us/windows/wsl/install-win10#install-your-linux-distribution-of-choice) and install them directly from the [Microsoft Store](https://aka.ms/wslstore).\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/en-us/windows/dev-environment/mac-to-windows#command-line-shells-and-terminals\\u0022},{\\u0022title\\u0022:\\u0022How to download and install Linux\\u0022,\\u0022content\\u0022:\\u0022# How to download and install Linux\\\\n## Step 3 - Follow install method instructions\\\\n### Install Linux with Windows Subsystem for Linux\\\\nTo install Linux on a Windows PC, use the install Linux command.\\\\n1. Open PowerShell or Windows Command Prompt in *administrator mode* by right-clicking and selecting \\\\u0022Run as administrator\\\\u0022.\\\\n2. Enter the Linux install command: \\\\u0060wsl --install\\\\u0060.\\\\n3. Restart your machine.\\\\n![WSL Linux Install List Command screenshot](https://learn.microsoft.com/en-us/linux/images/wsl-install-list-screenshot.png)\\\\n#### Install Linux command options\\\\nBy default, the install command will use the Ubuntu distribution of Linux. You can see the other distributions that are available in the Microsoft Store by entering the command: \\\\u0060wsl -l -o\\\\u0060. If you\\\\u0027d like to install a distribution other than Ubuntu, just add \\\\u0060-d\\\\u0060 to the command with the name of the distribution you\\\\u0027d like to install:\\\\n\\\\u0060\\\\u0060\\\\u0060powershell\\\\nwsl --install -d \\\\u003CDistro Name\\\\u003E\\\\n\\\\u0060\\\\u0060\\\\u0060\\\\nIf the distribution you want to install isn\\\\u0027t available in the Microsoft Store, see the Windows Subsystem for Linux (WSL) documentation on how to [Import any Linux distribution to use with WSL](https://learn.microsoft.com/en-us/windows/wsl/use-custom-distro).\\\\nFor more information on how to use VS Code, Git, databases, Docker, GUI apps, GPU acceleration, NodesJS, USB devices, or mount a disk, build a custom distribution, manage disk space, or set up WSL for your enterprise business, see the [WSL documentation](https://learn.microsoft.com/en-us/windows/wsl/).\\\\n### Create a Linux Virtual Machine in the cloud\\\\nTo create a Linux Virtual Machine (VM) that is hosted in the cloud on Azure, log-in to the Azure Portal and select the \\\\u201CCreate a Virtual Machine\\\\u201D service. Then follow the instructions for creating a user name, project, instance, SSH key, ports, and copying the IP address.\\\\nAzure supports a variety of options for setting up Linux VMs in the cloud. For a quickstart guide on how to set up a Linux VM, choose your preferred method:\\\\n1. [Azure Portal](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-portal?tabs=ubuntu\\\\u0026source=docs)\\\\n2. [Azure CLI](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-cli)\\\\n3. [Azure PowerShell](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-powershell)\\\\n4. [Terraform](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-terraform)\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/en-us/linux/install#step-3---follow-install-method-instructions\\u0022},{\\u0022title\\u0022:\\u0022How to download and install Linux\\u0022,\\u0022content\\u0022:\\u0022# How to download and install Linux\\\\n## Step 2 - Choose a Linux distribution\\\\nA large user community that consistently contributes to the distribution, fixing bugs, adding features, helping with questions, etc. is another good consideration. Ubuntu is one of the most popular distos. openSUSE has been around longer than most other distributions and offers in addition to the stable version (Leap) a rolling version (Tumbleweed). Many other distros rapidly grow popular as the needs and preferences of the user community change. The Linux.org site has resources to help you assess what\\\\u0027s available, well-supported, or growing in popularity.\\\\n![Screenshot of the list of distributions on Linux.org](https://learn.microsoft.com/en-us/linux/images/linux-distributions-list-screenshot.png)\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/en-us/linux/install#step-2---choose-a-linux-distribution\\u0022},{\\u0022title\\u0022:\\u0022Part 1.2 - Basic operations on Linux\\u0022,\\u0022content\\u0022:\\u0022# Part 1.2 - Basic operations on Linux\\\\n## Run commands as a superuser\\\\nTo become the root account user, run the \\\\u0060sudo su\\\\u0060 command.\\\\n![Screenshot of sudo su command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/sudo-su-command.png)\\\\nYou should notice two things: When you become root, the dollar sign character (\\\\u0060$\\\\u0060) becomes a pound sign character (\\\\u0060#\\\\u0060). Also, the username is changed to **root**. If you run the same \\\\u0060id\\\\u0060 command again, you see that the user and group ID of root are **0**.\\\\n![Screenshot of root command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/root-command.png)\\\\nImportant\\\\nNow you have elevated yourself to the \\\\u0022superuser\\\\u0022 role in your session, you have complete access to the system. Keep in mind that this is a potentially dangerous situation, and you should exercise caution as you continue.\\\\nNow, run the same command one more time:\\\\n\\\\u0060\\\\u0060\\\\u0060bash\\\\necho hello world \\\\u003E /etc/helloworld.txt\\\\n\\\\u0060\\\\u0060\\\\u0060\\\\nThis time, you don\\\\u0027t receive any error message. To verify if the file is created, run the following command:\\\\n\\\\u0060\\\\u0060\\\\u0060bash\\\\nll /etc/hello*\\\\n\\\\u0060\\\\u0060\\\\u0060\\\\nThe \\\\u0060ll\\\\u0060 command lists the files and subfolders that are in a specified folder. In this example, the \\\\u0060/etc/hello*\\\\u0060 parameter value limits the command output to the files or folders whose name begins in \\\\u0060hello\\\\u0060.\\\\n![Screenshot of hello command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/hello-command.png)\\\\nThe output shows that the file was created. What if we want to examine the content of the file? The \\\\u0060cat\\\\u0060 command helps here. To see the content, run \\\\u0060cat /etc/helloworld.txt\\\\u0060.\\\\n![Screenshot of cat command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/cat-command.png)\\\\nThe \\\\u0060cat\\\\u0060 command reads files sequentially, and writes their content to the standard output. Therefore, it writes \\\\u0022hello world\\\\u0022 in the console.\\\\nTo prevent mistakes that could damage the system while you\\\\u0027re elevated to root status, it\\\\u0027s a good practice to exit the root session and return to your standard user session to avoid some dangerous operations. To do this, run \\\\u0060exit\\\\u0060. You can see that the pound sign reverts to a dollar sign, and the username is displayed as your standard user.\\\\n![Screenshot of exit command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/exit-command.png)\\\\nFor additional practice, run the same \\\\u0060ll\\\\u0060 and \\\\u0060cat\\\\u0060 commands within your standard user context. You\\\\u0027ll see that you can\\\\u0027t create the file in the */etc/* folder, but can you list the file and read the contents.\\u0022,\\u0022contentUrl\\u0022:\\u0022https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-as-a-superuser\\u0022}]\"}]},\"id\":\"c4d77709f5314c9e9f097f08dd756438\",\"jsonrpc\":\"2.0\"}\n",
        "\n",
        "\n",
        "BODY END
" @@ -666,50 +650,11 @@ "name": "stdout", "output_type": "stream", "text": [ - "Title: \"How to download and install Linux\"\n", - "\n", - "Content: \"# How to download and install Linux\\n## Step 2 - Choose a Linux distribution\\nThe different versions of Linux are called \\\"distributions\\\" (sometimes shortened to “distros”). So there isn’t actually a way to just “install Linux”, you first need to choose which distribution that uses the Linux kernel you want to download and install. There are over 600 active Linux distributions, such as Ubuntu, Debian, Kali Linux, openSUSE, and more. There are a number of factors to consider in choosing a distribution, such as:\\n### Required Linux experience\\nIf you’re new to Linux, you may want to start with Ubuntu. This is one of the most common starting places for Linux users due to the level of support and ease of use. Ubuntu offers a desktop-version and a server-version depending on how you plan to use it. The specific tasks that the operating system will be responsible for may help you determine which distribution to use. Arch Linux is a popular choice for those who want a highly customizable, do-it-yourself approach that is still stable and well-supported by a large user base. It is a much more complicated place to start, but can help you to get a better understanding of how Linux works due to the amount of custom configuration.\\n### System requirements\\nA minimal, or \\\"lightweight\\\", distribution means a small footprint, with lower memory and processing speed requirements. Alpine Linux is a popular option where users will find most features disabled or not installed by default, but still has everything you need for a containerized app. Security needs: Some Linux users may have specific security concerns to consider. Kali Linux is an example of a distro designed for security tasks, such as Penetration Testing, Security Research, Computer Forensics and Reverse Engineering.\\n### Business and Enterprise requirements\\nBusiness, corporate, or academic environments may have unique concerns from consumer environments. For example Red Hat Enterprise Linux (RHEL), SUSE Linux Enterprise Server (SLES) and Oracle Linux offer 24/7 global support and subscription-based service. CentOS is another popular enterprise distro that is community-supported but still compatible with RHEL.\\n### Community support\"\n", - "Content URL: \"https://learn.microsoft.com/linux/install#step-2---choose-a-linux-distribution\"\n", - "Title: \"Part 1.2 - Basic operations on Linux\"\n", - "\n", - "Content: \"# Part 1.2 - Basic operations on Linux\\n## Run commands by using the \\\"sudo\\\" prefix\\nInstead of becoming the root account user in your session, you can run commands as root by using the `sudo ` format. This approach is safer because it will run the given command only as a superuser.\\nFor example, if you run `restart` while you're not elevated to superuser status, the result is as follows.\\n![Screenshot of restart command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/restart-command.png)\\nTo run this command as a superuser instead, you can either change the session context to root (by running `sudo su`) or add the `sudo` prefix, as follows:\\n```bash\\nsudo reboot\\n```\\nNow, the restart operation runs as expected by the virtual machine.\\n![Screenshot of reboot command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/reboot-command.png)\\n## Package managers\\nPackage managers are used to install, upgrade, and remove applications in Linux. There are several different package managers, such as DPKG (Debian Package Management System), RPM (Red Hat Package Manager), and Zypper for SUSE Package managers themselves are low-level. Therefore, it might be difficult to manage software directly by using the package manager. Fortunately, there are wrappers for these tools that make it easier to install and uninstall software. For example, [yum](https://wikipedia.org/wiki/Yum_%28software%29) is a wrapper around RPM (Red Hat Package Manager) and [APT](https://wikipedia.org/wiki/APT_%28software%29) is a wrapper around DPKG (Debian Package Management System).\\nThis tutorial assumes that you're running the [Ubuntu package management system](https://ubuntu.com/server/docs/package-management). This system is derived from the same system that's used by the Debian GNU/Linux distribution. Therefore, we can use APT to install the software.\\nDepending on your Linux choice, you might have to use other tools. For example, you can use yum to install or uninstall software if you're running Red Hat.\"\n", - "Content URL: \"https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-by-using-the-sudo-prefix\"\n", - "Title: \"Part 1.2 - Basic operations on Linux\"\n", - "\n", - "Content: \"# Part 1.2 - Basic operations on Linux\\n## Run commands as a superuser\\nThe superuser is the most privileged user account. It has root (unrestricted) access to all files and folders, and complete control over the operation of the computer. The system administrator uses this account for system maintenance.\\nOn UNIX-like systems, the conventional name of the superuser is \\\"root\\\". The root user can do tasks that are otherwise restricted for standard users, including the following ones:\\n1. Changing system directories\\n2. Changing user privileges\\n3. Changing file ownership\\n4. System-related tasks such as restarts\\n5. Installation of certain applications\\nThere are several ways to run commands as the root account user. One is to use the `sudo su` command. In this command, `sudo` is short for `superuser do`, and `su` is an abbreviation for \\\"switch user.\\\" You can also use the `su` command to switch between standard users.\\nImportant\\nAfter you become the root account user, everything you do runs in root context. Therefore, you should act carefully because the root account has access to the entire system.\\nTo demonstrate a permissions issue, create a text file in a special folder that's named */etc*.\\nThe */etc/* folder is where system configuration files are located. This folder protects its contents from being changed by a standard user.\\nRun the following command:\\n```bash\\necho hello world > /etc/helloworld.txt\\n```\\nThe `echo` command writes all the text that follows it to the output. The angle bracket (`>`) tells the system to send the output to the */etc/helloworld.txt* file instead of the console. This behavior is similar to how Windows works.\\nNote\\nLinux is case-sensitive: *Helloworld.txt*, *helloworld.txt* and *helloworld.Txt* are different files.\\nYou don't have to provide a file name extension. The name \\\"helloworld\\\" by itself is perfectly valid. (The \\\"txt\\\" extension is used here only as an example.)\\nWhen you run this command, you receive a **permission denied** error message.\\n![Screenshot of permission denied message.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/permission-denied-message.png)\\nThe operation fails because the */etc* folder is special folder that a standard user can't change. Verify the user by running the `id` command.\\n![Screenshot of I D command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/id-command.png)\"\n", - "Content URL: \"https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-as-a-superuser\"\n", - "Title: \"How to download and install Linux\"\n", - "\n", - "Content: \"# How to download and install Linux\\n## Step 1 - Choose a method to install Linux\\nThe choice of which method to use for installing Linux depends on your needs and preferences.\\n1. **New to using Linux?** We recommend starting with Windows Subsystem for Linux (WSL) as it's the easiest way to get up and running.\\n2. **Work in a business environment with more complex needs related to scale or security?** We recommend running Linux as a Virtual Machine (VM) in the cloud and checking out the support that Azure has to offer. This also applies if you want to run Linux as a server.\\n3. **Only want to run Linux as your primary operating system?** If you are good with a slightly more complicated install process and don't need access to Windows tools (like Outlook, Teams, Word, PowerPoint, etc), you can run Linux on bare metal to access the full potential of your hardware without any overhead from virtualization or emulation.\\nLearn more about these options below.\\n### Windows Subsystem for Linux (WSL)\\nAvailable to those with a Windows machine, this is the most simple way to install Linux. Just run the Linux install command: `wsl --install` to install the Ubuntu distribution.\\nThis method uses virtualization to integrate the Windows operating system (OS) with the Linux OS (running on an actual Linux kernel). You can add as many different Linux distributions as you please and run your favorite Linux tools, including GUI apps, alongside your favorite Windows tools. You can also mix and match Bash and PowerShell commands in the same command line. A Linux file system hosts your project files, while a separate Windows file systems hosts any developer tools, such as VS Code, that you'd like to work on the files with. This method of running Linux is highly efficient and performant. Once you choose a Linux distribution or opt to use the default Ubuntu distro, follow the instructions to Use the install Linux command with Windows Subsystem for Linux or learn more in the [WSL documentation](https://learn.microsoft.com/en-us/windows/wsl).\\n![WSL install command screenshot](https://learn.microsoft.com/linux/images/wsl-install-command-screenshot.png)\\n### Create a Virtual Machine (VM)\\nAvailable to everyone regardless if you're using a Windows or MacOS-based machine, but will require either an Azure account, if creating a VM in the cloud, or virtualization software, if creating a VM directly on your machine (locally).\"\n", - "Content URL: \"https://learn.microsoft.com/linux/install#step-1---choose-a-method-to-install-linux\"\n", - "Title: \"How to download and install Linux\"\n", - "\n", - "Content: \"# How to download and install Linux\\nLinux is an operating system, similar to Windows, but with many different versions due to the nature of being open source and fully customizable. To install Linux, you must choose an install method and choose a Linux distribution.\\nTo install Linux:\\n1. Choose an install method: Windows Subsystem for Linux (WSL), Bare metal Linux; or create a Virtual Machine (VM) to run Linux locally or in the cloud.\\n2. Choose a Linux distribution: Ubuntu, Debian, Kali Linux, openSUSE, etc.\\n3. Follow the steps for your preferred install method:\\n3.1. Use the install Linux command with Windows Subsystem for Linux (WSL)\\n3.2. Create a Linux Virtual Machine (VM) in the cloud\\n3.3. Create a Linux Virtual Machine (VM) on your local machine\\n3.4. Create a bootable USB to install bare-metal Linux\\n4. After installing Linux: Get familiar with your distribution's package manager, update and upgrade the packages available, and get familiar with the other [Linux resources at Microsoft](https://learn.microsoft.com/linux/), such as training courses, Linux-versions of popular tools, news, and Open Source events.\"\n", - "Content URL: \"https://learn.microsoft.com/linux/install\"\n", - "Title: \"New to Linux resources for SQL users\"\n", - "\n", - "Content: \"# New to Linux resources for SQL users\\n**Applies to:**![](https://learn.microsoft.com/sql/includes/media/yes-icon.svg?view=sql-server-ver17)[SQL Server](https://learn.microsoft.com/sql/sql-server/sql-docs-navigation-guide?view=sql-server-ver17#applies-to) on Linux\\nThis article provides a learning path through an introduction to Linux concepts. Use the sections in the article as a guided learning path to familiarize yourself with the Linux environment.\\nThis list isn't comprehensive but instead provides the bare minimum you need to help you administer and navigate your SQL Server on Linux environment. See the [full list of tutorials](https://www.linux.org/forums/linux-beginner-tutorials.123/) to learn more.\\n## What is Linux?\\nThe [What is Linux](https://www.linux.org/threads/what-is-linux.4106/) module introduces the history of Linux as an operating system. The module explains *the Kernel* and where Linux is today. This tutorial serves as an introduction to Linux, helping you get started.\\n## Select a distribution\\nAfter you know the history of Linux, decide which [distribution of Linux](https://www.linux.org/threads/selecting-a-linux-distribution.4117/) best suits your business needs.\\nSQL Server 2025 (17.x) is [supported](https://learn.microsoft.com/sql/linux/sql-server-linux-release-notes-2025?view=sql-server-ver17#supported-platforms) on Red Hat Enterprise Linux (RHEL) and Ubuntu.\\nSQL Server 2022 (16.x) and earlier versions are [also supported](https://learn.microsoft.com/sql/linux/sql-server-linux-release-notes-2022?view=sql-server-ver17#supported-platforms) on SUSE Linux Enterprise Server (SLES).\\n## Get around directories\\nAfter you choose the appropriate distribution of Linux, familiarize yourself with [getting around Linux directories](https://www.linux.org/threads/getting-around-in-linux-directories.4120/).\\nThis module helps you answer the following questions:\\n1. How to navigate between different files\\n2. How to identify a file from a directory\\n3. Identify different directories\\n## Install new software\\nNext, learn how to [install new software](https://www.linux.org/threads/installing-new-software-debian-red-hat-slackware.4119/) on your new Linux operating system. This module covers installing new software on the Debian and Red Hat Linux operating systems.\"\n", - "Content URL: \"https://learn.microsoft.com/sql/linux/new-to-linux-resources?view=sql-server-ver17\"\n", - "Title: \"How to download and install Linux\"\n", - "\n", - "Content: \"# How to download and install Linux\\n## Step 3 - Follow install method instructions\\n### Install Linux with Windows Subsystem for Linux\\nTo install Linux on a Windows PC, use the install Linux command.\\n1. Open PowerShell or Windows Command Prompt in *administrator mode* by right-clicking and selecting \\\"Run as administrator\\\".\\n2. Enter the Linux install command: `wsl --install`.\\n3. Restart your machine.\\n![WSL Linux Install List Command screenshot](https://learn.microsoft.com/linux/images/wsl-install-list-screenshot.png)\\n#### Install Linux command options\\nBy default, the install command will use the Ubuntu distribution of Linux. You can see the other distributions that are available in the Microsoft Store by entering the command: `wsl -l -o`. If you'd like to install a distribution other than Ubuntu, just add `-d` to the command with the name of the distribution you'd like to install:\\n```powershell\\nwsl --install -d \\n```\\nIf the distribution you want to install isn't available in the Microsoft Store, see the Windows Subsystem for Linux (WSL) documentation on how to [Import any Linux distribution to use with WSL](https://learn.microsoft.com/en-us/windows/wsl/use-custom-distro).\\nFor more information on how to use VS Code, Git, databases, Docker, GUI apps, GPU acceleration, NodesJS, USB devices, or mount a disk, build a custom distribution, manage disk space, or set up WSL for your enterprise business, see the [WSL documentation](https://learn.microsoft.com/en-us/windows/wsl/).\\n### Create a Linux Virtual Machine in the cloud\\nTo create a Linux Virtual Machine (VM) that is hosted in the cloud on Azure, log-in to the Azure Portal and select the “Create a Virtual Machine” service. Then follow the instructions for creating a user name, project, instance, SSH key, ports, and copying the IP address.\\nAzure supports a variety of options for setting up Linux VMs in the cloud. For a quickstart guide on how to set up a Linux VM, choose your preferred method:\\n1. [Azure Portal](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-portal?tabs=ubuntu&source=docs)\\n2. [Azure CLI](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-cli)\\n3. [Azure PowerShell](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-powershell)\\n4. [Terraform](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-terraform)\"\n", - "Content URL: \"https://learn.microsoft.com/linux/install#step-3---follow-install-method-instructions\"\n", - "Title: \"How to download and install Linux\"\n", - "\n", - "Content: \"# How to download and install Linux\\n## Step 2 - Choose a Linux distribution\\nA large user community that consistently contributes to the distribution, fixing bugs, adding features, helping with questions, etc. is another good consideration. Ubuntu is one of the most popular distos. openSUSE has been around longer than most other distributions and offers in addition to the stable version (Leap) a rolling version (Tumbleweed). Many other distros rapidly grow popular as the needs and preferences of the user community change. The Linux.org site has resources to help you assess what's available, well-supported, or growing in popularity.\\n![Screenshot of the list of distributions on Linux.org](https://learn.microsoft.com/linux/images/linux-distributions-list-screenshot.png)\"\n", - "Content URL: \"https://learn.microsoft.com/linux/install#step-2---choose-a-linux-distribution\"\n", - "Title: \"Part 1.2 - Basic operations on Linux\"\n", - "\n", - "Content: \"# Part 1.2 - Basic operations on Linux\\n## Run commands as a superuser\\nTo become the root account user, run the `sudo su` command.\\n![Screenshot of sudo su command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/sudo-su-command.png)\\nYou should notice two things: When you become root, the dollar sign character (`$`) becomes a pound sign character (`#`). Also, the username is changed to **root**. If you run the same `id` command again, you see that the user and group ID of root are **0**.\\n![Screenshot of root command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/root-command.png)\\nImportant\\nNow you have elevated yourself to the \\\"superuser\\\" role in your session, you have complete access to the system. Keep in mind that this is a potentially dangerous situation, and you should exercise caution as you continue.\\nNow, run the same command one more time:\\n```bash\\necho hello world > /etc/helloworld.txt\\n```\\nThis time, you don't receive any error message. To verify if the file is created, run the following command:\\n```bash\\nll /etc/hello*\\n```\\nThe `ll` command lists the files and subfolders that are in a specified folder. In this example, the `/etc/hello*` parameter value limits the command output to the files or folders whose name begins in `hello`.\\n![Screenshot of hello command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/hello-command.png)\\nThe output shows that the file was created. What if we want to examine the content of the file? The `cat` command helps here. To see the content, run `cat /etc/helloworld.txt`.\\n![Screenshot of cat command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/cat-command.png)\\nThe `cat` command reads files sequentially, and writes their content to the standard output. Therefore, it writes \\\"hello world\\\" in the console.\\nTo prevent mistakes that could damage the system while you're elevated to root status, it's a good practice to exit the root session and return to your standard user session to avoid some dangerous operations. To do this, run `exit`. You can see that the pound sign reverts to a dollar sign, and the username is displayed as your standard user.\\n![Screenshot of exit command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/exit-command.png)\\nFor additional practice, run the same `ll` and `cat` commands within your standard user context. You'll see that you can't create the file in the */etc/* folder, but can you list the file and read the contents.\"\n", - "Content URL: \"https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-as-a-superuser\"\n", - "Title: \"Part 1.2 - Basic operations on Linux\"\n", - "\n", - "Content: \"# Part 1.2 - Basic operations on Linux\\n## Remove packages\\nFinding the file doesn't involve package managers. Instead, you'll run a standard search for files in Linux. There are several ways to do so. One of the most common search commands is `find`. However, this command is also confusing to use, so we won't discuss it here. Instead, we'll use `whereis`.\\nNote\\nLinux is very well documented. Nearly every command has a useful help page available. Use the [man](https://www.geeksforgeeks.org/man-command-in-linux-with-examples/) and [info](https://www.geeksforgeeks.org/info-command-in-linux-with-examples/) commands to show the Help pages in Linux. For example, to learn more on your own about the `find` command, run `man find` or `info find`.\\nAccording to the Help page for `whereis`, the definition is as follows:\\n`whereis` locates the binary, source, and manual files for the specified command names.\\nIf you run `whereis apache2`, you should find the */etc/apache2* installation.\\n![Screenshot of whereis apache2 command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/whereis-apache2-command.png)\\nRemember that the */etc/* directory is where the system configuration files are located. To learn whether \\\"apache2\\\"is a file or folder, run `ll /etc/apache2`. As you can see, this is a folder, and it contains the apache2 configuration files.\\n![Screenshot of ll etc apache2 command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/ll-etc-apache2-command.png)\\nYou expect `apache2` to contain these files and folders because you used the `apt remove` command that doesn't delete the configuration files.\\nAs final step, run the `apt purge` command to see its effect. You can run a purge to clean up the installation folder even after you remove the package. The following screenshot shows that `whereis` didn't find anything. This is because the `purge` command removed the configuration files together with the binaries.\\n![Screenshot of apt purge command.](https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/apt-purge-command.png)\"\n", - "Content URL: \"https://learn.microsoft.com/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#remove-packages\"\n" + "TextContent(text=[{\"title\":\"How to download and install Linux\",\"content\":\"# How to download and install Linux\\n## Step 2 - Choose a Linux distribution\\nThe different versions of Linux are called \\u0022distributions\\u0022 (sometimes shortened to \\u201Cdistros\\u201D). So there isn\\u2019t actually a way to just \\u201Cinstall Linux\\u201D, you first need to choose which distribution that uses the Linux kernel you want to download and install. There are over 600 active Linux distributions, such as Ubuntu, Debian, Kali Linux, openSUSE, and more. There are a number of factors to consider in choosing a distribution, such as:\\n### Required Linux experience\\nIf you\\u2019re new to Linux, you may want to start with Ubuntu. This is one of the most common starting places for Linux users due to the level of support and ease of use. Ubuntu offers a desktop-version and a server-version depending on how you plan to use it. The specific tasks that the operating system will be responsible for may help you determine which distribution to use. Arch Linux is a popular choice for those who want a highly customizable, do-it-yourself approach that is still stable and well-supported by a large user base. It is a much more complicated place to start, but can help you to get a better understanding of how Linux works due to the amount of custom configuration.\\n### System requirements\\nA minimal, or \\u0022lightweight\\u0022, distribution means a small footprint, with lower memory and processing speed requirements. Alpine Linux is a popular option where users will find most features disabled or not installed by default, but still has everything you need for a containerized app. Security needs: Some Linux users may have specific security concerns to consider. Kali Linux is an example of a distro designed for security tasks, such as Penetration Testing, Security Research, Computer Forensics and Reverse Engineering.\\n### Business and Enterprise requirements\\nBusiness, corporate, or academic environments may have unique concerns from consumer environments. For example Red Hat Enterprise Linux (RHEL), SUSE Linux Enterprise Server (SLES) and Oracle Linux offer 24/7 global support and subscription-based service. CentOS is another popular enterprise distro that is community-supported but still compatible with RHEL.\\n### Community support\",\"contentUrl\":\"https://learn.microsoft.com/en-us/linux/install#step-2---choose-a-linux-distribution\"},{\"title\":\"New to Linux resources for SQL users\",\"content\":\"# New to Linux resources for SQL users\\n**Applies to:**[SQL Server](https://learn.microsoft.com/en-us/sql/sql-server/sql-docs-navigation-guide?view=sql-server-ver17#applies-to) - Linux\\nThis article is intended to provide a learning path through an introduction of Linux concepts. Use the sections in the article as a guided learning path to familiarize yourself with the Linux environment.\\nThis isn\\u0027t intended to be a comprehensive list but instead provides the bare minimum you need to help you administer and navigate your SQL Server on Linux environment. See the [full list of tutorials](https://www.linux.org/forums/linux-beginner-tutorials.123/) to learn more.\\n## What is Linux?\\nThe [What is Linux](https://www.linux.org/threads/what-is-linux.4106/) module introduces the history of Linux as an operating system. The module explains *the Kernel* and where Linux is today. This tutorial serves as an introduction to Linux, helping you get started.\\n## Select a distribution\\nAfter you know the history of Linux, decide which [distribution of Linux](https://www.linux.org/threads/selecting-a-linux-distribution.4117/) best suits your business needs. [SQL Server is supported](https://learn.microsoft.com/en-us/sql/linux/sql-server-linux-release-notes-2019?view=sql-server-ver17#supported-platforms) on various distributions such as Red Hat Enterprise Linux (RHEL), SUSE Linux Enterprise Server (SLES), and Ubuntu.\\n## Get around directories\\nAfter you have chosen the appropriate distribution of Linux, familiarize yourself with [getting around Linux directories](https://www.linux.org/threads/getting-around-in-linux-directories.4120/).\\nThis module will help you answer the following questions:\\n1. How to navigate between different files\\n2. How to identify a file from a directory\\n3. Identify different directories\\n## Install new software\\nNext, learn how to [install new software](https://www.linux.org/threads/installing-new-software-debian-red-hat-slackware.4119/) on your new Linux operating system. This module details installing new software to the Debian and Red Hat Linux operating systems.\",\"contentUrl\":\"https://learn.microsoft.com/en-us/sql/linux/new-to-linux-resources?view=sql-server-ver17\"},{\"title\":\"Part 1.2 - Basic operations on Linux\",\"content\":\"# Part 1.2 - Basic operations on Linux\\n## Run commands by using the \\u0022sudo\\u0022 prefix\\nInstead of becoming the root account user in your session, you can run commands as root by using the \\u0060sudo \\u003Ccommand\\u003E\\u0060 format. This approach is safer because it will run the given command only as a superuser.\\nFor example, if you run \\u0060restart\\u0060 while you\\u0027re not elevated to superuser status, the result is as follows.\\n![Screenshot of restart command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/restart-command.png)\\nTo run this command as a superuser instead, you can either change the session context to root (by running \\u0060sudo su\\u0060) or add the \\u0060sudo\\u0060 prefix, as follows:\\n\\u0060\\u0060\\u0060bash\\nsudo reboot\\n\\u0060\\u0060\\u0060\\nNow, the restart operation runs as expected by the virtual machine.\\n![Screenshot of reboot command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/reboot-command.png)\\n## Package managers\\nPackage managers are used to install, upgrade, and remove applications in Linux. There are several different package managers, such as DPKG (Debian Package Management System), RPM (Red Hat Package Manager), and Zypper for SUSE Package managers themselves are low-level. Therefore, it might be difficult to manage software directly by using the package manager. Fortunately, there are wrappers for these tools that make it easier to install and uninstall software. For example, [yum](https://wikipedia.org/wiki/Yum_%28software%29) is a wrapper around RPM (Red Hat Package Manager) and [APT](https://wikipedia.org/wiki/APT_%28software%29) is a wrapper around DPKG (Debian Package Management System).\\nThis tutorial assumes that you\\u0027re running the [Ubuntu package management system](https://ubuntu.com/server/docs/package-management). This system is derived from the same system that\\u0027s used by the Debian GNU/Linux distribution. Therefore, we can use APT to install the software.\\nDepending on your Linux choice, you might have to use other tools. For example, you can use yum to install or uninstall software if you\\u0027re running Red Hat.\",\"contentUrl\":\"https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-by-using-the-sudo-prefix\"},{\"title\":\"Part 1.2 - Basic operations on Linux\",\"content\":\"# Part 1.2 - Basic operations on Linux\\n## Run commands as a superuser\\nThe superuser is the most privileged user account. It has root (unrestricted) access to all files and folders, and complete control over the operation of the computer. The system administrator uses this account for system maintenance.\\nOn UNIX-like systems, the conventional name of the superuser is \\u0022root\\u0022. The root user can do tasks that are otherwise restricted for standard users, including the following ones:\\n1. Changing system directories\\n2. Changing user privileges\\n3. Changing file ownership\\n4. System-related tasks such as restarts\\n5. Installation of certain applications\\nThere are several ways to run commands as the root account user. One is to use the \\u0060sudo su\\u0060 command. In this command, \\u0060sudo\\u0060 is short for \\u0060superuser do\\u0060, and \\u0060su\\u0060 is an abbreviation for \\u0022switch user.\\u0022 You can also use the \\u0060su\\u0060 command to switch between standard users.\\nImportant\\nAfter you become the root account user, everything you do runs in root context. Therefore, you should act carefully because the root account has access to the entire system.\\nTo demonstrate a permissions issue, create a text file in a special folder that\\u0027s named */etc*.\\nThe */etc/* folder is where system configuration files are located. This folder protects its contents from being changed by a standard user.\\nRun the following command:\\n\\u0060\\u0060\\u0060bash\\necho hello world \\u003E /etc/helloworld.txt\\n\\u0060\\u0060\\u0060\\nThe \\u0060echo\\u0060 command writes all the text that follows it to the output. The angle bracket (\\u0060\\u003E\\u0060) tells the system to send the output to the */etc/helloworld.txt* file instead of the console. This behavior is similar to how Windows works.\\nNote\\nLinux is case-sensitive: *Helloworld.txt*, *helloworld.txt* and *helloworld.Txt* are different files.\\nYou don\\u0027t have to provide a file name extension. The name \\u0022helloworld\\u0022 by itself is perfectly valid. (The \\u0022txt\\u0022 extension is used here only as an example.)\\nWhen you run this command, you receive a **permission denied** error message.\\n![Screenshot of permission denied message.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/permission-denied-message.png)\\nThe operation fails because the */etc* folder is special folder that a standard user can\\u0027t change. Verify the user by running the \\u0060id\\u0060 command.\\n![Screenshot of I D command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/id-command.png)\",\"contentUrl\":\"https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-as-a-superuser\"},{\"title\":\"How to download and install Linux\",\"content\":\"# How to download and install Linux\\n## Step 1 - Choose a method to install Linux\\nThe choice of which method to use for installing Linux depends on your needs and preferences.\\n1. **New to using Linux?** We recommend starting with Windows Subsystem for Linux (WSL) as it\\u0027s the easiest way to get up and running.\\n2. **Work in a business environment with more complex needs related to scale or security?** We recommend running Linux as a Virtual Machine (VM) in the cloud and checking out the support that Azure has to offer. This also applies if you want to run Linux as a server.\\n3. **Only want to run Linux as your primary operating system?** If you are good with a slightly more complicated install process and don\\u0027t need access to Windows tools (like Outlook, Teams, Word, PowerPoint, etc), you can run Linux on bare metal to access the full potential of your hardware without any overhead from virtualization or emulation.\\nLearn more about these options below.\\n### Windows Subsystem for Linux (WSL)\\nAvailable to those with a Windows machine, this is the most simple way to install Linux. Just run the Linux install command: \\u0060wsl --install\\u0060 to install the Ubuntu distribution.\\nThis method uses virtualization to integrate the Windows operating system (OS) with the Linux OS (running on an actual Linux kernel). You can add as many different Linux distributions as you please and run your favorite Linux tools, including GUI apps, alongside your favorite Windows tools. You can also mix and match Bash and PowerShell commands in the same command line. A Linux file system hosts your project files, while a separate Windows file systems hosts any developer tools, such as VS Code, that you\\u0027d like to work on the files with. This method of running Linux is highly efficient and performant. Once you choose a Linux distribution or opt to use the default Ubuntu distro, follow the instructions to Use the install Linux command with Windows Subsystem for Linux or learn more in the [WSL documentation](https://learn.microsoft.com/en-us/windows/wsl).\\n![WSL install command screenshot](https://learn.microsoft.com/en-us/linux/images/wsl-install-command-screenshot.png)\\n### Create a Virtual Machine (VM)\\nAvailable to everyone regardless if you\\u0027re using a Windows or MacOS-based machine, but will require either an Azure account, if creating a VM in the cloud, or virtualization software, if creating a VM directly on your machine (locally).\",\"contentUrl\":\"https://learn.microsoft.com/en-us/linux/install#step-1---choose-a-method-to-install-linux\"},{\"title\":\"How to download and install Linux\",\"content\":\"# How to download and install Linux\\nLinux is an operating system, similar to Windows, but with many different versions due to the nature of being open source and fully customizable. To install Linux, you must choose an install method and choose a Linux distribution.\\nTo install Linux:\\n1. Choose an install method: Windows Subsystem for Linux (WSL), Bare metal Linux; or create a Virtual Machine (VM) to run Linux locally or in the cloud.\\n2. Choose a Linux distribution: Ubuntu, Debian, Kali Linux, openSUSE, etc.\\n3. Follow the steps for your preferred install method:\\n3.1. Use the install Linux command with Windows Subsystem for Linux (WSL)\\n3.2. Create a Linux Virtual Machine (VM) in the cloud\\n3.3. Create a Linux Virtual Machine (VM) on your local machine\\n3.4. Create a bootable USB to install bare-metal Linux\\n4. After installing Linux: Get familiar with your distribution\\u0027s package manager, update and upgrade the packages available, and get familiar with the other [Linux resources at Microsoft](https://learn.microsoft.com/en-us/linux/), such as training courses, Linux-versions of popular tools, news, and Open Source events.\",\"contentUrl\":\"https://learn.microsoft.com/en-us/linux/install\"},{\"title\":\"Guide for changing your dev environment from Mac to Windows\",\"content\":\"# Guide for changing your dev environment from Mac to Windows\\n## Command-line shells and terminals\\nYou can now install Windows Subsystem for Linux (WSL) to support running a Linux shell within Windows. This means that you can run **bash**, with whichever specific Linux distribution you choose, integrated right inside Windows. Using WSL provides the kind of environment most familiar to Mac users. For example, you use **ls** to list the files in a current directory, not **dir** as you would with the traditional Windows Cmd Shell. To learn about installing and using WSL, see the [Windows Subsystem for Linux Installation Guide](https://learn.microsoft.com/en-us/windows/wsl/install). Linux distributions that you can install on Windows with WSL include:\\n1. [Ubuntu 20.04 LTS](https://www.microsoft.com/store/apps/9n6svws3rx71)\\n2. [Kali Linux](https://www.microsoft.com/store/apps/9PKR34TNCV07)\\n3. [Debian GNU/Linux](https://www.microsoft.com/store/apps/9MSVKQC78PK6)\\n4. [openSUSE Leap 15.1](https://www.microsoft.com/store/apps/9NJFZK00FGKV)\\n5. [SUSE Linux Enterprise Server 15 SP1](https://www.microsoft.com/store/apps/9PN498VPMF3Z)\\nJust to name a few. Find more in the [WSL install docs](https://learn.microsoft.com/en-us/windows/wsl/install-win10#install-your-linux-distribution-of-choice) and install them directly from the [Microsoft Store](https://aka.ms/wslstore).\",\"contentUrl\":\"https://learn.microsoft.com/en-us/windows/dev-environment/mac-to-windows#command-line-shells-and-terminals\"},{\"title\":\"How to download and install Linux\",\"content\":\"# How to download and install Linux\\n## Step 3 - Follow install method instructions\\n### Install Linux with Windows Subsystem for Linux\\nTo install Linux on a Windows PC, use the install Linux command.\\n1. Open PowerShell or Windows Command Prompt in *administrator mode* by right-clicking and selecting \\u0022Run as administrator\\u0022.\\n2. Enter the Linux install command: \\u0060wsl --install\\u0060.\\n3. Restart your machine.\\n![WSL Linux Install List Command screenshot](https://learn.microsoft.com/en-us/linux/images/wsl-install-list-screenshot.png)\\n#### Install Linux command options\\nBy default, the install command will use the Ubuntu distribution of Linux. You can see the other distributions that are available in the Microsoft Store by entering the command: \\u0060wsl -l -o\\u0060. If you\\u0027d like to install a distribution other than Ubuntu, just add \\u0060-d\\u0060 to the command with the name of the distribution you\\u0027d like to install:\\n\\u0060\\u0060\\u0060powershell\\nwsl --install -d \\u003CDistro Name\\u003E\\n\\u0060\\u0060\\u0060\\nIf the distribution you want to install isn\\u0027t available in the Microsoft Store, see the Windows Subsystem for Linux (WSL) documentation on how to [Import any Linux distribution to use with WSL](https://learn.microsoft.com/en-us/windows/wsl/use-custom-distro).\\nFor more information on how to use VS Code, Git, databases, Docker, GUI apps, GPU acceleration, NodesJS, USB devices, or mount a disk, build a custom distribution, manage disk space, or set up WSL for your enterprise business, see the [WSL documentation](https://learn.microsoft.com/en-us/windows/wsl/).\\n### Create a Linux Virtual Machine in the cloud\\nTo create a Linux Virtual Machine (VM) that is hosted in the cloud on Azure, log-in to the Azure Portal and select the \\u201CCreate a Virtual Machine\\u201D service. Then follow the instructions for creating a user name, project, instance, SSH key, ports, and copying the IP address.\\nAzure supports a variety of options for setting up Linux VMs in the cloud. For a quickstart guide on how to set up a Linux VM, choose your preferred method:\\n1. [Azure Portal](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-portal?tabs=ubuntu\\u0026source=docs)\\n2. [Azure CLI](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-cli)\\n3. [Azure PowerShell](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-powershell)\\n4. [Terraform](https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-terraform)\",\"contentUrl\":\"https://learn.microsoft.com/en-us/linux/install#step-3---follow-install-method-instructions\"},{\"title\":\"How to download and install Linux\",\"content\":\"# How to download and install Linux\\n## Step 2 - Choose a Linux distribution\\nA large user community that consistently contributes to the distribution, fixing bugs, adding features, helping with questions, etc. is another good consideration. Ubuntu is one of the most popular distos. openSUSE has been around longer than most other distributions and offers in addition to the stable version (Leap) a rolling version (Tumbleweed). Many other distros rapidly grow popular as the needs and preferences of the user community change. The Linux.org site has resources to help you assess what\\u0027s available, well-supported, or growing in popularity.\\n![Screenshot of the list of distributions on Linux.org](https://learn.microsoft.com/en-us/linux/images/linux-distributions-list-screenshot.png)\",\"contentUrl\":\"https://learn.microsoft.com/en-us/linux/install#step-2---choose-a-linux-distribution\"},{\"title\":\"Part 1.2 - Basic operations on Linux\",\"content\":\"# Part 1.2 - Basic operations on Linux\\n## Run commands as a superuser\\nTo become the root account user, run the \\u0060sudo su\\u0060 command.\\n![Screenshot of sudo su command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/sudo-su-command.png)\\nYou should notice two things: When you become root, the dollar sign character (\\u0060$\\u0060) becomes a pound sign character (\\u0060#\\u0060). Also, the username is changed to **root**. If you run the same \\u0060id\\u0060 command again, you see that the user and group ID of root are **0**.\\n![Screenshot of root command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/root-command.png)\\nImportant\\nNow you have elevated yourself to the \\u0022superuser\\u0022 role in your session, you have complete access to the system. Keep in mind that this is a potentially dangerous situation, and you should exercise caution as you continue.\\nNow, run the same command one more time:\\n\\u0060\\u0060\\u0060bash\\necho hello world \\u003E /etc/helloworld.txt\\n\\u0060\\u0060\\u0060\\nThis time, you don\\u0027t receive any error message. To verify if the file is created, run the following command:\\n\\u0060\\u0060\\u0060bash\\nll /etc/hello*\\n\\u0060\\u0060\\u0060\\nThe \\u0060ll\\u0060 command lists the files and subfolders that are in a specified folder. In this example, the \\u0060/etc/hello*\\u0060 parameter value limits the command output to the files or folders whose name begins in \\u0060hello\\u0060.\\n![Screenshot of hello command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/hello-command.png)\\nThe output shows that the file was created. What if we want to examine the content of the file? The \\u0060cat\\u0060 command helps here. To see the content, run \\u0060cat /etc/helloworld.txt\\u0060.\\n![Screenshot of cat command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/cat-command.png)\\nThe \\u0060cat\\u0060 command reads files sequentially, and writes their content to the standard output. Therefore, it writes \\u0022hello world\\u0022 in the console.\\nTo prevent mistakes that could damage the system while you\\u0027re elevated to root status, it\\u0027s a good practice to exit the root session and return to your standard user session to avoid some dangerous operations. To do this, run \\u0060exit\\u0060. You can see that the pound sign reverts to a dollar sign, and the username is displayed as your standard user.\\n![Screenshot of exit command.](https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/media/1-2-linux-special-directories-users-package-managers/exit-command.png)\\nFor additional practice, run the same \\u0060ll\\u0060 and \\u0060cat\\u0060 commands within your standard user context. You\\u0027ll see that you can\\u0027t create the file in the */etc/* folder, but can you list the file and read the contents.\",\"contentUrl\":\"https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/aspnetcore/practice-troubleshoot-linux/1-2-linux-special-directories-users-package-managers#run-commands-as-a-superuser\"}], annotations=null, meta=null)\n" ] } ], - "execution_count": 14 + "execution_count": 16 } ], "metadata": { diff --git a/samples/notebooks/README.md b/samples/notebooks/README.md deleted file mode 100644 index 8db4f3b..0000000 --- a/samples/notebooks/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# MCP Client Notebook - -An interactive Kotlin notebook that demonstrates connecting to a remote MCP server and calling tools. - -## Overview - -This notebook walks through building an MCP client step by step: creating a Ktor HTTP client, -initializing an MCP `Client`, connecting via `StreamableHttpClientTransport`, and interacting with -the server (ping, list tools, call tools). It connects to the public -[Microsoft Learn MCP Server](https://learn.microsoft.com/api/mcp) as an example. - -## Prerequisites - -- [Kotlin Jupyter kernel](https://github.com/Kotlin/kotlin-jupyter) **or** IntelliJ IDEA with the - [Kotlin Notebook plugin](https://plugins.jetbrains.com/plugin/16340-kotlin-notebook) -- Internet access (the notebook connects to an external MCP server) - -## How to Run - -1. Open `McpClient.ipynb` in IntelliJ IDEA (with Kotlin Notebook plugin) or in Jupyter with the - Kotlin kernel installed. -2. Run cells sequentially from top to bottom. - -## What It Demonstrates - -- Adding MCP SDK dependencies in a notebook environment -- Creating a Ktor `HttpClient` with SSE and logging plugins -- Creating an MCP `Client` and connecting via `StreamableHttpClientTransport` -- Sending a `ping` request -- Listing available tools from the remote server -- Calling a tool (`microsoft_docs_search`) and displaying results \ No newline at end of file diff --git a/samples/simple-streamable-server/README.md b/samples/simple-streamable-server/README.md deleted file mode 100644 index 27856d7..0000000 --- a/samples/simple-streamable-server/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# Simple Streamable HTTP Server - -A minimal MCP server using the recommended Streamable HTTP transport with optional Bearer token authentication. - -## Overview - -This sample demonstrates a Streamable HTTP MCP server built with Ktor. It exposes tools, a prompt -template, and a resource over HTTP, and optionally supports Bearer token authentication. The -`multi-greet` tool showcases server-to-client logging notifications with streaming delays. - -## Prerequisites - -- JDK 17+ - -## Build & Run - -### Without authentication - -```shell -./gradlew run -``` - -The server starts on `http://localhost:3001/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:3001/mcp`. - -Pass a port number as an argument to change it: - -```shell -./gradlew run --args="8080" -``` - -### With authentication - -```shell -MCP_AUTH_TOKEN=my-secret ./gradlew run --args="--auth" -``` - -When `--auth` is passed, clients must include an `Authorization: Bearer ` header. -The token is read from the `MCP_AUTH_TOKEN` environment variable (required when `--auth` is used). - -### Authentication caveats - -This sample is intended **for demonstration only**. In production you should: - -- Use a proper identity provider instead of a static token. -- Restrict CORS origins to your deployment domain — the sample allows all origins. -- Serve the endpoint over HTTPS. - -## MCP Capabilities - -### Tools - -| Name | Description | -|------|-------------| -| `greet` | Returns a greeting for a given `name`. | -| `multi-greet` | Sends logging notifications between delayed greetings, demonstrating streaming. | - -### Prompts - -| Name | Description | -|------|-------------| -| `greeting-template` | Generates a friendly greeting message for a given `name`. | - -### Resources - -| Name | URI | Description | -|------|-----|-------------| -| `Default Greeting` | `https://example.com/greetings/default` | Returns a static "Hello, world!" text. | \ No newline at end of file diff --git a/samples/simple-streamable-server/build.gradle.kts b/samples/simple-streamable-server/build.gradle.kts deleted file mode 100644 index 0296429..0000000 --- a/samples/simple-streamable-server/build.gradle.kts +++ /dev/null @@ -1,38 +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("io.modelcontextprotocol.sample.server.MainKt") -} - -dependencies { - implementation(dependencies.platform(libs.ktor.bom)) - implementation(libs.mcp.kotlin.server) - implementation(libs.ktor.server.netty) - implementation(libs.ktor.server.auth) - implementation(libs.ktor.server.cors) - implementation(libs.ktor.server.content.negotiation) - implementation(libs.ktor.server.sse) - implementation(libs.ktor.serialization.kotlinx.json) - implementation(libs.slf4j.simple) - - testImplementation(libs.mcp.kotlin.client) - testImplementation(libs.ktor.client.cio) - testImplementation(libs.junit.jupiter) - testImplementation(kotlin("test")) -} - -tasks.test { - useJUnitPlatform() -} - -kotlin { - jvmToolchain(17) -} diff --git a/samples/simple-streamable-server/gradle.properties b/samples/simple-streamable-server/gradle.properties deleted file mode 100644 index 32cead6..0000000 --- a/samples/simple-streamable-server/gradle.properties +++ /dev/null @@ -1,5 +0,0 @@ -org.gradle.configuration-cache=true -org.gradle.parallel=true -org.gradle.caching=true - -#mcp.kotlin.overrideVersion=0.0.1-SNAPSHOT diff --git a/samples/simple-streamable-server/gradle/libs.versions.toml b/samples/simple-streamable-server/gradle/libs.versions.toml deleted file mode 100644 index 5f35f74..0000000 --- a/samples/simple-streamable-server/gradle/libs.versions.toml +++ /dev/null @@ -1,26 +0,0 @@ -[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" - -[libraries] -ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } -ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty" } -ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors" } -ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation" } -ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json" } -ktor-server-auth = { group = "io.ktor", name = "ktor-server-auth" } -ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse" } -mcp-kotlin-client = { group = "io.modelcontextprotocol", name = "kotlin-sdk-client", version.ref = "mcp-kotlin" } -mcp-kotlin-server = { group = "io.modelcontextprotocol", name = "kotlin-sdk-server", version.ref = "mcp-kotlin" } -ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio" } -junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit" } -slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } - -[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/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index eddabd2..0000000 Binary files a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index ad7845b..0000000 --- a/samples/simple-streamable-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/simple-streamable-server/gradlew b/samples/simple-streamable-server/gradlew deleted file mode 100755 index 249efbb..0000000 --- a/samples/simple-streamable-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/simple-streamable-server/gradlew.bat b/samples/simple-streamable-server/gradlew.bat deleted file mode 100644 index 8508ef6..0000000 --- a/samples/simple-streamable-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/simple-streamable-server/settings.gradle.kts b/samples/simple-streamable-server/settings.gradle.kts deleted file mode 100644 index 3c36808..0000000 --- a/samples/simple-streamable-server/settings.gradle.kts +++ /dev/null @@ -1,25 +0,0 @@ -rootProject.name = "simple-streamable-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/simple-streamable-server/src/main/kotlin/io/modelcontextprotocol/sample/server/main.kt b/samples/simple-streamable-server/src/main/kotlin/io/modelcontextprotocol/sample/server/main.kt deleted file mode 100644 index 161edc7..0000000 --- a/samples/simple-streamable-server/src/main/kotlin/io/modelcontextprotocol/sample/server/main.kt +++ /dev/null @@ -1,28 +0,0 @@ -package io.modelcontextprotocol.sample.server - -import io.ktor.server.engine.embeddedServer -import io.ktor.server.netty.Netty - -fun main(vararg args: String) { - val authEnabled = args.any { it == "--auth" } - val port = args.firstOrNull { it != "--auth" }?.toIntOrNull() ?: 3001 - val authToken = if (authEnabled) { - val envToken = System.getenv("MCP_AUTH_TOKEN") - requireNotNull(envToken) { - "MCP_AUTH_TOKEN environment variable must be set when using --auth" - } - envToken - } else { - null - } - - println("Starting MCP Streamable HTTP server on port $port") - println("Use MCP inspector to connect to http://localhost:$port/mcp") - if (authToken != null) { - println("Bearer auth enabled (token sourced from MCP_AUTH_TOKEN)") - } - - embeddedServer(Netty, host = "127.0.0.1", port = port) { - configureServer(authToken) - }.start(wait = true) -} diff --git a/samples/simple-streamable-server/src/main/kotlin/io/modelcontextprotocol/sample/server/server.kt b/samples/simple-streamable-server/src/main/kotlin/io/modelcontextprotocol/sample/server/server.kt deleted file mode 100644 index cdffdae..0000000 --- a/samples/simple-streamable-server/src/main/kotlin/io/modelcontextprotocol/sample/server/server.kt +++ /dev/null @@ -1,297 +0,0 @@ -package io.modelcontextprotocol.sample.server - -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.serialization.kotlinx.json.json -import io.ktor.server.application.Application -import io.ktor.server.application.ApplicationCall -import io.ktor.server.application.install -import io.ktor.server.auth.Authentication -import io.ktor.server.auth.authenticate -import io.ktor.server.auth.bearer -import io.ktor.server.plugins.contentnegotiation.ContentNegotiation -import io.ktor.server.plugins.cors.routing.CORS -import io.ktor.server.request.header -import io.ktor.server.response.respond -import io.ktor.server.routing.delete -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.sse.sse -import io.ktor.util.collections.ConcurrentMap -import io.modelcontextprotocol.kotlin.sdk.server.Server -import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions -import io.modelcontextprotocol.kotlin.sdk.server.StreamableHttpServerTransport -import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp -import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel -import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification -import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams -import io.modelcontextprotocol.kotlin.sdk.types.McpJson -import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument -import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage -import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult -import io.modelcontextprotocol.kotlin.sdk.types.Role -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents -import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations -import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema -import kotlinx.coroutines.delay -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonObject -import kotlin.time.Duration.Companion.milliseconds - -private const val MCP_SESSION_ID_HEADER = "mcp-session-id" - -fun Application.configureServer(authToken: String? = null) { - installCors(authEnabled = authToken != null) - install(ContentNegotiation) { - json(McpJson) - } - - if (authToken == null) { - mcpStreamableHttp { - createMcpServer() - } - } else { - configureAuthenticatedMcp(authToken) - } -} - -private fun Application.configureAuthenticatedMcp(authToken: String) { - install(SSE) - install(Authentication) { - bearer("mcp-bearer") { - authenticate { credential -> - if (credential.token == authToken) { - io.ktor.server.auth.UserIdPrincipal("mcp-client") - } else { - null - } - } - } - } - - val transports = ConcurrentMap() - - routing { - authenticate("mcp-bearer") { - route("/mcp") { - sse { - val transport = findTransport(call, transports) ?: return@sse - transport.handleRequest(this, call) - } - - post { - val transport = getOrCreateTransport(call, transports) ?: return@post - transport.handleRequest(null, call) - } - - delete { - val transport = findTransport(call, transports) ?: return@delete - transport.handleRequest(null, call) - } - } - } - } -} - -private suspend fun findTransport( - call: ApplicationCall, - transports: ConcurrentMap, -): StreamableHttpServerTransport? { - val sessionId = call.request.header(MCP_SESSION_ID_HEADER) - if (sessionId.isNullOrEmpty()) { - call.respond(HttpStatusCode.BadRequest, "Bad Request: No valid session ID provided") - return null - } - val transport = transports[sessionId] - if (transport == null) { - call.respond(HttpStatusCode.NotFound, "Session not found") - return null - } - return transport -} - -private suspend fun getOrCreateTransport( - call: ApplicationCall, - transports: ConcurrentMap, -): StreamableHttpServerTransport? { - val sessionId = call.request.header(MCP_SESSION_ID_HEADER) - if (sessionId != null) { - val transport = transports[sessionId] - if (transport == null) { - call.respond(HttpStatusCode.NotFound, "Session not found") - } - return transport - } - - val configuration = StreamableHttpServerTransport.Configuration( - enableJsonResponse = true, - ) - val transport = StreamableHttpServerTransport(configuration) - - transport.setOnSessionInitialized { initializedSessionId -> - transports[initializedSessionId] = transport - } - transport.setOnSessionClosed { closedSessionId -> - transports.remove(closedSessionId) - } - - val server = createMcpServer() - server.onClose { - transport.sessionId?.let { transports.remove(it) } - } - server.createSession(transport) - - return transport -} - -private fun Application.installCors(authEnabled: Boolean = false) { - install(CORS) { - anyHost() // Don't do this in production if possible. Try to limit it. - allowMethod(HttpMethod.Options) - allowMethod(HttpMethod.Get) - allowMethod(HttpMethod.Post) - allowMethod(HttpMethod.Delete) - allowNonSimpleContentTypes = true - allowHeader("Mcp-Session-Id") - allowHeader("Mcp-Protocol-Version") - exposeHeader("Mcp-Session-Id") - exposeHeader("Mcp-Protocol-Version") - if (authEnabled) { - allowHeader(HttpHeaders.Authorization) - } - } -} - -private fun createMcpServer(): Server { - val server = Server( - Implementation( - name = "simple-streamable-http-server", - version = "1.0.0", - ), - ServerOptions( - capabilities = ServerCapabilities( - prompts = ServerCapabilities.Prompts(listChanged = true), - resources = ServerCapabilities.Resources(subscribe = true, listChanged = true), - tools = ServerCapabilities.Tools(listChanged = true), - logging = ServerCapabilities.Logging, - ), - ), - ) - - // Tool: greet - server.addTool( - name = "greet", - description = "A simple greeting tool", - inputSchema = ToolSchema( - properties = buildJsonObject { - putJsonObject("name") { - put("type", "string") - put("description", "Name to greet") - } - }, - required = listOf("name"), - ), - ) { request -> - val name = request.arguments?.get("name")?.jsonPrimitive?.content ?: "World" - CallToolResult(content = listOf(TextContent("Hello, $name!"))) - } - - // Tool: multi-greet (demonstrates logging notifications) - server.addTool( - name = "multi-greet", - description = "A tool that sends different greetings with delays between them", - inputSchema = ToolSchema( - properties = buildJsonObject { - putJsonObject("name") { - put("type", "string") - put("description", "Name to greet") - } - }, - required = listOf("name"), - ), - toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = false), - ) { request -> - val name = request.arguments?.get("name")?.jsonPrimitive?.content ?: "World" - - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = LoggingLevel.Debug, - data = JsonPrimitive("Starting multi-greet for $name") - ) - ) - ) - delay(1000.milliseconds) - - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = LoggingLevel.Info, - data = JsonPrimitive("Sending first greeting to $name") - ) - ) - ) - delay(1000.milliseconds) - - sendLoggingMessage( - LoggingMessageNotification( - LoggingMessageNotificationParams( - level = LoggingLevel.Info, - data = JsonPrimitive("Sending second greeting to $name") - ) - ) - ) - - CallToolResult(content = listOf(TextContent("Good morning, $name!"))) - } - - // Prompt: greeting-template - server.addPrompt( - name = "greeting-template", - description = "A simple greeting prompt template", - arguments = listOf( - PromptArgument( - name = "name", - description = "Name to include in greeting", - required = true, - ), - ), - ) { request -> - val name = request.arguments?.get("name") ?: "World" - GetPromptResult( - messages = listOf( - PromptMessage( - role = Role.User, - content = TextContent("Please greet $name in a friendly manner."), - ), - ), - ) - } - - // Resource: greeting-resource - server.addResource( - uri = "https://example.com/greetings/default", - name = "Default Greeting", - description = "A simple greeting resource", - mimeType = "text/plain", - ) { request -> - ReadResourceResult( - contents = listOf( - TextResourceContents("Hello, world!", request.uri, "text/plain"), - ), - ) - } - - return server -} diff --git a/samples/simple-streamable-server/src/test/kotlin/io/modelcontextprotocol/sample/server/ServerIntegrationTest.kt b/samples/simple-streamable-server/src/test/kotlin/io/modelcontextprotocol/sample/server/ServerIntegrationTest.kt deleted file mode 100644 index c4dae46..0000000 --- a/samples/simple-streamable-server/src/test/kotlin/io/modelcontextprotocol/sample/server/ServerIntegrationTest.kt +++ /dev/null @@ -1,146 +0,0 @@ -package io.modelcontextprotocol.sample.server - -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.sse.SSE -import io.ktor.server.engine.embeddedServer -import io.ktor.server.netty.Netty -import io.modelcontextprotocol.kotlin.sdk.client.Client -import io.modelcontextprotocol.kotlin.sdk.client.mcpStreamableHttp -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest -import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest -import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.Role -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class ServerIntegrationTest { - - private val server = embeddedServer(Netty, host = "127.0.0.1", port = 0) { - configureServer() - } - - private val httpClient = HttpClient(CIO) { - install(SSE) - } - - private lateinit var mcpClient: Client - - @BeforeAll - suspend fun beforeAll() { - server.start(wait = false) - val port = server.engine.resolvedConnectors().single().port - - mcpClient = httpClient.mcpStreamableHttp( - url = "http://127.0.0.1:$port/mcp", - ) - } - - @AfterAll - suspend fun afterAll() { - mcpClient.close() - httpClient.close() - server.stop(500, 1000) - } - - @Test - fun `should negotiate server capabilities`() { - val capabilities = mcpClient.serverCapabilities - assertNotNull(capabilities) - assertNotNull(capabilities.tools) - assertNotNull(capabilities.prompts) - assertNotNull(capabilities.resources) - assertNotNull(capabilities.logging) - } - - @Test - fun `should report server version`() { - val version = mcpClient.serverVersion - assertNotNull(version) - assertEquals("simple-streamable-http-server", version.name) - assertEquals("1.0.0", version.version) - } - - @Test - suspend fun `should list tools`() { - val result = mcpClient.listTools() - val toolNames = result.tools.map { it.name }.sorted() - assertEquals(listOf("greet", "multi-greet"), toolNames) - } - - @Test - suspend fun `should call greet tool`() { - val result = mcpClient.callTool(name = "greet", arguments = mapOf("name" to "Alice")) - val content = result.content.single() - assertIs(content) - assertEquals("Hello, Alice!", content.text) - } - - @Test - suspend fun `should call greet tool with default name`() { - val result = mcpClient.callTool(name = "greet", arguments = emptyMap()) - val content = result.content.single() - assertIs(content) - assertEquals("Hello, World!", content.text) - } - - @Test - suspend fun `should call multi-greet tool with logging notifications`() { - val result = mcpClient.callTool(name = "multi-greet", arguments = mapOf("name" to "Bob")) - val content = result.content.single() - assertIs(content) - assertEquals("Good morning, Bob!", content.text) - } - - @Test - suspend fun `should list prompts`() { - val result = mcpClient.listPrompts() - assertEquals(listOf("greeting-template"), result.prompts.map { it.name }) - } - - @Test - suspend fun `should get greeting template prompt`() { - val result = mcpClient.getPrompt( - GetPromptRequest( - GetPromptRequestParams( - name = "greeting-template", - arguments = mapOf("name" to "Charlie"), - ), - ), - ) - val message = result.messages.single() - assertEquals(Role.User, message.role) - val content = message.content - assertIs(content) - assertEquals("Please greet Charlie in a friendly manner.", content.text) - } - - @Test - suspend fun `should list resources`() { - val result = mcpClient.listResources() - assertEquals(listOf("Default Greeting"), result.resources.map { it.name }) - } - - @Test - suspend fun `should read default greeting resource`() { - val result = mcpClient.readResource( - ReadResourceRequest( - ReadResourceRequestParams(uri = "https://example.com/greetings/default"), - ), - ) - val content = result.contents.single() - assertIs(content) - assertEquals("Hello, world!", content.text) - assertEquals("text/plain", content.mimeType) - } -} - diff --git a/samples/simple-streamable-server/src/test/resources/simplelogger.properties b/samples/simple-streamable-server/src/test/resources/simplelogger.properties deleted file mode 100644 index ad2d3de..0000000 --- a/samples/simple-streamable-server/src/test/resources/simplelogger.properties +++ /dev/null @@ -1,6 +0,0 @@ -org.slf4j.simpleLogger.defaultLogLevel=INFO -org.slf4j.simpleLogger.showThreadName=true -org.slf4j.simpleLogger.showDateTime=false - -# Log level for specific packages or classes -org.slf4j.simpleLogger.log.io.modelcontextprotocol.sample.server=DEBUG diff --git a/samples/weather-stdio-server/README.md b/samples/weather-stdio-server/README.md index f377d55..0e8efc0 100644 --- a/samples/weather-stdio-server/README.md +++ b/samples/weather-stdio-server/README.md @@ -1,67 +1,140 @@ -# Weather STDIO Server +# Kotlin MCP Weather STDIO Server -A minimal MCP server that exposes weather forecast and alert tools using the National Weather -Service API over STDIO transport. +This project demonstrates how to build a Model Context Protocol (MCP) server in Kotlin that provides weather-related +tools by consuming the National Weather Service (weather.gov) API. The server uses STDIO as the transport layer and +leverages the Kotlin MCP SDK to expose weather forecast and alert tools. -## Overview - -This sample shows how to build a STDIO-based MCP server in Kotlin. It registers two tools that -query the [weather.gov](https://www.weather.gov/) API — one for weather forecasts by -latitude/longitude and one for active alerts by US state. Because it uses STDIO, the server is -launched as a subprocess by an MCP client or a desktop application like Claude Desktop. +For more information about the MCP SDK and protocol, please refer to +the [MCP documentation](https://modelcontextprotocol.io/introduction). ## Prerequisites -- JDK 17+ -- Internet access (the server calls the weather.gov API at runtime) +- Java 17 or later +- Gradle (or the Gradle wrapper provided with the project) +- Basic understanding of MCP concepts +- Basic understanding of Kotlin and Kotlin ecosystems (sush as kotlinx-serialization, coroutines, ktor) -## Build & Run +## MCP Weather Server -Run the server (it communicates via stdin/stdout): +The project provides: + +- A lightweight MCP server built with Kotlin. +- STDIO transport layer implementation for server-client communication. +- Two weather tools: + - **Weather Forecast Tool** — returns details such as temperature, wind information, and a detailed forecast for a + given latitude/longitude. + - **Weather Alerts Tool** — returns active weather alerts for a given US state. + +## Building and running + +Use the Gradle wrapper to build the application. In a terminal run: ```shell -./gradlew run +./gradlew clean build ``` -### MCP Inspector - -Build the fat JAR first, then connect with the -[MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector): +To run the server: ```shell -./gradlew build -npx @modelcontextprotocol/inspector -- java -jar samples/weather-stdio-server/build/libs/weather-stdio-server-0.1.0-all.jar +java -jar build/libs/weather-stdio-server-0.1.0-all.jar ``` -### Claude Desktop integration +> [!NOTE] +> The server uses STDIO transport, so it is typically launched in an environment where the client connects via standard +> input/output. -Add the following to your Claude Desktop configuration: +## Tool Implementation -```json -{ - "mcpServers": { - "weather": { - "command": "java", - "args": [ - "-jar", - "/absolute/path/to/samples/weather-stdio-server/build/libs/weather-stdio-server-0.1.0-all.jar" - ] - } - } +The project registers two MCP tools using the Kotlin MCP SDK. Below is an overview of the core tool implementations: + +### 1. Weather Forecast Tool + +This tool fetches the weather forecast for a specific latitude and longitude using the `weather.gov` API. + +Example tool registration in Kotlin: + +```kotlin +server.addTool( + name = "get_forecast", + description = """ + Get weather forecast for a specific latitude/longitude + """.trimIndent(), + inputSchema = Tool.Input( + properties = JsonObject( + mapOf( + "latitude" to JsonObject(mapOf("type" to JsonPrimitive("number"))), + "longitude" to JsonObject(mapOf("type" to JsonPrimitive("number"))), + ) + ), + required = listOf("latitude", "longitude") + ) +) { request -> + // Implementation tool } ``` -## MCP Capabilities +### 2. Weather Alerts Tool -### Tools +This tool retrieves active weather alerts for a US state. -| Name | Description | -|----------------|------------------------------------------------------------------------------------------| -| `get_alerts` | Returns active weather alerts for a two-letter US `state` code (e.g. `CA`, `NY`). | -| `get_forecast` | Returns weather forecast for a given `latitude` / `longitude` using the weather.gov API. | +Example tool registration in Kotlin: + +```kotlin +server.addTool( + name = "get_alerts", + description = """ + Get weather alerts for a US state. Input is Two-letter US state code (e.g. CA, NY) + """.trimIndent(), + inputSchema = Tool.Input( + properties = JsonObject( + mapOf( + "state" to JsonObject( + mapOf( + "type" to JsonPrimitive("string"), + "description" to JsonPrimitive("Two-letter US state code (e.g. CA, NY)") + ) + ), + ) + ), + required = listOf("state") + ) +) { request -> + // Implementation tool +} +``` + +## Client Integration + +### Kotlin Client Example + +Since the server uses STDIO for transport, the client typically connects via standard input/output streams. A sample +client implementation can be found in the tests, demonstrating how to send tool requests and process responses. + +### Claude for Desktop + +To integrate with Claude Desktop, add the following configuration to your Claude Desktop settings: + +```json +{ + "mcpServers": { + "weather": { + "command": "java", + "args": [ + "-jar", + "/absolute/path/to/.jar" + ] + } + } +} +``` + +> [!NOTE] +> Replace `/absolute/path/to/.jar` with the actual absolute path to your built jar file. ## Additional Resources -- [MCP Specification](https://modelcontextprotocol.io/specification/latest) +- [MCP Specification](https://spec.modelcontextprotocol.io/) - [Kotlin MCP SDK](https://github.com/modelcontextprotocol/kotlin-sdk) -- [National Weather Service API](https://www.weather.gov/documentation/services-web-api) +- [Ktor Client Documentation](https://ktor.io/docs/welcome.html) +- [Kotlinx Serialization](https://kotlinlang.org/docs/serialization.html) + diff --git a/samples/weather-stdio-server/build.gradle.kts b/samples/weather-stdio-server/build.gradle.kts index d6f5fc2..0420cc0 100644 --- a/samples/weather-stdio-server/build.gradle.kts +++ b/samples/weather-stdio-server/build.gradle.kts @@ -14,12 +14,14 @@ version = "0.1.0" dependencies { implementation(dependencies.platform(libs.ktor.bom)) - implementation(libs.mcp.kotlin.server) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.mcp.kotlin.server) + implementation(libs.ktor.server.cio) implementation(libs.ktor.client.cio) implementation(libs.slf4j.simple) runtimeOnly(libs.kotlin.logging) + runtimeOnly(libs.kotlinx.collections.immutable) testImplementation(kotlin("test")) diff --git a/samples/weather-stdio-server/gradle/libs.versions.toml b/samples/weather-stdio-server/gradle/libs.versions.toml index 9d609a9..6056b94 100644 --- a/samples/weather-stdio-server/gradle/libs.versions.toml +++ b/samples/weather-stdio-server/gradle/libs.versions.toml @@ -1,19 +1,22 @@ [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" +collections-immutable = "0.4.0" +coroutines = "1.10.2" +kotlin = "2.2.21" +ktor = "3.2.3" +logging = "7.0.13" +mcp-kotlin = "0.8.1" +shadow = "9.2.2" +slf4j = "2.0.17" [libraries] kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging-jvm", version.ref = "logging" } +kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable-jvm", version.ref = "collections-immutable" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } 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-jvm" } +ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" } mcp-kotlin-client = { group = "io.modelcontextprotocol", name = "kotlin-sdk-client", version.ref = "mcp-kotlin" } mcp-kotlin-server = { group = "io.modelcontextprotocol", name = "kotlin-sdk-server", version.ref = "mcp-kotlin" } slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } diff --git a/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar b/samples/weather-stdio-server/gradle/wrapper/gradle-wrapper.jar index eddabd2..f8e1ee3 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..bad7c24 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.2.0-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..adff685 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/HEAD/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..c4bdd3a 100644 --- a/samples/weather-stdio-server/gradlew.bat +++ b/samples/weather-stdio-server/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/samples/weather-stdio-server/src/main/kotlin/io/modelcontextprotocol/sample/server/McpWeatherServer.kt b/samples/weather-stdio-server/src/main/kotlin/io/modelcontextprotocol/sample/server/McpWeatherServer.kt index 1dee706..f08c436 100644 --- a/samples/weather-stdio-server/src/main/kotlin/io/modelcontextprotocol/sample/server/McpWeatherServer.kt +++ b/samples/weather-stdio-server/src/main/kotlin/io/modelcontextprotocol/sample/server/McpWeatherServer.kt @@ -1,7 +1,6 @@ package io.modelcontextprotocol.sample.server import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.http.ContentType @@ -16,7 +15,6 @@ import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import kotlinx.coroutines.Job import kotlinx.coroutines.runBlocking @@ -34,59 +32,47 @@ import kotlinx.serialization.json.putJsonObject * weather alerts by state and weather forecasts by latitude/longitude. */ fun runMcpServer() { - createHttpClient().use { httpClient -> - val server = Server( - Implementation( - name = "weather", - version = "1.0.0", - ), - ServerOptions( - capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)), - ), - ) + // Base URL for the Weather API + val baseUrl = "https://api.weather.gov" - server.registerTools(httpClient) - - val transport = StdioServerTransport( - System.`in`.asInput(), - System.out.asSink().buffered(), - ) - - runBlocking { - val session = server.createSession(transport) - val done = Job() - session.onClose { - done.complete() + // Create an HTTP client with a default request configuration and JSON content negotiation + val httpClient = HttpClient { + defaultRequest { + url(baseUrl) + headers { + append("Accept", "application/geo+json") + append("User-Agent", "WeatherApiClient/1.0") } - done.join() + contentType(ContentType.Application.Json) + } + // Install content negotiation plugin for JSON serialization/deserialization + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + prettyPrint = true + }, + ) } } -} -private fun createHttpClient(): HttpClient = HttpClient(CIO) { - defaultRequest { - url("https://api.weather.gov") - headers { - append("Accept", "application/geo+json") - append("User-Agent", "WeatherApiClient/1.0") - } - contentType(ContentType.Application.Json) - } - install(ContentNegotiation) { - json( - Json { - ignoreUnknownKeys = true - prettyPrint = true - }, - ) - } -} + // Create the MCP Server instance with a basic implementation + val server = Server( + Implementation( + name = "weather", // Tool name is "weather" + version = "1.0.0", // Version of the implementation + ), + ServerOptions( + capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)), + ), + ) -private fun Server.registerTools(httpClient: HttpClient) { // Register a tool to fetch weather alerts by state - addTool( + server.addTool( name = "get_alerts", - description = "Get weather alerts for a US state. Input is a two-letter US state code (e.g. CA, NY)", + description = """ + Get weather alerts for a US state. Input is Two-letter US state code (e.g. CA, NY) + """.trimIndent(), inputSchema = ToolSchema( properties = buildJsonObject { putJsonObject("state") { @@ -96,62 +82,33 @@ private fun Server.registerTools(httpClient: HttpClient) { }, required = listOf("state"), ), - toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = true), ) { request -> - val state = request.arguments?.get("state")?.jsonPrimitive?.content - ?: return@addTool CallToolResult( - content = listOf(TextContent("The 'state' parameter is required.")), - ) - - if (state.length != 2) { - return@addTool CallToolResult( - content = listOf(TextContent("Invalid state code: '$state'. Must be a two-letter US state code.")), - isError = true, - ) - } - - val stateCode = state.uppercase() - - httpClient.getAlerts(stateCode).fold( - onSuccess = { alerts -> - if (alerts.isEmpty()) { - CallToolResult(content = listOf(TextContent("No active alerts for $stateCode"))) - } else { - val alertsText = "Active alerts for $stateCode:\n\n${alerts.joinToString("\n---\n")}" - CallToolResult(content = listOf(TextContent(alertsText))) - } - }, - onFailure = { e -> - CallToolResult( - content = listOf(TextContent("Failed to retrieve alerts data: ${e.message}")), - isError = true, - ) - }, + val state = request.arguments?.get("state")?.jsonPrimitive?.content ?: return@addTool CallToolResult( + content = listOf(TextContent("The 'state' parameter is required.")), ) + + val alerts = httpClient.getAlerts(state) + + CallToolResult(content = alerts.map { TextContent(it) }) } // Register a tool to fetch weather forecast by latitude and longitude - addTool( + server.addTool( name = "get_forecast", - description = "Get weather forecast for a location. Note: only US locations are supported by the NWS API.", + description = """ + Get weather forecast for a specific latitude/longitude + """.trimIndent(), inputSchema = ToolSchema( properties = buildJsonObject { putJsonObject("latitude") { put("type", "number") - put("description", "Latitude of the location") - put("minimum", -90) - put("maximum", 90) } putJsonObject("longitude") { put("type", "number") - put("description", "Longitude of the location") - put("minimum", -180) - put("maximum", 180) } }, required = listOf("latitude", "longitude"), ), - toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = true), ) { request -> val latitude = request.arguments?.get("latitude")?.jsonPrimitive?.doubleOrNull val longitude = request.arguments?.get("longitude")?.jsonPrimitive?.doubleOrNull @@ -161,26 +118,23 @@ private fun Server.registerTools(httpClient: HttpClient) { ) } - httpClient.getForecast(latitude, longitude).fold( - onSuccess = { periods -> - if (periods.isEmpty()) { - CallToolResult(content = listOf(TextContent("No forecast periods available"))) - } else { - val forecastText = periods.joinToString("\n---\n") - CallToolResult(content = listOf(TextContent(forecastText))) - } - }, - onFailure = { _ -> - CallToolResult( - content = listOf( - TextContent( - "Failed to retrieve grid point data for coordinates: $latitude, $longitude. " + - "This location may not be supported by the NWS API (only US locations are supported).", - ), - ), - isError = true, - ) - }, - ) + val forecast = httpClient.getForecast(latitude, longitude) + + CallToolResult(content = forecast.map { TextContent(it) }) + } + + // Create a transport using standard IO for server communication + val transport = StdioServerTransport( + System.`in`.asInput(), + System.out.asSink().buffered(), + ) + + runBlocking { + val session = server.createSession(transport) + val done = Job() + session.onClose { + done.complete() + } + done.join() } } diff --git a/samples/weather-stdio-server/src/main/kotlin/io/modelcontextprotocol/sample/server/WeatherApi.kt b/samples/weather-stdio-server/src/main/kotlin/io/modelcontextprotocol/sample/server/WeatherApi.kt index 000ac12..671186c 100644 --- a/samples/weather-stdio-server/src/main/kotlin/io/modelcontextprotocol/sample/server/WeatherApi.kt +++ b/samples/weather-stdio-server/src/main/kotlin/io/modelcontextprotocol/sample/server/WeatherApi.kt @@ -4,90 +4,88 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import kotlinx.serialization.Serializable -import java.util.Locale +import kotlinx.serialization.json.JsonObject + +// Extension function to fetch forecast information for given latitude and longitude +suspend fun HttpClient.getForecast(latitude: Double, longitude: Double): List { + // Build the URI using provided latitude and longitude + val uri = "/points/$latitude,$longitude" + // Request the points data from the API + val points = this.get(uri).body() + + // Request the forecast using the URL provided in the points response + val forecast = this.get(points.properties.forecast).body() + + // Map each forecast period to a formatted string + return forecast.properties.periods.map { period -> + """ + ${period.name}: + Temperature: ${period.temperature} ${period.temperatureUnit} + Wind: ${period.windSpeed} ${period.windDirection} + Forecast: ${period.detailedForecast} + """.trimIndent() + } +} // Extension function to fetch weather alerts for a given state -suspend fun HttpClient.getAlerts(state: String): Result> = runCatching { +suspend fun HttpClient.getAlerts(state: String): List { + // Build the URI using the given state code val uri = "/alerts/active/area/$state" - val alerts = this.get(uri).body() + // Request the alerts data from the API + val alerts = this.get(uri).body() - alerts.features.map { feature -> + // Map each alert feature to a formatted string + return alerts.features.map { feature -> """ Event: ${feature.properties.event} Area: ${feature.properties.areaDesc} Severity: ${feature.properties.severity} - Status: ${feature.properties.status} - Headline: ${feature.properties.headline} + Description: ${feature.properties.description} + Instruction: ${feature.properties.instruction} """.trimIndent() } } -// Extension function to fetch forecast information for given latitude and longitude -suspend fun HttpClient.getForecast(latitude: Double, longitude: Double): Result> = runCatching { - val lat = String.format(Locale.US, "%.4f", latitude) - val lon = String.format(Locale.US, "%.4f", longitude) - val uri = "/points/$lat,$lon" - val points = this.get(uri).body() - - val forecastUrl = points.properties.forecast - ?: error("No forecast URL available for coordinates: $latitude, $longitude") - val forecast = this.get(forecastUrl).body() - - forecast.properties.periods.map { period -> - """ - ${period.name}: - Temperature: ${period.temperature}°${period.temperatureUnit} - Wind: ${period.windSpeed} ${period.windDirection} - ${period.shortForecast} - """.trimIndent() - } +// Data class representing the points response from the API +@Serializable +data class Points( + val properties: Properties +) { + @Serializable + data class Properties(val forecast: String) } +// Data class representing the forecast response from the API @Serializable -data class PointsResponse( - val properties: PointsProperties, -) +data class Forecast( + val properties: Properties +) { + @Serializable + data class Properties(val periods: List) -@Serializable -data class PointsProperties( - val forecast: String? = null, -) + @Serializable + data class Period( + val number: Int, val name: String, val startTime: String, val endTime: String, + val isDaytime: Boolean, val temperature: Int, val temperatureUnit: String, + val temperatureTrend: String, val probabilityOfPrecipitation: JsonObject, + val windSpeed: String, val windDirection: String, + val shortForecast: String, val detailedForecast: String, + ) +} +// Data class representing the alerts response from the API @Serializable -data class ForecastResponse( - val properties: ForecastProperties, -) +data class Alert( + val features: List +) { + @Serializable + data class Feature( + val properties: Properties + ) -@Serializable -data class ForecastProperties( - val periods: List = emptyList(), -) - -@Serializable -data class ForecastPeriod( - val name: String? = null, - val temperature: Int? = null, - val temperatureUnit: String? = null, - val windSpeed: String? = null, - val windDirection: String? = null, - val shortForecast: String? = null, -) - -@Serializable -data class AlertsResponse( - val features: List = emptyList(), -) - -@Serializable -data class AlertFeature( - val properties: AlertProperties, -) - -@Serializable -data class AlertProperties( - val event: String? = null, - val areaDesc: String? = null, - val severity: String? = null, - val status: String? = null, - val headline: String? = null, -) + @Serializable + data class Properties( + val event: String, val areaDesc: String, val severity: String, + val description: String, val instruction: String?, + ) +} diff --git a/samples/weather-stdio-server/src/test/kotlin/io/modelcontextprotocol/sample/client/ClientStdio.kt b/samples/weather-stdio-server/src/test/kotlin/io/modelcontextprotocol/sample/client/ClientStdio.kt index 32fd550..5934ee2 100644 --- a/samples/weather-stdio-server/src/test/kotlin/io/modelcontextprotocol/sample/client/ClientStdio.kt +++ b/samples/weather-stdio-server/src/test/kotlin/io/modelcontextprotocol/sample/client/ClientStdio.kt @@ -13,8 +13,8 @@ fun main(): Unit = runBlocking { val process = ProcessBuilder( "java", "-jar", - "${System.getProperty("user.dir")}/samples/weather-stdio-server/build/libs/weather-stdio-server-0.1.0-all.jar", - ).redirectError(ProcessBuilder.Redirect.INHERIT) + "${System.getProperty("user.dir")}/build/libs/weather-stdio-server-0.1.0-all.jar", + ).redirectErrorStream(true) .start() val transport = StdioClientTransport( @@ -44,7 +44,7 @@ fun main(): Unit = runBlocking { val alertResult = client.callTool( - name = "get_alerts", + name = "get_alert", arguments = mapOf("state" to "TX"), ).content.map { if (it is TextContent) it.text else it.toString() } diff --git a/settings.gradle.kts b/settings.gradle.kts index 60e3d1c..9a646a1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,9 +22,7 @@ include( ":kotlin-sdk-core", ":kotlin-sdk-client", ":kotlin-sdk-server", - ":kotlin-sdk-testing", ":kotlin-sdk", ":integration-test", - ":docs", ":conformance-test", ) diff --git a/test-utils/build.gradle.kts b/test-utils/build.gradle.kts index c6baf2d..346001a 100644 --- a/test-utils/build.gradle.kts +++ b/test-utils/build.gradle.kts @@ -1,55 +1,24 @@ -@file:OptIn(ExperimentalWasmDsl::class) - -import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl - plugins { - id("mcp.multiplatform") - id("mcp.detekt") + kotlin("jvm") } kotlin { + explicitApi() - iosArm64() - iosX64() - iosSimulatorArm64() - watchosArm64() - watchosSimulatorArm64() - tvosArm64() - tvosSimulatorArm64() - js { - browser() - nodejs() - } - wasmJs { - browser() - nodejs() + dependencies { + api(kotlin("test")) + api(libs.kotest.assertions.core) + api(libs.kotest.assertions.json) + api(libs.kotlinx.coroutines.test) + api(libs.ktor.server.core) + api(libs.mockk) + api(libs.awaitility) + api(libs.junit.jupiter.params) + api(libs.kotlin.logging) + runtimeOnly(libs.slf4j.simple) } - sourceSets { - commonMain { - dependencies { - - api(kotlin("test")) - api(libs.kotest.assertions.core) - api(libs.kotest.assertions.json) - api(libs.kotlinx.serialization.json) - api(libs.kotlinx.coroutines.test) - api(libs.ktor.server.core) - api(libs.kotlin.logging) - } - } - - jvmMain { - dependencies { - api(libs.mockk) - api(libs.awaitility) - api(libs.junit.jupiter.params) - runtimeOnly(libs.slf4j.simple) - } - } + tasks.withType { + useJUnitPlatform() } } - -tasks.withType { - useJUnitPlatform() -} 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/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/serializationUtils.kt b/test-utils/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/serializationUtils.kt deleted file mode 100644 index 8f3d33a..0000000 --- a/test-utils/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/serializationUtils.kt +++ /dev/null @@ -1,88 +0,0 @@ -package io.modelcontextprotocol.kotlin.test.utils - -import io.kotest.assertions.json.shouldEqualJson -import io.kotest.assertions.withClue -import io.kotest.matchers.shouldBe -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import org.intellij.lang.annotations.Language -import kotlin.test.fail - -/** - * Verifies the serialization and deserialization process for a given object, ensuring: - * - The object can be serialized to JSON. - * - The serialized JSON matches the expected JSON. - * - The serialized JSON can be deserialized back into the original object. - * - * This is especially useful for round-trip serialization tests. - * - * @param T The type of the object to be verified. - * @param value The object to be serialized and deserialized. - * @param json The JSON serializer/deserializer used for the serialization process. - * @param expectedJson A lambda returning the expected JSON string for comparison purposes. - */ -public inline fun verifySerialization(value: T, json: Json, expectedJson: () -> String) { - verifySerialization(value, json, expectedJson.invoke()) -} - -/** - * Verifies the serialization and deserialization process for a given object, ensuring: - * - The object can be serialized to JSON. - * - The serialized JSON matches the expected JSON. - * - The serialized JSON can be deserialized back into the original object. - * - * This is especially useful for round-trip serialization tests. - * - * @param T The type of the object being tested for serialization and deserialization. - * @param value The object to be serialized and deserialized. - * @param json The JSON serialization/deserialization processor. - * @param expectedJson The expected JSON string to compare against the serialized output. - */ -public inline fun verifySerialization(value: T, json: Json, @Language("json") expectedJson: String) { - val jsonString = json.encodeToString(value) - - jsonString shouldEqualJson expectedJson - - val parsedObject: T = try { - json.decodeFromString(jsonString) - } catch (e: SerializationException) { - fail("Failed to parse generated json string: ${e.message}:\n$value", e) - } - withClue("Parsed object is not equal to original value") { - parsedObject shouldBe value - } -} - -/** - * Verifies the deserialization of a JSON payload into an object of the specified type, - * and ensures that the reverse serialization process returns the same JSON payload. - * - * This is especially useful for round-trip serialization tests. - * - * @param T The type of the object being deserialized from the JSON payload. - * @param json The JSON processor used for deserialization and serialization. - * @param payload The JSON string to deserialize into an object and verify against. - * @return The deserialized object of type T. - */ -public inline fun verifyDeserialization(json: Json, @Language("json") payload: String): T { - val value: T = json.decodeFromString(payload) - verifySerialization(value = value, json = json, expectedJson = payload) - return value -} - -/** - * Verifies the round-trip serialization and deserialization process for a given object. - * This method ensures that: - * - The object can be serialized into a JSON string. - * - The serialized JSON string can be deserialized back into an equivalent object. - * - * This is particularly useful for testing the integrity of serialization logic. - * - * @param T The type of the object being tested for serialization and deserialization. - * @param value The object to be serialized and deserialized. - * @param json The JSON processor used for the serialization and deserialization process. - */ -public inline fun verifySerializationRoundTrip(value: T, json: Json) { - val jsonString: String = json.encodeToString(value) - verifySerialization(value = value, json = json, expectedJson = jsonString) -} diff --git a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/testing.kt b/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/testing.kt deleted file mode 100644 index 32f55c9..0000000 --- a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/testing.kt +++ /dev/null @@ -1,28 +0,0 @@ -package io.modelcontextprotocol.kotlin.test.utils - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import kotlin.coroutines.CoroutineContext -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -private val defaultTestDispatcher: CoroutineContext = Dispatchers.IO.limitedParallelism(100) -private val defaultTestTimeout: Duration = 5.seconds - -/** - * Executes a suspendable integration test block within a coroutine scope, applying a timeout constraint. - * - * @param timeout The maximum duration allowed for the test execution. Defaults to 5 seconds. - * @param context The coroutine context within which the test block is executed. - * Defaults to [Dispatchers.IO] with 100 parallel tasks. - * @param block The suspendable test logic to be executed. - */ -public fun runIntegrationTest( - context: CoroutineContext = defaultTestDispatcher, - timeout: Duration = defaultTestTimeout, - block: suspend CoroutineScope.() -> Unit, -): Unit = runBlocking(context) { - withTimeout(timeout, block) -} diff --git a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/DisabledOnCI.kt b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/DisabledOnCI.kt similarity index 100% rename from test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/DisabledOnCI.kt rename to test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/DisabledOnCI.kt diff --git a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/Retry.kt b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/Retry.kt similarity index 97% rename from test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/Retry.kt rename to test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/Retry.kt index 1dcf585..e173f3f 100644 --- a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/Retry.kt +++ b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/Retry.kt @@ -87,7 +87,7 @@ public class RetryExtension : InvocationInterceptor { } } - throw lastError ?: error("Unexpected state in retry logic") + throw lastError ?: IllegalStateException("Unexpected state in retry logic") } private fun describeTest(ctx: ExtensionContext): String { diff --git a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt similarity index 97% rename from test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt rename to test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt index ff9292e..3aa1de7 100644 --- a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt +++ b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/TypeScriptRunner.kt @@ -1,6 +1,5 @@ package io.modelcontextprotocol.kotlin.test.utils -import org.junit.jupiter.api.fail import java.io.File /** @@ -84,7 +83,7 @@ public object TypeScriptRunner { patchPackageJsonRecursively(typescriptDir, patchProtocols = true) if (tryCommand(listOf("npm", "install"), typescriptDir)) return - fail("Failed to install TypeScript dependencies in ${typescriptDir.absolutePath}") + throw RuntimeException("Failed to install TypeScript dependencies in ${typescriptDir.absolutePath}") } private fun tryCommand(command: List, workingDir: File): Boolean { diff --git a/test-utils/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/ktor.kt b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/ktor.kt similarity index 100% rename from test-utils/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/ktor.kt rename to test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/ktor.kt diff --git a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/platform.jvm.kt b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/platform.kt similarity index 100% rename from test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/platform.jvm.kt rename to test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/platform.kt diff --git a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/processBuilders.kt b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/processBuilders.kt similarity index 100% rename from test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/processBuilders.kt rename to test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/processBuilders.kt diff --git a/test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/processUtils.kt b/test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/processUtils.kt similarity index 100% rename from test-utils/src/jvmMain/kotlin/io/modelcontextprotocol/kotlin/test/utils/processUtils.kt rename to test-utils/src/main/kotlin/io/modelcontextprotocol/kotlin/test/utils/processUtils.kt