-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat(server): accept Standard Schemas for elicitation #2369
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
79dc1cd
c230f1a
a3817bb
1580662
bb7a377
0ad602d
1308739
a5ba576
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@modelcontextprotocol/core': minor | ||
| '@modelcontextprotocol/server': minor | ||
| --- | ||
|
Check failure on line 4 in .changeset/standard-schema-elicitation.md
|
||
|
|
||
| Allow form elicitation requests to accept Standard Schema values such as Zod objects for `requestedSchema`. The server converts these schemas to MCP's restricted elicitation JSON Schema before sending and parses accepted content with the original schema before returning typed | ||
| results. Zod string formats that map to MCP's supported `email`, `uri`, `date`, or `date-time` formats are accepted; arbitrary regex patterns remain rejected because form elicitation does not carry JSON Schema `pattern`. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ | |
| import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; | ||
| import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; | ||
| import type { Request, Response } from 'express'; | ||
| import * as z from 'zod/v4'; | ||
|
|
||
| // Create a fresh MCP server per client connection to avoid shared state between clients. | ||
| // The validator supports format validation (email, date, etc.) if ajv-formats is installed. | ||
|
|
@@ -38,51 +39,23 @@ | |
| }, | ||
| async () => { | ||
| try { | ||
| 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?' }) | ||
| }); | ||
|
Check warning on line 47 in examples/server/src/elicitationFormExample.ts
|
||
|
Comment on lines
+42
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 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 Where it appears. Both occurrences are newly introduced by this PR — they are the only usages of
Notably, the same test file already uses the modern form a little further down: the rejection test uses 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 ( Why nothing breaks at runtime. Both Step-by-step. (1) A user copies the How to fix. One-token change in both places: |
||
|
|
||
| // Request user information through form elicitation | ||
| const result = await mcpServer.server.elicitInput({ | ||
| mode: 'form', | ||
| message: 'Please provide your registration information:', | ||
| requestedSchema: { | ||
| type: 'object', | ||
| properties: { | ||
| username: { | ||
| type: 'string', | ||
| title: 'Username', | ||
| description: 'Your desired username (3-20 characters)', | ||
| minLength: 3, | ||
| maxLength: 20 | ||
| }, | ||
| email: { | ||
| type: 'string', | ||
| title: 'Email', | ||
| description: 'Your email address', | ||
| format: 'email' | ||
| }, | ||
| password: { | ||
| type: 'string', | ||
| title: 'Password', | ||
| description: 'Your password (min 8 characters)', | ||
| minLength: 8 | ||
| }, | ||
| newsletter: { | ||
| type: 'boolean', | ||
| title: 'Newsletter', | ||
| description: 'Subscribe to newsletter?', | ||
| default: false | ||
| } | ||
| }, | ||
| required: ['username', 'email', 'password'] | ||
| } | ||
| requestedSchema: registrationSchema | ||
|
claude[bot] marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| // Handle the different possible actions | ||
| if (result.action === 'accept' && result.content) { | ||
| const { username, email, newsletter } = result.content as { | ||
| username: string; | ||
| email: string; | ||
| password: string; | ||
| newsletter?: boolean; | ||
| }; | ||
| const { username, email, newsletter } = result.content; | ||
|
|
||
| return { | ||
| content: [ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,9 @@ | ||
| // AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. | ||
| export const V2_PACKAGE_VERSIONS: Record<string, string> = { | ||
| '@modelcontextprotocol/client': '^2.0.0-alpha.2', | ||
| '@modelcontextprotocol/server': '^2.0.0-alpha.2', | ||
| '@modelcontextprotocol/node': '^2.0.0-alpha.2', | ||
| '@modelcontextprotocol/express': '^2.0.0-alpha.2', | ||
| '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2', | ||
| '@modelcontextprotocol/core': '^2.0.0-alpha.0' | ||
| '@modelcontextprotocol/client': '^2.0.0-alpha.3', | ||
| '@modelcontextprotocol/server': '^2.0.0-alpha.3', | ||
| '@modelcontextprotocol/node': '^2.0.0-alpha.3', | ||
| '@modelcontextprotocol/express': '^2.0.0-alpha.3', | ||
| '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3', | ||
| '@modelcontextprotocol/core': '^2.0.0-alpha.1' | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import type { ElicitInputFormParams, ElicitRequestFormParams, StandardSchemaWithJSON } from '@modelcontextprotocol/core-internal'; | ||
| import { | ||
| ElicitRequestFormParamsSchema, | ||
| parseSchema, | ||
| ProtocolError, | ||
| ProtocolErrorCode, | ||
| standardSchemaToJsonSchema | ||
| } from '@modelcontextprotocol/core-internal'; | ||
|
|
||
| export type NormalizedElicitInputFormParams = { | ||
| params: ElicitRequestFormParams; | ||
| standardSchema?: StandardSchemaWithJSON; | ||
| }; | ||
|
|
||
| function isJsonObject(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === 'object' && value !== null && !Array.isArray(value); | ||
| } | ||
|
|
||
| const ZOD_ISO_DATE_PATTERN = String.raw`(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))`; | ||
| const ZOD_ISO_TIME_PREFIX = String.raw`(?:[01]\d|2[0-3]):[0-5]\d`; | ||
| const ZOD_ISO_OFFSET_PATTERN = String.raw`([+-](?:[01]\d|2[0-3]):[0-5]\d)`; | ||
|
|
||
| const ZOD_REDUNDANT_FORMAT_PATTERNS: ReadonlyMap<string, ReadonlySet<string>> = new Map([ | ||
| ['email', new Set([String.raw`^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$`])], | ||
| [ | ||
| 'date', | ||
| new Set([ | ||
| String.raw`^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$` | ||
| ]) | ||
| ] | ||
| ]); | ||
|
|
||
| const ZOD_DATETIME_ZONE_SUFFIXES = [ | ||
| String.raw`(?:Z)`, | ||
| String.raw`(?:Z|)`, | ||
| String.raw`(?:Z|${ZOD_ISO_OFFSET_PATTERN})`, | ||
| String.raw`(?:Z||${ZOD_ISO_OFFSET_PATTERN})` | ||
| ] as const; | ||
|
|
||
| function escapeRegExpLiteral(value: string): string { | ||
| return value.replaceAll(/[.*+?^${}()|[\]\\]/g, match => `\\${match}`); | ||
| } | ||
|
|
||
| const ZOD_PRECISION_TIME_PATTERN = new RegExp(String.raw`^${escapeRegExpLiteral(String.raw`${ZOD_ISO_TIME_PREFIX}:[0-5]\d\.\d{`)}\d+\}$`); | ||
|
|
||
| function isZodIsoDatetimePattern(pattern: string): boolean { | ||
| const prefix = `^${ZOD_ISO_DATE_PATTERN}T(?:`; | ||
| if (!pattern.startsWith(prefix) || !pattern.endsWith(')$')) { | ||
| return false; | ||
| } | ||
|
|
||
| const innerPattern = pattern.slice(prefix.length, -2); | ||
| const zoneSuffix = ZOD_DATETIME_ZONE_SUFFIXES.find(suffix => innerPattern.endsWith(suffix)); | ||
| if (!zoneSuffix) { | ||
| return false; | ||
| } | ||
|
|
||
| const timePattern = innerPattern.slice(0, -zoneSuffix.length); | ||
| return ( | ||
| timePattern === String.raw`${ZOD_ISO_TIME_PREFIX}` || | ||
| timePattern === String.raw`${ZOD_ISO_TIME_PREFIX}:[0-5]\d` || | ||
| timePattern === String.raw`${ZOD_ISO_TIME_PREFIX}(?::[0-5]\d(?:\.\d+)?)?` || | ||
| ZOD_PRECISION_TIME_PATTERN.test(timePattern) | ||
| ); | ||
| } | ||
|
|
||
| function isRedundantFormatPattern(original: Record<string, unknown>, parsed: Record<string, unknown>, key: string): boolean { | ||
| if ( | ||
| key !== 'pattern' || | ||
| typeof original.pattern !== 'string' || | ||
| parsed.type !== 'string' || | ||
| typeof parsed.format !== 'string' || | ||
| original.format !== parsed.format | ||
| ) { | ||
| return false; | ||
| } | ||
|
|
||
| if (parsed.format === 'date-time') { | ||
| return isZodIsoDatetimePattern(original.pattern); | ||
| } | ||
|
|
||
| return ZOD_REDUNDANT_FORMAT_PATTERNS.get(parsed.format)?.has(original.pattern) === true; | ||
| } | ||
|
mattzcarey marked this conversation as resolved.
|
||
|
|
||
| function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = ''): string[] { | ||
| if (Array.isArray(original) && Array.isArray(parsed)) { | ||
| return original.flatMap((item, index) => findStrippedJsonSchemaPaths(item, parsed[index], `${path}[${index}]`)); | ||
| } | ||
|
|
||
| if (!isJsonObject(original) || !isJsonObject(parsed)) { | ||
| return []; | ||
| } | ||
|
|
||
| return Object.entries(original).flatMap(([key, value]) => { | ||
| const childPath = path ? `${path}.${key}` : key; | ||
| if (!Object.prototype.hasOwnProperty.call(parsed, key)) { | ||
| if (isRedundantFormatPattern(original, parsed, key)) { | ||
| return []; | ||
| } | ||
| return [childPath]; | ||
| } | ||
| return findStrippedJsonSchemaPaths(value, parsed[key], childPath); | ||
| }); | ||
| } | ||
|
|
||
| function isElicitInputSchema( | ||
| schema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON | ||
| ): schema is StandardSchemaWithJSON { | ||
| return typeof schema === 'object' && schema !== null && '~standard' in schema; | ||
| } | ||
|
|
||
| export function normalizeElicitInputFormParams( | ||
| params: ElicitRequestFormParams | ElicitInputFormParams<StandardSchemaWithJSON> | ||
| ): NormalizedElicitInputFormParams { | ||
| const formParams = | ||
| params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' as const }; | ||
|
|
||
| if (isElicitInputSchema(formParams.requestedSchema)) { | ||
| const standardSchema = formParams.requestedSchema; | ||
| const normalizedParams = { | ||
| ...formParams, | ||
| requestedSchema: standardSchemaToJsonSchema(standardSchema, 'input') | ||
| }; | ||
| const parsedParams = parseSchema(ElicitRequestFormParamsSchema, normalizedParams); | ||
| if (!parsedParams.success) { | ||
| throw new ProtocolError( | ||
| ProtocolErrorCode.InvalidParams, | ||
| `Elicitation requestedSchema only supports flat primitive properties (string, number, integer, boolean, and string enums): ${parsedParams.error.message}` | ||
| ); | ||
| } | ||
| const strippedSchemaPaths = findStrippedJsonSchemaPaths(normalizedParams.requestedSchema, parsedParams.data.requestedSchema); | ||
| if (strippedSchemaPaths.length > 0) { | ||
| throw new ProtocolError( | ||
| ProtocolErrorCode.InvalidParams, | ||
| `Elicitation requestedSchema contains unsupported JSON Schema keyword(s) after Standard Schema conversion: ${strippedSchemaPaths.join(', ')}` | ||
| ); | ||
| } | ||
| return { params: parsedParams.data, standardSchema }; | ||
| } | ||
|
|
||
| return { params: formParams }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 The changeset bumps
@modelcontextprotocol/core, but this PR does not touchpackages/coreat all — the protocol changes (newElicitInputFormParams/ElicitInputResulttypes, widenedServerContext.mcpReq.elicitInput) live inpackages/core-internalpluspackages/server, and@modelcontextprotocol/clientre-exports the changed public barrel so its bundled types change too. As written,coregets a no-op minor release whilecore-internalandclientare not versioned; the front-matter should be'@modelcontextprotocol/core-internal': minor,'@modelcontextprotocol/server': minor, and'@modelcontextprotocol/client': minor, withcoreremoved.Extended reasoning...
What the bug is.
.changeset/standard-schema-elicitation.mddeclares minor bumps for'@modelcontextprotocol/core'and'@modelcontextprotocol/server'. But this PR's library changes live entirely inpackages/core-internal(src/shared/protocol.tsaddsElicitInputFormParams/ElicitInputResultand widensServerContext.mcpReq.elicitInputinto an overload set;src/exports/public/index.tsexports the new types) andpackages/server(server.tsoverloads, the newelicitation.tsmodule). Nothing underpackages/coreis touched.Why
coreis the wrong package.packages/coreis the public schemas-only package — its barrel re-exports only the*SchemaZod constants (spec + OAuth/OpenID) bundled fromcore-internal/schemasandcore-internal/auth. This PR adds TypeScript types and runtime conversion logic, not new*Schemaconstants, 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 confirmscore: Minoris what would be released).Why
core-internalandclientshould be listed instead. Repo convention is consistent: the other pending changesets that touchcore-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 (clientand/orserver); the only changeset that names'@modelcontextprotocol/core'isadd-core-public-package.md, which created that package.core-internalis private but is versioned via changesets (it appears inpre.jsoninitialVersions and in ~30 existing changesets), so omitting it breaks the dependency-cascade bookkeeping. Additionally, bothpackages/client/src/index.tsandpackages/server/src/index.tsre-export@modelcontextprotocol/core-internal/public, and that barrel now gainsElicitInputFormParams/ElicitInputResultplus the modifiedServerContext— so the client package's published type surface changes too, yetclientgets no bump from this changeset (and withcore-internalomitted, theupdateInternalDependenciescascade 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/.'@modelcontextprotocol/core': minorand'@modelcontextprotocol/server': minor.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.@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:
(
clientcould arguably be patch, but minor matches how sibling changesets likeadd-sdk-http-error.mdtreat new public type exports.) The changeset body text can stay as-is.