fix: allow zod 4 transformations (#1213)

This commit is contained in:
Matt 2025-12-02 12:56:38 +00:00 • committed by GitHub
commit 82041262dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 238 additions and 11 deletions

View file

@ -4765,6 +4765,201 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
});
});
describe('Tools with transformation schemas', () => {
test('should support z.preprocess() schemas', async () => {
const server = new McpServer({
name: 'test',
version: '1.0.0'
});
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
// z.preprocess() allows transforming input before validation
const preprocessSchema = z.preprocess(
input => {
// Normalize input by trimming strings
if (typeof input === 'object' && input !== null) {
const obj = input as Record<string, unknown>;
if (typeof obj.name === 'string') {
return { ...obj, name: obj.name.trim() };
}
}
return input;
},
z.object({ name: z.string() })
);
server.registerTool('preprocess-test', { inputSchema: preprocessSchema }, async args => {
return {
content: [{ type: 'text' as const, text: `Hello, ${args.name}!` }]
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// Test with input that has leading/trailing whitespace
const result = await client.callTool({
name: 'preprocess-test',
arguments: { name: ' World ' }
});
expect(result.content).toEqual([
{
type: 'text',
text: 'Hello, World!'
}
]);
});
test('should support z.transform() schemas', async () => {
const server = new McpServer({
name: 'test',
version: '1.0.0'
});
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
// z.transform() allows transforming validated output
const transformSchema = z
.object({
firstName: z.string(),
lastName: z.string()
})
.transform(data => ({
...data,
fullName: `${data.firstName} ${data.lastName}`
}));
server.registerTool('transform-test', { inputSchema: transformSchema }, async args => {
return {
content: [{ type: 'text' as const, text: `Full name: ${args.fullName}` }]
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({
name: 'transform-test',
arguments: { firstName: 'John', lastName: 'Doe' }
});
expect(result.content).toEqual([
{
type: 'text',
text: 'Full name: John Doe'
}
]);
});
test('should support z.pipe() schemas', async () => {
const server = new McpServer({
name: 'test',
version: '1.0.0'
});
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
// z.pipe() chains multiple schemas together
const pipeSchema = z
.object({ value: z.string() })
.transform(data => ({ ...data, processed: true }))
.pipe(z.object({ value: z.string(), processed: z.boolean() }));
server.registerTool('pipe-test', { inputSchema: pipeSchema }, async args => {
return {
content: [{ type: 'text' as const, text: `Value: ${args.value}, Processed: ${args.processed}` }]
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({
name: 'pipe-test',
arguments: { value: 'test' }
});
expect(result.content).toEqual([
{
type: 'text',
text: 'Value: test, Processed: true'
}
]);
});
test('should support nested transformation schemas', async () => {
const server = new McpServer({
name: 'test',
version: '1.0.0'
});
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
// Complex schema with both preprocess and transform
const complexSchema = z.preprocess(
input => {
if (typeof input === 'object' && input !== null) {
const obj = input as Record<string, unknown>;
// Convert string numbers to actual numbers
if (typeof obj.count === 'string') {
return { ...obj, count: parseInt(obj.count, 10) };
}
}
return input;
},
z
.object({
name: z.string(),
count: z.number()
})
.transform(data => ({
...data,
doubled: data.count * 2
}))
);
server.registerTool('complex-transform', { inputSchema: complexSchema }, async args => {
return {
content: [{ type: 'text' as const, text: `${args.name}: ${args.count} -> ${args.doubled}` }]
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
// Pass count as string, preprocess will convert it
const result = await client.callTool({
name: 'complex-transform',
arguments: { name: 'items', count: '5' }
});
expect(result.content).toEqual([
{
type: 'text',
text: 'items: 5 -> 10'
}
]);
});
});
describe('resource()', () => {
/***
* Test: Resource Registration with URI and Read Callback

View file

@ -1312,17 +1312,9 @@ const EMPTY_OBJECT_JSON_SCHEMA = {
properties: {}
};
// Helper to check if an object is a Zod schema (ZodRawShapeCompat)
function isZodRawShapeCompat(obj: unknown): obj is ZodRawShapeCompat {
if (typeof obj !== 'object' || obj === null) return false;
const isEmptyObject = Object.keys(obj).length === 0;
// Check if object is empty or at least one property is a ZodType instance
// Note: use heuristic check to avoid instanceof failure across different Zod versions
return isEmptyObject || Object.values(obj as object).some(isZodTypeLike);
}
/**
* Checks if a value looks like a Zod schema by checking for parse/safeParse methods.
*/
function isZodTypeLike(value: unknown): value is AnySchema {
return (
value !== null &&
@ -1334,6 +1326,46 @@ function isZodTypeLike(value: unknown): value is AnySchema {
);
}
/**
* Checks if an object is a Zod schema instance (v3 or v4).
*
* Zod schemas have internal markers:
* - v3: `_def` property
* - v4: `_zod` property
*
* This includes transformed schemas like z.preprocess(), z.transform(), z.pipe().
*/
function isZodSchemaInstance(obj: object): boolean {
return '_def' in obj || '_zod' in obj || isZodTypeLike(obj);
}
/**
* Checks if an object is a "raw shape" - a plain object where values are Zod schemas.
*
* Raw shapes are used as shorthand: `{ name: z.string() }` instead of `z.object({ name: z.string() })`.
*
* IMPORTANT: This must NOT match actual Zod schema instances (like z.preprocess, z.pipe),
* which have internal properties that could be mistaken for schema values.
*/
function isZodRawShapeCompat(obj: unknown): obj is ZodRawShapeCompat {
if (typeof obj !== 'object' || obj === null) {
return false;
}
// If it's already a Zod schema instance, it's NOT a raw shape
if (isZodSchemaInstance(obj)) {
return false;
}
// Empty objects are valid raw shapes (tools with no parameters)
if (Object.keys(obj).length === 0) {
return true;
}
// A raw shape has at least one property that is a Zod schema
return Object.values(obj).some(isZodTypeLike);
}
/**
* Converts a provided Zod schema to a Zod object if it is a ZodRawShapeCompat,
* otherwise returns the schema as is.