Repository navigation
fix: validate elicitation replies before returning them - #669
Conversation
2de2594 to
a8a1581
Compare
a8a1581 to
9834993
Compare
| requested_schema = params[:requestedSchema] || params["requestedSchema"] | ||
| return unless requested_schema | ||
|
|
||
| content = result.fetch(:content) { result["content"] } |
There was a problem hiding this comment.
An accept reply without content is refused here, even for a schema whose properties are all optional. ElicitResult.content is optional, and the TypeScript server validates it only when present. Validating content || {} keeps the check for required properties while letting an empty form through; skipping the check when content is absent, as TypeScript does, is the other option. The test "an accepted form reply must include content even when no property is required" pins the current refusal and changes with it.
There was a problem hiding this comment.
Changed this to skip the schema check when content is absent or null. TypeScript's legacy result schema also treats null as omitted. Tests cover both cases with symbol and string keys and with required and optional schemas. Other content still gets checked, including an empty object.
| @@ -85,6 +85,9 @@ primitive types (`string`, `number`, `integer`, `boolean`). Nested objects and a | |||
| the schema simple enough for clients to render as a form. Per the MCP specification, the client validates | |||
There was a problem hiding this comment.
"Per the MCP specification, the client validates the user's input" overstates the specification, which lists validation among the things a client can do with the schema. With this change the sentence can say that the SDK checks accepted form content against the schema.
There was a problem hiding this comment.
Removed that claim. The docs now say the legacy path checks accepted form content against the requested schema unless content is omitted or null.
| the schema simple enough for clients to render as a form. Per the MCP specification, the client validates | ||
| the user's input against this schema before returning it, so the `content` of an `accept` response matches the requested shape. | ||
|
|
||
| `create_form_elicitation`, `create_url_elicitation`, and the legacy `input_required` shim validate reply actions and accepted form content. |
There was a problem hiding this comment.
Worth noting that this covers the legacy path only: on the 2026-07-28 lifecycle the inputResponses of a retried request reach the handler through server_context.input_response without this check.
There was a problem hiding this comment.
Added the lifecycle distinction. I checked the retry path: on 2026-07-28, inputResponses reach server_context.input_response without this validation.
|
The validation is sound: the validator runs with One change is needed before merge: an |
9834993 to
ffb5cbd
Compare
ffb5cbd to
5c56eb2
Compare
|
Fixed the missing-content case, kept TypeScript's legacy null handling, and addressed both docs points. The SEP-1330 test is unchanged, and other non-null content still gets validated. I also removed the form-reply mock changes that are no longer needed. |
Motivation and Context
Elicitation replies were returned without checking their action or accepted form content. Validate them in the shared session request path before returning to a handler or replaying a legacy
input_requiredresult.A reply must be an object with an
actionofaccept,declineorcancel. For form acceptance, checkcontentagainstrequested_schemaunless it is omitted ornil(nullon the wire). Those replies are returned unchanged, even if the schema has required properties. Present empty content and other non-null values are still checked. Decline, cancel and URL acceptance keep their existing content behavior. A mismatch raisesMCP::Server::ValidationErrorwith a message starting withResponse validation failed:. This reusesTool::OutputSchema, including its existingformat: falsebehavior.This covers the legacy
create_form_elicitation,create_url_elicitationandinput_requiredshim paths. It does not add this check to the 2026-07-28 retry path:inputResponsesreach the handler throughserver_context.input_response.Specification and TypeScript references:
ElicitResultin the 2025-11-25 schema and 2026-07-28 schema:action: "accept" | "decline" | "cancel"and optionalcontent?: { [key: string]: string | number | boolean | string[] }.Server.elicitInput/_sendElicitationLegchecks accepted form content only undervalidateAcceptedContent && result.action === 'accept' && result.content && formParams.requestedSchema. Skipping absent content matches that public helper; replacing it with{}would reject replies that helper accepts.ElicitResultSchemaalso appliesval => (val === null ? undefined : val)tocontent: "We normalizenulltoundefinedfor leniency while maintaining type compatibility." The nil guard preserves that legacy acceptance. This does not change the modern 2026-07-28 retry path or its wire schema.inputResponses, rather than returning through the legacy session request helper.oneOfwithconstfor titled choices anditemsfor multiple selections. The enum-builder acceptance test is retained.Fixes #661.
How Has This Been Tested?
{}still rejects missing required properties;false, numbers, strings, arrays and invalid object values remain checked.--force-exclusion: four changed Ruby files, no offenses.git diff --checkis clean.Local tests used Ruby 4.0.7 with the existing Minitest 5 and ActiveSupport 7.2.2.2 gems, without Bundler. Upstream CI covers the supported Ruby matrix and conformance checks.
Breaking Changes
Yes. Invalid reply actions, non-object results and non-null accepted form content that does not match
requested_schemaused to reach legacy handlers. They now raiseMCP::Server::ValidationError. Acceptance with omitted or null content is unchanged. The modern 2026-07-28 path is untouched.Types of changes
Checklist