fix: allow zod 4 transformations (#1213)
This commit is contained in:
parent
608360047d
commit
82041262dc
2 changed files with 238 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue