Skip to content

fix: harden Standard Schema elicitation conversion#2437

Draft
felixweinberger wants to merge 2 commits into
feat/elicitation-standard-schemafrom
fweinberger/elicitation-standard-schema-fixes
Draft

fix: harden Standard Schema elicitation conversion#2437
felixweinberger wants to merge 2 commits into
feat/elicitation-standard-schemafrom
fweinberger/elicitation-standard-schema-fixes

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

Follow-up hardening for the Standard Schema elicitation support — four contained fixes, sent as a PR onto your branch so you can review and merge it into #2369 directly. No changes to the API shape or the overall design, which look right.

Motivation and Context

1. Unknown root-level keywords passed the flat-primitive gate onto the wire.
The gate works by parsing with ElicitRequestFormParamsSchema and diffing for stripped keys — but that schema's requestedSchema root is .catchall(z.unknown()), so unknown root keys are never stripped and the diff can't fire for them. Concretely, z.strictObject(...) shipped additionalProperties: false in the outgoing request, .catchall(z.object(...)) shipped a nested schema, and a custom Standard Schema could ship $defs. The spec declares a closed root shape for requestedSchema ($schema/type/properties/required), so the fix whitelists the root after conversion — the keyword set is derived from the wire schema so it tracks spec revisions, $schema stays (it's spec-declared and zod emits it on every conversion), annotation-only root keywords (title/description from .meta()) are dropped, and anything else throws the existing "unsupported JSON Schema keyword(s)" error before sending.

One deliberate trade-off to flag: root title/description are now silently dropped rather than sent or rejected — the spec root shape doesn't declare them, so forwarding risks strict-client rejections, and throwing on .describe() felt hostile. Happy to change that if you see it differently.

2. The vendored zod regexes were a time bomb against the ^4.2.0 peer range.
ZOD_ISO_DATE_PATTERN and friends were byte-copies of what one zod version emits. zod is a peer at ^4.2.0: if a future in-range zod tweaked any of those regexes, previously-working z.email() / z.iso.datetime() elicitations would start throwing at user runtime while lockfile-pinned CI stayed green. The reference patterns are now derived from the installed zod at runtime (z.toJSONSchema(z.email(), ...) etc., with the date-time option space enumerated from the pattern under test), so the converter and the reference can't drift apart. Behavior is unchanged — I checked the new derivation against the old matcher's full accepted set under zod 4.3.6 and they're equivalent in both directions, and your customized-pattern rejection tests (z.email({ pattern }), the hand-built date-time case) pass untouched.

Residual limitation worth knowing: if an app resolves two zod copies whose regexes differ, the mismatch rejects (fail closed). That's much rarer than the single-copy upgrade the vendored approach broke on, and zod being a peer dep is exactly what makes installs dedupe.

3. Function-typed Standard Schemas (ArkType) were misrouted past conversion.
The schema-detection guard required typeof schema === 'object', but ArkType schemas are functions — they satisfied the typed overload, then fell through to the raw branch: no conversion, no flat-primitive gate, and the arktype-internal object shipped as requestedSchema. The guard now delegates to isStandardSchema, which accepts functions and actually verifies ~standard.validate (and is the guard normalizeRawShapeSchema already uses for this routing decision — gating on ~standard.jsonSchema would front-run the zod 4.0/4.1 fallback). This also fixes the reverse misroute: a plain JSON requestedSchema containing a literal "~standard" key — wire-legal via the catchall — was routed into conversion and threw; it now stays on the raw branch.

4. inputRequired.elicit now throws TypeError on unsupported schemas.
inputRequired() throws TypeError for authoring mistakes, but elicit with an unsupported schema threw ProtocolError(InvalidParams) — inside a prompts/get or resources/read handler that reaches the client as -32602 on its own request, when the defect is server-side. The builder now rewraps as TypeError (with the original error as cause). elicitInput keeps throwing ProtocolError, where it maps to the outgoing request.

How Has This Been Tested?

  • New packages/core-internal/test/shared/elicitation.test.ts unit-testing normalizeElicitInputFormParams directly: root-keyword rejections (strictObject, looseObject, catchall, root default, $defs), $schema/annotation handling, the format acceptance matrix (z.email(), z.url(), z.iso.date(), all z.iso.datetime variants incl. precision −1/0/3), customized-pattern rejections, an installed-zod equivalence case, and the schema-detection cases (function-typed schema converts; literal "~standard" key stays raw).
  • A server-level strictObject case asserting the request throws before anything reaches the client.
  • Existing suites pass unmodified except the two builder tests updated for the TypeError contract (1,328 core-internal + 381 server tests green).
  • Manually drove a real Server over InMemoryTransport through the public package exports: observed the wire requestedSchema (formats kept, no patterns, clean root; a function schema converts rather than shipping vendor internals), the typed/coerced result, pre-send rejections, and the builder error type.

Breaking Changes

None released — this adjusts behavior introduced on this branch. Two in-branch behavior changes: schemas emitting unknown root keywords now reject instead of leaking them onto the wire, and inputRequired.elicit authoring errors are TypeError instead of ProtocolError. The changeset is updated to describe the root handling.

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

The root-keyword set is derived from ElicitRequestFormParamsSchema's declared shape plus $schema, so a future spec revision that adds a root key only needs the schema change. One behavior note on the guard fix: an object carrying a ~standard key that is not a valid Standard Schema (no validate function) now passes through the raw branch onto the wire instead of throwing a vendor error — matching how the same object behaves without this feature. Not touched here (can follow up separately): JSDoc for the new public types/overloads, enum and response-failure test coverage, and the response-validation asymmetry between elicitInput and inputRequired.elicit + acceptedContent.

@changeset-bot

changeset-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 85d7365

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 7 packages
Name Type
@modelcontextprotocol/core-internal Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/client 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 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/core

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 85d7365

@mattzcarey mattzcarey force-pushed the feat/elicitation-standard-schema branch 2 times, most recently from 15e6243 to 12eb9e5 Compare July 6, 2026 19:01
@felixweinberger felixweinberger force-pushed the fweinberger/elicitation-standard-schema-fixes branch from a849288 to fb22446 Compare July 6, 2026 19:02
- Reject unknown root-level requestedSchema keywords after conversion.
  The wire schema's root is a catchall, so keys like the
  additionalProperties emitted by z.strictObject() passed the
  stripped-keys gate onto the wire; the root is now held to the
  spec-declared shape (type/properties/required/$schema, derived from
  the wire schema), with annotation-only root keywords dropped.
- Derive redundant format-pattern references from the installed zod at
  runtime instead of vendoring its regexes. The vendored literals were
  byte-coupled to one zod version against a ^4.2.0 peer range, so any
  in-range regex change would reject working schemas at user runtime
  while pinned CI stayed green. Customized patterns still reject.
- Throw TypeError (with cause) from inputRequired.elicit on unsupported
  schemas, matching the builder's authoring-error convention;
  ProtocolError from a prompts/get handler would reach the client as
  InvalidParams on its own request.
…sion

The schema-detection guard required typeof object, so function-typed
Standard Schemas (e.g. ArkType) fell through to the raw branch and the
vendor-internal object shipped as requestedSchema with no conversion or
flat-primitive gate. Delegate to isStandardSchema, which accepts
functions and verifies ~standard.validate — and, matching the precedent
in normalizeRawShapeSchema, does not gate on ~standard.jsonSchema so
the zod 4.0/4.1 fallback stays reachable. Also fixes the reverse
misroute: a plain JSON requestedSchema containing a literal '~standard'
key is wire-legal and now stays on the raw branch instead of throwing a
vendor error.
@felixweinberger felixweinberger force-pushed the fweinberger/elicitation-standard-schema-fixes branch from fb22446 to 85d7365 Compare July 6, 2026 19:05
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