fix: harden Standard Schema elicitation conversion#2437
Draft
felixweinberger wants to merge 2 commits into
Draft
fix: harden Standard Schema elicitation conversion#2437felixweinberger wants to merge 2 commits into
felixweinberger wants to merge 2 commits into
Conversation
|
| 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
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
15e6243 to
12eb9e5
Compare
a849288 to
fb22446
Compare
- 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.
fb22446 to
85d7365
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ElicitRequestFormParamsSchemaand diffing for stripped keys — but that schema'srequestedSchemaroot is.catchall(z.unknown()), so unknown root keys are never stripped and the diff can't fire for them. Concretely,z.strictObject(...)shippedadditionalProperties: falsein 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 forrequestedSchema($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,$schemastays (it's spec-declared and zod emits it on every conversion), annotation-only root keywords (title/descriptionfrom.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/descriptionare 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.0peer range.ZOD_ISO_DATE_PATTERNand 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-workingz.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 asrequestedSchema. The guard now delegates toisStandardSchema, which accepts functions and actually verifies~standard.validate(and is the guardnormalizeRawShapeSchemaalready uses for this routing decision — gating on~standard.jsonSchemawould front-run the zod 4.0/4.1 fallback). This also fixes the reverse misroute: a plain JSONrequestedSchemacontaining a literal"~standard"key — wire-legal via the catchall — was routed into conversion and threw; it now stays on the raw branch.4.
inputRequired.elicitnow throwsTypeErroron unsupported schemas.inputRequired()throwsTypeErrorfor authoring mistakes, butelicitwith an unsupported schema threwProtocolError(InvalidParams)— inside aprompts/getorresources/readhandler that reaches the client as-32602on its own request, when the defect is server-side. The builder now rewraps asTypeError(with the original error ascause).elicitInputkeeps throwingProtocolError, where it maps to the outgoing request.How Has This Been Tested?
packages/core-internal/test/shared/elicitation.test.tsunit-testingnormalizeElicitInputFormParamsdirectly: root-keyword rejections (strictObject,looseObject,catchall, rootdefault,$defs),$schema/annotation handling, the format acceptance matrix (z.email(),z.url(),z.iso.date(), allz.iso.datetimevariants 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).strictObjectcase asserting the request throws before anything reaches the client.TypeErrorcontract (1,328 core-internal + 381 server tests green).ServeroverInMemoryTransportthrough the public package exports: observed the wirerequestedSchema(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.elicitauthoring errors areTypeErrorinstead ofProtocolError. The changeset is updated to describe the root handling.Types of changes
Checklist
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~standardkey that is not a valid Standard Schema (novalidatefunction) 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 betweenelicitInputandinputRequired.elicit+acceptedContent.