Adds a comprehensive conformance test suite for the Kotlin MCP SDK, covering core protocol operations, tool calls, elicitation, resources, prompts, and 20 OAuth/auth scenarios - Conformance server and client implementations - OAuth/auth test scenarios: JWT, authorization code flow, client credentials, PKCE, scope handling, cross-app access, client registration - CI workflow - Baseline file for tracking expected failures - Shell script fixes: - #592 - #593 - #596 ## Remaining known failures (tracked issues, will be fixed directly in `main`) - [x] `tools-call-with-logging`, `tools-call-with-progress`, `tools-call-sampling`, `tools-call-elicitation`, `elicitation-sep1034-defaults`- see #599, - [x] `elicitation-sep1330-enums` - #587 #600 - [x] `initialize` - #588 - [x] `tools_call`, `auth/scope-step-up`, `auth/scope-retry-limit` - #589 - [ ] `elicitation-sep1034-client-defaults` - #414 - [x] `sse-retry` - #590 - [ ] `resources-templates-read` - #591 ## Breaking Changes from #596 - `StreamableHttpClientTransport` and `mcpStreamableHttp`/`mcpStreamableHttpTransport`: old constructors accepting `Duration` timeout are now `@Deprecated` — use the new overloads with `ReconnectionOptions` instead - `StreamableHttpClientTransport.close()` no longer calls `terminateSession()` automatically ## Types of changes - [x] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed --------- Co-authored-by: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com>
This commit is contained in:
parent
338f94804f
commit
e88b34170c
42 changed files with 3186 additions and 1122 deletions
185
.github/workflows/conformance.yml
vendored
185
.github/workflows/conformance.yml
vendored
|
|
@ -7,67 +7,166 @@ on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
JAVA_VERSION: '21'
|
||||||
|
JAVA_DISTRIBUTION: temurin
|
||||||
|
NODE_VERSION: '22'
|
||||||
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
# Cancel only when the run is NOT on `main` branch
|
# Cancel only when the run is NOT on `main` branch
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
run-conformance:
|
server:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ubuntu-latest
|
||||||
name: Run Conformance Tests on ${{ matrix.os }}
|
name: Conformance Server Tests
|
||||||
timeout-minutes: 20
|
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:
|
|
||||||
include:
|
|
||||||
- os: ubuntu-latest
|
|
||||||
max-workers: 3
|
|
||||||
- os: windows-latest
|
|
||||||
max-workers: 3
|
|
||||||
- os: macos-latest
|
|
||||||
max-workers: 2
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
java-version: '21'
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
distribution: 'temurin'
|
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||||
|
|
||||||
- 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
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v5
|
uses: gradle/actions/setup-gradle@v5
|
||||||
with:
|
with:
|
||||||
add-job-summary: 'always'
|
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||||
gradle-home-cache-includes: |
|
|
||||||
caches
|
|
||||||
notifications
|
|
||||||
sdks
|
|
||||||
../.konan/**
|
|
||||||
|
|
||||||
- name: Run Conformance Tests
|
- name: Build
|
||||||
run: |-
|
run: ./gradlew :conformance-test:installDist
|
||||||
./gradlew :conformance-test:test --no-daemon --max-workers ${{ matrix.max-workers }}
|
|
||||||
|
|
||||||
- name: Upload Conformance Results
|
- name: Start server
|
||||||
if: always()
|
run: |
|
||||||
uses: actions/upload-artifact@v7
|
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.15
|
||||||
with:
|
with:
|
||||||
name: conformance-results-${{ matrix.os }}
|
mode: server
|
||||||
path: conformance-test/results/
|
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 }}"
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
scenario:
|
||||||
|
- initialize
|
||||||
|
- tools_call
|
||||||
|
- elicitation-sep1034-client-defaults
|
||||||
|
- sse-retry
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up JDK
|
||||||
|
uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
|
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||||
|
|
||||||
|
- name: Setup Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v5
|
||||||
|
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.15
|
||||||
|
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@v6
|
||||||
|
|
||||||
|
- name: Set up JDK
|
||||||
|
uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
|
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||||
|
|
||||||
|
- name: Setup Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v5
|
||||||
|
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.15
|
||||||
|
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@v6
|
||||||
|
|
||||||
|
- name: Set up JDK
|
||||||
|
uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
|
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||||
|
|
||||||
|
- name: Setup Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v5
|
||||||
|
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.15
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,15 @@ dependencies {
|
||||||
subprojects {
|
subprojects {
|
||||||
apply(plugin = "org.jlleitschuh.gradle.ktlint")
|
apply(plugin = "org.jlleitschuh.gradle.ktlint")
|
||||||
apply(plugin = "org.jetbrains.kotlinx.kover")
|
apply(plugin = "org.jetbrains.kotlinx.kover")
|
||||||
apply(plugin = "dev.detekt")
|
|
||||||
|
|
||||||
detekt {
|
if (name != "conformance-test" && name != "docs") {
|
||||||
config = files("$rootDir/config/detekt/detekt.yml")
|
apply(plugin = "dev.detekt")
|
||||||
buildUponDefaultConfig = true
|
|
||||||
failOnSeverity.set(FailOnSeverity.Error)
|
detekt {
|
||||||
|
config = files("$rootDir/config/detekt/detekt.yml")
|
||||||
|
buildUponDefaultConfig = true
|
||||||
|
failOnSeverity.set(FailOnSeverity.Error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1
conformance-test/.gitignore
vendored
Normal file
1
conformance-test/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/results/
|
||||||
130
conformance-test/README.md
Normal file
130
conformance-test/README.md
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# 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 <command> [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/<command>/`
|
||||||
|
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
|
||||||
|
|
||||||
|
8 scenarios are expected to fail due to current SDK limitations (tracked in [
|
||||||
|
`conformance-baseline.yml`](conformance-baseline.yml).
|
||||||
|
|
||||||
|
| Scenario | Suite | Root Cause |
|
||||||
|
|---------------------------------------|--------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `tools-call-with-logging` | server | Notifications from tool handlers have no `relatedRequestId`; transport routes them to the standalone SSE stream instead of the request-specific stream |
|
||||||
|
| `tools-call-with-progress` | server | *(same as above)* |
|
||||||
|
| `tools-call-sampling` | server | *(same as above)* |
|
||||||
|
| `tools-call-elicitation` | server | *(same as above)* |
|
||||||
|
| `elicitation-sep1034-defaults` | server | *(same as above)* |
|
||||||
|
| `elicitation-sep1330-enums` | server | *(same as above)* |
|
||||||
|
| `resources-templates-read` | server | SDK does not implement `addResourceTemplate()` with URI pattern matching; resources are looked up by exact URI |
|
||||||
|
| `elicitation-sep1034-client-defaults` | client | SDK does not fill in `default` values from the elicitation request schema before sending the response |
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -1,39 +1,34 @@
|
||||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
kotlin("jvm")
|
kotlin("jvm")
|
||||||
|
application
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("io.modelcontextprotocol.kotlin.sdk.conformance.ConformanceServerKt")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<CreateStartScripts>("conformanceClientScripts") {
|
||||||
|
mainClass.set("io.modelcontextprotocol.kotlin.sdk.conformance.ConformanceClientKt")
|
||||||
|
applicationName = "conformance-client"
|
||||||
|
outputDir = tasks.named<CreateStartScripts>("startScripts").get().outputDir
|
||||||
|
classpath = tasks.named<Jar>("jar").get().outputs.files + configurations.named("runtimeClasspath").get()
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("installDist") {
|
||||||
|
dependsOn("conformanceClientScripts")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named<Delete>("clean") {
|
||||||
|
delete("results")
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
testImplementation(project(":kotlin-sdk"))
|
implementation(project(":kotlin-sdk"))
|
||||||
testImplementation(project(":test-utils"))
|
implementation(libs.ktor.server.cio)
|
||||||
testImplementation(kotlin("test"))
|
implementation(libs.ktor.server.content.negotiation)
|
||||||
testImplementation(libs.kotlin.logging)
|
implementation(libs.ktor.serialization)
|
||||||
testImplementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
testImplementation(libs.ktor.server.cio)
|
implementation(libs.ktor.client.auth)
|
||||||
testImplementation(libs.ktor.server.websockets)
|
implementation(libs.kotlin.logging)
|
||||||
testRuntimeOnly(libs.slf4j.simple)
|
runtimeOnly(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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
conformance-test/conformance-baseline.yml
Normal file
13
conformance-test/conformance-baseline.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Conformance test baseline - expected failures
|
||||||
|
# Add entries here as tests are identified as known SDK limitations
|
||||||
|
server:
|
||||||
|
- tools-call-with-logging
|
||||||
|
- tools-call-with-progress
|
||||||
|
- tools-call-sampling
|
||||||
|
- tools-call-elicitation
|
||||||
|
- elicitation-sep1034-defaults
|
||||||
|
- elicitation-sep1330-enums
|
||||||
|
- resources-templates-read
|
||||||
|
|
||||||
|
client:
|
||||||
|
- elicitation-sep1034-client-defaults
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?xml version='1.0' encoding='UTF-8'?>
|
|
||||||
<SmellBaseline>
|
|
||||||
<ManuallySuppressedIssues/>
|
|
||||||
<CurrentIssues>
|
|
||||||
<ID>BracesOnWhenStatements:ConformanceServer.kt:HttpServerTransport$when</ID>
|
|
||||||
<ID>BracesOnWhenStatements:ConformanceTest.kt:ConformanceTest$when</ID>
|
|
||||||
<ID>ForbiddenComment:ConformanceTest.kt:ConformanceTest.Companion$// TODO: Fix</ID>
|
|
||||||
</CurrentIssues>
|
|
||||||
</SmellBaseline>
|
|
||||||
194
conformance-test/run-conformance.sh
Executable file
194
conformance-test/run-conformance.sh
Executable file
|
|
@ -0,0 +1,194 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Script to run MCP conformance tests for the Kotlin SDK.
|
||||||
|
#
|
||||||
|
# Usage: ./conformance-test/run-conformance.sh <command> [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.15"
|
||||||
|
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 <command> [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
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
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<String, ScenarioHandler>()
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main entry point
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
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=<scenario> conformance-client <server-url>" }
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
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<CompleteRequest>(Method.Defined.CompletionComplete) { _, _ ->
|
||||||
|
CompleteResult(CompleteResult.Completion(values = emptyList(), total = 0, hasMore = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
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")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
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
|
||||||
|
// Note: The SDK does not currently support addResourceTemplate().
|
||||||
|
// Register as a static resource; template listing is handled separately.
|
||||||
|
addResource(
|
||||||
|
uri = "test://template/{id}/data",
|
||||||
|
name = "template",
|
||||||
|
description = "A template resource for testing",
|
||||||
|
mimeType = "application/json",
|
||||||
|
) { request ->
|
||||||
|
ReadResourceResult(
|
||||||
|
listOf(
|
||||||
|
TextResourceContents(
|
||||||
|
text = "content for ${request.uri}",
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package io.modelcontextprotocol.kotlin.sdk.conformance
|
||||||
|
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.cio.CIO
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
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.McpJson
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
val port = System.getenv("MCP_PORT")?.toIntOrNull() ?: 3001
|
||||||
|
embeddedServer(CIO, port = port) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(McpJson)
|
||||||
|
}
|
||||||
|
mcpStreamableHttp(
|
||||||
|
enableDnsRebindingProtection = true,
|
||||||
|
allowedHosts = listOf("localhost", "127.0.0.1", "localhost:$port", "127.0.0.1:$port"),
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,604 @@
|
||||||
|
package io.modelcontextprotocol.kotlin.sdk.conformance
|
||||||
|
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.AudioContent
|
||||||
|
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.ElicitRequestParams
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.EmbeddedResource
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.ImageContent
|
||||||
|
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.TextContent
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
|
||||||
|
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
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="
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
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, TextContent(prompt))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CallToolResult(listOf(TextContent(result.content.toString())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = buildJsonObject {
|
||||||
|
put(
|
||||||
|
"username",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("string"))
|
||||||
|
put("description", JsonPrimitive("User's response"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"email",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("string"))
|
||||||
|
put("description", JsonPrimitive("User's email address"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
required = listOf("username", "email"),
|
||||||
|
)
|
||||||
|
val result = createElicitation(message, schema)
|
||||||
|
CallToolResult(listOf(TextContent("User response: <action: ${result.action}, content: ${result.content}>")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Elicitation SEP1034 (defaults)
|
||||||
|
addTool(
|
||||||
|
name = "test_elicitation_sep1034_defaults",
|
||||||
|
description = "test_elicitation_sep1034_defaults",
|
||||||
|
) {
|
||||||
|
val schema = ElicitRequestParams.RequestedSchema(
|
||||||
|
properties = buildJsonObject {
|
||||||
|
put(
|
||||||
|
"name",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("string"))
|
||||||
|
put("description", JsonPrimitive("User name"))
|
||||||
|
put("default", JsonPrimitive("John Doe"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"age",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("integer"))
|
||||||
|
put("description", JsonPrimitive("User age"))
|
||||||
|
put("default", JsonPrimitive(30))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"score",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("number"))
|
||||||
|
put("description", JsonPrimitive("User score"))
|
||||||
|
put("default", JsonPrimitive(95.5))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"status",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("string"))
|
||||||
|
put("description", JsonPrimitive("User status"))
|
||||||
|
put("default", JsonPrimitive("active"))
|
||||||
|
put(
|
||||||
|
"enum",
|
||||||
|
JsonArray(
|
||||||
|
listOf(JsonPrimitive("active"), JsonPrimitive("inactive"), JsonPrimitive("pending")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"verified",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("boolean"))
|
||||||
|
put("description", JsonPrimitive("Verification status"))
|
||||||
|
put("default", JsonPrimitive(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 = buildJsonObject {
|
||||||
|
// Untitled single-select
|
||||||
|
put(
|
||||||
|
"untitledSingle",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("string"))
|
||||||
|
put(
|
||||||
|
"enum",
|
||||||
|
JsonArray(
|
||||||
|
listOf(JsonPrimitive("option1"), JsonPrimitive("option2"), JsonPrimitive("option3")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// Titled single-select
|
||||||
|
put(
|
||||||
|
"titledSingle",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("string"))
|
||||||
|
put(
|
||||||
|
"oneOf",
|
||||||
|
JsonArray(
|
||||||
|
listOf(
|
||||||
|
buildJsonObject {
|
||||||
|
put("const", JsonPrimitive("value1"))
|
||||||
|
put("title", JsonPrimitive("First Option"))
|
||||||
|
},
|
||||||
|
buildJsonObject {
|
||||||
|
put("const", JsonPrimitive("value2"))
|
||||||
|
put("title", JsonPrimitive("Second Option"))
|
||||||
|
},
|
||||||
|
buildJsonObject {
|
||||||
|
put("const", JsonPrimitive("value3"))
|
||||||
|
put("title", JsonPrimitive("Third Option"))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// Legacy titled (deprecated)
|
||||||
|
put(
|
||||||
|
"legacyEnum",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("string"))
|
||||||
|
put(
|
||||||
|
"oneOf",
|
||||||
|
JsonArray(
|
||||||
|
listOf(
|
||||||
|
buildJsonObject {
|
||||||
|
put("const", JsonPrimitive("opt1"))
|
||||||
|
put("title", JsonPrimitive("Option One"))
|
||||||
|
},
|
||||||
|
buildJsonObject {
|
||||||
|
put("const", JsonPrimitive("opt2"))
|
||||||
|
put("title", JsonPrimitive("Option Two"))
|
||||||
|
},
|
||||||
|
buildJsonObject {
|
||||||
|
put("const", JsonPrimitive("opt3"))
|
||||||
|
put("title", JsonPrimitive("Option Three"))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// Untitled multi-select
|
||||||
|
put(
|
||||||
|
"untitledMulti",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("array"))
|
||||||
|
put(
|
||||||
|
"items",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("string"))
|
||||||
|
put(
|
||||||
|
"enum",
|
||||||
|
JsonArray(
|
||||||
|
listOf(
|
||||||
|
JsonPrimitive("option1"),
|
||||||
|
JsonPrimitive("option2"),
|
||||||
|
JsonPrimitive("option3"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// Titled multi-select
|
||||||
|
put(
|
||||||
|
"titledMulti",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("array"))
|
||||||
|
put(
|
||||||
|
"items",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("string"))
|
||||||
|
put(
|
||||||
|
"oneOf",
|
||||||
|
JsonArray(
|
||||||
|
listOf(
|
||||||
|
buildJsonObject {
|
||||||
|
put("const", JsonPrimitive("value1"))
|
||||||
|
put("title", JsonPrimitive("First Choice"))
|
||||||
|
},
|
||||||
|
buildJsonObject {
|
||||||
|
put("const", JsonPrimitive("value2"))
|
||||||
|
put("title", JsonPrimitive("Second Choice"))
|
||||||
|
},
|
||||||
|
buildJsonObject {
|
||||||
|
put("const", JsonPrimitive("value3"))
|
||||||
|
put("title", JsonPrimitive("Third Choice"))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val result = createElicitation(
|
||||||
|
"Please review and update the form fields with defaults",
|
||||||
|
schema,
|
||||||
|
)
|
||||||
|
CallToolResult(listOf(TextContent(result.content.toString())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
) {
|
||||||
|
sendLoggingMessage(
|
||||||
|
LoggingMessageNotification(
|
||||||
|
LoggingMessageNotificationParams(
|
||||||
|
level = LoggingLevel.Info,
|
||||||
|
data = JsonPrimitive("Tool execution started"),
|
||||||
|
logger = "conformance",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
delay(50.milliseconds)
|
||||||
|
sendLoggingMessage(
|
||||||
|
LoggingMessageNotification(
|
||||||
|
LoggingMessageNotificationParams(
|
||||||
|
level = LoggingLevel.Info,
|
||||||
|
data = JsonPrimitive("Tool processing data"),
|
||||||
|
logger = "conformance",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
delay(50.milliseconds)
|
||||||
|
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 = buildJsonObject {
|
||||||
|
put(
|
||||||
|
"name",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("string"))
|
||||||
|
put("description", JsonPrimitive("User name"))
|
||||||
|
put("default", JsonPrimitive("John Doe"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"age",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("integer"))
|
||||||
|
put("description", JsonPrimitive("User age"))
|
||||||
|
put("default", JsonPrimitive(30))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"score",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("number"))
|
||||||
|
put("description", JsonPrimitive("User score"))
|
||||||
|
put("default", JsonPrimitive(95.5))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"status",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("string"))
|
||||||
|
put("description", JsonPrimitive("User status"))
|
||||||
|
put("default", JsonPrimitive("active"))
|
||||||
|
put(
|
||||||
|
"enum",
|
||||||
|
JsonArray(
|
||||||
|
listOf(JsonPrimitive("active"), JsonPrimitive("inactive"), JsonPrimitive("pending")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"verified",
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("boolean"))
|
||||||
|
put("description", JsonPrimitive("Verification status"))
|
||||||
|
put("default", JsonPrimitive(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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
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<String, Pair<JSONRPCMessage, String>>()
|
||||||
|
private val streamEvents = ConcurrentHashMap<String, MutableList<String>>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue