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
1 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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue