Skip to content

Commit 0152b26

Browse files
fix(compat): narrow ZodRawShape to Zod-only (detector + type); add outputSchema raw-shape test
1 parent 9576f20 commit 0152b26

4 files changed

Lines changed: 32 additions & 8 deletions

File tree

packages/core/src/util/standardSchema.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,19 +138,25 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch
138138
return isStandardJSONSchema(schema) && isStandardSchema(schema);
139139
}
140140

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+
141147
/**
142148
* Detects a "raw shape" — a plain object whose values are Zod field schemas,
143149
* e.g. `{ name: z.string() }`. Powers the auto-wrap in
144150
* {@linkcode normalizeRawShapeSchema}, which wraps with `z.object()`, so only
145-
* Zod values are supported even though the predicate accepts any Standard Schema.
151+
* Zod values are supported.
146152
*
147153
* @internal
148154
*/
149-
export function isZodRawShape(obj: unknown): obj is Record<string, StandardSchemaV1> {
155+
export function isZodRawShape(obj: unknown): obj is Record<string, z.ZodType> {
150156
if (typeof obj !== 'object' || obj === null) return false;
151157
if (isStandardSchema(obj)) return false;
152158
// [].every() is true, so an empty object is a valid raw shape (matches v1).
153-
return Object.values(obj).every(v => isStandardSchema(v) || (typeof v === 'object' && v !== null && '_def' in v));
159+
return Object.values(obj).every(v => isZodSchema(v));
154160
}
155161

156162
/**
@@ -162,11 +168,11 @@ export function isZodRawShape(obj: unknown): obj is Record<string, StandardSchem
162168
* @internal
163169
*/
164170
export function normalizeRawShapeSchema(
165-
schema: StandardSchemaWithJSON | Record<string, StandardSchemaV1> | undefined
171+
schema: StandardSchemaWithJSON | Record<string, z.ZodType> | undefined
166172
): StandardSchemaWithJSON | undefined {
167173
if (schema === undefined) return undefined;
168174
if (isZodRawShape(schema)) {
169-
return z.object(schema as z.ZodRawShape) as StandardSchemaWithJSON;
175+
return z.object(schema) as StandardSchemaWithJSON;
170176
}
171177
return schema;
172178
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ describe('isZodRawShape', () => {
1212
test('rejects a Standard Schema instance', () => {
1313
expect(isZodRawShape(z.object({ a: z.string() }))).toBe(false);
1414
});
15+
test('rejects a shape with non-Zod Standard Schema fields', () => {
16+
const nonZod = { '~standard': { version: 1, vendor: 'arktype', validate: () => ({ value: 'x' }) } };
17+
expect(isZodRawShape({ a: nonZod })).toBe(false);
18+
});
1519
});
1620

1721
describe('normalizeRawShapeSchema', () => {

packages/server/src/server/mcp.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
validateStandardSchema
4141
} from '@modelcontextprotocol/core';
4242

43+
import type * as z from 'zod/v4';
4344
import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js';
4445
import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js';
4546
import { getCompleter, isCompletable } from './completable.js';
@@ -1110,13 +1111,14 @@ export class ResourceTemplate {
11101111
}
11111112

11121113
/**
1113-
* A plain record of field schemas, e.g. `{ name: z.string() }`. Accepted by
1114+
* A plain record of Zod field schemas, e.g. `{ name: z.string() }`. Accepted by
11141115
* `registerTool`/`registerPrompt` as a shorthand; auto-wrapped with `z.object()`.
1116+
* Zod schemas only — `z.object()` cannot wrap other Standard Schema libraries.
11151117
*/
1116-
export type ZodRawShape = Record<string, StandardSchemaWithJSON>;
1118+
export type ZodRawShape = Record<string, z.ZodType>;
11171119

11181120
/** Infers the parsed-output type of a {@linkcode ZodRawShape}. */
1119-
export type InferRawShape<S extends ZodRawShape> = { [K in keyof S]: StandardSchemaWithJSON.InferOutput<S[K]> };
1121+
export type InferRawShape<S extends ZodRawShape> = { [K in keyof S]: z.output<S[K]> };
11201122

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

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () =
2424
warn.mockRestore();
2525
});
2626

27+
it('registerTool accepts a raw shape for outputSchema and auto-wraps it', () => {
28+
const server = new McpServer({ name: 't', version: '1.0.0' });
29+
30+
server.registerTool('out', { inputSchema: { n: z.number() }, outputSchema: { result: z.string() } }, async ({ n }) => ({
31+
content: [{ type: 'text' as const, text: String(n) }],
32+
structuredContent: { result: String(n) }
33+
}));
34+
35+
const tools = (server as unknown as { _registeredTools: Record<string, { outputSchema?: unknown }> })._registeredTools;
36+
expect(isStandardSchema(tools['out']?.outputSchema)).toBe(true);
37+
});
38+
2739
it('registerTool with z.object() inputSchema also works without warning', () => {
2840
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
2941
const server = new McpServer({ name: 't', version: '1.0.0' });

0 commit comments

Comments
 (0)