-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(compat): registerTool/registerPrompt accept raw Zod shape, auto-wrap with z.object() #1901
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
Changes from 12 commits
27e4ddf
5266131
f2fdbe7
9576f20
0152b26
1af9ed2
3155be7
aba1d39
0febd83
7e80880
c75bc88
a6b25ee
b5854c1
9b7ee90
27e2c4b
617830b
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,8 @@ | ||
| --- | ||
| '@modelcontextprotocol/core': patch | ||
| '@modelcontextprotocol/server': patch | ||
| --- | ||
|
|
||
| `registerTool`/`registerPrompt` accept a raw Zod shape (`{ field: z.string() }`) for `inputSchema`/`outputSchema`/`argsSchema` in addition to a wrapped Standard Schema. Raw shapes are auto-wrapped with `z.object()`. The raw-shape overloads are `@deprecated`; prefer wrapping with `z.object()`. | ||
|
|
||
| Also widens the `completable()` constraint from `StandardSchemaWithJSON` to `StandardSchemaV1` so v1's `completable(z.string(), fn)` continues to work. |
| Original file line number | Diff line number | Diff line change | |||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,72 @@ | |||||||||||||||||||||||||||||||||||
| /** | |||||||||||||||||||||||||||||||||||
| * Zod-specific helpers for the v1-compat raw-shape shorthand on | |||||||||||||||||||||||||||||||||||
| * `registerTool`/`registerPrompt`. Kept separate from `standardSchema.ts` so | |||||||||||||||||||||||||||||||||||
| * that file stays library-agnostic per the Standard Schema spec. | |||||||||||||||||||||||||||||||||||
| */ | |||||||||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||||||||
| import * as z from 'zod/v4'; | |||||||||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||||||||
| import type { StandardSchemaWithJSON } from './standardSchema.js'; | |||||||||||||||||||||||||||||||||||
| import { isStandardSchema, isStandardSchemaWithJSON } from './standardSchema.js'; | |||||||||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||||||||
| function isZodV4Schema(v: unknown): v is z.ZodType { | |||||||||||||||||||||||||||||||||||
| // `_zod` is the v4 internal namespace property. Zod v3 schemas have `_def` | |||||||||||||||||||||||||||||||||||
| // and (since 3.24) `~standard.vendor === 'zod'`, but never `_zod`. We require | |||||||||||||||||||||||||||||||||||
| // v4 because the wrap path below uses v4's `z.object()`, which cannot consume | |||||||||||||||||||||||||||||||||||
| // v3 field schemas. | |||||||||||||||||||||||||||||||||||
| return typeof v === 'object' && v !== null && '_zod' in v; | |||||||||||||||||||||||||||||||||||
| } | |||||||||||||||||||||||||||||||||||
|
Comment on lines
+12
to
+18
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. 🟡 nit: same guard-over-acceptance class as the ArkType (0152b26) and Zod-v3 (068ff56) cases — Extended reasoning...What the bug is
The code path that triggers itimport * as zm from 'zod/mini';
server.registerTool('x', { inputSchema: { a: zm.string() } } as any, async ({ a }) => …);
Notably, if the user instead passes Why nothing else catches itThe guard chain ends at Step-by-step proof (verified against zod@4.3.6 in the repo)
ImpactLow — hence nit. TypeScript rejects it ( FixTighten return typeof v === 'object' && v !== null && '_zod' in v && '_def' in v;Classic v4 has both |
|||||||||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||||||||
| function looksLikeZodV3(v: unknown): boolean { | |||||||||||||||||||||||||||||||||||
| // v3 schemas have `_def.typeName` (e.g. 'ZodString') and no `_zod`. | |||||||||||||||||||||||||||||||||||
| return ( | |||||||||||||||||||||||||||||||||||
| typeof v === 'object' && | |||||||||||||||||||||||||||||||||||
| v !== null && | |||||||||||||||||||||||||||||||||||
| !('_zod' in v) && | |||||||||||||||||||||||||||||||||||
| '_def' in v && | |||||||||||||||||||||||||||||||||||
| typeof (v as { _def?: { typeName?: unknown } })._def?.typeName === 'string' | |||||||||||||||||||||||||||||||||||
| ); | |||||||||||||||||||||||||||||||||||
| } | |||||||||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||||||||
| /** | |||||||||||||||||||||||||||||||||||
| * Detects a "raw shape" — a plain object whose values are Zod field schemas, | |||||||||||||||||||||||||||||||||||
| * e.g. `{ name: z.string() }`. Powers the auto-wrap in | |||||||||||||||||||||||||||||||||||
| * {@linkcode normalizeRawShapeSchema}, which wraps with `z.object()`, so only | |||||||||||||||||||||||||||||||||||
| * Zod values are supported. | |||||||||||||||||||||||||||||||||||
| * | |||||||||||||||||||||||||||||||||||
| * @internal | |||||||||||||||||||||||||||||||||||
| */ | |||||||||||||||||||||||||||||||||||
| export function isZodRawShape(obj: unknown): obj is Record<string, z.ZodType> { | |||||||||||||||||||||||||||||||||||
| if (typeof obj !== 'object' || obj === null) return false; | |||||||||||||||||||||||||||||||||||
| if (isStandardSchema(obj)) return false; | |||||||||||||||||||||||||||||||||||
| // [].every() is true, so an empty object is a valid raw shape (matches v1). | |||||||||||||||||||||||||||||||||||
| return Object.values(obj).every(v => isZodV4Schema(v)); | |||||||||||||||||||||||||||||||||||
| } | |||||||||||||||||||||||||||||||||||
|
Check warning on line 44 in packages/core/src/util/zodCompat.ts
|
|||||||||||||||||||||||||||||||||||
|
claude[bot] marked this conversation as resolved.
|
|||||||||||||||||||||||||||||||||||
|
|
|||||||||||||||||||||||||||||||||||
| /** | |||||||||||||||||||||||||||||||||||
| * Accepts either a {@linkcode StandardSchemaWithJSON} or a raw Zod shape | |||||||||||||||||||||||||||||||||||
| * `{ field: z.string() }` and returns a {@linkcode StandardSchemaWithJSON}. | |||||||||||||||||||||||||||||||||||
| * Raw shapes are wrapped with `z.object()` so the rest of the pipeline sees a | |||||||||||||||||||||||||||||||||||
| * uniform schema type; already-wrapped schemas pass through unchanged. | |||||||||||||||||||||||||||||||||||
| * | |||||||||||||||||||||||||||||||||||
| * @internal | |||||||||||||||||||||||||||||||||||
| */ | |||||||||||||||||||||||||||||||||||
| export function normalizeRawShapeSchema( | |||||||||||||||||||||||||||||||||||
| schema: StandardSchemaWithJSON | Record<string, z.ZodType> | undefined | |||||||||||||||||||||||||||||||||||
| ): StandardSchemaWithJSON | undefined { | |||||||||||||||||||||||||||||||||||
| if (schema === undefined) return undefined; | |||||||||||||||||||||||||||||||||||
| if (isZodRawShape(schema)) { | |||||||||||||||||||||||||||||||||||
| return z.object(schema) as StandardSchemaWithJSON; | |||||||||||||||||||||||||||||||||||
| } | |||||||||||||||||||||||||||||||||||
| if (typeof schema === 'object' && !isStandardSchema(schema) && Object.values(schema).some(v => looksLikeZodV3(v))) { | |||||||||||||||||||||||||||||||||||
|
claude[bot] marked this conversation as resolved.
Outdated
|
|||||||||||||||||||||||||||||||||||
| throw new TypeError( | |||||||||||||||||||||||||||||||||||
| 'Raw-shape inputSchema/outputSchema/argsSchema fields must be Zod v4 schemas. Got a Zod v3 field schema. Import from `zod/v4` (or upgrade your zod import), or wrap with `z.object({...})` yourself.' | |||||||||||||||||||||||||||||||||||
| ); | |||||||||||||||||||||||||||||||||||
| } | |||||||||||||||||||||||||||||||||||
| if (!isStandardSchemaWithJSON(schema)) { | |||||||||||||||||||||||||||||||||||
| throw new TypeError( | |||||||||||||||||||||||||||||||||||
| 'inputSchema/outputSchema/argsSchema must be a Standard Schema with JSON Schema export (`~standard.jsonSchema`, e.g. z.object({...}) from zod >=4.2.0) or a raw Zod shape ({ field: z.string() }).' | |||||||||||||||||||||||||||||||||||
| ); | |||||||||||||||||||||||||||||||||||
| } | |||||||||||||||||||||||||||||||||||
|
claude[bot] marked this conversation as resolved.
Outdated
|
|||||||||||||||||||||||||||||||||||
| return schema; | |||||||||||||||||||||||||||||||||||
|
felixweinberger marked this conversation as resolved.
|
|||||||||||||||||||||||||||||||||||
| } | |||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import * as z from 'zod/v4'; | ||
|
|
||
| import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; | ||
| import { isZodRawShape, normalizeRawShapeSchema } from '../../src/util/zodCompat.js'; | ||
|
|
||
| describe('isZodRawShape', () => { | ||
| test('treats empty object as a raw shape (matches v1)', () => { | ||
| expect(isZodRawShape({})).toBe(true); | ||
| }); | ||
| test('detects raw shape with zod fields', () => { | ||
| expect(isZodRawShape({ a: z.string() })).toBe(true); | ||
| }); | ||
| test('rejects a Standard Schema instance', () => { | ||
| expect(isZodRawShape(z.object({ a: z.string() }))).toBe(false); | ||
| }); | ||
| test('rejects a shape with non-Zod Standard Schema fields', () => { | ||
| const nonZod = { '~standard': { version: 1, vendor: 'arktype', validate: () => ({ value: 'x' }) } }; | ||
| expect(isZodRawShape({ a: nonZod })).toBe(false); | ||
| }); | ||
| test('rejects a shape with Zod v3 fields (only v4 is wrappable)', () => { | ||
| expect(isZodRawShape({ a: mockZodV3String() })).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| // Minimal structural mock of a Zod v3 schema: has `_def.typeName` and | ||
| // `~standard.vendor === 'zod'` (zod >=3.24), but no `_zod`. | ||
| function mockZodV3String(): unknown { | ||
| return { | ||
| _def: { typeName: 'ZodString', checks: [], coerce: false }, | ||
| '~standard': { version: 1, vendor: 'zod', validate: (v: unknown) => ({ value: v }) }, | ||
| parse: (v: unknown) => v | ||
| }; | ||
| } | ||
|
|
||
| describe('normalizeRawShapeSchema', () => { | ||
| test('wraps empty raw shape into z.object({})', () => { | ||
| const wrapped = normalizeRawShapeSchema({}); | ||
| expect(wrapped).toBeDefined(); | ||
| expect(standardSchemaToJsonSchema(wrapped!, 'input').type).toBe('object'); | ||
| }); | ||
| test('passes through an already-wrapped Standard Schema unchanged', () => { | ||
| const schema = z.object({ a: z.string() }); | ||
| expect(normalizeRawShapeSchema(schema)).toBe(schema); | ||
| }); | ||
| test('returns undefined for undefined input', () => { | ||
| expect(normalizeRawShapeSchema(undefined)).toBeUndefined(); | ||
| }); | ||
| test('throws TypeError for an invalid object that is neither raw shape nor Standard Schema', () => { | ||
| expect(() => normalizeRawShapeSchema({ a: 'not a zod schema' } as never)).toThrow(TypeError); | ||
| }); | ||
| test('throws TypeError for a Standard Schema without JSON Schema export', () => { | ||
| const noJson = { '~standard': { version: 1, vendor: 'x', validate: () => ({ value: {} }) } }; | ||
| expect(() => normalizeRawShapeSchema(noJson as never)).toThrow(/~standard\.jsonSchema/); | ||
| }); | ||
| test('throws actionable TypeError for a raw shape with Zod v3 fields', () => { | ||
| expect(() => normalizeRawShapeSchema({ a: mockZodV3String() } as never)).toThrow(/Zod v4 schemas.*Got a Zod v3 field schema/); | ||
| }); | ||
| }); |
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.
🟡 nit: this file header says it's "Kept separate from
standardSchema.tsso that file stays library-agnostic", and the reply on the earlier thread said "standardSchema.tsno longer imports zod" — both were true at 0febd83, but merge 9b7ee90 brought in #1895 (b256546), which re-addedimport * as z from 'zod/v4'tostandardSchema.ts:9for thez.toJSONSchema()fallback at line 178. Suggest softening to e.g. "Kept separate sostandardSchema.tsstays focused on the Standard Schema spec interface", or moving the #1895 zod fallback intozodCompat.tsso the claim actually holds.Extended reasoning...
What's wrong
The new file-level JSDoc at
packages/core/src/util/zodCompat.ts:3-4reads:And the author's reply on inline thread 3147227355 (resolving KKonstantinov's "move these out into a
zodCompatone" ask) stated "standardSchema.tsno longer imports zod." Both statements were accurate at commit 0febd83, when the split was made. But the subsequent merge commit 9b7ee90 brought b256546 (#1895) onto this branch, and #1895 re-introduced a zod import intostandardSchema.tsfor its zod-4.0–4.1z.toJSONSchema()fallback. So at HEAD (617830b), the rationale clause in this PR's newly-added comment is factually incorrect —standardSchema.tsis not library-agnostic.Step-by-step proof
packages/core/src/util/zodCompat.ts:1-5(newly added in this PR's diff):git logshows 0febd83 (createszodCompat.tsand removes the zod import fromstandardSchema.ts) was committed before merge 9b7ee90.standardSchemaToJsonSchema.packages/core/src/util/standardSchema.ts:9readsimport * as z from 'zod/v4';and line 178 readsresult = z.toJSONSchema(schema as unknown as z.ZodType, { target: 'draft-2020-12', io }) ....standardSchema.ts.Why nothing catches it
There's no lint or test that cross-references prose comments against imports. The comment was correct when written; it became stale via a textual-clean merge (no conflict markers, since #1895 touched
standardSchema.tsand this PR added a new file). The author's earlier resolved reply ("standardSchema.tsno longer imports zod") is on a closed thread, so nothing prompts a re-check after the merge.Impact
Zero behavioral impact — this is an internal file-header comment, not user-facing JSDoc, changelog, or
.d.ts. The organizational split itself (raw-shape compat helpers vs. Standard Schema spec utilities) remains perfectly sound; only the stated rationale for the split is wrong. Hence nit, non-blocking. The reason it's worth a one-line fix is that it sits at the top of a brand-new file added by this PR and will mislead the next contributor who reads it ("so I shouldn't add zod tostandardSchema.ts" — but it's already there).Suggested fix
Either soften the comment so it doesn't make a falsifiable claim:
…or, if you actually want to deliver on the "library-agnostic" claim, move #1895's
z.toJSONSchema()fallback (standardSchema.ts:158-178) intozodCompat.tsand call it fromstandardSchemaToJsonSchema— that would make both files match their stated purpose. The first option is the one-liner.