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:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
JAVA_VERSION: '21'
|
||||
JAVA_DISTRIBUTION: temurin
|
||||
NODE_VERSION: '22'
|
||||
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# Cancel only when the run is NOT on `main` branch
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
run-conformance:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Run Conformance Tests on ${{ matrix.os }}
|
||||
server:
|
||||
runs-on: ubuntu-latest
|
||||
name: Conformance Server Tests
|
||||
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:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed
|
||||
|
||||
- name: Setup Conformance Tests
|
||||
working-directory: conformance-test
|
||||
run: |-
|
||||
npm install -g @modelcontextprotocol/conformance@0.1.8
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
add-job-summary: 'always'
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
gradle-home-cache-includes: |
|
||||
caches
|
||||
notifications
|
||||
sdks
|
||||
../.konan/**
|
||||
|
||||
- name: Run Conformance Tests
|
||||
run: |-
|
||||
./gradlew :conformance-test:test --no-daemon --max-workers ${{ matrix.max-workers }}
|
||||
- name: Build
|
||||
run: ./gradlew :conformance-test:installDist
|
||||
|
||||
- name: Upload Conformance Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
- name: Start server
|
||||
run: |
|
||||
MCP_PORT=3001 conformance-test/build/install/conformance-test/bin/conformance-test &
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s -o /dev/null http://localhost:3001/mcp; then
|
||||
echo "Server is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run conformance tests
|
||||
uses: modelcontextprotocol/conformance@v0.1.15
|
||||
with:
|
||||
name: conformance-results-${{ matrix.os }}
|
||||
path: conformance-test/results/
|
||||
mode: server
|
||||
url: http://localhost:3001/mcp
|
||||
suite: active
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
expected-failures: ./conformance-test/conformance-baseline.yml
|
||||
|
||||
client:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Conformance Client Tests: ${{ matrix.scenario }}"
|
||||
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 {
|
||||
apply(plugin = "org.jlleitschuh.gradle.ktlint")
|
||||
apply(plugin = "org.jetbrains.kotlinx.kover")
|
||||
apply(plugin = "dev.detekt")
|
||||
|
||||
detekt {
|
||||
config = files("$rootDir/config/detekt/detekt.yml")
|
||||
buildUponDefaultConfig = true
|
||||
failOnSeverity.set(FailOnSeverity.Error)
|
||||
if (name != "conformance-test" && name != "docs") {
|
||||
apply(plugin = "dev.detekt")
|
||||
|
||||
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 {
|
||||
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 {
|
||||
testImplementation(project(":kotlin-sdk"))
|
||||
testImplementation(project(":test-utils"))
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation(libs.kotlin.logging)
|
||||
testImplementation(libs.ktor.client.cio)
|
||||
testImplementation(libs.ktor.server.cio)
|
||||
testImplementation(libs.ktor.server.websockets)
|
||||
testRuntimeOnly(libs.slf4j.simple)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
showStandardStreams = true
|
||||
showExceptions = true
|
||||
showCauses = true
|
||||
showStackTraces = true
|
||||
exceptionFormat = TestExceptionFormat.FULL
|
||||
}
|
||||
|
||||
doFirst {
|
||||
systemProperty("test.classpath", classpath.asPath)
|
||||
|
||||
println("\n" + "=".repeat(60))
|
||||
println("MCP CONFORMANCE TESTS")
|
||||
println("=".repeat(60))
|
||||
println("These tests validate compliance with the MCP specification.")
|
||||
println("=".repeat(60) + "\n")
|
||||
}
|
||||
implementation(project(":kotlin-sdk"))
|
||||
implementation(libs.ktor.server.cio)
|
||||
implementation(libs.ktor.server.content.negotiation)
|
||||
implementation(libs.ktor.serialization)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.auth)
|
||||
implementation(libs.kotlin.logging)
|
||||
runtimeOnly(libs.slf4j.simple)
|
||||
}
|
||||
|
|
|
|||
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