fix: apply elicitation schema defaults for missing fields in client response (#661)

Apply default values from the elicitation schema to the client's
response when the handler returns `Accept` with missing fields

closes #414 

## How Has This Been Tested?
integration and conformance tests 

## Breaking Changes
none

## Types of changes
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] 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
This commit is contained in:
Pavel Gorgulov 2026-04-01 16:31:34 +02:00 • committed by GitHub
commit e21d2298a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 248 additions and 3 deletions

View file

@ -2,5 +2,4 @@
# Add entries here as tests are identified as known SDK limitations
server: []
client:
- elicitation-sep1034-client-defaults
client: []

View file

@ -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<kotlinx.serialization.json.JsonArray>(tags)
assertEquals(2, tags.size)
assertEquals("a", tags[0].jsonPrimitive.content)
assertEquals("b", tags[1].jsonPrimitive.content)
val options = content["options"]!!
assertIs<kotlinx.serialization.json.JsonArray>(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<Client, ServerSession> = 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<ServerSession>()
listOf(
launch { client.connect(clientTransport) },
launch { serverSessionResult.complete(server.createSession(serverTransport)) },
).joinAll()
client to serverSessionResult.await()
}
}

View file

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