Adds a comprehensive conformance test suite for the Kotlin MCP SDK, covering core protocol operations, tool calls, elicitation, resources, prompts, and 20 OAuth/auth scenarios - Conformance server and client implementations - OAuth/auth test scenarios: JWT, authorization code flow, client credentials, PKCE, scope handling, cross-app access, client registration - CI workflow - Baseline file for tracking expected failures - Shell script fixes: - #592 - #593 - #596 ## Remaining known failures (tracked issues, will be fixed directly in `main`) - [x] `tools-call-with-logging`, `tools-call-with-progress`, `tools-call-sampling`, `tools-call-elicitation`, `elicitation-sep1034-defaults`- see #599, - [x] `elicitation-sep1330-enums` - #587 #600 - [x] `initialize` - #588 - [x] `tools_call`, `auth/scope-step-up`, `auth/scope-retry-limit` - #589 - [ ] `elicitation-sep1034-client-defaults` - #414 - [x] `sse-retry` - #590 - [ ] `resources-templates-read` - #591 ## Breaking Changes from #596 - `StreamableHttpClientTransport` and `mcpStreamableHttp`/`mcpStreamableHttpTransport`: old constructors accepting `Duration` timeout are now `@Deprecated` — use the new overloads with `ReconnectionOptions` instead - `StreamableHttpClientTransport.close()` no longer calls `terminateSession()` automatically ## Types of changes - [x] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed --------- Co-authored-by: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com>
This commit is contained in:
parent
338f94804f
commit
e88b34170c
23 changed files with 3186 additions and 1122 deletions
|
|
@ -0,0 +1,199 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.conformance
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.sse.SSE
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.conformance.auth.registerAuthScenarios
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
typealias ScenarioHandler = suspend (serverUrl: String) -> Unit
|
||||
|
||||
internal val scenarioHandlers = mutableMapOf<String, ScenarioHandler>()
|
||||
|
||||
// ============================================================================
|
||||
// Main entry point
|
||||
// ============================================================================
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
val scenarioName = System.getenv("MCP_CONFORMANCE_SCENARIO")
|
||||
val serverUrl = args.lastOrNull()
|
||||
|
||||
// Register all scenario handlers
|
||||
registerCoreScenarios()
|
||||
registerAuthScenarios()
|
||||
|
||||
if (scenarioName == null || serverUrl == null) {
|
||||
logger.error { "Usage: MCP_CONFORMANCE_SCENARIO=<scenario> conformance-client <server-url>" }
|
||||
logger.error { "\nThe MCP_CONFORMANCE_SCENARIO env var is set automatically by the conformance runner." }
|
||||
logger.error { "\nAvailable scenarios:" }
|
||||
for (name in scenarioHandlers.keys.sorted()) {
|
||||
logger.error { " - $name" }
|
||||
}
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
val handler = scenarioHandlers[scenarioName]
|
||||
if (handler == null) {
|
||||
logger.error { "Unknown scenario: $scenarioName" }
|
||||
logger.error { "\nAvailable scenarios:" }
|
||||
for (name in scenarioHandlers.keys.sorted()) {
|
||||
logger.error { " - $name" }
|
||||
}
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
handler(serverUrl)
|
||||
}
|
||||
exitProcess(0)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Error: ${e.message}" }
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shared HTTP client factory
|
||||
// ============================================================================
|
||||
|
||||
private fun createHttpClient(): HttpClient = HttpClient(CIO) {
|
||||
install(SSE)
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 30.seconds.inWholeMilliseconds
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Basic scenarios (initialize, tools_call)
|
||||
// ============================================================================
|
||||
|
||||
private suspend fun runBasicClient(serverUrl: String) {
|
||||
createHttpClient().use { httpClient ->
|
||||
val transport = StreamableHttpClientTransport(httpClient, serverUrl)
|
||||
val client = Client(
|
||||
clientInfo = Implementation("test-client", "1.0.0"),
|
||||
options = ClientOptions(capabilities = ClientCapabilities()),
|
||||
)
|
||||
client.connect(transport)
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runToolsCallClient(serverUrl: String) {
|
||||
createHttpClient().use { httpClient ->
|
||||
val transport = StreamableHttpClientTransport(httpClient, serverUrl)
|
||||
val client = Client(
|
||||
clientInfo = Implementation("test-client", "1.0.0"),
|
||||
options = ClientOptions(capabilities = ClientCapabilities()),
|
||||
)
|
||||
client.connect(transport)
|
||||
|
||||
val tools = client.listTools()
|
||||
val addTool = tools.tools.find { it.name == "add_numbers" }
|
||||
if (addTool != null) {
|
||||
client.callTool(
|
||||
CallToolRequest(
|
||||
CallToolRequestParams(
|
||||
name = "add_numbers",
|
||||
arguments = buildJsonObject {
|
||||
put("a", 5)
|
||||
put("b", 3)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Elicitation defaults scenario
|
||||
// ============================================================================
|
||||
|
||||
private suspend fun runElicitationDefaultsClient(serverUrl: String) {
|
||||
createHttpClient().use { httpClient ->
|
||||
val transport = StreamableHttpClientTransport(httpClient, serverUrl)
|
||||
val client = Client(
|
||||
clientInfo = Implementation("elicitation-defaults-test-client", "1.0.0"),
|
||||
options = ClientOptions(
|
||||
capabilities = ClientCapabilities(
|
||||
elicitation = ClientCapabilities.elicitation,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Register elicitation handler that returns empty content — SDK should fill in defaults
|
||||
client.setElicitationHandler { _ ->
|
||||
ElicitResult(
|
||||
action = ElicitResult.Action.Accept,
|
||||
content = JsonObject(emptyMap()),
|
||||
)
|
||||
}
|
||||
|
||||
client.connect(transport)
|
||||
|
||||
val tools = client.listTools()
|
||||
val testTool = tools.tools.find { it.name == "test_client_elicitation_defaults" }
|
||||
?: error("Test tool not found: test_client_elicitation_defaults")
|
||||
|
||||
client.callTool(
|
||||
CallToolRequest(CallToolRequestParams(name = testTool.name)),
|
||||
)
|
||||
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSE retry scenario
|
||||
// ============================================================================
|
||||
|
||||
private suspend fun runSSERetryClient(serverUrl: String) {
|
||||
createHttpClient().use { httpClient ->
|
||||
val transport = StreamableHttpClientTransport(httpClient, serverUrl)
|
||||
val client = Client(
|
||||
clientInfo = Implementation("sse-retry-test-client", "1.0.0"),
|
||||
options = ClientOptions(capabilities = ClientCapabilities()),
|
||||
)
|
||||
client.connect(transport)
|
||||
|
||||
client.listTools()
|
||||
|
||||
client.callTool(
|
||||
CallToolRequest(CallToolRequestParams(name = "test_reconnection")),
|
||||
)
|
||||
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Register core scenarios
|
||||
// ============================================================================
|
||||
|
||||
private fun registerCoreScenarios() {
|
||||
scenarioHandlers["initialize"] = ::runBasicClient
|
||||
scenarioHandlers["tools_call"] = ::runToolsCallClient
|
||||
scenarioHandlers["elicitation-sep1034-client-defaults"] = ::runElicitationDefaultsClient
|
||||
scenarioHandlers["sse-retry"] = ::runSSERetryClient
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.conformance
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CompleteRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CompleteResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Method
|
||||
|
||||
fun Server.registerConformanceCompletions() {
|
||||
onConnect {
|
||||
val session = sessions.values.lastOrNull() ?: return@onConnect
|
||||
session.setRequestHandler<CompleteRequest>(Method.Defined.CompletionComplete) { _, _ ->
|
||||
CompleteResult(CompleteResult.Completion(values = emptyList(), total = 0, hasMore = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.conformance
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.EmbeddedResource
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ImageContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Role
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
fun Server.registerConformancePrompts() {
|
||||
// 1. Simple prompt
|
||||
addPrompt(
|
||||
name = "test_simple_prompt",
|
||||
description = "test_simple_prompt",
|
||||
) {
|
||||
GetPromptResult(
|
||||
messages = listOf(
|
||||
PromptMessage(Role.User, TextContent("This is a simple prompt for testing.")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Prompt with arguments
|
||||
addPrompt(
|
||||
name = "test_prompt_with_arguments",
|
||||
description = "test_prompt_with_arguments",
|
||||
arguments = listOf(
|
||||
PromptArgument(name = "arg1", description = "First test argument", required = true),
|
||||
PromptArgument(name = "arg2", description = "Second test argument", required = true),
|
||||
),
|
||||
) { request ->
|
||||
val arg1 = request.arguments?.get("arg1") ?: ""
|
||||
val arg2 = request.arguments?.get("arg2") ?: ""
|
||||
GetPromptResult(
|
||||
messages = listOf(
|
||||
PromptMessage(
|
||||
Role.User,
|
||||
TextContent("Prompt with arguments: arg1='$arg1', arg2='$arg2'"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Prompt with image
|
||||
addPrompt(
|
||||
name = "test_prompt_with_image",
|
||||
description = "test_prompt_with_image",
|
||||
) {
|
||||
GetPromptResult(
|
||||
messages = listOf(
|
||||
PromptMessage(Role.User, ImageContent(data = PNG_BASE64, mimeType = "image/png")),
|
||||
PromptMessage(Role.User, TextContent("Please analyze the image above.")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Prompt with embedded resource
|
||||
addPrompt(
|
||||
name = "test_prompt_with_embedded_resource",
|
||||
description = "test_prompt_with_embedded_resource",
|
||||
arguments = listOf(
|
||||
PromptArgument(name = "resourceUri", description = "URI of the resource to embed", required = true),
|
||||
),
|
||||
) { request ->
|
||||
val resourceUri = request.arguments?.get("resourceUri") ?: "test://embedded-resource"
|
||||
GetPromptResult(
|
||||
messages = listOf(
|
||||
PromptMessage(
|
||||
Role.User,
|
||||
EmbeddedResource(
|
||||
resource = TextResourceContents(
|
||||
text = "Embedded resource content for testing",
|
||||
uri = resourceUri,
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
),
|
||||
PromptMessage(Role.User, TextContent("Please process the embedded resource above.")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Dynamic prompt
|
||||
val server = this
|
||||
addPrompt(
|
||||
name = "test_dynamic_prompt",
|
||||
description = "test_dynamic_prompt",
|
||||
) {
|
||||
// Add a temporary prompt, triggering listChanged
|
||||
server.addPrompt(
|
||||
name = "test_dynamic_prompt_temp",
|
||||
description = "Temporary dynamic prompt",
|
||||
) {
|
||||
GetPromptResult(
|
||||
messages = listOf(
|
||||
PromptMessage(Role.User, TextContent("Temporary prompt response")),
|
||||
),
|
||||
)
|
||||
}
|
||||
delay(100.milliseconds)
|
||||
// Remove the temporary prompt, triggering listChanged again
|
||||
server.removePrompt("test_dynamic_prompt_temp")
|
||||
GetPromptResult(
|
||||
messages = listOf(
|
||||
PromptMessage(Role.User, TextContent("Dynamic prompt executed successfully")),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.conformance
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.BlobResourceContents
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
fun Server.registerConformanceResources() {
|
||||
// 1. Static text resource
|
||||
addResource(
|
||||
uri = "test://static-text",
|
||||
name = "static-text",
|
||||
description = "A static text resource for testing",
|
||||
mimeType = "text/plain",
|
||||
) {
|
||||
ReadResourceResult(
|
||||
listOf(
|
||||
TextResourceContents(
|
||||
text = "This is the content of the static text resource.",
|
||||
uri = "test://static-text",
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Static binary resource
|
||||
addResource(
|
||||
uri = "test://static-binary",
|
||||
name = "static-binary",
|
||||
description = "A static binary resource for testing",
|
||||
mimeType = "image/png",
|
||||
) {
|
||||
ReadResourceResult(
|
||||
listOf(
|
||||
BlobResourceContents(
|
||||
blob = PNG_BASE64,
|
||||
uri = "test://static-binary",
|
||||
mimeType = "image/png",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Template resource
|
||||
// Note: The SDK does not currently support addResourceTemplate().
|
||||
// Register as a static resource; template listing is handled separately.
|
||||
addResource(
|
||||
uri = "test://template/{id}/data",
|
||||
name = "template",
|
||||
description = "A template resource for testing",
|
||||
mimeType = "application/json",
|
||||
) { request ->
|
||||
ReadResourceResult(
|
||||
listOf(
|
||||
TextResourceContents(
|
||||
text = "content for ${request.uri}",
|
||||
uri = request.uri,
|
||||
mimeType = "application/json",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Watched resource
|
||||
addResource(
|
||||
uri = "test://watched-resource",
|
||||
name = "watched-resource",
|
||||
description = "A watched resource for testing",
|
||||
mimeType = "text/plain",
|
||||
) {
|
||||
ReadResourceResult(
|
||||
listOf(
|
||||
TextResourceContents(
|
||||
text = "Watched resource content.",
|
||||
uri = "test://watched-resource",
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Dynamic resource
|
||||
val server = this
|
||||
addResource(
|
||||
uri = "test://dynamic-resource",
|
||||
name = "dynamic-resource",
|
||||
description = "A dynamic resource for testing",
|
||||
mimeType = "text/plain",
|
||||
) {
|
||||
// Add a temporary resource, triggering listChanged
|
||||
server.addResource(
|
||||
uri = "test://dynamic-resource-temp",
|
||||
name = "dynamic-resource-temp",
|
||||
description = "Temporary dynamic resource",
|
||||
mimeType = "text/plain",
|
||||
) {
|
||||
ReadResourceResult(
|
||||
listOf(
|
||||
TextResourceContents(
|
||||
text = "Temporary resource content.",
|
||||
uri = "test://dynamic-resource-temp",
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
delay(100.milliseconds)
|
||||
// Remove the temporary resource, triggering listChanged again
|
||||
server.removeResource("test://dynamic-resource-temp")
|
||||
ReadResourceResult(
|
||||
listOf(
|
||||
TextResourceContents(
|
||||
text = "Dynamic resource content.",
|
||||
uri = "test://dynamic-resource",
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.conformance
|
||||
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
|
||||
fun main() {
|
||||
val port = System.getenv("MCP_PORT")?.toIntOrNull() ?: 3001
|
||||
embeddedServer(CIO, port = port) {
|
||||
install(ContentNegotiation) {
|
||||
json(McpJson)
|
||||
}
|
||||
mcpStreamableHttp(
|
||||
enableDnsRebindingProtection = true,
|
||||
allowedHosts = listOf("localhost", "127.0.0.1", "localhost:$port", "127.0.0.1:$port"),
|
||||
eventStore = InMemoryEventStore(),
|
||||
) {
|
||||
createConformanceServer()
|
||||
}
|
||||
}.start(wait = true)
|
||||
}
|
||||
|
||||
fun createConformanceServer(): Server = Server(
|
||||
serverInfo = Implementation("mcp-kotlin-sdk-conformance", "0.1.0"),
|
||||
options = ServerOptions(
|
||||
ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(listChanged = true),
|
||||
resources = ServerCapabilities.Resources(subscribe = true, listChanged = true),
|
||||
prompts = ServerCapabilities.Prompts(listChanged = true),
|
||||
logging = ServerCapabilities.Logging,
|
||||
completions = ServerCapabilities.Completions,
|
||||
),
|
||||
),
|
||||
) {
|
||||
registerConformanceTools()
|
||||
registerConformanceResources()
|
||||
registerConformancePrompts()
|
||||
registerConformanceCompletions()
|
||||
}
|
||||
|
|
@ -0,0 +1,604 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.conformance
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.AudioContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.EmbeddedResource
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ImageContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ProgressNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ProgressNotificationParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Role
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.double
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// Minimal 1x1 PNG (base64)
|
||||
internal const val PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
|
||||
|
||||
// Minimal WAV (base64)
|
||||
internal const val WAV_BASE64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA="
|
||||
|
||||
@Suppress("LongMethod")
|
||||
fun Server.registerConformanceTools() {
|
||||
// 1. Simple text
|
||||
addTool(
|
||||
name = "test_simple_text",
|
||||
description = "test_simple_text",
|
||||
) {
|
||||
CallToolResult(listOf(TextContent("Simple text content")))
|
||||
}
|
||||
|
||||
// 2. Image content
|
||||
addTool(
|
||||
name = "test_image_content",
|
||||
description = "test_image_content",
|
||||
) {
|
||||
CallToolResult(listOf(ImageContent(data = PNG_BASE64, mimeType = "image/png")))
|
||||
}
|
||||
|
||||
// 3. Audio content
|
||||
addTool(
|
||||
name = "test_audio_content",
|
||||
description = "test_audio_content",
|
||||
) {
|
||||
CallToolResult(listOf(AudioContent(data = WAV_BASE64, mimeType = "audio/wav")))
|
||||
}
|
||||
|
||||
// 4. Embedded resource
|
||||
addTool(
|
||||
name = "test_embedded_resource",
|
||||
description = "test_embedded_resource",
|
||||
) {
|
||||
CallToolResult(
|
||||
listOf(
|
||||
EmbeddedResource(
|
||||
resource = TextResourceContents(
|
||||
text = "This is an embedded resource content.",
|
||||
uri = "test://embedded-resource",
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Multiple content types
|
||||
addTool(
|
||||
name = "test_multiple_content_types",
|
||||
description = "test_multiple_content_types",
|
||||
) {
|
||||
CallToolResult(
|
||||
listOf(
|
||||
TextContent("Simple text content"),
|
||||
ImageContent(data = PNG_BASE64, mimeType = "image/png"),
|
||||
EmbeddedResource(
|
||||
resource = TextResourceContents(
|
||||
text = "This is an embedded resource content.",
|
||||
uri = "test://embedded-resource",
|
||||
mimeType = "text/plain",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 6. Progress tool
|
||||
addTool(
|
||||
name = "test_tool_with_progress",
|
||||
description = "test_tool_with_progress",
|
||||
) { request ->
|
||||
val progressToken = request.meta?.progressToken
|
||||
if (progressToken != null) {
|
||||
notification(
|
||||
ProgressNotification(
|
||||
ProgressNotificationParams(
|
||||
progressToken,
|
||||
0.0,
|
||||
100.0,
|
||||
"Completed step 0 of 100",
|
||||
),
|
||||
),
|
||||
)
|
||||
delay(50.milliseconds)
|
||||
notification(
|
||||
ProgressNotification(
|
||||
ProgressNotificationParams(
|
||||
progressToken,
|
||||
50.0,
|
||||
100.0,
|
||||
"Completed step 50 of 100",
|
||||
),
|
||||
),
|
||||
)
|
||||
delay(50.milliseconds)
|
||||
notification(
|
||||
ProgressNotification(
|
||||
ProgressNotificationParams(
|
||||
progressToken,
|
||||
100.0,
|
||||
100.0,
|
||||
"Completed step 100 of 100",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
CallToolResult(listOf(TextContent("Simple text content")))
|
||||
}
|
||||
|
||||
// 7. Error handling
|
||||
addTool(
|
||||
name = "test_error_handling",
|
||||
description = "test_error_handling",
|
||||
) {
|
||||
throw Exception("This tool intentionally returns an error for testing")
|
||||
}
|
||||
|
||||
// 8. Sampling
|
||||
addTool(
|
||||
name = "test_sampling",
|
||||
description = "test_sampling",
|
||||
inputSchema = ToolSchema(
|
||||
properties = buildJsonObject {
|
||||
put("prompt", buildJsonObject { put("type", JsonPrimitive("string")) })
|
||||
},
|
||||
required = listOf("prompt"),
|
||||
),
|
||||
) { request ->
|
||||
val prompt = request.arguments?.get("prompt")?.jsonPrimitive?.content ?: "Hello"
|
||||
val result = createMessage(
|
||||
CreateMessageRequest(
|
||||
CreateMessageRequestParams(
|
||||
maxTokens = 10000,
|
||||
messages = listOf(SamplingMessage(Role.User, TextContent(prompt))),
|
||||
),
|
||||
),
|
||||
)
|
||||
CallToolResult(listOf(TextContent(result.content.toString())))
|
||||
}
|
||||
|
||||
// 9. Elicitation
|
||||
addTool(
|
||||
name = "test_elicitation",
|
||||
description = "test_elicitation",
|
||||
inputSchema = ToolSchema(
|
||||
properties = buildJsonObject {
|
||||
put("message", buildJsonObject { put("type", JsonPrimitive("string")) })
|
||||
},
|
||||
required = listOf("message"),
|
||||
),
|
||||
) { request ->
|
||||
val message = request.arguments?.get("message")?.jsonPrimitive?.content ?: "Please provide input"
|
||||
val schema = ElicitRequestParams.RequestedSchema(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"username",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put("description", JsonPrimitive("User's response"))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"email",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put("description", JsonPrimitive("User's email address"))
|
||||
},
|
||||
)
|
||||
},
|
||||
required = listOf("username", "email"),
|
||||
)
|
||||
val result = createElicitation(message, schema)
|
||||
CallToolResult(listOf(TextContent("User response: <action: ${result.action}, content: ${result.content}>")))
|
||||
}
|
||||
|
||||
// 10. Elicitation SEP1034 (defaults)
|
||||
addTool(
|
||||
name = "test_elicitation_sep1034_defaults",
|
||||
description = "test_elicitation_sep1034_defaults",
|
||||
) {
|
||||
val schema = ElicitRequestParams.RequestedSchema(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"name",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put("description", JsonPrimitive("User name"))
|
||||
put("default", JsonPrimitive("John Doe"))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"age",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("integer"))
|
||||
put("description", JsonPrimitive("User age"))
|
||||
put("default", JsonPrimitive(30))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"score",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("number"))
|
||||
put("description", JsonPrimitive("User score"))
|
||||
put("default", JsonPrimitive(95.5))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"status",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put("description", JsonPrimitive("User status"))
|
||||
put("default", JsonPrimitive("active"))
|
||||
put(
|
||||
"enum",
|
||||
JsonArray(
|
||||
listOf(JsonPrimitive("active"), JsonPrimitive("inactive"), JsonPrimitive("pending")),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
put(
|
||||
"verified",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("boolean"))
|
||||
put("description", JsonPrimitive("Verification status"))
|
||||
put("default", JsonPrimitive(true))
|
||||
},
|
||||
)
|
||||
},
|
||||
required = listOf("name", "age", "score", "status", "verified"),
|
||||
)
|
||||
val result = createElicitation(
|
||||
"Please review and update the form fields with defaults",
|
||||
schema,
|
||||
)
|
||||
CallToolResult(listOf(TextContent(result.content.toString())))
|
||||
}
|
||||
|
||||
// 11. Elicitation SEP1330 enums
|
||||
addTool(
|
||||
name = "test_elicitation_sep1330_enums",
|
||||
description = "test_elicitation_sep1330_enums",
|
||||
) {
|
||||
val schema = ElicitRequestParams.RequestedSchema(
|
||||
properties = buildJsonObject {
|
||||
// Untitled single-select
|
||||
put(
|
||||
"untitledSingle",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put(
|
||||
"enum",
|
||||
JsonArray(
|
||||
listOf(JsonPrimitive("option1"), JsonPrimitive("option2"), JsonPrimitive("option3")),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
// Titled single-select
|
||||
put(
|
||||
"titledSingle",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put(
|
||||
"oneOf",
|
||||
JsonArray(
|
||||
listOf(
|
||||
buildJsonObject {
|
||||
put("const", JsonPrimitive("value1"))
|
||||
put("title", JsonPrimitive("First Option"))
|
||||
},
|
||||
buildJsonObject {
|
||||
put("const", JsonPrimitive("value2"))
|
||||
put("title", JsonPrimitive("Second Option"))
|
||||
},
|
||||
buildJsonObject {
|
||||
put("const", JsonPrimitive("value3"))
|
||||
put("title", JsonPrimitive("Third Option"))
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
// Legacy titled (deprecated)
|
||||
put(
|
||||
"legacyEnum",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put(
|
||||
"oneOf",
|
||||
JsonArray(
|
||||
listOf(
|
||||
buildJsonObject {
|
||||
put("const", JsonPrimitive("opt1"))
|
||||
put("title", JsonPrimitive("Option One"))
|
||||
},
|
||||
buildJsonObject {
|
||||
put("const", JsonPrimitive("opt2"))
|
||||
put("title", JsonPrimitive("Option Two"))
|
||||
},
|
||||
buildJsonObject {
|
||||
put("const", JsonPrimitive("opt3"))
|
||||
put("title", JsonPrimitive("Option Three"))
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
// Untitled multi-select
|
||||
put(
|
||||
"untitledMulti",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("array"))
|
||||
put(
|
||||
"items",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put(
|
||||
"enum",
|
||||
JsonArray(
|
||||
listOf(
|
||||
JsonPrimitive("option1"),
|
||||
JsonPrimitive("option2"),
|
||||
JsonPrimitive("option3"),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
// Titled multi-select
|
||||
put(
|
||||
"titledMulti",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("array"))
|
||||
put(
|
||||
"items",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put(
|
||||
"oneOf",
|
||||
JsonArray(
|
||||
listOf(
|
||||
buildJsonObject {
|
||||
put("const", JsonPrimitive("value1"))
|
||||
put("title", JsonPrimitive("First Choice"))
|
||||
},
|
||||
buildJsonObject {
|
||||
put("const", JsonPrimitive("value2"))
|
||||
put("title", JsonPrimitive("Second Choice"))
|
||||
},
|
||||
buildJsonObject {
|
||||
put("const", JsonPrimitive("value3"))
|
||||
put("title", JsonPrimitive("Third Choice"))
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
val result = createElicitation(
|
||||
"Please review and update the form fields with defaults",
|
||||
schema,
|
||||
)
|
||||
CallToolResult(listOf(TextContent(result.content.toString())))
|
||||
}
|
||||
|
||||
// 12. Dynamic tool
|
||||
val server = this
|
||||
addTool(
|
||||
name = "test_dynamic_tool",
|
||||
description = "test_dynamic_tool",
|
||||
) {
|
||||
// Add a temporary tool, triggering listChanged
|
||||
server.addTool(
|
||||
name = "test_dynamic_tool_temp",
|
||||
description = "Temporary dynamic tool",
|
||||
) {
|
||||
CallToolResult(listOf(TextContent("Temporary tool response")))
|
||||
}
|
||||
delay(100.milliseconds)
|
||||
// Remove the temporary tool, triggering listChanged again
|
||||
server.removeTool("test_dynamic_tool_temp")
|
||||
CallToolResult(listOf(TextContent("Dynamic tool executed successfully")))
|
||||
}
|
||||
|
||||
// 13. Logging tool
|
||||
addTool(
|
||||
name = "test_tool_with_logging",
|
||||
description = "test_tool_with_logging",
|
||||
) {
|
||||
sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotificationParams(
|
||||
level = LoggingLevel.Info,
|
||||
data = JsonPrimitive("Tool execution started"),
|
||||
logger = "conformance",
|
||||
),
|
||||
),
|
||||
)
|
||||
delay(50.milliseconds)
|
||||
sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotificationParams(
|
||||
level = LoggingLevel.Info,
|
||||
data = JsonPrimitive("Tool processing data"),
|
||||
logger = "conformance",
|
||||
),
|
||||
),
|
||||
)
|
||||
delay(50.milliseconds)
|
||||
sendLoggingMessage(
|
||||
LoggingMessageNotification(
|
||||
LoggingMessageNotificationParams(
|
||||
level = LoggingLevel.Info,
|
||||
data = JsonPrimitive("Tool execution completed"),
|
||||
logger = "conformance",
|
||||
),
|
||||
),
|
||||
)
|
||||
CallToolResult(listOf(TextContent("Simple text content")))
|
||||
}
|
||||
|
||||
// 14. add_numbers — used by tools_call client scenario
|
||||
addTool(
|
||||
name = "add_numbers",
|
||||
description = "Adds two numbers together",
|
||||
inputSchema = ToolSchema(
|
||||
properties = buildJsonObject {
|
||||
put("a", buildJsonObject { put("type", JsonPrimitive("number")) })
|
||||
put("b", buildJsonObject { put("type", JsonPrimitive("number")) })
|
||||
},
|
||||
required = listOf("a", "b"),
|
||||
),
|
||||
) { request ->
|
||||
val a = request.arguments?.get("a")?.jsonPrimitive?.double ?: 0.0
|
||||
val b = request.arguments?.get("b")?.jsonPrimitive?.double ?: 0.0
|
||||
val sum = a + b
|
||||
CallToolResult(listOf(TextContent("The sum of $a and $b is $sum")))
|
||||
}
|
||||
|
||||
// 15. test_reconnection — SEP-1699, closes SSE stream to test client reconnection
|
||||
addTool(
|
||||
name = "test_reconnection",
|
||||
description = "Tests SSE stream disconnection and client reconnection (SEP-1699)",
|
||||
) {
|
||||
// SDK limitation: cannot access the JSONRPC request ID from the tool handler
|
||||
// to close the SSE stream. Return success text; this test may fail at the
|
||||
// conformance runner level because the stream isn't actually closed.
|
||||
delay(100.milliseconds)
|
||||
CallToolResult(
|
||||
listOf(
|
||||
TextContent(
|
||||
"Reconnection test completed successfully. " +
|
||||
"If you received this, the client properly reconnected after stream closure.",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 16. json_schema_2020_12_tool — SEP-1613
|
||||
addTool(
|
||||
name = "json_schema_2020_12_tool",
|
||||
description = "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)",
|
||||
inputSchema = ToolSchema(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"name",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"address",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("object"))
|
||||
put(
|
||||
"properties",
|
||||
buildJsonObject {
|
||||
put("street", buildJsonObject { put("type", JsonPrimitive("string")) })
|
||||
put("city", buildJsonObject { put("type", JsonPrimitive("string")) })
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
) { request ->
|
||||
CallToolResult(
|
||||
listOf(TextContent("JSON Schema 2020-12 tool called with: ${request.arguments}")),
|
||||
)
|
||||
}
|
||||
|
||||
// 17. test_client_elicitation_defaults — used by elicitation-sep1034-client-defaults scenario
|
||||
addTool(
|
||||
name = "test_client_elicitation_defaults",
|
||||
description = "test_client_elicitation_defaults",
|
||||
) {
|
||||
val schema = ElicitRequestParams.RequestedSchema(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"name",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put("description", JsonPrimitive("User name"))
|
||||
put("default", JsonPrimitive("John Doe"))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"age",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("integer"))
|
||||
put("description", JsonPrimitive("User age"))
|
||||
put("default", JsonPrimitive(30))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"score",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("number"))
|
||||
put("description", JsonPrimitive("User score"))
|
||||
put("default", JsonPrimitive(95.5))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"status",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put("description", JsonPrimitive("User status"))
|
||||
put("default", JsonPrimitive("active"))
|
||||
put(
|
||||
"enum",
|
||||
JsonArray(
|
||||
listOf(JsonPrimitive("active"), JsonPrimitive("inactive"), JsonPrimitive("pending")),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
put(
|
||||
"verified",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("boolean"))
|
||||
put("description", JsonPrimitive("Verification status"))
|
||||
put("default", JsonPrimitive(true))
|
||||
},
|
||||
)
|
||||
},
|
||||
required = emptyList(),
|
||||
)
|
||||
val result = createElicitation(
|
||||
"Please review and update the form fields with defaults",
|
||||
schema,
|
||||
)
|
||||
CallToolResult(listOf(TextContent("Elicitation completed: action=${result.action}, content=${result.content}")))
|
||||
}
|
||||
|
||||
// 18. test-tool — simple tool used by auth scenarios
|
||||
addTool(
|
||||
name = "test-tool",
|
||||
description = "Simple test tool for auth scenarios",
|
||||
) {
|
||||
CallToolResult(listOf(TextContent("Test tool executed successfully")))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.conformance
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.EventStore
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
class InMemoryEventStore : EventStore {
|
||||
|
||||
private val events = ConcurrentHashMap<String, Pair<JSONRPCMessage, String>>()
|
||||
private val streamEvents = ConcurrentHashMap<String, MutableList<String>>()
|
||||
|
||||
override suspend fun storeEvent(streamId: String, message: JSONRPCMessage): String {
|
||||
val eventId = "$streamId::${System.currentTimeMillis()}_${Uuid.random()}"
|
||||
events[eventId] = message to streamId
|
||||
streamEvents.getOrPut(streamId) { mutableListOf() }.add(eventId)
|
||||
return eventId
|
||||
}
|
||||
|
||||
override suspend fun replayEventsAfter(
|
||||
lastEventId: String,
|
||||
sender: suspend (eventId: String, message: JSONRPCMessage) -> Unit,
|
||||
): String {
|
||||
val streamId = getStreamIdForEventId(lastEventId)
|
||||
?: error("Unknown event ID: $lastEventId")
|
||||
val eventIds = streamEvents[streamId] ?: return streamId
|
||||
|
||||
var found = false
|
||||
for (eventId in eventIds) {
|
||||
if (!found) {
|
||||
if (eventId == lastEventId) found = true
|
||||
continue
|
||||
}
|
||||
val (message, _) = events[eventId] ?: continue
|
||||
sender(eventId, message)
|
||||
}
|
||||
return streamId
|
||||
}
|
||||
|
||||
override suspend fun getStreamIdForEventId(eventId: String): String? {
|
||||
val idx = eventId.indexOf("::")
|
||||
return if (idx >= 0) eventId.substring(0, idx) else null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.conformance.auth
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.sse.SSE
|
||||
import io.ktor.client.request.forms.submitForm
|
||||
import io.ktor.http.Parameters
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import java.security.KeyFactory
|
||||
import java.security.Signature
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.Base64
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
// Client Credentials JWT scenario
|
||||
internal suspend fun runClientCredentialsJwt(serverUrl: String) {
|
||||
val ctx = conformanceContext()
|
||||
val clientId = ctx.requiredString("client_id")
|
||||
val privateKeyPem = ctx.requiredString("private_key_pem")
|
||||
val signingAlgorithm = ctx["signing_algorithm"]?.jsonPrimitive?.content ?: "ES256"
|
||||
|
||||
val httpClient = HttpClient(CIO) {
|
||||
install(SSE)
|
||||
followRedirects = false
|
||||
}
|
||||
|
||||
httpClient.use { client ->
|
||||
val resourceMetadata = discoverResourceMetadata(client, serverUrl)
|
||||
val authServer = resourceMetadata["authorization_servers"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.content
|
||||
?: error("No authorization_servers in resource metadata")
|
||||
val oauthMetadata = fetchOAuthMetadata(client, authServer)
|
||||
val tokenEndpoint = oauthMetadata["token_endpoint"]?.jsonPrimitive?.content
|
||||
?: error("No token_endpoint in AS metadata")
|
||||
val issuer = oauthMetadata["issuer"]?.jsonPrimitive?.content
|
||||
?: error("No issuer in AS metadata")
|
||||
|
||||
// Create JWT client assertion
|
||||
val assertion = createJwtAssertion(clientId, issuer, privateKeyPem, signingAlgorithm)
|
||||
|
||||
// Exchange for token
|
||||
val tokenResponse = client.submitForm(
|
||||
url = tokenEndpoint,
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "client_credentials")
|
||||
append("client_id", clientId)
|
||||
append("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
|
||||
append("client_assertion", assertion)
|
||||
},
|
||||
)
|
||||
val accessToken = extractAccessToken(tokenResponse)
|
||||
|
||||
withBearerToken(accessToken) { authedClient ->
|
||||
val transport = StreamableHttpClientTransport(authedClient, serverUrl)
|
||||
val client = Client(
|
||||
clientInfo = Implementation("conformance-client-credentials-jwt", "1.0.0"),
|
||||
options = ClientOptions(capabilities = ClientCapabilities()),
|
||||
)
|
||||
client.connect(transport)
|
||||
client.listTools()
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JWT Assertion
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private fun createJwtAssertion(
|
||||
clientId: String,
|
||||
audience: String,
|
||||
privateKeyPem: String,
|
||||
algorithm: String,
|
||||
): String {
|
||||
val header = buildJsonObject {
|
||||
put("alg", algorithm)
|
||||
put("typ", "JWT")
|
||||
}.toString()
|
||||
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
val payload = buildJsonObject {
|
||||
put("iss", clientId)
|
||||
put("sub", clientId)
|
||||
put("aud", audience)
|
||||
put("iat", now)
|
||||
put("exp", now + 300)
|
||||
put("jti", Uuid.random().toString())
|
||||
}.toString()
|
||||
|
||||
val headerB64 = Base64.getUrlEncoder().withoutPadding().encodeToString(header.toByteArray())
|
||||
val payloadB64 = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.toByteArray())
|
||||
val signingInput = "$headerB64.$payloadB64"
|
||||
|
||||
val signature = signJwt(signingInput, privateKeyPem, algorithm)
|
||||
return "$signingInput.$signature"
|
||||
}
|
||||
|
||||
private fun signJwt(input: String, privateKeyPem: String, algorithm: String): String {
|
||||
val pemBody = privateKeyPem
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replace("-----BEGIN EC PRIVATE KEY-----", "")
|
||||
.replace("-----END EC PRIVATE KEY-----", "")
|
||||
.replace("-----BEGIN RSA PRIVATE KEY-----", "")
|
||||
.replace("-----END RSA PRIVATE KEY-----", "")
|
||||
.replace("\n", "")
|
||||
.replace("\r", "")
|
||||
.trim()
|
||||
|
||||
val keyBytes = Base64.getDecoder().decode(pemBody)
|
||||
val keySpec = PKCS8EncodedKeySpec(keyBytes)
|
||||
|
||||
val (keyAlgorithm, signatureAlgorithm) = when (algorithm) {
|
||||
"ES256" -> "EC" to "SHA256withECDSA"
|
||||
"RS256" -> "RSA" to "SHA256withRSA"
|
||||
else -> error("Unsupported signing algorithm: $algorithm")
|
||||
}
|
||||
|
||||
val keyFactory = KeyFactory.getInstance(keyAlgorithm)
|
||||
val privateKey = keyFactory.generatePrivate(keySpec)
|
||||
|
||||
val sig = Signature.getInstance(signatureAlgorithm)
|
||||
sig.initSign(privateKey)
|
||||
sig.update(input.toByteArray())
|
||||
val rawSignature = sig.sign()
|
||||
|
||||
// For EC, convert DER to raw r||s format for JWS
|
||||
val signatureBytes = if (keyAlgorithm == "EC") {
|
||||
derToRawEcSignature(rawSignature)
|
||||
} else {
|
||||
rawSignature
|
||||
}
|
||||
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(signatureBytes)
|
||||
}
|
||||
|
||||
private fun derToRawEcSignature(der: ByteArray): ByteArray {
|
||||
// DER format: 0x30 len 0x02 rLen r 0x02 sLen s
|
||||
require(der.size >= 2) { "DER signature too short" }
|
||||
|
||||
var offset = 2 // skip SEQUENCE tag and length
|
||||
if (der[1].toInt() and 0x80 != 0) {
|
||||
offset += (der[1].toInt() and 0x7f)
|
||||
}
|
||||
|
||||
// Read r
|
||||
require(offset < der.size) { "DER signature truncated before r tag" }
|
||||
check(der[offset] == 0x02.toByte()) { "Expected INTEGER tag for r" }
|
||||
offset++
|
||||
require(offset < der.size) { "DER signature truncated before r length" }
|
||||
val rLen = der[offset].toInt() and 0xff
|
||||
offset++
|
||||
require(offset + rLen <= der.size) { "DER signature truncated in r value" }
|
||||
val r = der.copyOfRange(offset, offset + rLen)
|
||||
offset += rLen
|
||||
|
||||
// Read s
|
||||
require(offset < der.size) { "DER signature truncated before s tag" }
|
||||
check(der[offset] == 0x02.toByte()) { "Expected INTEGER tag for s" }
|
||||
offset++
|
||||
require(offset < der.size) { "DER signature truncated before s length" }
|
||||
val sLen = der[offset].toInt() and 0xff
|
||||
offset++
|
||||
require(offset + sLen <= der.size) { "DER signature truncated in s value" }
|
||||
val s = der.copyOfRange(offset, offset + sLen)
|
||||
|
||||
// Each component should be 32 bytes for P-256
|
||||
val componentLen = 32
|
||||
val result = ByteArray(componentLen * 2)
|
||||
|
||||
// Copy r (may need padding or trimming of leading zero)
|
||||
val rStart = if (r.size > componentLen) r.size - componentLen else 0
|
||||
val rDest = if (r.size < componentLen) componentLen - r.size else 0
|
||||
r.copyInto(result, rDest, rStart, r.size)
|
||||
|
||||
// Copy s
|
||||
val sStart = if (s.size > componentLen) s.size - componentLen else 0
|
||||
val sDest = componentLen + if (s.size < componentLen) componentLen - s.size else 0
|
||||
s.copyInto(result, sDest, sStart, s.size)
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.conformance.auth
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.HttpSend
|
||||
import io.ktor.client.plugins.plugin
|
||||
import io.ktor.client.plugins.sse.SSE
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.util.UUID
|
||||
|
||||
internal suspend fun runAuthClient(serverUrl: String) {
|
||||
val httpClient = HttpClient(CIO) {
|
||||
install(SSE)
|
||||
followRedirects = false
|
||||
}
|
||||
|
||||
var accessToken: String? = null
|
||||
var authAttempts = 0
|
||||
// Cache discovery and credentials across retries
|
||||
var cachedDiscovery: DiscoveryResult? = null
|
||||
var cachedCredentials: ClientCredentials? = null
|
||||
|
||||
httpClient.plugin(HttpSend).intercept { request ->
|
||||
// Add existing token if available
|
||||
if (accessToken != null) {
|
||||
request.headers.remove(HttpHeaders.Authorization)
|
||||
request.headers.append(HttpHeaders.Authorization, "Bearer $accessToken")
|
||||
}
|
||||
|
||||
val response = execute(request)
|
||||
val status = response.response.status
|
||||
|
||||
// Determine if we need to (re-)authorize
|
||||
val needsAuth = status == HttpStatusCode.Unauthorized
|
||||
val wwwAuth = response.response.headers[HttpHeaders.WWWAuthenticate] ?: ""
|
||||
val stepUpScope = if (status == HttpStatusCode.Forbidden) parseStepUpScope(wwwAuth) else null
|
||||
val needsStepUp = stepUpScope != null
|
||||
|
||||
if ((needsAuth || needsStepUp) && authAttempts < 3) {
|
||||
authAttempts++
|
||||
|
||||
// Discover metadata (cache across retries)
|
||||
if (cachedDiscovery == null) {
|
||||
val resourceMetadataUrl = extractParam(wwwAuth, "resource_metadata")
|
||||
cachedDiscovery = discoverOAuthMetadata(httpClient, serverUrl, resourceMetadataUrl)
|
||||
}
|
||||
val discovery: DiscoveryResult = cachedDiscovery
|
||||
|
||||
// Validate PRM resource matches server URL (RFC 8707)
|
||||
val discoveredResource = discovery.resourceUrl
|
||||
if (discoveredResource != null) {
|
||||
val normalizedResource = discoveredResource.trimEnd('/')
|
||||
val normalizedServerUrl = serverUrl.trimEnd('/')
|
||||
val matches = normalizedServerUrl == normalizedResource ||
|
||||
normalizedServerUrl.startsWith("$normalizedResource/")
|
||||
require(matches) {
|
||||
"PRM resource mismatch: resource='$discoveredResource' does not match server URL='$serverUrl'"
|
||||
}
|
||||
}
|
||||
|
||||
val metadata = discovery.asMetadata
|
||||
|
||||
val authEndpoint = metadata["authorization_endpoint"]?.jsonPrimitive?.content
|
||||
?: error("No authorization_endpoint in metadata")
|
||||
val tokenEndpoint = metadata["token_endpoint"]?.jsonPrimitive?.content
|
||||
?: error("No token_endpoint in metadata")
|
||||
|
||||
val tokenEndpointAuthMethods = metadata["token_endpoint_auth_methods_supported"]
|
||||
?.jsonArray?.map { it.jsonPrimitive.content }
|
||||
?: listOf("client_secret_post")
|
||||
val tokenAuthMethod = tokenEndpointAuthMethods.firstOrNull() ?: "client_secret_post"
|
||||
|
||||
// Verify PKCE support
|
||||
verifyPkceSupport(metadata)
|
||||
|
||||
// Resolve client credentials (cache across retries)
|
||||
if (cachedCredentials == null) {
|
||||
cachedCredentials = resolveClientCredentials(httpClient, metadata)
|
||||
}
|
||||
val creds: ClientCredentials = cachedCredentials
|
||||
|
||||
// Determine scope
|
||||
val scope = if (needsStepUp) {
|
||||
stepUpScope
|
||||
} else {
|
||||
val wwwAuthScope = extractParam(wwwAuth, "scope")
|
||||
selectScope(wwwAuthScope, discovery.scopesSupported)
|
||||
}
|
||||
|
||||
// PKCE
|
||||
val codeVerifier = generateCodeVerifier()
|
||||
val codeChallenge = generateCodeChallenge(codeVerifier)
|
||||
|
||||
// CSRF state parameter
|
||||
val state = UUID.randomUUID().toString()
|
||||
|
||||
// Build authorization URL
|
||||
val authUrl = buildAuthorizationUrl(
|
||||
authEndpoint,
|
||||
creds.clientId,
|
||||
CALLBACK_URL,
|
||||
codeChallenge,
|
||||
scope,
|
||||
discovery.resourceUrl,
|
||||
state,
|
||||
)
|
||||
|
||||
// Follow the authorization redirect to get auth code
|
||||
val authCode = followAuthorizationRedirect(httpClient, authUrl, CALLBACK_URL, state)
|
||||
|
||||
// Exchange code for tokens
|
||||
accessToken = exchangeCodeForTokens(
|
||||
httpClient,
|
||||
tokenEndpoint,
|
||||
authCode,
|
||||
creds.clientId,
|
||||
creds.clientSecret,
|
||||
CALLBACK_URL,
|
||||
codeVerifier,
|
||||
tokenAuthMethod,
|
||||
discovery.resourceUrl,
|
||||
)
|
||||
|
||||
// Retry the original request with the token
|
||||
request.headers.remove(HttpHeaders.Authorization)
|
||||
request.headers.append(HttpHeaders.Authorization, "Bearer $accessToken")
|
||||
execute(request)
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
httpClient.use { client ->
|
||||
val transport = StreamableHttpClientTransport(client, serverUrl)
|
||||
val mcpClient = Client(
|
||||
clientInfo = Implementation("test-auth-client", "1.0.0"),
|
||||
options = ClientOptions(capabilities = ClientCapabilities()),
|
||||
)
|
||||
mcpClient.connect(transport)
|
||||
mcpClient.listTools()
|
||||
mcpClient.callTool(CallToolRequest(CallToolRequestParams(name = "test-tool")))
|
||||
mcpClient.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.conformance.auth
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.sse.SSE
|
||||
import io.ktor.client.request.forms.submitForm
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.Parameters
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import java.util.Base64
|
||||
|
||||
// Client Credentials Basic scenario
|
||||
internal suspend fun runClientCredentialsBasic(serverUrl: String) {
|
||||
val ctx = conformanceContext()
|
||||
val clientId = ctx.requiredString("client_id")
|
||||
val clientSecret = ctx.requiredString("client_secret")
|
||||
|
||||
val httpClient = HttpClient(CIO) {
|
||||
install(SSE)
|
||||
followRedirects = false
|
||||
}
|
||||
|
||||
httpClient.use { client ->
|
||||
val tokenEndpoint = discoverTokenEndpoint(client, serverUrl)
|
||||
|
||||
// Exchange credentials for token using Basic auth
|
||||
val basicAuth = Base64.getEncoder().encodeToString("$clientId:$clientSecret".toByteArray())
|
||||
val tokenResponse = client.submitForm(
|
||||
url = tokenEndpoint,
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "client_credentials")
|
||||
},
|
||||
) {
|
||||
header(HttpHeaders.Authorization, "Basic $basicAuth")
|
||||
}
|
||||
val accessToken = extractAccessToken(tokenResponse)
|
||||
|
||||
withBearerToken(accessToken) { authedClient ->
|
||||
val transport = StreamableHttpClientTransport(authedClient, serverUrl)
|
||||
val client = Client(
|
||||
clientInfo = Implementation("conformance-client-credentials-basic", "1.0.0"),
|
||||
options = ClientOptions(capabilities = ClientCapabilities()),
|
||||
)
|
||||
client.connect(transport)
|
||||
client.listTools()
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.conformance.auth
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal data class ClientCredentials(val clientId: String, val clientSecret: String?)
|
||||
|
||||
/**
|
||||
* Resolve client credentials per spec priority:
|
||||
* 1. Pre-registered (from MCP_CONFORMANCE_CONTEXT)
|
||||
* 2. CIMD (client_id_metadata_document_supported)
|
||||
* 3. Dynamic registration (registration_endpoint)
|
||||
* 4. Error
|
||||
*/
|
||||
internal suspend fun resolveClientCredentials(httpClient: HttpClient, asMetadata: JsonObject): ClientCredentials {
|
||||
// 1. Pre-registered
|
||||
val contextJson = System.getenv("MCP_CONFORMANCE_CONTEXT")
|
||||
if (contextJson != null) {
|
||||
val ctx = json.parseToJsonElement(contextJson).jsonObject
|
||||
val clientId = ctx["client_id"]?.jsonPrimitive?.content
|
||||
if (clientId != null) {
|
||||
val clientSecret = ctx["client_secret"]?.jsonPrimitive?.content
|
||||
return ClientCredentials(clientId, clientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. CIMD
|
||||
val cimdSupported = asMetadata["client_id_metadata_document_supported"]
|
||||
?.jsonPrimitive?.content?.toBoolean() ?: false
|
||||
if (cimdSupported) {
|
||||
return ClientCredentials(CIMD_CLIENT_METADATA_URL, null)
|
||||
}
|
||||
|
||||
// 3. Dynamic registration
|
||||
val registrationEndpoint = asMetadata["registration_endpoint"]?.jsonPrimitive?.content
|
||||
if (registrationEndpoint != null) {
|
||||
return dynamicClientRegistration(httpClient, registrationEndpoint)
|
||||
}
|
||||
|
||||
error("No way to register client: no pre-registered credentials, CIMD not supported, and no registration_endpoint")
|
||||
}
|
||||
|
||||
private suspend fun dynamicClientRegistration(
|
||||
httpClient: HttpClient,
|
||||
registrationEndpoint: String,
|
||||
): ClientCredentials {
|
||||
val regBody = buildJsonObject {
|
||||
put("client_name", "test-auth-client")
|
||||
put("redirect_uris", buildJsonArray { add(kotlinx.serialization.json.JsonPrimitive(CALLBACK_URL)) })
|
||||
put("grant_types", buildJsonArray { add(kotlinx.serialization.json.JsonPrimitive("authorization_code")) })
|
||||
put("response_types", buildJsonArray { add(kotlinx.serialization.json.JsonPrimitive("code")) })
|
||||
put("token_endpoint_auth_method", "client_secret_post")
|
||||
}
|
||||
|
||||
val response = httpClient.post(registrationEndpoint) {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(regBody.toString())
|
||||
}
|
||||
val regJson = json.parseToJsonElement(response.bodyAsText()).jsonObject
|
||||
val clientId = regJson["client_id"]?.jsonPrimitive?.content ?: error("No client_id in registration response")
|
||||
val clientSecret = regJson["client_secret"]?.jsonPrimitive?.content
|
||||
return ClientCredentials(clientId, clientSecret)
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.conformance.auth
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.sse.SSE
|
||||
import io.ktor.client.request.forms.submitForm
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.Parameters
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.util.Base64
|
||||
|
||||
// SEP-990 Enterprise Managed OAuth: Cross-App Access complete flow
|
||||
internal suspend fun runCrossAppAccess(serverUrl: String) {
|
||||
val ctx = conformanceContext()
|
||||
val clientId = ctx.requiredString("client_id")
|
||||
val clientSecret = ctx.requiredString("client_secret")
|
||||
val idpIdToken = ctx.requiredString("idp_id_token")
|
||||
val idpTokenEndpoint = ctx.requiredString("idp_token_endpoint")
|
||||
|
||||
val httpClient = HttpClient(CIO) {
|
||||
install(SSE)
|
||||
followRedirects = false
|
||||
}
|
||||
|
||||
httpClient.use { client ->
|
||||
// Discover PRM + AS metadata
|
||||
val resourceMeta = discoverResourceMetadata(client, serverUrl)
|
||||
val resourceUrl = resourceMeta["resource"]?.jsonPrimitive?.content
|
||||
?: error("No resource in resource metadata")
|
||||
val authServer = resourceMeta["authorization_servers"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.content
|
||||
?: error("No authorization_servers in resource metadata")
|
||||
val asMeta = fetchOAuthMetadata(client, authServer)
|
||||
val tokenEndpoint = asMeta["token_endpoint"]?.jsonPrimitive?.content
|
||||
?: error("No token_endpoint in AS metadata")
|
||||
|
||||
// RFC 8693 Token Exchange at IDP: exchange ID token for ID-JAG
|
||||
val idpResponse = client.submitForm(
|
||||
url = idpTokenEndpoint,
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
|
||||
append("subject_token", idpIdToken)
|
||||
append("subject_token_type", "urn:ietf:params:oauth:token-type:id_token")
|
||||
append("requested_token_type", "urn:ietf:params:oauth:token-type:id-jag")
|
||||
append("audience", authServer)
|
||||
append("resource", resourceUrl)
|
||||
},
|
||||
)
|
||||
val idJag = extractAccessToken(idpResponse)
|
||||
|
||||
// RFC 7523 JWT Bearer Grant at AS with Basic auth
|
||||
val basicAuth = Base64.getEncoder().encodeToString("$clientId:$clientSecret".toByteArray())
|
||||
val asResponse = client.submitForm(
|
||||
url = tokenEndpoint,
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||
append("assertion", idJag)
|
||||
},
|
||||
) {
|
||||
header(HttpHeaders.Authorization, "Basic $basicAuth")
|
||||
}
|
||||
val accessToken = extractAccessToken(asResponse)
|
||||
|
||||
// Use access token for MCP requests
|
||||
withBearerToken(accessToken) { authedClient ->
|
||||
val transport = StreamableHttpClientTransport(authedClient, serverUrl)
|
||||
val mcpClient = Client(
|
||||
clientInfo = Implementation("conformance-cross-app-access", "1.0.0"),
|
||||
options = ClientOptions(capabilities = ClientCapabilities()),
|
||||
)
|
||||
mcpClient.connect(transport)
|
||||
mcpClient.listTools()
|
||||
mcpClient.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue