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:
parent
9c4a2394b4
commit
e21d2298a1
3 changed files with 248 additions and 3 deletions
|
|
@ -2,5 +2,4 @@
|
|||
# Add entries here as tests are identified as known SDK limitations
|
||||
server: []
|
||||
|
||||
client:
|
||||
- elicitation-sep1034-client-defaults
|
||||
client: []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue