feat(server): accept Standard Schemas for elicitation#2369
Conversation
🦋 Changeset detectedLatest commit: a5ba576 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
01f43f4 to
f732d6f
Compare
ea55f4b to
1308739
Compare
| --- | ||
| '@modelcontextprotocol/core': minor | ||
| '@modelcontextprotocol/server': minor | ||
| --- |
There was a problem hiding this comment.
🔴 The changeset bumps @modelcontextprotocol/core, but this PR does not touch packages/core at all — the protocol changes (new ElicitInputFormParams/ElicitInputResult types, widened ServerContext.mcpReq.elicitInput) live in packages/core-internal plus packages/server, and @modelcontextprotocol/client re-exports the changed public barrel so its bundled types change too. As written, core gets a no-op minor release while core-internal and client are not versioned; the front-matter should be '@modelcontextprotocol/core-internal': minor, '@modelcontextprotocol/server': minor, and '@modelcontextprotocol/client': minor, with core removed.
Extended reasoning...
What the bug is. .changeset/standard-schema-elicitation.md declares minor bumps for '@modelcontextprotocol/core' and '@modelcontextprotocol/server'. But this PR's library changes live entirely in packages/core-internal (src/shared/protocol.ts adds ElicitInputFormParams/ElicitInputResult and widens ServerContext.mcpReq.elicitInput into an overload set; src/exports/public/index.ts exports the new types) and packages/server (server.ts overloads, the new elicitation.ts module). Nothing under packages/core is touched.
Why core is the wrong package. packages/core is the public schemas-only package — its barrel re-exports only the *Schema Zod constants (spec + OAuth/OpenID) bundled from core-internal/schemas and core-internal/auth. This PR adds TypeScript types and runtime conversion logic, not new *Schema constants, so @modelcontextprotocol/core's published surface is byte-for-byte unchanged. Bumping it produces a pointless minor release with a misleading changelog entry (the changeset-bot table on this PR confirms core: Minor is what would be released).
Why core-internal and client should be listed instead. Repo convention is consistent: the other pending changesets that touch core-internal/src/shared/protocol.ts (e.g. custom-methods-minimal.md, wraphandler-hook.md, add-sdk-http-error.md, support-standard-json-schema.md) all attribute the change to '@modelcontextprotocol/core-internal' plus the consuming public packages (client and/or server); the only changeset that names '@modelcontextprotocol/core' is add-core-public-package.md, which created that package. core-internal is private but is versioned via changesets (it appears in pre.json initialVersions and in ~30 existing changesets), so omitting it breaks the dependency-cascade bookkeeping. Additionally, both packages/client/src/index.ts and packages/server/src/index.ts re-export @modelcontextprotocol/core-internal/public, and that barrel now gains ElicitInputFormParams/ElicitInputResult plus the modified ServerContext — so the client package's published type surface changes too, yet client gets no bump from this changeset (and with core-internal omitted, the updateInternalDependencies cascade can't pick it up either).
Why nothing catches it. The changeset-bot only checks that a changeset exists; it does not verify that the named packages match the touched paths. The earlier claude[bot] comment that asked for a changeset itself suggested "@modelcontextprotocol/core and /server", which referred to the pre-rename path layout and likely propagated the wrong package name into this changeset.
Step-by-step proof.
git difffor this PR shows changes underpackages/core-internal/,packages/server/, docs, and examples — zero files underpackages/core/.- The changeset front-matter lists
'@modelcontextprotocol/core': minorand'@modelcontextprotocol/server': minor. - On the next
changeset version/release,@modelcontextprotocol/coreis published with a bumped version and a changelog entry describing Standard Schema elicitation — a feature that does not exist in that package's contents. - Meanwhile
@modelcontextprotocol/client(whosed.tssurface now includes the new exported types and the changedServerContext.mcpReq.elicitInputoverloads via the re-exported public barrel) and@modelcontextprotocol/core-internal(where the change actually lives) get no version bump, so the change is unrecorded for the packages that actually changed.
How to fix. Replace the front-matter with:
---
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/client': minor
---(client could arguably be patch, but minor matches how sibling changesets like add-sdk-http-error.md treat new public type exports.) The changeset body text can stay as-is.
| const registrationSchema = z.object({ | ||
| username: z.string().min(3).max(20).meta({ title: 'Username', description: 'Your desired username (3-20 characters)' }), | ||
| email: z.string().email().meta({ title: 'Email', description: 'Your email address' }), | ||
| password: z.string().min(8).meta({ title: 'Password', description: 'Your password (min 8 characters)' }), | ||
| newsletter: z.boolean().default(false).meta({ title: 'Newsletter', description: 'Subscribe to newsletter?' }) | ||
| }); |
There was a problem hiding this comment.
🟡 The new register_user example (examples/server/src/elicitationFormExample.ts:44) and the new accept-path test in jsonSchemaValidatorOverride.test.ts:138 use z.string().email(), but in Zod v4 the ZodString .email() method is @deprecated in favor of the top-level z.email() helper (which the same test file already uses elsewhere). Since this is brand-new showcase code for the Zod-based elicitation path, switch to z.email().meta({...}) — runtime behavior is identical.
Extended reasoning...
What the issue is. This PR rewrites the register_user example and adds a new accept-path test, both using email: z.string().email(). In the workspace's Zod v4 (catalog ^4.2.0, currently resolving to 4.3.x), ZodString.prototype.email() is explicitly marked /** @deprecated Use z.email() instead. */ in zod/v4/classic/schemas.d.ts (the same applies to .url(), .uuid(), etc.). Zod's v4 docs steer users to the top-level z.email() helper, and the deprecated string-method form is slated for removal in a future major.
Where it appears. Both occurrences are newly introduced by this PR — they are the only usages of z.string().email() in the repo, so this is not following an existing convention:
examples/server/src/elicitationFormExample.ts:44—email: z.string().email().meta({ title: 'Email', ... })in the rewrittenregister_userregistration schema.packages/server/test/server/jsonSchemaValidatorOverride.test.ts:138— the new "accepts a Standard Schema requestedSchema" test.
Notably, the same test file already uses the modern form a little further down: the rejection test uses z.email({ pattern: /@corp\.com$/ }). So the PR is internally inconsistent about which API it models.
Why it matters. These two files are the canonical showcase for the new Standard Schema elicitation path — the example is what users will copy. Teaching the deprecated API surfaces editor strikethrough/lint deprecation warnings for anyone following the example, and would break when Zod removes the method. Since the SDK's docs prose added by this PR (docs/server.md, docs/migration.md) already recommends the Zod v4 idioms (.meta({ title })), the example should model the current API as well.
Why nothing breaks at runtime. Both z.string().email() and z.email() emit identical JSON Schema through standardSchemaToJsonSchema — format: 'email' plus Zod's canonical email regex as pattern — and that canonical pattern is exactly what the new redundant-pattern exemption in isRedundantFormatPattern (packages/server/src/server/elicitation.ts) accepts. So the wire schema, the strip check, and the accept-path validation behave the same either way. This is purely an API-currency issue, not a functional bug.
Step-by-step. (1) A user copies the register_user example into their project. (2) Their editor shows email() struck through with "Use z.email() instead", and lint rules like @typescript-eslint/no-deprecated flag it. (3) They either ignore the warning (and inherit a removal-pending API) or have to figure out the replacement themselves — neither of which a flagship example should require. The same applies to anyone reading the test as reference usage.
How to fix. One-token change in both places: email: z.email().meta({ title: 'Email', description: 'Your email address' }) in the example, and email: z.email().meta({ title: 'Email', description: 'Email address' }) in the test. The expected wire schema and assertions in the test remain valid unchanged.
Fixes #662.
Allows
Server.elicitInput()andctx.mcpReq.elicitInput()to accept a Standard Schema/Zod object asrequestedSchema, converts it to the existing JSON Schema wire shape, and keeps JSON Schema input working unchanged.Verification:
pnpm --filter @modelcontextprotocol/server test -- jsonSchemaValidatorOverride.test.tspnpm --filter @modelcontextprotocol/core typecheckpnpm --filter @modelcontextprotocol/server typecheckpnpm --filter @modelcontextprotocol/core lintpnpm --filter @modelcontextprotocol/server lintpnpm --filter @modelcontextprotocol/server buildpnpm run typecheck:all,pnpm run build:all,pnpm run lint:allgit diff --check