From 737b46f03299f038270b30176fc3b2f849770266 Mon Sep 17 00:00:00 2001 From: Takuya Akamatsu Date: Wed, 22 Jul 2026 01:17:16 +0900 Subject: [PATCH] feat(server): validate accepted elicitation content against the requested schema (#896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate accepted form-mode elicitation `content` against the `requestedSchema` on the server, rejecting mismatches with an `INVALID_PARAMS` error. fixes #895 ## Motivation and Context `createElicitation` returned accepted content to server code as-is. The specification's form-mode security guidance says "Servers SHOULD validate received data matches the requested schema" ([2025-11-25, Form Mode Security](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#form-mode-security)). Comparable validation already exists in the TypeScript SDK's `elicitInput` and Python's `elicit_with_validation` helper. Details and design notes are in #895. ## How Has This Been Tested? New unit tests for the validator and integration tests over an in-memory client–server pair. ## Breaking Changes No API changes. Behavioral note: an accepted form-mode response that does not match the requested schema now fails `createElicitation` with `McpException` (`INVALID_PARAMS`) instead of being returned to the caller. ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [x] 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 Co-authored-by: Pavel Gorgulov --- .../kotlin/sdk/client/ClientTest.kt | 97 ++++ .../kotlin/sdk/server/ClientConnection.kt | 22 +- .../sdk/server/ElicitationValidation.kt | 174 +++++++ .../kotlin/sdk/server/ServerSession.kt | 7 +- .../sdk/server/ElicitationValidationTest.kt | 438 ++++++++++++++++++ 5 files changed, 735 insertions(+), 3 deletions(-) create mode 100644 kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt create mode 100644 kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidationTest.kt 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 54d25ee..099329d 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 @@ -1124,6 +1124,103 @@ class ClientTest { assertEquals(elicitationResultContent, result.content) } + @Test + fun `should reject accepted elicitation content that does not match requested schema`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + required = listOf("name"), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = buildJsonObject { put("name", 42) }) + } + + val exception = assertFailsWith { + serverSession.createElicitation(message = "mismatch", requestedSchema = requestedSchema) + } + assertTrue( + exception.message.orEmpty() + .startsWith("Elicitation response content does not match requested schema: "), + "Unexpected message: ${exception.message}", + ) + assertTrue("'name' must be string" in exception.message.orEmpty(), "Unexpected message: ${exception.message}") + + client.close() + } + + @Test + fun `should reject accepted elicitation without content when schema has required properties`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + required = listOf("name"), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = null) + } + + val exception = assertFailsWith { + serverSession.createElicitation(message = "no content", requestedSchema = requestedSchema) + } + assertTrue( + "must have required property 'name'" in exception.message.orEmpty(), + "Unexpected message: ${exception.message}", + ) + + client.close() + } + + @Test + fun `should accept elicitation without content when schema has no required properties`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Accept, content = null) + } + + val result = serverSession.createElicitation(message = "no required", requestedSchema = requestedSchema) + + assertEquals(ElicitResult.Action.Accept, result.action) + assertNull(result.content) + + client.close() + } + + @Test + fun `should pass through declined elicitation without validation`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + required = listOf("name"), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Decline) + } + + val result = serverSession.createElicitation(message = "decline", requestedSchema = requestedSchema) + + assertEquals(ElicitResult.Action.Decline, result.action) + assertNull(result.content) + + client.close() + } + + @Test + fun `should pass through cancelled elicitation without validation`() = runTest { + val requestedSchema = ElicitRequestParams.RequestedSchema( + properties = mapOf("name" to StringSchema()), + required = listOf("name"), + ) + val (client, serverSession) = setupElicitationPair { + ElicitResult(action = ElicitResult.Action.Cancel) + } + + val result = serverSession.createElicitation(message = "cancel", requestedSchema = requestedSchema) + + assertEquals(ElicitResult.Action.Cancel, result.action) + assertNull(result.content) + + client.close() + } + @Test fun `should apply elicitation defaults for missing fields in empty content`() = runTest { val schema = defaultsTestSchema() diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt index 343774b..21e0925 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ClientConnection.kt @@ -10,12 +10,14 @@ import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification +import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult import io.modelcontextprotocol.kotlin.sdk.types.IncludeContext import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest import io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification +import io.modelcontextprotocol.kotlin.sdk.types.McpException import io.modelcontextprotocol.kotlin.sdk.types.PingRequest import io.modelcontextprotocol.kotlin.sdk.types.PromptListChangedNotification import io.modelcontextprotocol.kotlin.sdk.types.Request @@ -95,12 +97,17 @@ public interface ClientConnection { * Sends a message to the client requesting elicitation. * This typically results in a form being displayed to the end user. * + * Accepted content is validated against [requestedSchema]. If content is absent, an empty + * object is used for validation only, so declared `required` properties still apply. The + * returned [ElicitResult] is not modified. + * * @param message The message for the elicitation to display. - * @param requestedSchema The schema requested by the client for the elicitation result. + * @param requestedSchema The schema the server requests for the elicitation result. * Influences the form displayed to the user. * @param options Optional request options. * @return The result of the elicitation request. * @throws IllegalStateException If the server or client does not support elicitation. + * @throws McpException If an accepted response does not match [requestedSchema]. */ public suspend fun createElicitation( message: String, @@ -130,10 +137,15 @@ public interface ClientConnection { * Sends a message to the client requesting elicitation. * This typically results in a form being displayed to the user. * + * For form-mode requests, accepted content is validated against the requested schema. If + * content is absent, an empty object is used for validation only, so declared `required` + * properties still apply. The returned [ElicitResult] is not modified. + * * @param request The elicitation request parameters. * @param options Optional request options. * @return The result of the elicitation request. * @throws IllegalStateException If the server or client does not support elicitation. + * @throws McpException If an accepted form-mode response does not match its requested schema. */ public suspend fun createElicitation(request: ElicitRequest, options: RequestOptions? = null): ElicitResult @@ -256,7 +268,13 @@ internal class ClientConnectionImpl(private val session: ServerSession) : Client } } logger.trace { "ElicitRequest: $request" } - return request(request, options) + val result: ElicitResult = request(request, options) + if (params is ElicitRequestFormParams && result.action == ElicitResult.Action.Accept) { + // Peer-response backstop: accepted form content is client-supplied. Absent content is + // validated as an empty object so declared `required` properties still apply. + validateElicitationContent(params.requestedSchema, result.content ?: EmptyJsonObject) + } + return result } override suspend fun createElicitation( diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt new file mode 100644 index 0000000..d976d34 --- /dev/null +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidation.kt @@ -0,0 +1,174 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema +import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema +import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema +import io.modelcontextprotocol.kotlin.sdk.types.LegacyTitledEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.PrimitiveSchemaDefinition +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.StringSchema +import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull +import kotlin.math.floor + +/** + * Validates accepted form-mode elicitation [content] against the supported constraints in + * [requestedSchema]: types, `required`, numeric bounds, string lengths, enum membership, and item + * counts. Undeclared properties are permitted. + * [StringSchema.format] values are treated as annotations and are not asserted. + * + * @throws McpException with [RPCError.ErrorCode.INVALID_PARAMS] listing the detected violations + * (at most one per property). + */ +internal fun validateElicitationContent(requestedSchema: ElicitRequestParams.RequestedSchema, content: JsonObject) { + val errors = buildList { + for (name in requestedSchema.required.orEmpty()) { + if (name !in content) add("must have required property '$name'") + } + for ((name, schema) in requestedSchema.properties) { + val value = content[name] ?: continue + checkProperty(name, schema, value)?.let(::add) + } + } + if (errors.isNotEmpty()) { + throw McpException( + code = RPCError.ErrorCode.INVALID_PARAMS, + message = "Elicitation response content does not match requested schema: ${errors.joinToString(", ")}", + ) + } +} + +private fun checkProperty(name: String, schema: PrimitiveSchemaDefinition, value: JsonElement): String? = + when (schema) { + is StringSchema -> { + val string = stringValue(value) ?: return "'$name' must be string" + val minLength = schema.minLength + val maxLength = schema.maxLength + val length = codePointCount(string) + when { + minLength != null && length < minLength -> + "'$name' must NOT have fewer than $minLength characters" + + maxLength != null && length > maxLength -> + "'$name' must NOT have more than $maxLength characters" + + else -> null + } + } + + is IntegerSchema -> { + val integer = integerValue(value) ?: return "'$name' must be integer" + checkRange(name, integer, schema.minimum?.toLong(), schema.maximum?.toLong()) + } + + is DoubleSchema -> { + val number = doubleValue(value) ?: return "'$name' must be number" + checkRange(name, number, schema.minimum, schema.maximum) + } + + is BooleanSchema -> if (booleanValue(value) == null) "'$name' must be boolean" else null + + is UntitledSingleSelectEnumSchema -> checkEnum(name, value, schema.enumValues) + + is TitledSingleSelectEnumSchema -> checkEnum(name, value, schema.oneOf.map { it.const }) + + is LegacyTitledEnumSchema -> checkEnum(name, value, schema.enumValues) + + is UntitledMultiSelectEnumSchema -> + checkMultiSelect(name, value, schema.items.enumValues, schema.minItems, schema.maxItems) + + is TitledMultiSelectEnumSchema -> + checkMultiSelect(name, value, schema.items.anyOf.map { it.const }, schema.minItems, schema.maxItems) + } + +// Keep Double statically typed so comparisons use IEEE 754 semantics; generic Comparable ordering +// treats -0.0 as less than 0.0. +private fun checkRange( + name: String, + value: Long, + minimum: Long?, + maximum: Long?, +): String? = when { + minimum != null && value < minimum -> "'$name' must be >= $minimum" + maximum != null && value > maximum -> "'$name' must be <= $maximum" + else -> null +} + +private fun checkRange( + name: String, + value: Double, + minimum: Double?, + maximum: Double?, +): String? = when { + minimum != null && value < minimum -> "'$name' must be >= $minimum" + maximum != null && value > maximum -> "'$name' must be <= $maximum" + else -> null +} + +private fun checkEnum(name: String, value: JsonElement, allowed: List): String? { + val string = stringValue(value) ?: return "'$name' must be string" + return if (string in allowed) null else "'$name' must be equal to one of the allowed values" +} + +private fun checkMultiSelect( + name: String, + value: JsonElement, + allowed: List, + minItems: Int?, + maxItems: Int?, +): String? { + val array = value as? JsonArray ?: return "'$name' must be array" + if (minItems != null && array.size < minItems) return "'$name' must NOT have fewer than $minItems items" + if (maxItems != null && array.size > maxItems) return "'$name' must NOT have more than $maxItems items" + for (element in array) { + val item = stringValue(element) ?: return "'$name' items must be string" + if (item !in allowed) return "'$name' items must be equal to one of the allowed values" + } + return null +} + +// JSON distinguishes the string "42" from the number 42; kotlinx's JsonPrimitive accessors parse +// string content too, so every helper checks isString to preserve that distinction. + +private fun stringValue(value: JsonElement): String? = (value as? JsonPrimitive)?.takeIf { it.isString }?.content + +private fun integerValue(value: JsonElement): Long? { + val primitive = (value as? JsonPrimitive)?.takeIf { !it.isString } ?: return null + primitive.longOrNull?.let { return it } + // JSON Schema's "integer" also matches numbers with a zero fractional part (e.g. 5.0, 1e2). + // Values that do not parse as Long are evaluated using their finite Double representation. + val number = primitive.doubleOrNull ?: return null + return if (number.isFinite() && number == floor(number)) number.toLong() else null +} + +private fun doubleValue(value: JsonElement): Double? = + (value as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() } + +private fun booleanValue(value: JsonElement): Boolean? = + (value as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull + +// JSON Schema string lengths count Unicode code points (RFC 8259 characters), not UTF-16 units, +// so a surrogate pair is one character. +private fun codePointCount(string: String): Int { + var count = 0 + var index = 0 + while (index < string.length) { + if (string[index].isHighSurrogate() && index + 1 < string.length && string[index + 1].isLowSurrogate()) { + index++ + } + index++ + count++ + } + return count +} diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt index 880f1cf..f20d3e1 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSession.kt @@ -421,12 +421,17 @@ public open class ServerSession( * Sends a message to the client requesting an elicitation. * This typically results in a form being displayed to the end user. * + * Accepted content is validated against [requestedSchema]. If content is absent, an empty + * object is used for validation only, so declared `required` properties still apply. The + * returned [ElicitResult] is not modified. + * * @param message The message for the elicitation to display. - * @param requestedSchema The schema requested by the client for the elicitation result. + * @param requestedSchema The schema the server requests for the elicitation result. * Influences the form displayed to the user. * @param options Optional request options. * @return The result of the elicitation request. * @throws IllegalStateException If the server or client does not support elicitation. + * @throws McpException If an accepted response does not match [requestedSchema]. */ public suspend fun createElicitation( message: String, diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidationTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidationTest.kt new file mode 100644 index 0000000..b218b47 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ElicitationValidationTest.kt @@ -0,0 +1,438 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema +import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema +import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.EnumOption +import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema +import io.modelcontextprotocol.kotlin.sdk.types.LegacyTitledEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.McpException +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.PrimitiveSchemaDefinition +import io.modelcontextprotocol.kotlin.sdk.types.RPCError +import io.modelcontextprotocol.kotlin.sdk.types.StringSchema +import io.modelcontextprotocol.kotlin.sdk.types.StringSchemaFormat +import io.modelcontextprotocol.kotlin.sdk.types.TitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.TitledSingleSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema +import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.junit.jupiter.api.assertDoesNotThrow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Unit tests for [validateElicitationContent]: pure-function checks of accepted form-mode + * content against the requested schema. + * + * End-to-end tests for the `createElicitation` round trip live in the `integration-test` + * module under `io.modelcontextprotocol.kotlin.sdk.client`. + */ +class ElicitationValidationTest { + + private fun schema(vararg properties: Pair, required: List? = null) = + ElicitRequestParams.RequestedSchema(properties = mapOf(*properties), required = required) + + private fun assertRejected( + schema: ElicitRequestParams.RequestedSchema, + content: JsonObject, + expectedError: String, + ) { + val exception = assertFailsWith { validateElicitationContent(schema, content) } + assertEquals(RPCError.ErrorCode.INVALID_PARAMS, exception.code) + val message = exception.message.orEmpty() + assertTrue( + message.startsWith("Elicitation response content does not match requested schema: "), + "Unexpected message prefix: $message", + ) + assertTrue(expectedError in message, "Expected '$expectedError' in: $message") + } + + @Test + fun `validate content matching schema passes`() { + assertDoesNotThrow { + validateElicitationContent( + schema( + "name" to StringSchema(minLength = 1, maxLength = 10), + "age" to IntegerSchema(minimum = 0, maximum = 120), + "score" to DoubleSchema(minimum = 0.0, maximum = 1.0), + "subscribed" to BooleanSchema(), + "color" to UntitledSingleSelectEnumSchema(enumValues = listOf("red", "green")), + "size" to TitledSingleSelectEnumSchema( + oneOf = listOf(EnumOption("s", "Small"), EnumOption("l", "Large")), + ), + "tags" to UntitledMultiSelectEnumSchema( + items = UntitledMultiSelectEnumSchema.Items(enumValues = listOf("a", "b", "c")), + minItems = 1, + maxItems = 2, + ), + required = listOf("name", "age"), + ), + buildJsonObject { + put("name", "octocat") + put("age", 42) + put("score", 0.5) + put("subscribed", true) + put("color", "red") + put("size", "l") + putJsonArray("tags") { add("a") } + }, + ) + } + } + + @Test + fun `validate missing required property fails`() { + assertRejected( + schema("name" to StringSchema(), required = listOf("name")), + JsonObject(emptyMap()), + "must have required property 'name'", + ) + } + + @Test + fun `validate empty content with no required properties passes`() { + assertDoesNotThrow { + validateElicitationContent(schema("name" to StringSchema()), JsonObject(emptyMap())) + } + } + + @Test + fun `validate wrong type for string property fails`() { + assertRejected( + schema("name" to StringSchema()), + buildJsonObject { put("name", 42) }, + "'name' must be string", + ) + } + + @Test + fun `validate string length constraints fail`() { + assertRejected( + schema("name" to StringSchema(minLength = 3)), + buildJsonObject { put("name", "ab") }, + "'name' must NOT have fewer than 3 characters", + ) + assertRejected( + schema("name" to StringSchema(maxLength = 3)), + buildJsonObject { put("name", "abcd") }, + "'name' must NOT have more than 3 characters", + ) + } + + @Test + fun `validate json string is not accepted for integer boolean or number`() { + // JsonPrimitive("42") is the JSON string "42", not the number 42; the validator must not + // parse string content as a number. + assertRejected( + schema("age" to IntegerSchema()), + buildJsonObject { put("age", "42") }, + "'age' must be integer", + ) + assertRejected( + schema("score" to DoubleSchema()), + buildJsonObject { put("score", "1.5") }, + "'score' must be number", + ) + assertRejected( + schema("subscribed" to BooleanSchema()), + buildJsonObject { put("subscribed", "true") }, + "'subscribed' must be boolean", + ) + } + + @Test + fun `validate integer outside minimum and maximum fails`() { + assertRejected( + schema("age" to IntegerSchema(minimum = 0)), + buildJsonObject { put("age", -1) }, + "'age' must be >= 0", + ) + assertRejected( + schema("age" to IntegerSchema(maximum = 120)), + buildJsonObject { put("age", 200) }, + "'age' must be <= 120", + ) + } + + @Test + fun `validate non-integral value for integer property fails`() { + assertRejected( + schema("age" to IntegerSchema()), + buildJsonObject { put("age", 3.5) }, + "'age' must be integer", + ) + } + + @Test + fun `validate integral number forms are accepted for integer property`() { + // JSON Schema's "integer" matches numbers with a zero fractional part. + assertDoesNotThrow { + validateElicitationContent( + schema("age" to IntegerSchema(minimum = 0, maximum = 200)), + buildJsonObject { put("age", 5.0) }, + ) + } + } + + @Test + fun `validate exponent form is accepted for integer property`() { + val content = McpJson.parseToJsonElement("""{"age": 1e2}""") as JsonObject + assertDoesNotThrow { + validateElicitationContent( + schema("age" to IntegerSchema(minimum = 0, maximum = 200)), + content, + ) + } + } + + @Test + fun `validate number outside range fails`() { + assertRejected( + schema("score" to DoubleSchema(maximum = 1.0)), + buildJsonObject { put("score", 1.5) }, + "'score' must be <= 1.0", + ) + } + + @Test + fun `validate integral value is accepted for number property`() { + assertDoesNotThrow { + validateElicitationContent( + schema("score" to DoubleSchema(minimum = 0.0)), + buildJsonObject { put("score", 5) }, + ) + } + } + + @Test + fun `validate string length counts unicode code points`() { + // JSON Schema string length counts RFC 8259 characters: one emoji is one character + // even when it is two UTF-16 units. + assertDoesNotThrow { + validateElicitationContent( + schema("emoji" to StringSchema(maxLength = 1)), + buildJsonObject { put("emoji", "😀") }, + ) + } + assertRejected( + schema("emoji" to StringSchema(minLength = 2)), + buildJsonObject { put("emoji", "😀") }, + "'emoji' must NOT have fewer than 2 characters", + ) + } + + @Test + fun `validate string format is treated as an annotation`() { + assertDoesNotThrow { + validateElicitationContent( + schema("email" to StringSchema(format = StringSchemaFormat.Email)), + buildJsonObject { put("email", "not-an-email") }, + ) + } + } + + @Test + fun `validate negative zero satisfies an inclusive zero bound`() { + // IEEE 754 comparison: -0.0 == 0.0, so an inclusive minimum of 0.0 admits -0.0. + assertDoesNotThrow { + validateElicitationContent( + schema("score" to DoubleSchema(minimum = 0.0)), + buildJsonObject { put("score", -0.0) }, + ) + } + } + + @Test + fun `validate enum value not in allowed set fails`() { + assertRejected( + schema("color" to UntitledSingleSelectEnumSchema(enumValues = listOf("red", "green"))), + buildJsonObject { put("color", "blue") }, + "'color' must be equal to one of the allowed values", + ) + assertRejected( + schema("size" to TitledSingleSelectEnumSchema(oneOf = listOf(EnumOption("s", "Small")))), + buildJsonObject { put("size", "xl") }, + "'size' must be equal to one of the allowed values", + ) + assertRejected( + schema("legacy" to LegacyTitledEnumSchema(enumValues = listOf("a", "b"))), + buildJsonObject { put("legacy", "c") }, + "'legacy' must be equal to one of the allowed values", + ) + } + + @Test + fun `validate multi-select constraints fail`() { + val tags = UntitledMultiSelectEnumSchema( + items = UntitledMultiSelectEnumSchema.Items(enumValues = listOf("a", "b", "c")), + minItems = 1, + maxItems = 2, + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { put("tags", "a") }, + "'tags' must be array", + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { + putJsonArray("tags") { + add("a") + add(42) + } + }, + "'tags' items must be string", + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { + putJsonArray("tags") { + add("a") + add("z") + } + }, + "'tags' items must be equal to one of the allowed values", + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { putJsonArray("tags") {} }, + "'tags' must NOT have fewer than 1 items", + ) + assertRejected( + schema("tags" to tags), + buildJsonObject { + putJsonArray("tags") { + add("a") + add("b") + add("c") + } + }, + "'tags' must NOT have more than 2 items", + ) + } + + @Test + fun `validate titled multi-select membership uses option consts`() { + val sizes = TitledMultiSelectEnumSchema( + items = TitledMultiSelectEnumSchema.Items( + anyOf = listOf(EnumOption("s", "Small"), EnumOption("l", "Large")), + ), + ) + assertDoesNotThrow { + validateElicitationContent( + schema("sizes" to sizes), + buildJsonObject { putJsonArray("sizes") { add("s") } }, + ) + } + assertRejected( + schema("sizes" to sizes), + buildJsonObject { putJsonArray("sizes") { add("Small") } }, + "'sizes' items must be equal to one of the allowed values", + ) + } + + @Test + fun `validate json null is rejected with the type error for each schema kind`() { + // JsonNull is a JsonPrimitive whose content is "null"; it must fail the type check, not + // accidentally parse as a value. + assertRejected( + schema("name" to StringSchema()), + buildJsonObject { put("name", JsonNull) }, + "'name' must be string", + ) + assertRejected( + schema("age" to IntegerSchema()), + buildJsonObject { put("age", JsonNull) }, + "'age' must be integer", + ) + assertRejected( + schema("subscribed" to BooleanSchema()), + buildJsonObject { put("subscribed", JsonNull) }, + "'subscribed' must be boolean", + ) + } + + @Test + fun `validate non-primitive value is rejected for scalar schemas`() { + assertRejected( + schema("name" to StringSchema()), + buildJsonObject { putJsonArray("name") { add("x") } }, + "'name' must be string", + ) + assertRejected( + schema("age" to IntegerSchema()), + buildJsonObject { putJsonObject("age") {} }, + "'age' must be integer", + ) + } + + @Test + fun `validate number below minimum fails`() { + assertRejected( + schema("score" to DoubleSchema(minimum = 0.0)), + buildJsonObject { put("score", -0.5) }, + "'score' must be >= 0.0", + ) + } + + @Test + fun `validate non-finite value for number property fails`() { + // McpJson is lenient, so an unquoted NaN literal can arrive off the wire; it decodes to + // the same primitive as Double.NaN here. + assertRejected( + schema("score" to DoubleSchema(minimum = 0.0, maximum = 1.0)), + buildJsonObject { put("score", Double.NaN) }, + "'score' must be number", + ) + } + + @Test + fun `validate required property not declared in properties is still required`() { + // JSON Schema's `required` is independent of `properties`. + assertRejected( + schema("name" to StringSchema(), required = listOf("name", "consent")), + buildJsonObject { put("name", "octocat") }, + "must have required property 'consent'", + ) + } + + @Test + fun `validate properties not declared in schema pass`() { + // JSON Schema default: without an additionalProperties restriction, undeclared keys are permitted. + assertDoesNotThrow { + validateElicitationContent( + schema("name" to StringSchema(), required = listOf("name")), + buildJsonObject { + put("name", "octocat") + put("undeclared", 42) + }, + ) + } + } + + @Test + fun `validate reports every violation in one error`() { + val exception = assertFailsWith { + validateElicitationContent( + schema( + "name" to StringSchema(), + "age" to IntegerSchema(), + required = listOf("name"), + ), + buildJsonObject { put("age", "old") }, + ) + } + val message = exception.message.orEmpty() + assertTrue("must have required property 'name'" in message, message) + assertTrue("'age' must be integer" in message, message) + } +}