feat!: add mcp conformance test infra (#585); add SSE reconnection with retry support (#596) (#585)

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:
Pavel Gorgulov 2026-03-12 14:44:59 +01:00 • committed by GitHub
commit e88b34170c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 3186 additions and 1122 deletions

View file

@ -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

View file

@ -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
View file

@ -0,0 +1 @@
/results/

130
conformance-test/README.md Normal file
View 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

View file

@ -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)
}

View 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

View file

@ -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>

View 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

View file

@ -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
}

View file

@ -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))
}
}
}

View file

@ -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")),
),
)
}
}

View file

@ -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",
),
),
)
}
}

View file

@ -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()
}

View file

@ -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")))
}
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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()
}
}
}

View file

@ -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)
}

View file

@ -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