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:
parent
078e739424
commit
737b46f032
5 changed files with 735 additions and 3 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue