Skip to content

Commit 0febd83

Browse files
refactor(compat): move zod helpers to zodCompat.ts; throw on invalid normalizeRawShapeSchema input; preserve optional in InferRawShape
- Move isZodSchema/isZodRawShape/normalizeRawShapeSchema from standardSchema.ts to a new zodCompat.ts so standardSchema.ts is Standard-Schema-spec only. - normalizeRawShapeSchema now throws TypeError for inputs that are neither a raw shape nor a Standard Schema, instead of silently returning them. - InferRawShape now uses z.infer<z.ZodObject<S>> so .optional() fields produce ?: keys. - Changeset mentions the completable() constraint widening.
1 parent aba1d39 commit 0febd83

7 files changed

Lines changed: 72 additions & 44 deletions

File tree

.changeset/register-rawshape-compat.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@
44
---
55

66
`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()`.
7+
8+
Also widens the `completable()` constraint from `StandardSchemaWithJSON` to `StandardSchemaV1` so v1's `completable(z.string(), fn)` continues to work.

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './types/index.js';
1515
export * from './util/inMemory.js';
1616
export * from './util/schema.js';
1717
export * from './util/standardSchema.js';
18+
export * from './util/zodCompat.js';
1819

1920
// experimental exports
2021
export * from './experimental/index.js';

packages/core/src/util/standardSchema.ts

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66

77
/* eslint-disable @typescript-eslint/no-namespace */
88

9-
import * as z from 'zod/v4';
10-
119
// Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025)
1210

1311
export interface StandardTypedV1<Input = unknown, Output = Input> {
@@ -138,45 +136,6 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch
138136
return isStandardJSONSchema(schema) && isStandardSchema(schema);
139137
}
140138

141-
function isZodSchema(v: unknown): v is z.ZodType {
142-
if (typeof v !== 'object' || v === null) return false;
143-
if ('_def' in v) return true;
144-
return isStandardSchema(v) && (v as StandardSchemaV1)['~standard'].vendor === 'zod';
145-
}
146-
147-
/**
148-
* Detects a "raw shape" — a plain object whose values are Zod field schemas,
149-
* e.g. `{ name: z.string() }`. Powers the auto-wrap in
150-
* {@linkcode normalizeRawShapeSchema}, which wraps with `z.object()`, so only
151-
* Zod values are supported.
152-
*
153-
* @internal
154-
*/
155-
export function isZodRawShape(obj: unknown): obj is Record<string, z.ZodType> {
156-
if (typeof obj !== 'object' || obj === null) return false;
157-
if (isStandardSchema(obj)) return false;
158-
// [].every() is true, so an empty object is a valid raw shape (matches v1).
159-
return Object.values(obj).every(v => isZodSchema(v));
160-
}
161-
162-
/**
163-
* Accepts either a {@linkcode StandardSchemaWithJSON} or a raw Zod shape
164-
* `{ field: z.string() }` and returns a {@linkcode StandardSchemaWithJSON}.
165-
* Raw shapes are wrapped with `z.object()` so the rest of the pipeline sees a
166-
* uniform schema type; already-wrapped schemas pass through unchanged.
167-
*
168-
* @internal
169-
*/
170-
export function normalizeRawShapeSchema(
171-
schema: StandardSchemaWithJSON | Record<string, z.ZodType> | undefined
172-
): StandardSchemaWithJSON | undefined {
173-
if (schema === undefined) return undefined;
174-
if (isZodRawShape(schema)) {
175-
return z.object(schema) as StandardSchemaWithJSON;
176-
}
177-
return schema;
178-
}
179-
180139
// JSON Schema conversion
181140

182141
/**
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Zod-specific helpers for the v1-compat raw-shape shorthand on
3+
* `registerTool`/`registerPrompt`. Kept separate from `standardSchema.ts` so
4+
* that file stays library-agnostic per the Standard Schema spec.
5+
*/
6+
7+
import * as z from 'zod/v4';
8+
9+
import type { StandardSchemaV1, StandardSchemaWithJSON } from './standardSchema.js';
10+
import { isStandardSchema } from './standardSchema.js';
11+
12+
function isZodSchema(v: unknown): v is z.ZodType {
13+
if (typeof v !== 'object' || v === null) return false;
14+
if ('_def' in v) return true;
15+
return isStandardSchema(v) && (v as StandardSchemaV1)['~standard'].vendor === 'zod';
16+
}
17+
18+
/**
19+
* Detects a "raw shape" — a plain object whose values are Zod field schemas,
20+
* e.g. `{ name: z.string() }`. Powers the auto-wrap in
21+
* {@linkcode normalizeRawShapeSchema}, which wraps with `z.object()`, so only
22+
* Zod values are supported.
23+
*
24+
* @internal
25+
*/
26+
export function isZodRawShape(obj: unknown): obj is Record<string, z.ZodType> {
27+
if (typeof obj !== 'object' || obj === null) return false;
28+
if (isStandardSchema(obj)) return false;
29+
// [].every() is true, so an empty object is a valid raw shape (matches v1).
30+
return Object.values(obj).every(v => isZodSchema(v));
31+
}
32+
33+
/**
34+
* Accepts either a {@linkcode StandardSchemaWithJSON} or a raw Zod shape
35+
* `{ field: z.string() }` and returns a {@linkcode StandardSchemaWithJSON}.
36+
* Raw shapes are wrapped with `z.object()` so the rest of the pipeline sees a
37+
* uniform schema type; already-wrapped schemas pass through unchanged.
38+
*
39+
* @internal
40+
*/
41+
export function normalizeRawShapeSchema(
42+
schema: StandardSchemaWithJSON | Record<string, z.ZodType> | undefined
43+
): StandardSchemaWithJSON | undefined {
44+
if (schema === undefined) return undefined;
45+
if (isZodRawShape(schema)) {
46+
return z.object(schema) as StandardSchemaWithJSON;
47+
}
48+
if (!isStandardSchema(schema)) {
49+
throw new TypeError(
50+
'inputSchema/outputSchema/argsSchema must be a Standard Schema (e.g. z.object({...})) or a raw Zod shape ({ field: z.string() }).'
51+
);
52+
}
53+
return schema;
54+
}

packages/core/test/util/standardSchema.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as z from 'zod/v4';
22

3-
import { isZodRawShape, normalizeRawShapeSchema, standardSchemaToJsonSchema } from '../../src/util/standardSchema.js';
3+
import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js';
4+
import { isZodRawShape, normalizeRawShapeSchema } from '../../src/util/zodCompat.js';
45

56
describe('isZodRawShape', () => {
67
test('treats empty object as a raw shape (matches v1)', () => {
@@ -31,6 +32,9 @@ describe('normalizeRawShapeSchema', () => {
3132
test('returns undefined for undefined input', () => {
3233
expect(normalizeRawShapeSchema(undefined)).toBeUndefined();
3334
});
35+
test('throws TypeError for an invalid object that is neither raw shape nor Standard Schema', () => {
36+
expect(() => normalizeRawShapeSchema({ a: 'not a zod schema' } as never)).toThrow(TypeError);
37+
});
3438
});
3539

3640
describe('standardSchemaToJsonSchema', () => {

packages/server/src/server/mcp.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1118,7 +1118,7 @@ export class ResourceTemplate {
11181118
export type ZodRawShape = Record<string, z.ZodType>;
11191119

11201120
/** Infers the parsed-output type of a {@linkcode ZodRawShape}. */
1121-
export type InferRawShape<S extends ZodRawShape> = { [K in keyof S]: z.output<S[K]> };
1121+
export type InferRawShape<S extends ZodRawShape> = z.infer<z.ZodObject<S>>;
11221122

11231123
/** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */
11241124
export type LegacyToolCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape

packages/server/test/server/mcp.compat.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
22
import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core';
3-
import { describe, expect, it, vi } from 'vitest';
3+
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
44
import * as z from 'zod/v4';
55
import { McpServer } from '../../src/index.js';
6+
import type { InferRawShape } from '../../src/server/mcp.js';
67
import { completable } from '../../src/server/completable.js';
78

89
describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => {
@@ -119,3 +120,10 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () =
119120
await server.close();
120121
});
121122
});
123+
124+
describe('InferRawShape', () => {
125+
it('preserves optionality from .optional() as ?: keys', () => {
126+
type S = InferRawShape<{ a: z.ZodString; b: z.ZodOptional<z.ZodString> }>;
127+
expectTypeOf<S>().toEqualTypeOf<{ a: string; b?: string | undefined }>();
128+
});
129+
});

0 commit comments

Comments
 (0)