Skip to content

fix: convert elicitation Standard Schemas via an explicit wire-grammar walk#2454

Open
felixweinberger wants to merge 1 commit into
feat/elicitation-standard-schemafrom
fweinberger/elicitation-minimal-fixes
Open

fix: convert elicitation Standard Schemas via an explicit wire-grammar walk#2454
felixweinberger wants to merge 1 commit into
feat/elicitation-standard-schemafrom
fweinberger/elicitation-minimal-fixes

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Follow-up for the reworked Standard Schema elicitation support, as a PR onto this branch so CI and review run before it lands in #2369. One commit: it fixes the outstanding conversion bugs and restructures the gate so this class of bug can't quietly return.

Motivation and Context

The mechanism. The previous gate worked by parsing the converted schema through the wire zod schema (which strips unknown keys) and diffing for what disappeared. That indirection is what made the recent bugs possible: the root catchall meant root keywords never showed up in the diff, zod's format-companion regexes showed up as false positives, and rejection errors were whatever zod's union parse produced (multi-KB dumps naming the offending property once, at the end).

This PR replaces it with an explicit walk of the converted schema against the wire grammar. The grammar — root keys, per-type property key sets, the supported format enum — is derived at module load from the wire zod schemas themselves, so a spec revision updates it automatically. The walk decides each key's fate (keep / drop annotation / reject with its exact path); value validity stays owned by the wire schemas; a residual check rejects key combinations no single wire shape can carry (e.g. enum beside minLength).

The behavior fixes riding on it:

  • Natural zod format forms work. z.email(), z.iso.date(), and z.iso.datetime() emit a format-check regex beside the format that the wire can't carry — previously they all threw (masked in tests by z.string().meta({ format }), which emits no pattern and validates nothing). The library's own pattern now drops as redundant. For zod the exact patterns are derived from the resolved zod at runtime — never vendored, so an in-range zod upgrade can't silently break this — and a customized z.email({ pattern }) still rejects rather than silently weakening the advertised constraint. Other vendors' format realizations are unknowable from here, so a pattern beside a matching retained format is trusted as the library's own: ArkType's string.email now converts (pinned with real ArkType and Valibot schemas in the integration suite).
  • Unknown root keywords reject instead of leaking onto the wire. The root catchall let z.strictObject()'s additionalProperties: false (and $defs, registry ids, …) ship in requestedSchema; the spec declares a closed root shape, and the converted root is now held to it. Annotation-only root keywords (title/description from .meta()) drop rather than reject.
  • Schema detection routes on isStandardSchema rather than isStandardSchemaWithJSON, matching normalizeRawShapeSchema: zod 4.0/4.1 schemas (validate but no ~standard.jsonSchema) now reach the converter's fallback instead of shipping zod internals raw on the wire.
  • Rejection errors name the offending paths. z.union([z.literal('a'), z.literal('b')]) now produces a 154-character error naming properties.role, instead of a ~5KB zod union dump. Parsing is scoped to the converted schema, so params-level fields behave exactly as on the raw branch (no more message: 42 blamed on requestedSchema, and unknown params extension keys survive on both branches).
  • Corrupt-conversion guard. A converted schema whose required names a property that properties doesn't define (zod's toJSONSchema does this for a __proto__ property) rejects instead of shipping.
  • The contract is documented. The elicit JSDoc and the input-required guide state the accepted forms, what rejects, the supported formats, and that responses are validated by re-passing the schema to acceptedContent().

How Has This Been Tested?

  • The behavior matrix is pinned through inputRequired.elicit and was written before the mechanism rework — every test passed unchanged across the restructure, which is the behavior-preservation argument: format variants (incl. offset/precision datetimes and the .meta({ format }) style), customized-pattern and literal-union rejections, root keyword rejections and annotation drops, function-typed (ArkType-style) and validate-only schema routing, multi-select/optional/default/z.literal(['a','b']) shapes, params extension-key parity, and the __proto__ guard.
  • Real ArkType (type({ email: 'string.email' })) and Valibot conversions run in the integration suite.
  • An adversarial equivalence review of the old-vs-new mechanism found the accept-space identical by construction (any key surviving the old union parse lies within the derived key sets, and vice versa); all divergences are error-message composition.
  • Full suites green (1,342 core-internal + 439 server + 351 integration) plus root pnpm check:all; manually drove the builder and the 2025 shim leg end-to-end over InMemoryTransport through the public package exports.

Breaking Changes

None released — this adjusts behavior introduced on this branch. In-branch: the natural zod format forms now work instead of throwing; unknown root keywords and corrupt conversions reject instead of shipping; validate-only Standard Schemas route to the converter. The changeset names the accepted and rejected forms.

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

Additional context

Known residual, documented in a code comment: the zod reference patterns come from the zod copy this package resolves, so an install with two zod copies whose format regexes diverged fails closed (a false rejection, never a bad wire message). Deferred follow-ups worth separate PRs: a CI leg testing the minimum supported zod peer version, converted-schema coverage in the conformance everything-server, and surfacing schema-validation failures distinctly from missing/declined in acceptedContent.

@changeset-bot

changeset-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 257dc98

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core-internal Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2454

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2454

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2454

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2454

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2454

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2454

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2454

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2454

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2454

commit: 257dc98

@felixweinberger felixweinberger marked this pull request as ready for review July 7, 2026 12:42
@felixweinberger felixweinberger requested a review from a team as a code owner July 7, 2026 12:42
@felixweinberger felixweinberger force-pushed the fweinberger/elicitation-minimal-fixes branch from 998367c to 23e8f6a Compare July 7, 2026 14:29
@felixweinberger felixweinberger changed the title fix: accept zod format patterns and enforce the elicitation schema root shape fix: convert elicitation Standard Schemas via an explicit wire-grammar walk Jul 7, 2026
…r walk

Replaces the parse-then-diff gate with a walk of the converted schema
against the wire grammar, derived at module load from the wire zod
schemas (root keys, per-type property key sets, the format enum) so a
spec revision updates it automatically. Each key is kept, dropped as
annotation-only, or rejected with its exact path; value validity stays
owned by the wire schemas, with a residual check rejecting key
combinations no single wire shape can carry. Behavior fixes riding on
the mechanism:

- The natural zod format forms work: z.email(), z.iso.date(), and
  z.iso.datetime() emit a format-check regex beside the format that the
  wire cannot carry; the library's own pattern is dropped as redundant.
  For zod the exact patterns are derived from the resolved zod at
  runtime (never vendored, so in-range zod upgrades cannot break them)
  and a customized z.email({ pattern }) still rejects rather than
  silently weakening the advertised constraint. Other vendors'
  realizations are unknowable, so a pattern beside a matching retained
  format is trusted as the library's own — ArkType's string.email now
  converts (pinned with real ArkType and Valibot in the integration
  suite).
- Unknown root keywords reject instead of leaking onto the wire: the
  wire schema's root catchall let z.strictObject()'s
  additionalProperties: false (and $defs, etc.) through; the root is
  now held to the spec shape, with annotation-only root keywords
  dropped.
- Schema detection routes on isStandardSchema rather than
  isStandardSchemaWithJSON, matching normalizeRawShapeSchema, so zod
  4.0/4.1 schemas reach the converter's fallback instead of shipping
  zod internals raw.
- Rejection errors name the offending paths concisely (previously a
  multi-kilobyte zod union dump buried the property name); parsing is
  scoped to the converted schema so params-level fields behave exactly
  as on the raw branch, which also keeps unknown params extension keys.
- Converted schemas whose required entries name properties that
  properties does not define (zod's toJSONSchema does this for a
  __proto__ property) reject instead of shipping a corrupt schema.
- The elicit JSDoc and the input-required guide document the conversion
  contract: accepted forms, what rejects, and that responses are
  validated by re-passing the schema to acceptedContent().

Tests pin the full behavior matrix through inputRequired.elicit —
formats and their variants, customized-pattern and literal-union
rejections, root keyword and annotation handling, function-typed and
validate-only schema routing, multi-select/optional/default shapes,
params extension-key parity, and real ArkType/Valibot conversion in the
integration suite.
@felixweinberger felixweinberger force-pushed the fweinberger/elicitation-minimal-fixes branch from 23e8f6a to 257dc98 Compare July 7, 2026 15:10

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any bugs in the wire-grammar walk or the format-pattern handling, but this reworks the entire elicitation Standard Schema conversion mechanism (grammar derivation, per-vendor pattern trust, new rejection semantics), so the design decisions here deserve a human look.

Extended reasoning...

Overview

This PR replaces the parse-and-diff gate for Standard Schema elicitation conversion with an explicit walk of the converted JSON Schema against a wire grammar derived at module load from the Zod wire schemas (packages/core-internal/src/shared/elicitation.ts), adds a format-companion-pattern classifier in packages/core-internal/src/util/standardSchema.ts, a small shapeKeys util, docs/JSDoc/changeset updates, and a substantial new test matrix (unit + integration with real ArkType/Valibot schemas).

Security risks

Low. The changes affect server-side outbound request construction (what requestedSchema ships in elicitation/create), not inbound untrusted input handling. The main risk class is silently weakening an advertised constraint (dropping a user's custom pattern), which the PR explicitly guards against by rejecting customized zod patterns; non-zod vendors' companion patterns are trusted-and-dropped by design, which is a documented policy choice rather than a bug. The __proto__/dangling-required guard is a hardening improvement.

Level of scrutiny

This is core-internal SDK logic with several judgment calls a maintainer should weigh: deriving the wire grammar reflectively from the Zod schemas (.shape, .unwrap().options), trusting non-zod vendors' format-companion regexes as droppable, generating zod reference patterns at runtime, and the new reject-vs-drop semantics for root keywords. None of these looked incorrect to me — the walk's key sets match the wire schemas in schemas.ts, the residual dropped-constraint check remains as a safety net, and the behavior matrix is well pinned in tests — but the mechanism is intricate enough that it falls well outside "simple and obviously correct".

Other factors

The PR targets the in-progress Standard Schema elicitation branch (follow-up to #2369) rather than main, so nothing here is released behavior. Test coverage is thorough (format variants, rejection paths, vendor routing, extension-key parity, real ArkType/Valibot integration), and the docs/changeset accurately describe the implemented behavior. No bugs were found by the automated review; deferring solely because the design/mechanism decisions warrant maintainer judgment.

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.

1 participant