diff --git a/conformance-test/conformance-baseline.yml b/conformance-test/conformance-baseline.yml index 337d8ce..f349b8c 100644 --- a/conformance-test/conformance-baseline.yml +++ b/conformance-test/conformance-baseline.yml @@ -2,5 +2,4 @@ # Add entries here as tests are identified as known SDK limitations server: [] -client: - - elicitation-sep1034-client-defaults +client: [] diff --git a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt index 1d84732..d0acda5 100644 --- a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt +++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt @@ -7,9 +7,11 @@ import io.modelcontextprotocol.kotlin.sdk.server.ServerSession import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions +import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult +import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult @@ -17,6 +19,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult +import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse @@ -38,8 +41,11 @@ import io.modelcontextprotocol.kotlin.sdk.types.SUPPORTED_PROTOCOL_VERSIONS import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.StringSchema import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.Tool import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel @@ -48,7 +54,10 @@ import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlin.coroutines.cancellation.CancellationException import kotlin.test.Test @@ -1110,4 +1119,175 @@ class ClientTest { assertEquals(elicitationResultAction, result.action) assertEquals(elicitationResultContent, result.content) } + + @Test + fun `should apply elicitation defaults for missing fields in empty content`() = runTest { + val schema = defaultsTestSchema() + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = JsonObject(emptyMap())) + } + + val result = serverSession.createElicitation(message = "fill defaults", requestedSchema = schema) + + assertEquals(ElicitResult.Action.Accept, result.action) + val content = result.content!! + assertEquals(JsonPrimitive("John Doe"), content["name"]) + assertEquals(JsonPrimitive(30), content["age"]) + assertEquals(JsonPrimitive(95.5), content["score"]) + assertEquals(JsonPrimitive("active"), content["status"]) + assertEquals(JsonPrimitive(true), content["verified"]) + + client.close() + } + + @Test + fun `should apply elicitation defaults only for missing fields preserving user values`() = runTest { + val schema = defaultsTestSchema() + val userContent = buildJsonObject { put("name", "Custom Name") } + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = userContent) + } + + val result = serverSession.createElicitation(message = "partial", requestedSchema = schema) + + val content = result.content!! + assertEquals(JsonPrimitive("Custom Name"), content["name"]) + assertEquals(JsonPrimitive(30), content["age"]) + assertEquals(JsonPrimitive(95.5), content["score"]) + assertEquals(JsonPrimitive("active"), content["status"]) + assertEquals(JsonPrimitive(true), content["verified"]) + + client.close() + } + + @Test + fun `should not apply elicitation defaults when action is decline`() = runTest { + val schema = defaultsTestSchema() + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Decline) + } + + val result = serverSession.createElicitation(message = "decline", requestedSchema = schema) + + assertEquals(ElicitResult.Action.Decline, result.action) + assertNull(result.content) + + client.close() + } + + @Test + fun `should not apply elicitation defaults when action is cancel`() = runTest { + val schema = defaultsTestSchema() + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Cancel) + } + + val result = serverSession.createElicitation(message = "cancel", requestedSchema = schema) + + assertEquals(ElicitResult.Action.Cancel, result.action) + assertNull(result.content) + + client.close() + } + + @Test + fun `should not modify content when schema has no defaults`() = runTest { + val schema = ElicitRequestParams.RequestedSchema( + properties = mapOf( + "name" to StringSchema(description = "name"), + "age" to IntegerSchema(description = "age"), + ), + ) + val emptyContent = JsonObject(emptyMap()) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = emptyContent) + } + + val result = serverSession.createElicitation(message = "no defaults", requestedSchema = schema) + + assertEquals(ElicitResult.Action.Accept, result.action) + assertTrue(result.content!!.isEmpty()) + + client.close() + } + + @Test + fun `should apply elicitation defaults for multi-select enum schemas`() = runTest { + val schema = ElicitRequestParams.RequestedSchema( + properties = mapOf( + "tags" to UntitledMultiSelectEnumSchema( + description = "tags", + items = UntitledMultiSelectEnumSchema.Items(enumValues = listOf("a", "b", "c")), + default = listOf("a", "b"), + ), + "options" to TitledMultiSelectEnumSchema( + description = "options", + items = TitledMultiSelectEnumSchema.Items( + anyOf = listOf( + io.modelcontextprotocol.kotlin.sdk.types.EnumOption("x", "X"), + io.modelcontextprotocol.kotlin.sdk.types.EnumOption("y", "Y"), + ), + ), + default = listOf("x"), + ), + ), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = JsonObject(emptyMap())) + } + + val result = serverSession.createElicitation(message = "multi-select", requestedSchema = schema) + + val content = result.content!! + val tags = content["tags"]!! + assertIs(tags) + assertEquals(2, tags.size) + assertEquals("a", tags[0].jsonPrimitive.content) + assertEquals("b", tags[1].jsonPrimitive.content) + + val options = content["options"]!! + assertIs(options) + assertEquals(1, options.size) + assertEquals("x", options[0].jsonPrimitive.content) + + client.close() + } + + private fun defaultsTestSchema(): ElicitRequestParams.RequestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf( + "name" to StringSchema(description = "User name", default = "John Doe"), + "age" to IntegerSchema(description = "User age", default = 30), + "score" to DoubleSchema(description = "User score", default = 95.5), + "status" to UntitledSingleSelectEnumSchema( + description = "User status", + enumValues = listOf("active", "inactive", "pending"), + default = "active", + ), + "verified" to BooleanSchema(description = "Verification status", default = true), + ), + ) + + private suspend fun setupElicitationPair( + handler: (io.modelcontextprotocol.kotlin.sdk.types.ElicitRequest) -> ElicitResult, + ): Pair = kotlinx.coroutines.coroutineScope { + val client = Client( + Implementation(name = "test client", version = "1.0"), + ClientOptions(capabilities = ClientCapabilities(elicitation = EmptyJsonObject)), + ) + client.setElicitationHandler(handler) + + val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair() + val server = Server( + serverInfo = Implementation(name = "test server", version = "1.0"), + options = ServerOptions(capabilities = ServerCapabilities()), + ) + + val serverSessionResult = CompletableDeferred() + listOf( + launch { client.connect(clientTransport) }, + launch { serverSessionResult.complete(server.createSession(serverTransport)) }, + ).joinAll() + + client to serverSessionResult.await() + } } diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt index e3added..34b9a5b 100644 --- a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/Client.kt @@ -6,13 +6,16 @@ import io.modelcontextprotocol.kotlin.sdk.shared.Protocol import io.modelcontextprotocol.kotlin.sdk.shared.ProtocolOptions import io.modelcontextprotocol.kotlin.sdk.shared.RequestOptions import io.modelcontextprotocol.kotlin.sdk.shared.Transport +import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities import io.modelcontextprotocol.kotlin.sdk.types.CompleteRequest import io.modelcontextprotocol.kotlin.sdk.types.CompleteResult +import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequest +import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest @@ -22,7 +25,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification +import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION +import io.modelcontextprotocol.kotlin.sdk.types.LegacyTitledEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.ListPromptsRequest import io.modelcontextprotocol.kotlin.sdk.types.ListPromptsResult import io.modelcontextprotocol.kotlin.sdk.types.ListResourceTemplatesRequest @@ -37,6 +42,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.Method import io.modelcontextprotocol.kotlin.sdk.types.PingRequest +import io.modelcontextprotocol.kotlin.sdk.types.PrimitiveSchemaDefinition import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult import io.modelcontextprotocol.kotlin.sdk.types.RequestMeta @@ -46,8 +52,13 @@ import io.modelcontextprotocol.kotlin.sdk.types.SUPPORTED_PROTOCOL_VERSIONS import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.SetLevelRequest import io.modelcontextprotocol.kotlin.sdk.types.SetLevelRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.StringSchema import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest +import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.UnsubscribeRequest +import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.toJson import kotlinx.atomicfu.atomic import kotlinx.atomicfu.getAndUpdate @@ -56,7 +67,10 @@ import kotlinx.collections.immutable.minus import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toPersistentSet import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray import kotlin.coroutines.cancellation.CancellationException private val logger = KotlinLogging.logger {} @@ -576,6 +590,10 @@ public open class Client(private val clientInfo: Implementation, options: Client /** * Sets the elicitation handler. * + * When the handler returns [ElicitResult.Action.Accept], any properties missing from + * [ElicitResult.content] are automatically populated with default values defined in the + * elicitation schema. + * * @param handler The elicitation handler. * @throws IllegalStateException if the client does not support elicitation. */ @@ -587,12 +605,60 @@ public open class Client(private val clientInfo: Implementation, options: Client logger.info { "Setting the elicitation handler" } setRequestHandler(Method.Defined.ElicitationCreate) { request, _ -> - handler(request) + val result = handler(request) + applyElicitationDefaults(request, result) } } // --- Internal Handlers --- + @Suppress("ReturnCount") + private fun applyElicitationDefaults(request: ElicitRequest, result: ElicitResult): ElicitResult { + if (result.action != ElicitResult.Action.Accept) return result + val formParams = request.params as? ElicitRequestFormParams ?: return result + val content = result.content ?: return result + + val merged = buildMap { + putAll(content) + for ((key, schemaDef) in formParams.requestedSchema.properties) { + if (key !in content) { + schemaDef.defaultJsonValue()?.let { put(key, it) } + } + } + } + + return if (merged.size == content.size) { + result + } else { + result.copy(content = JsonObject(merged)) + } + } + + @Suppress("DEPRECATION", "CyclomaticComplexMethod") + private fun PrimitiveSchemaDefinition.defaultJsonValue(): JsonElement? = when (this) { + is StringSchema -> default?.let { JsonPrimitive(it) } + + is IntegerSchema -> default?.let { JsonPrimitive(it) } + + is DoubleSchema -> default?.let { JsonPrimitive(it) } + + is BooleanSchema -> default?.let { JsonPrimitive(it) } + + is UntitledSingleSelectEnumSchema -> default?.let { JsonPrimitive(it) } + + is TitledSingleSelectEnumSchema -> default?.let { JsonPrimitive(it) } + + is LegacyTitledEnumSchema -> default?.let { JsonPrimitive(it) } + + is UntitledMultiSelectEnumSchema -> default?.let { list -> + buildJsonArray { list.forEach { add(JsonPrimitive(it)) } } + } + + is TitledMultiSelectEnumSchema -> default?.let { list -> + buildJsonArray { list.forEach { add(JsonPrimitive(it)) } } + } + } + private fun handleListRoots(): ListRootsResult { val rootList = roots.value.values.toList() return ListRootsResult(rootList)