Skip to content

fix: validate elicitation replies before returning them - #669

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
atesgoral:fix/elicitation-result-validation
Nov 7, 2026
Merged

koic merged 1 commit into
modelcontextprotocol:mainfrom
atesgoral:fix/elicitation-result-validation

Conversation

@atesgoral

@atesgoral atesgoral commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

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_required result.

A reply must be an object with an action of accept, decline or cancel. For form acceptance, check content against requested_schema unless it is omitted or nil (null on 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 raises MCP::Server::ValidationError with a message starting with Response validation failed:. This reuses Tool::OutputSchema, including its existing format: false behavior.

This covers the legacy create_form_elicitation, create_url_elicitation and input_required shim paths. It does not add this check to the 2026-07-28 retry path: inputResponses reach the handler through server_context.input_response.

Specification and TypeScript references:

  • ElicitResult in the 2025-11-25 schema and 2026-07-28 schema: action: "accept" | "decline" | "cancel" and optional content?: { [key: string]: string | number | boolean | string[] }.
  • Elicitation, Requested Schema (legacy, 2026-07-28): "Clients can use this schema to" "Validate user input before sending". Form Mode Security says clients and servers SHOULD validate (legacy, 2026-07-28). The docs no longer promise that clients always validate.
  • TypeScript Server.elicitInput / _sendElicitationLeg checks accepted form content only under validateAcceptedContent && result.action === 'accept' && result.content && formParams.requestedSchema. Skipping absent content matches that public helper; replacing it with {} would reject replies that helper accepts.
  • TypeScript's legacy ElicitResultSchema also applies val => (val === null ? undefined : val) to content: "We normalize null to undefined for 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.
  • 2026-07-28 MRTR, Basic Workflow: "the requests in each step are completely independent". The modern retry carries inputResponses, rather than returning through the legacy session request helper.
  • SEP-1330, single- and multiple-selection enums defines oneOf with const for titled choices and items for multiple selections. The enum-builder acceptance test is retained.

Fixes #661.

How Has This Been Tested?

  • The omitted-content regression errors on the original published head. The explicit-null regression errors on the subsequent published head before the nil guard. Both pass with this change. Tests cover symbol- and string-keyed omitted and null content, with required and optional schemas. Present {} still rejects missing required properties; false, numbers, strings, arrays and invalid object values remain checked.
  • The new test file also covers invalid actions, non-object results, types, enums, required fields, SEP-1330 enum builders, string keys, decline/cancel, URL acceptance, unchanged replies, transport errors and legacy replay. The existing form-reply mock edits are no longer needed and were removed.
  • Focused elicitation, cancellation, request-timeout and legacy-shim tests: 75 runs, 245 assertions, no failures, errors or skips.
  • Full Ruby suite on AC power with a shared heavy-test slot: 2,516 runs, 8,358 assertions, no failures, errors or skips.
  • RuboCop with --force-exclusion: four changed Ruby files, no offenses. git diff --check is 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_schema used to reach legacy handlers. They now raise MCP::Server::ValidationError. Acceptance with omitted or null content is unchanged. The modern 2026-07-28 path is untouched.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@atesgoral
atesgoral force-pushed the fix/elicitation-result-validation branch from 2de2594 to a8a1581 Compare November 6, 2026 06:36
@atesgoral
atesgoral marked this pull request as ready for review November 6, 2026 06:57
@atesgoral
atesgoral marked this pull request as draft November 6, 2026 07:54
@atesgoral
atesgoral force-pushed the fix/elicitation-result-validation branch from a8a1581 to 9834993 Compare November 6, 2026 13:59
@atesgoral
atesgoral marked this pull request as ready for review November 6, 2026 14:13
Comment thread lib/mcp/server_session.rb
requested_schema = params[:requestedSchema] || params["requestedSchema"]
return unless requested_schema

content = result.fetch(:content) { result["content"] }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/_server/elicitation.md Outdated
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed that claim. The docs now say the legacy path checks accepted form content against the requested schema unless content is omitted or null.

Comment thread docs/_server/elicitation.md Outdated
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.

@koic koic Nov 7, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the lifecycle distinction. I checked the retry path: on 2026-07-28, inputResponses reach server_context.input_response without this validation.

@koic

koic commented Nov 7, 2026

Copy link
Copy Markdown
Member

The validation is sound: the validator runs with format: false, SEP-1330 enum shapes and string-keyed replies pass, and error messages carry the schema's allowed values, not the client's input.

One change is needed before merge: an accept reply without content must not be refused (see the inline comment on lib/mcp/server_session.rb). The two docs comments can land in the same push.

@atesgoral
atesgoral marked this pull request as draft November 7, 2026 15:18
@atesgoral
atesgoral force-pushed the fix/elicitation-result-validation branch from 9834993 to ffb5cbd Compare November 7, 2026 15:35
@atesgoral
atesgoral force-pushed the fix/elicitation-result-validation branch from ffb5cbd to 5c56eb2 Compare November 7, 2026 15:40
@atesgoral
atesgoral marked this pull request as ready for review November 7, 2026 15:58
@atesgoral

Copy link
Copy Markdown
Contributor Author

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.

@koic
koic merged commit 5cae935 into modelcontextprotocol:main Nov 7, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Elicitation results are not checked against requested_schema, but the docs say they match it

2 participants