From b2d162d4b5de3ca003d53e8c2057621b32106369 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Jul 2026 20:59:51 +0000 Subject: [PATCH 1/2] fix: harden Standard Schema elicitation conversion - 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. --- .changeset/standard-schema-elicitation.md | 2 +- .../core-internal/src/shared/elicitation.ts | 134 +++++++++++------ .../core-internal/src/shared/inputRequired.ts | 16 ++- .../test/shared/elicitation.test.ts | 136 ++++++++++++++++++ .../test/shared/inputRequired.test.ts | 20 ++- .../jsonSchemaValidatorOverride.test.ts | 10 ++ 6 files changed, 264 insertions(+), 54 deletions(-) create mode 100644 packages/core-internal/test/shared/elicitation.test.ts diff --git a/.changeset/standard-schema-elicitation.md b/.changeset/standard-schema-elicitation.md index ee0122362e..d746c314de 100644 --- a/.changeset/standard-schema-elicitation.md +++ b/.changeset/standard-schema-elicitation.md @@ -5,4 +5,4 @@ --- 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`. +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`. The converted schema's root is held to the spec's shape (`type`, `properties`, `required`, `$schema`): unknown root keywords — such as the `additionalProperties` emitted by `z.strictObject()` — are rejected before sending, and annotation-only root keywords (`title`, `description`) are dropped. diff --git a/packages/core-internal/src/shared/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts index 8550759f85..8e6407e6d4 100644 --- a/packages/core-internal/src/shared/elicitation.ts +++ b/packages/core-internal/src/shared/elicitation.ts @@ -1,3 +1,5 @@ +import * as z from 'zod/v4'; + import { ProtocolErrorCode } from '../types/enums'; import { ProtocolError } from '../types/errors'; import { ElicitRequestFormParamsSchema } from '../types/schemas'; @@ -16,52 +18,66 @@ function isJsonObject(value: unknown): value is Record { 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> = 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}`); +// A `pattern` emitted beside a supported `format` is redundant — zod realizes every format +// check as a regex — and is dropped so the wire schema stays within the elicitation subset. +// The reference patterns are derived from the installed zod at runtime rather than vendored +// as string literals: zod's format regexes change across in-range releases, so a vendored +// copy would start rejecting schemas produced by any newer zod while CI (lockfile-pinned) +// stays green. A pattern the installed zod would not emit for that format — e.g. a +// customized `z.email({ pattern })` — still rejects, because the wire schema cannot carry it. +// Residual limitation: if the app resolves a second zod copy whose regexes differ from this +// package's resolved zod, its emissions won't match the reference and reject (fail closed); +// zod is a peer dependency precisely so installs dedupe to one copy. +// Derivation is cheap (a handful of toJSONSchema calls) and only runs when a stripped +// `pattern` sits beside a matching `format`, so no caching. + +function zodEmittedPattern(schema: z.ZodType): string | undefined { + // Conversion options must stay in lockstep with standardSchemaToJsonSchema (which + // produced the pattern under comparison via `~standard.jsonSchema.input`). + const jsonSchema = z.toJSONSchema(schema, { target: 'draft-2020-12', io: 'input' }) as Record; + return typeof jsonSchema.pattern === 'string' ? jsonSchema.pattern : undefined; } -const ZOD_PRECISION_TIME_PATTERN = new RegExp(String.raw`^${escapeRegExpLiteral(String.raw`${ZOD_ISO_TIME_PREFIX}:[0-5]\d\.\d{`)}\d+\}$`); +const DATETIME_FRACTION_DIGITS = /\\\.\\d\{(\d+)\}/; -function isZodIsoDatetimePattern(pattern: string): boolean { - const prefix = `^${ZOD_ISO_DATE_PATTERN}T(?:`; - if (!pattern.startsWith(prefix) || !pattern.endsWith(')$')) { - return false; +function datetimeReferenceSchemas(pattern: string): z.ZodType[] { + // The emitted pattern depends on the authoring options (offset/local/precision); the + // fraction-digit count recovered from the pattern under test keeps the candidate set + // finite. Duplicate candidates are fine — the result feeds a Set. + const fractionDigits = DATETIME_FRACTION_DIGITS.exec(pattern); + const precisions: Array = [undefined, -1, 0]; + if (fractionDigits) { + precisions.push(Number(fractionDigits[1])); } + return [false, true].flatMap(local => + [false, true].flatMap(offset => precisions.map(precision => z.iso.datetime({ local, offset, precision }))) + ); +} - const innerPattern = pattern.slice(prefix.length, -2); - const zoneSuffix = ZOD_DATETIME_ZONE_SUFFIXES.find(suffix => innerPattern.endsWith(suffix)); - if (!zoneSuffix) { - return false; +function referencePatternsForFormat(format: string, pattern: string): ReadonlySet { + let referenceSchemas: z.ZodType[]; + switch (format) { + case 'email': { + referenceSchemas = [z.email()]; + break; + } + case 'uri': { + referenceSchemas = [z.url()]; + break; + } + case 'date': { + referenceSchemas = [z.iso.date()]; + break; + } + case 'date-time': { + referenceSchemas = datetimeReferenceSchemas(pattern); + break; + } + default: { + referenceSchemas = []; + } } - - 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) - ); + return new Set(referenceSchemas.map(schema => zodEmittedPattern(schema)).filter((emitted): emitted is string => emitted !== undefined)); } function isRedundantFormatPattern(original: Record, parsed: Record, key: string): boolean { @@ -75,11 +91,7 @@ function isRedundantFormatPattern(original: Record, parsed: Rec return false; } - if (parsed.format === 'date-time') { - return isZodIsoDatetimePattern(original.pattern); - } - - return ZOD_REDUNDANT_FORMAT_PATTERNS.get(parsed.format)?.has(original.pattern) === true; + return referencePatternsForFormat(parsed.format, original.pattern).has(original.pattern); } const ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS = new Set(['$comment', 'deprecated', 'examples', 'readOnly', 'writeOnly']); @@ -88,6 +100,36 @@ function isAnnotationOnlyJsonSchemaKeyword(key: string): boolean { return ANNOTATION_ONLY_JSON_SCHEMA_KEYWORDS.has(key) || key.startsWith('x-'); } +// The spec declares a closed shape for the `requestedSchema` root: `$schema`, `type`, +// `properties` and `required` only. The wire schema cannot enforce that (its root is a +// catchall so hand-authored extensions stay wire-legal), and the stripped-keys diff below +// never fires for root keys the catchall retains — so converted Standard Schemas are pruned +// here: annotation-only root keywords are dropped, anything else unknown rejects (e.g. +// `z.strictObject()` emits a root `additionalProperties: false`). The keyword set is derived +// from the wire schema so it tracks spec revisions; `$schema` is spec-declared but absent +// from the wire schema's declared keys (the catchall admits it). +const ELICITATION_ROOT_KEYWORDS = new Set(['$schema', ...Object.keys(ElicitRequestFormParamsSchema.shape.requestedSchema.shape)]); +const ROOT_ANNOTATION_KEYWORDS = new Set(['title', 'description']); + +function pruneElicitationSchemaRoot(schema: Record): Record { + const requestedSchema: Record = {}; + const unsupportedKeys: string[] = []; + for (const [key, value] of Object.entries(schema)) { + if (ELICITATION_ROOT_KEYWORDS.has(key)) { + requestedSchema[key] = value; + } else if (!ROOT_ANNOTATION_KEYWORDS.has(key) && !isAnnotationOnlyJsonSchemaKeyword(key)) { + unsupportedKeys.push(key); + } + } + if (unsupportedKeys.length > 0) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation requestedSchema contains unsupported JSON Schema keyword(s) after Standard Schema conversion: ${unsupportedKeys.join(', ')}` + ); + } + return requestedSchema; +} + 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}]`)); @@ -137,7 +179,7 @@ export function normalizeElicitInputFormParams( const standardSchema = formParams.requestedSchema; const normalizedParams = { ...formParams, - requestedSchema: convertStandardElicitationSchema(standardSchema) + requestedSchema: pruneElicitationSchemaRoot(convertStandardElicitationSchema(standardSchema)) }; const parsedParams = parseSchema(ElicitRequestFormParamsSchema, normalizedParams); if (!parsedParams.success) { diff --git a/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index 09f8255985..6ae6a3bd02 100644 --- a/packages/core-internal/src/shared/inputRequired.ts +++ b/packages/core-internal/src/shared/inputRequired.ts @@ -17,6 +17,7 @@ * discriminator, and hand-built result literals are equally legal — the * server seam re-checks the at-least-one rule for them. */ +import { ProtocolError } from '../types/errors'; import { isInputRequiredResult } from '../types/guards'; import type { CreateMessageRequestParams, @@ -128,10 +129,17 @@ export const inputRequired: InputRequiredBuilder = Object.assign(buildInputRequi | (Omit & { mode?: 'form' }) | (Omit, 'mode'> & { mode?: 'form' }) ): InputRequest { - const { params: normalizedParams } = normalizeElicitInputFormParams( - params as ElicitRequestFormParams | ElicitInputFormParams - ); - return { method: 'elicitation/create', params: normalizedParams }; + try { + const { params: normalizedParams } = normalizeElicitInputFormParams( + params as ElicitRequestFormParams | ElicitInputFormParams + ); + return { method: 'elicitation/create', params: normalizedParams }; + } catch (error) { + // Authoring mistakes in the builder surface as TypeError, matching inputRequired() + // itself; a ProtocolError escaping a prompts/get or resources/read handler would + // reach the client as InvalidParams on its own request. + throw error instanceof ProtocolError ? new TypeError(error.message, { cause: error }) : error; + } }, elicitUrl(params: Omit): InputRequest { // The neutral ElicitRequestURLParams keeps `elicitationId` (it is required on the diff --git a/packages/core-internal/test/shared/elicitation.test.ts b/packages/core-internal/test/shared/elicitation.test.ts new file mode 100644 index 0000000000..471f75994a --- /dev/null +++ b/packages/core-internal/test/shared/elicitation.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import { normalizeElicitInputFormParams } from '../../src/shared/elicitation'; +import { ProtocolError } from '../../src/types/errors'; +import type { StandardSchemaWithJSON } from '../../src/util/standardSchema'; + +function schemaFromJson(jsonSchema: Record): StandardSchemaWithJSON { + return { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => jsonSchema, + output: () => ({}) + } + } + } satisfies StandardSchemaWithJSON; +} + +describe('normalizeElicitInputFormParams root keywords', () => { + test('keeps the spec-declared root keys including $schema', () => { + const { params } = normalizeElicitInputFormParams({ + message: 'Name?', + requestedSchema: z.object({ name: z.string() }) + }); + + expect(params.requestedSchema).toEqual({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + }); + + test('drops annotation-only root keywords', () => { + const { params } = normalizeElicitInputFormParams({ + message: 'Name?', + requestedSchema: z.object({ name: z.string() }).meta({ title: 'User', description: 'User info', 'x-ui-hint': 'compact' }) + }); + + expect(params.requestedSchema).toEqual({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + }); + + test.each([ + ['z.strictObject emits additionalProperties: false', z.strictObject({ name: z.string() }), /additionalProperties/], + ['z.looseObject emits additionalProperties: {}', z.looseObject({ name: z.string() }), /additionalProperties/], + [ + 'catchall emits a nested schema under additionalProperties', + z.object({ name: z.string() }).catchall(z.object({ x: z.string() })), + /additionalProperties/ + ], + ['a root default cannot ride the wire', z.object({ name: z.string() }).default({ name: 'x' }), /default/], + [ + 'a custom schema emitting $defs', + schemaFromJson({ + $defs: { Name: { type: 'string' } }, + type: 'object', + properties: { name: { $ref: '#/$defs/Name' } }, + required: ['name'] + }), + /\$defs/ + ] + ])('rejects unsupported root keywords: %s', (_label, requestedSchema, message) => { + const params = { message: 'Name?', requestedSchema } as Parameters[0]; + const act = () => normalizeElicitInputFormParams(params); + expect(act).toThrow(ProtocolError); + expect(act).toThrow(/unsupported JSON Schema keyword\(s\)/); + expect(act).toThrow(message); + }); +}); + +describe('normalizeElicitInputFormParams redundant format patterns', () => { + test.each([ + ['z.email()', z.email(), 'email'], + ['z.url()', z.url(), 'uri'], + ['z.iso.date()', z.iso.date(), 'date'], + ['z.iso.datetime()', z.iso.datetime(), 'date-time'], + ['z.iso.datetime({ offset: true })', z.iso.datetime({ offset: true }), 'date-time'], + ['z.iso.datetime({ local: true })', z.iso.datetime({ local: true }), 'date-time'], + ['z.iso.datetime({ precision: -1 })', z.iso.datetime({ precision: -1 }), 'date-time'], + ['z.iso.datetime({ precision: 0 })', z.iso.datetime({ precision: 0 }), 'date-time'], + ['z.iso.datetime({ precision: 3, offset: true })', z.iso.datetime({ precision: 3, offset: true }), 'date-time'] + ])('accepts %s and strips the zod-emitted pattern', (_label, fieldSchema, format) => { + const { params } = normalizeElicitInputFormParams({ + message: 'Value?', + requestedSchema: z.object({ value: fieldSchema }) + }); + + const valueSchema = (params.requestedSchema.properties as Record>).value!; + expect(valueSchema.format).toBe(format); + expect(valueSchema.pattern).toBeUndefined(); + }); + + test('accepts exactly the pattern the installed zod emits for a format', () => { + const emittedPattern = (z.toJSONSchema(z.email(), { target: 'draft-2020-12', io: 'input' }) as Record) + .pattern as string; + const { params } = normalizeElicitInputFormParams({ + message: 'Email?', + requestedSchema: schemaFromJson({ + type: 'object', + properties: { email: { type: 'string', format: 'email', pattern: emittedPattern } }, + required: ['email'] + }) + }); + + const emailSchema = (params.requestedSchema.properties as Record>).email!; + expect(emailSchema.format).toBe('email'); + expect(emailSchema.pattern).toBeUndefined(); + }); + + test.each([ + ['a customized email pattern', z.object({ email: z.email({ pattern: /@corp\.com$/ }) }), /properties\.email\.pattern/], + [ + 'a pattern the installed zod would not emit for the format', + schemaFromJson({ + type: 'object', + properties: { email: { type: 'string', format: 'email', pattern: '^different$' } }, + required: ['email'] + }), + /properties\.email\.pattern/ + ], + ['a pattern without a supported format', z.object({ code: z.string().regex(/^[A-Z]{3}$/) }), /properties\.code\.pattern/] + ])('rejects %s', (_label, requestedSchema, message) => { + const params = { message: 'Value?', requestedSchema } as Parameters[0]; + const act = () => normalizeElicitInputFormParams(params); + expect(act).toThrow(ProtocolError); + expect(act).toThrow(message); + }); +}); diff --git a/packages/core-internal/test/shared/inputRequired.test.ts b/packages/core-internal/test/shared/inputRequired.test.ts index cbc08f93ea..e183b4bd83 100644 --- a/packages/core-internal/test/shared/inputRequired.test.ts +++ b/packages/core-internal/test/shared/inputRequired.test.ts @@ -8,7 +8,6 @@ import { describe, expect, test } from 'vitest'; import * as z from 'zod/v4'; import { acceptedContent, inputRequired, withInputRequired } from '../../src/shared/inputRequired'; -import { ProtocolError } from '../../src/types/errors'; import { isInputRequiredResult } from '../../src/types/guards'; import { validateStandardSchema } from '../../src/util/standardSchema'; @@ -103,13 +102,13 @@ describe('inputRequired() builder', () => { }); }); - test('elicit rejects non-object Standard Schema roots as invalid elicitation params', () => { + test('elicit rejects non-object Standard Schema roots as a TypeError', () => { expect(() => inputRequired.elicit({ message: 'Name?', requestedSchema: z.string() }) - ).toThrow(ProtocolError); + ).toThrow(TypeError); expect(() => inputRequired.elicit({ message: 'Name?', @@ -117,6 +116,21 @@ describe('inputRequired() builder', () => { }) ).toThrow(/Elicitation requestedSchema must describe an object/); }); + + test('elicit rejects schemas outside the elicitation subset as a TypeError', () => { + expect(() => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.strictObject({ name: z.string() }) + }) + ).toThrow(TypeError); + expect(() => + inputRequired.elicit({ + message: 'Name?', + requestedSchema: z.strictObject({ name: z.string() }) + }) + ).toThrow(/unsupported JSON Schema keyword\(s\).*additionalProperties/); + }); }); describe('acceptedContent()', () => { diff --git a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts index 6f4e2ce4bb..b9bb4ad674 100644 --- a/packages/server/test/server/jsonSchemaValidatorOverride.test.ts +++ b/packages/server/test/server/jsonSchemaValidatorOverride.test.ts @@ -304,6 +304,16 @@ describe('server JSON Schema validator overrides', () => { ).rejects.toThrow(/properties\.count\.multipleOf/); expect(sawElicitationRequest).toBe(false); + await expect( + server.elicitInput({ + message: 'What is your name?', + requestedSchema: z.strictObject({ + name: z.string() + }) + }) + ).rejects.toThrow(/unsupported JSON Schema keyword\(s\).*additionalProperties/); + expect(sawElicitationRequest).toBe(false); + await expect( server.elicitInput({ message: 'How many?', From 85d736534095ae50b7150890f936f58fae10f217 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Jul 2026 21:11:11 +0000 Subject: [PATCH 2/2] fix: route function-typed Standard Schemas through elicitation conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../core-internal/src/shared/elicitation.ts | 9 +++- .../test/shared/elicitation.test.ts | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/core-internal/src/shared/elicitation.ts b/packages/core-internal/src/shared/elicitation.ts index 8e6407e6d4..81b39f9622 100644 --- a/packages/core-internal/src/shared/elicitation.ts +++ b/packages/core-internal/src/shared/elicitation.ts @@ -6,7 +6,7 @@ import { ElicitRequestFormParamsSchema } from '../types/schemas'; import type { ElicitRequestFormParams } from '../types/types'; import { parseSchema } from '../util/schema'; import type { StandardSchemaWithJSON } from '../util/standardSchema'; -import { standardSchemaToJsonSchema } from '../util/standardSchema'; +import { isStandardSchema, standardSchemaToJsonSchema } from '../util/standardSchema'; import type { ElicitInputFormParams } from './protocol'; export type NormalizedElicitInputFormParams = { @@ -154,7 +154,12 @@ function findStrippedJsonSchemaPaths(original: unknown, parsed: unknown, path = function isElicitInputSchema( schema: ElicitRequestFormParams['requestedSchema'] | StandardSchemaWithJSON ): schema is StandardSchemaWithJSON { - return typeof schema === 'object' && schema !== null && '~standard' in schema; + // isStandardSchema, not a plain '~standard' key probe: ArkType schemas are functions + // (a typeof-object check routes them, unconverted, to the raw branch), and a plain JSON + // schema containing a literal '~standard' key — wire-legal via the catchall — must stay + // on the raw branch. Not isStandardSchemaWithJSON: gating on `~standard.jsonSchema` here + // would front-run standardSchemaToJsonSchema's zod 4.0/4.1 fallback (see zodCompat.ts). + return isStandardSchema(schema); } function convertStandardElicitationSchema(standardSchema: StandardSchemaWithJSON): Record { diff --git a/packages/core-internal/test/shared/elicitation.test.ts b/packages/core-internal/test/shared/elicitation.test.ts index 471f75994a..14003e79a8 100644 --- a/packages/core-internal/test/shared/elicitation.test.ts +++ b/packages/core-internal/test/shared/elicitation.test.ts @@ -19,6 +19,51 @@ function schemaFromJson(jsonSchema: Record): StandardSchemaWith } satisfies StandardSchemaWithJSON; } +describe('normalizeElicitInputFormParams schema detection', () => { + test('converts function-typed Standard Schemas (ArkType-style)', () => { + const jsonSchema = { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }; + const functionSchema = Object.assign(() => {}, { + '~standard': { + version: 1 as const, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => jsonSchema, + output: () => ({}) + } + } + }); + + const { params, standardSchema } = normalizeElicitInputFormParams({ + message: 'Name?', + requestedSchema: functionSchema as unknown as StandardSchemaWithJSON + }); + + expect(standardSchema).toBe(functionSchema); + expect(params.requestedSchema).toEqual(jsonSchema); + }); + + test('routes a plain JSON schema containing a literal "~standard" key to the raw branch', () => { + const rawSchema = { + type: 'object' as const, + properties: { name: { type: 'string' as const } }, + '~standard': 'extension-data' + }; + + const { params, standardSchema } = normalizeElicitInputFormParams({ + message: 'Name?', + requestedSchema: rawSchema as never + }); + + expect(standardSchema).toBeUndefined(); + expect(params.requestedSchema).toBe(rawSchema); + }); +}); + describe('normalizeElicitInputFormParams root keywords', () => { test('keeps the spec-declared root keys including $schema', () => { const { params } = normalizeElicitInputFormParams({