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
23 changed files with 3186 additions and 1122 deletions

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

View file

@ -0,0 +1,48 @@
package io.modelcontextprotocol.kotlin.sdk.conformance.auth
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal data class DiscoveryResult(
val asMetadata: JsonObject,
val resourceUrl: String?,
val scopesSupported: List<String>?,
)
internal suspend fun discoverOAuthMetadata(
httpClient: HttpClient,
serverUrl: String,
resourceMetadataUrl: String?,
): DiscoveryResult {
// Get resource metadata
val resourceMeta = if (resourceMetadataUrl != null) {
val resp = httpClient.get(resourceMetadataUrl)
if (!resp.status.isSuccess()) {
error("Failed to fetch resource metadata from $resourceMetadataUrl: ${resp.status}")
}
json.parseToJsonElement(resp.bodyAsText()).jsonObject
} else {
discoverResourceMetadata(httpClient, serverUrl)
}
val resourceUrl = resourceMeta["resource"]?.jsonPrimitive?.content
val scopesSupported = resourceMeta["scopes_supported"]
?.jsonArray?.map { it.jsonPrimitive.content }
val authServer = resourceMeta["authorization_servers"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.content
val oauthMeta = if (authServer != null) {
fetchOAuthMetadata(httpClient, authServer)
} else {
// Fallback: try well-known on server URL origin
val origin = extractOrigin(serverUrl)
fetchOAuthMetadata(httpClient, origin)
}
return DiscoveryResult(oauthMeta, resourceUrl, scopesSupported)
}

View file

@ -0,0 +1,30 @@
package io.modelcontextprotocol.kotlin.sdk.conformance.auth
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.security.MessageDigest
import java.util.Base64
internal fun generateCodeVerifier(): String {
val bytes = ByteArray(32)
java.security.SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
internal fun generateCodeChallenge(verifier: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
}
/**
* Verify that the AS metadata advertises S256 in code_challenge_methods_supported.
* Abort if PKCE S256 is not supported.
*/
internal fun verifyPkceSupport(asMetadata: JsonObject) {
val methods = asMetadata["code_challenge_methods_supported"]
?.jsonArray?.map { it.jsonPrimitive.content }
require(methods != null && "S256" in methods) {
"Authorization server does not support PKCE S256 (code_challenge_methods_supported: $methods)"
}
}

View file

@ -0,0 +1,32 @@
package io.modelcontextprotocol.kotlin.sdk.conformance.auth
import io.modelcontextprotocol.kotlin.sdk.conformance.scenarioHandlers
// Registration
fun registerAuthScenarios() {
val authScenarios = listOf(
"auth/metadata-default",
"auth/metadata-var1",
"auth/metadata-var2",
"auth/metadata-var3",
"auth/basic-cimd",
"auth/scope-from-www-authenticate",
"auth/scope-from-scopes-supported",
"auth/scope-omitted-when-undefined",
"auth/scope-step-up",
"auth/scope-retry-limit",
"auth/token-endpoint-auth-basic",
"auth/token-endpoint-auth-post",
"auth/token-endpoint-auth-none",
"auth/resource-mismatch",
"auth/pre-registration",
"auth/2025-03-26-oauth-metadata-backcompat",
"auth/2025-03-26-oauth-endpoint-fallback",
)
for (name in authScenarios) {
scenarioHandlers[name] = ::runAuthClient
}
scenarioHandlers["auth/client-credentials-jwt"] = ::runClientCredentialsJwt
scenarioHandlers["auth/client-credentials-basic"] = ::runClientCredentialsBasic
scenarioHandlers["auth/cross-app-access-complete-flow"] = ::runCrossAppAccess
}

View file

@ -0,0 +1,29 @@
package io.modelcontextprotocol.kotlin.sdk.conformance.auth
internal fun extractParam(wwwAuth: String, param: String): String? {
val regex = Regex("""$param="([^"]+)"""")
return regex.find(wwwAuth)?.groupValues?.get(1)
}
/**
* Select scope per MCP spec priority:
* 1. scope from WWW-Authenticate header
* 2. scopes_supported from Protected Resource Metadata (space-joined)
* 3. null (omit scope entirely)
*/
internal fun selectScope(wwwAuthScope: String?, scopesSupported: List<String>?): String? {
if (wwwAuthScope != null) return wwwAuthScope
if (!scopesSupported.isNullOrEmpty()) return scopesSupported.joinToString(" ")
return null
}
/**
* Detect 403 with error="insufficient_scope" and extract the new scope.
* Returns the scope string if step-up is needed, null otherwise.
*/
internal fun parseStepUpScope(wwwAuth: String?): String? {
if (wwwAuth == null) return null
val error = extractParam(wwwAuth, "error")
if (error != "insufficient_scope") return null
return extractParam(wwwAuth, "scope")
}

View file

@ -0,0 +1,174 @@
package io.modelcontextprotocol.kotlin.sdk.conformance.auth
import io.ktor.client.HttpClient
import io.ktor.client.request.forms.submitForm
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.Parameters
import io.ktor.http.isSuccess
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.net.URI
import java.net.URLEncoder
import java.util.Base64
internal fun buildAuthorizationUrl(
authEndpoint: String,
clientId: String,
redirectUri: String,
codeChallenge: String,
scope: String?,
resource: String?,
state: String,
): String {
val params = buildString {
append("response_type=code")
append("&client_id=${URLEncoder.encode(clientId, "UTF-8")}")
append("&redirect_uri=${URLEncoder.encode(redirectUri, "UTF-8")}")
append("&code_challenge=${URLEncoder.encode(codeChallenge, "UTF-8")}")
append("&code_challenge_method=S256")
append("&state=${URLEncoder.encode(state, "UTF-8")}")
if (scope != null) {
append("&scope=${URLEncoder.encode(scope, "UTF-8")}")
}
if (resource != null) {
append("&resource=${URLEncoder.encode(resource, "UTF-8")}")
}
}
return if (authEndpoint.contains("?")) "$authEndpoint&$params" else "$authEndpoint?$params"
}
internal suspend fun followAuthorizationRedirect(
httpClient: HttpClient,
authUrl: String,
expectedCallbackUrl: String,
expectedState: String,
): String {
val response = httpClient.get(authUrl)
if (response.status == HttpStatusCode.Found ||
response.status == HttpStatusCode.MovedPermanently ||
response.status == HttpStatusCode.TemporaryRedirect ||
response.status == HttpStatusCode.SeeOther
) {
val location = response.headers[HttpHeaders.Location]
?: error("No Location header in redirect response")
require(location.startsWith(expectedCallbackUrl)) {
"Redirect location does not match expected callback URL"
}
val uri = URI(location)
val queryParams = uri.query?.split("&")?.mapNotNull {
val parts = it.split("=", limit = 2)
if (parts.size == 2) parts[0] to java.net.URLDecoder.decode(parts[1], "UTF-8") else null
}?.toMap() ?: emptyMap()
val returnedState = queryParams["state"]
require(returnedState == expectedState) {
"State parameter mismatch in authorization redirect"
}
return queryParams["code"] ?: error("No authorization code in redirect response")
}
error("Expected redirect from auth endpoint, got ${response.status}")
}
internal suspend fun exchangeCodeForTokens(
httpClient: HttpClient,
tokenEndpoint: String,
code: String,
clientId: String,
clientSecret: String?,
redirectUri: String,
codeVerifier: String,
tokenAuthMethod: String,
resource: String?,
): String {
val response = when (tokenAuthMethod) {
"client_secret_basic" -> {
val basicAuth = Base64.getEncoder()
.encodeToString("$clientId:${clientSecret ?: ""}".toByteArray())
httpClient.submitForm(
url = tokenEndpoint,
formParameters = Parameters.build {
append("grant_type", "authorization_code")
append("code", code)
append("redirect_uri", redirectUri)
append("code_verifier", codeVerifier)
if (resource != null) {
append("resource", resource)
}
},
) {
header(HttpHeaders.Authorization, "Basic $basicAuth")
}
}
"none" -> {
httpClient.submitForm(
url = tokenEndpoint,
formParameters = Parameters.build {
append("grant_type", "authorization_code")
append("code", code)
append("client_id", clientId)
append("redirect_uri", redirectUri)
append("code_verifier", codeVerifier)
if (resource != null) {
append("resource", resource)
}
},
)
}
else -> {
// client_secret_post (default)
httpClient.submitForm(
url = tokenEndpoint,
formParameters = Parameters.build {
append("grant_type", "authorization_code")
append("code", code)
append("client_id", clientId)
if (clientSecret != null) {
append("client_secret", clientSecret)
}
append("redirect_uri", redirectUri)
append("code_verifier", codeVerifier)
if (resource != null) {
append("resource", resource)
}
},
)
}
}
// Check HTTP status
if (!response.status.isSuccess()) {
val body = response.bodyAsText()
val errorDetail = try {
val obj = json.parseToJsonElement(body).jsonObject
val err = obj["error"]?.jsonPrimitive?.content ?: "unknown"
val desc = obj["error_description"]?.jsonPrimitive?.content
if (desc != null) "$err: $desc" else err
} catch (_: Exception) {
body
}
error("Token exchange failed (${response.status}): $errorDetail")
}
val tokenJson = json.parseToJsonElement(response.bodyAsText()).jsonObject
// Check for error field in response body (some servers return 200 with error)
val errorField = tokenJson["error"]?.jsonPrimitive?.content
if (errorField != null) {
val desc = tokenJson["error_description"]?.jsonPrimitive?.content
error("Token exchange error: $errorField${if (desc != null) " - $desc" else ""}")
}
return tokenJson["access_token"]?.jsonPrimitive?.content
?: error("No access_token in token response")
}

View file

@ -0,0 +1,120 @@
package io.modelcontextprotocol.kotlin.sdk.conformance.auth
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.plugin
import io.ktor.client.plugins.sse.SSE
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.isSuccess
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.net.URI
import kotlin.text.ifEmpty
internal val json = Json { ignoreUnknownKeys = true }
internal fun conformanceContext(): JsonObject {
val contextJson = System.getenv("MCP_CONFORMANCE_CONTEXT")
?: error("MCP_CONFORMANCE_CONTEXT not set")
return json.parseToJsonElement(contextJson).jsonObject
}
internal fun JsonObject.requiredString(key: String): String = this[key]?.jsonPrimitive?.content ?: error("Missing $key")
internal const val CIMD_CLIENT_METADATA_URL = "https://conformance-test.local/client-metadata.json"
internal const val CALLBACK_URL = "http://localhost:3000/callback"
internal fun extractOrigin(url: String): String {
val uri = URI(url)
return "${uri.scheme}://${uri.host}${if (uri.port > 0) ":${uri.port}" else ""}"
}
internal suspend fun discoverResourceMetadata(httpClient: HttpClient, serverUrl: String): JsonObject {
val origin = extractOrigin(serverUrl)
val path = URI(serverUrl).path.ifEmpty { "/" }
// Try RFC 9728 format first: /.well-known/oauth-protected-resource/<path>
val wellKnownUrl = "$origin/.well-known/oauth-protected-resource$path"
val response = httpClient.get(wellKnownUrl)
if (response.status.isSuccess()) {
return json.parseToJsonElement(response.bodyAsText()).jsonObject
}
// Fallback: try root
val fallbackUrl = "$origin/.well-known/oauth-protected-resource"
val fallbackResponse = httpClient.get(fallbackUrl)
if (!fallbackResponse.status.isSuccess()) {
error(
"Failed to discover resource metadata at $wellKnownUrl (${response.status}) and $fallbackUrl (${fallbackResponse.status})",
)
}
return json.parseToJsonElement(fallbackResponse.bodyAsText()).jsonObject
}
internal suspend fun fetchOAuthMetadata(httpClient: HttpClient, authServerUrl: String): JsonObject {
val origin = extractOrigin(authServerUrl)
val path = URI(authServerUrl).path.ifEmpty { "/" }
// RFC 8414 §3: /.well-known/oauth-authorization-server/<path>
val oauthUrl = "$origin/.well-known/oauth-authorization-server$path"
val oauthResponse = httpClient.get(oauthUrl)
if (oauthResponse.status.isSuccess()) {
return json.parseToJsonElement(oauthResponse.bodyAsText()).jsonObject
}
// OIDC Discovery with path insertion: /.well-known/openid-configuration/<path>
val oidcPathUrl = "$origin/.well-known/openid-configuration$path"
val oidcPathResponse = httpClient.get(oidcPathUrl)
if (oidcPathResponse.status.isSuccess()) {
return json.parseToJsonElement(oidcPathResponse.bodyAsText()).jsonObject
}
// Fallback: OpenID Connect discovery (issuer + /.well-known/openid-configuration)
val oidcUrl = "$authServerUrl/.well-known/openid-configuration"
val oidcResponse = httpClient.get(oidcUrl)
if (oidcResponse.status.isSuccess()) {
return json.parseToJsonElement(oidcResponse.bodyAsText()).jsonObject
}
error(
"Failed to fetch OAuth metadata from $oauthUrl (${oauthResponse.status}) and $oidcUrl (${oidcResponse.status})",
)
}
internal suspend fun discoverTokenEndpoint(httpClient: HttpClient, serverUrl: String): String {
val resourceMetadata = discoverResourceMetadata(httpClient, serverUrl)
val authServer = resourceMetadata["authorization_servers"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.content
?: error("No authorization_servers in resource metadata")
val oauthMetadata = fetchOAuthMetadata(httpClient, authServer)
return oauthMetadata["token_endpoint"]?.jsonPrimitive?.content
?: error("No token_endpoint")
}
internal suspend fun extractAccessToken(tokenResponse: HttpResponse): String {
if (!tokenResponse.status.isSuccess()) {
error("Token request failed: ${tokenResponse.status}")
}
val tokenJson = json.parseToJsonElement(tokenResponse.bodyAsText()).jsonObject
return tokenJson["access_token"]?.jsonPrimitive?.content
?: error("No access_token in token response")
}
internal suspend fun <T> withBearerToken(accessToken: String, block: suspend (HttpClient) -> T): T {
val client = HttpClient(CIO) {
install(SSE)
}
client.plugin(HttpSend).intercept { request ->
request.headers.remove(HttpHeaders.Authorization)
request.headers.append(HttpHeaders.Authorization, "Bearer $accessToken")
execute(request)
}
return client.use { block(it) }
}

View file

@ -1,85 +0,0 @@
package io.modelcontextprotocol.kotlin.sdk.conformance
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.sse.SSE
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
private val logger = KotlinLogging.logger {}
fun main(args: Array<String>) {
require(args.isNotEmpty()) {
"Server URL must be provided as an argument"
}
val serverUrl = args.last()
logger.info { "Connecting to test server at: $serverUrl" }
val httpClient = HttpClient(CIO) {
install(SSE)
}
val transport: Transport = StreamableHttpClientTransport(httpClient, serverUrl)
val client = Client(
clientInfo = Implementation(
name = "kotlin-conformance-client",
version = "1.0.0",
),
)
var exitCode = 0
runBlocking {
try {
client.connect(transport)
logger.info { "✅ Connected to server successfully" }
try {
val tools = client.listTools()
logger.info { "Available tools: ${tools.tools.map { it.name }}" }
if (tools.tools.isNotEmpty()) {
val toolName = tools.tools.first().name
logger.info { "Calling tool: $toolName" }
val result = client.callTool(
CallToolRequest(
params = CallToolRequestParams(
name = toolName,
arguments = buildJsonObject {
put("input", JsonPrimitive("test"))
},
),
),
)
logger.info { "Tool result: ${result.content}" }
}
} catch (e: Exception) {
logger.debug(e) { "Error during tool operations (may be expected for some scenarios)" }
}
logger.info { "✅ Client operations completed successfully" }
} catch (e: Exception) {
logger.error(e) { "❌ Client failed" }
exitCode = 1
} finally {
try {
transport.close()
} catch (e: Exception) {
logger.warn(e) { "Error closing transport" }
}
httpClient.close()
}
}
kotlin.system.exitProcess(exitCode)
}

View file

@ -1,426 +0,0 @@
package io.modelcontextprotocol.kotlin.sdk.conformance
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
import io.ktor.server.request.header
import io.ktor.server.request.receiveText
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.response.respondTextWriter
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.WebSocketMcpServerTransport
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument
import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import io.modelcontextprotocol.kotlin.sdk.types.Role
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
private val logger = KotlinLogging.logger {}
private val serverTransports = ConcurrentHashMap<String, HttpServerTransport>()
private val jsonFormat = Json { ignoreUnknownKeys = true }
private const val SESSION_CREATION_TIMEOUT_MS = 2000L
private const val REQUEST_TIMEOUT_MS = 10_000L
private const val MESSAGE_QUEUE_CAPACITY = 256
private fun isInitializeRequest(json: JsonElement): Boolean =
json is JsonObject && json["method"]?.jsonPrimitive?.contentOrNull == "initialize"
@Suppress("CyclomaticComplexMethod", "LongMethod")
fun main(args: Array<String>) {
val port = args.getOrNull(0)?.toIntOrNull() ?: 3000
logger.info { "Starting MCP Conformance Server on port $port" }
embeddedServer(CIO, port = port, host = "127.0.0.1") {
install(WebSockets)
routing {
webSocket("/ws") {
logger.info { "WebSocket connection established" }
val transport = WebSocketMcpServerTransport(this)
val server = createConformanceServer()
try {
server.createSession(transport)
} catch (e: Exception) {
logger.error(e) { "Error in WebSocket session" }
throw e
}
}
get("/mcp") {
val sessionId = call.request.header("mcp-session-id")
?: run {
call.respond(HttpStatusCode.BadRequest, "Missing mcp-session-id header")
return@get
}
val transport = serverTransports[sessionId]
?: run {
call.respond(HttpStatusCode.BadRequest, "Invalid mcp-session-id")
return@get
}
transport.stream(call)
}
post("/mcp") {
val sessionId = call.request.header("mcp-session-id")
val requestBody = call.receiveText()
logger.debug { "Received request with sessionId: $sessionId" }
logger.trace { "Request body: $requestBody" }
val jsonElement = try {
jsonFormat.parseToJsonElement(requestBody)
} catch (e: Exception) {
logger.error(e) { "Failed to parse request body as JSON" }
call.respond(
HttpStatusCode.BadRequest,
jsonFormat.encodeToString(
JsonObject.serializer(),
buildJsonObject {
put("jsonrpc", "2.0")
put(
"error",
buildJsonObject {
put("code", -32700)
put("message", "Parse error: ${e.message}")
},
)
put("id", JsonNull)
},
),
)
return@post
}
val transport = sessionId?.let { serverTransports[it] }
if (transport != null) {
logger.debug { "Using existing transport for session: $sessionId" }
transport.handleRequest(call, jsonElement)
} else {
if (isInitializeRequest(jsonElement)) {
val newSessionId = UUID.randomUUID().toString()
logger.info { "Creating new session with ID: $newSessionId" }
val newTransport = HttpServerTransport(newSessionId)
serverTransports[newSessionId] = newTransport
val mcpServer = createConformanceServer()
call.response.header("mcp-session-id", newSessionId)
val sessionReady = CompletableDeferred<Unit>()
CoroutineScope(Dispatchers.IO).launch {
try {
mcpServer.createSession(newTransport)
sessionReady.complete(Unit)
} catch (e: Exception) {
logger.error(e) { "Failed to create session" }
serverTransports.remove(newSessionId)
newTransport.close()
sessionReady.completeExceptionally(e)
}
}
val sessionCreated = withTimeoutOrNull(SESSION_CREATION_TIMEOUT_MS) {
sessionReady.await()
}
if (sessionCreated == null) {
logger.error { "Session creation timed out" }
serverTransports.remove(newSessionId)
call.respond(
HttpStatusCode.InternalServerError,
jsonFormat.encodeToString(
JsonObject.serializer(),
buildJsonObject {
put("jsonrpc", "2.0")
put(
"error",
buildJsonObject {
put("code", -32000)
put("message", "Session creation timed out")
},
)
put("id", JsonNull)
},
),
)
return@post
}
newTransport.handleRequest(call, jsonElement)
} else {
logger.warn { "Invalid request: no session ID or not an initialization request" }
call.respond(
HttpStatusCode.BadRequest,
jsonFormat.encodeToString(
JsonObject.serializer(),
buildJsonObject {
put("jsonrpc", "2.0")
put(
"error",
buildJsonObject {
put("code", -32000)
put("message", "Bad Request: No valid session ID provided")
},
)
put("id", JsonNull)
},
),
)
}
}
}
delete("/mcp") {
val sessionId = call.request.header("mcp-session-id")
val transport = sessionId?.let { serverTransports[it] }
if (transport != null) {
logger.info { "Terminating session: $sessionId" }
serverTransports.remove(sessionId)
transport.close()
call.respond(HttpStatusCode.OK)
} else {
logger.warn { "Invalid session termination request: $sessionId" }
call.respond(HttpStatusCode.BadRequest, "Invalid or missing session ID")
}
}
}
}.start(wait = true)
}
@Suppress("LongMethod")
private fun createConformanceServer(): Server {
val server = Server(
Implementation(
name = "kotlin-conformance-server",
version = "1.0.0",
),
ServerOptions(
capabilities = ServerCapabilities(
tools = ServerCapabilities.Tools(listChanged = true),
resources = ServerCapabilities.Resources(subscribe = true, listChanged = true),
prompts = ServerCapabilities.Prompts(listChanged = true),
),
),
)
server.addTool(
name = "test-tool",
description = "A test tool for conformance testing",
inputSchema = ToolSchema(
properties = buildJsonObject {
put(
"input",
buildJsonObject {
put("type", "string")
put("description", "Test input parameter")
},
)
},
required = listOf("input"),
),
) { request ->
val input = (request.params.arguments?.get("input") as? JsonPrimitive)?.content ?: "no input"
CallToolResult(
content = listOf(TextContent("Tool executed with input: $input")),
)
}
server.addResource(
uri = "test://test-resource",
name = "Test Resource",
description = "A test resource for conformance testing",
mimeType = "text/plain",
) { request ->
ReadResourceResult(
contents = listOf(
TextResourceContents("Test resource content", request.params.uri, "text/plain"),
),
)
}
server.addPrompt(
name = "test-prompt",
description = "A test prompt for conformance testing",
arguments = listOf(
PromptArgument(
name = "arg",
description = "Test argument",
required = false,
),
),
) {
GetPromptResult(
messages = listOf(
PromptMessage(
role = Role.User,
content = TextContent("Test prompt content"),
),
),
description = "Test prompt description",
)
}
return server
}
private class HttpServerTransport(private val sessionId: String) : AbstractTransport() {
private val logger = KotlinLogging.logger {}
private val pendingResponses = ConcurrentHashMap<String, CompletableDeferred<JSONRPCMessage>>()
private val messageQueue = Channel<JSONRPCMessage>(MESSAGE_QUEUE_CAPACITY)
suspend fun stream(call: ApplicationCall) {
logger.debug { "Starting SSE stream for session $sessionId" }
call.response.apply {
header("Cache-Control", "no-cache")
header("Connection", "keep-alive")
}
call.respondTextWriter(ContentType.Text.EventStream) {
try {
while (true) {
val msg = messageQueue.receiveCatching().getOrNull() ?: break
write("event: message\ndata: ${McpJson.encodeToString(msg)}\n\n")
flush()
}
} catch (e: Exception) {
logger.warn(e) { "SSE stream terminated for session $sessionId" }
} finally {
logger.debug { "SSE stream closed for session $sessionId" }
}
}
}
suspend fun handleRequest(call: ApplicationCall, requestBody: JsonElement) {
try {
val message = McpJson.decodeFromJsonElement<JSONRPCMessage>(requestBody)
logger.debug { "Handling ${message::class.simpleName}: $requestBody" }
when (message) {
is JSONRPCRequest -> {
val idKey = when (val id = message.id) {
is RequestId.NumberId -> id.value.toString()
is RequestId.StringId -> id.value
}
val responseDeferred = CompletableDeferred<JSONRPCMessage>()
pendingResponses[idKey] = responseDeferred
_onMessage.invoke(message)
val response = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { responseDeferred.await() }
if (response != null) {
call.respondText(McpJson.encodeToString(response), ContentType.Application.Json)
} else {
pendingResponses.remove(idKey)
logger.warn { "Timeout for request $idKey" }
call.respondText(
McpJson.encodeToString(
JSONRPCError(
message.id,
RPCError(RPCError.ErrorCode.REQUEST_TIMEOUT, "Request timed out"),
),
),
ContentType.Application.Json,
)
}
}
else -> {
call.respond(HttpStatusCode.Accepted)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.error(e) { "Error handling request" }
if (!call.response.isCommitted) {
call.respondText(
McpJson.encodeToString(
JSONRPCError(
RequestId(0),
RPCError(RPCError.ErrorCode.INTERNAL_ERROR, "Internal error: ${e.message}"),
),
),
ContentType.Application.Json,
HttpStatusCode.InternalServerError,
)
}
}
}
override suspend fun start() {
logger.debug { "Started transport for session $sessionId" }
}
override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
when (message) {
is JSONRPCResponse -> {
val idKey = when (val id = message.id) {
is RequestId.NumberId -> id.value.toString()
is RequestId.StringId -> id.value
}
pendingResponses.remove(idKey)?.complete(message) ?: run {
logger.warn { "No pending response for ID $idKey, queueing" }
messageQueue.send(message)
}
}
else -> messageQueue.send(message)
}
}
override suspend fun close() {
logger.debug { "Closing transport for session $sessionId" }
messageQueue.close()
pendingResponses.clear()
invokeOnCloseCallback()
}
}

Some files were not shown because too many files have changed in this diff Show more