feat(server): validate accepted elicitation content against the requested schema (#896)

Validate accepted form-mode elicitation `content` against the
`requestedSchema` on the server, rejecting mismatches with an
`INVALID_PARAMS` error.

fixes #895

<!-- Provide a brief summary of your changes -->

## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->
`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?
<!-- Have you tested this in a real application? Which scenarios were
tested? -->
New unit tests for the validator and integration tests over an in-memory
client–server pair.

## Breaking Changes
<!-- Will users need to update their code or configurations? -->
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
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] 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
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [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 <devcrocod@gmail.com>
This commit is contained in:
Takuya Akamatsu 2026-07-22 01:17:16 +09:00 • committed by GitHub
commit 737b46f032
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 735 additions and 3 deletions

View file

@ -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<McpException> {
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<McpException> {
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()

View file

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

View file

@ -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>): 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<String>,
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
}

View file

@ -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,

View file

@ -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<String, PrimitiveSchemaDefinition>, required: List<String>? = null) =
ElicitRequestParams.RequestedSchema(properties = mapOf(*properties), required = required)
private fun assertRejected(
schema: ElicitRequestParams.RequestedSchema,
content: JsonObject,
expectedError: String,
) {
val exception = assertFailsWith<McpException> { 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<McpException> {
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)
}
}