Skip to content

Commit 8204126

Browse files
authored
fix: allow zod 4 transformations (#1213)
1 parent 6083600 commit 8204126

File tree

2 files changed

+238
-11
lines changed

2 files changed

+238
-11
lines changed

src/server/mcp.test.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4765,6 +4765,201 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
47654765
});
47664766
});
47674767

4768+
describe('Tools with transformation schemas', () => {
4769+
test('should support z.preprocess() schemas', async () => {
4770+
const server = new McpServer({
4771+
name: 'test',
4772+
version: '1.0.0'
4773+
});
4774+
4775+
const client = new Client({
4776+
name: 'test-client',
4777+
version: '1.0.0'
4778+
});
4779+
4780+
// z.preprocess() allows transforming input before validation
4781+
const preprocessSchema = z.preprocess(
4782+
input => {
4783+
// Normalize input by trimming strings
4784+
if (typeof input === 'object' && input !== null) {
4785+
const obj = input as Record<string, unknown>;
4786+
if (typeof obj.name === 'string') {
4787+
return { ...obj, name: obj.name.trim() };
4788+
}
4789+
}
4790+
return input;
4791+
},
4792+
z.object({ name: z.string() })
4793+
);
4794+
4795+
server.registerTool('preprocess-test', { inputSchema: preprocessSchema }, async args => {
4796+
return {
4797+
content: [{ type: 'text' as const, text: `Hello, ${args.name}!` }]
4798+
};
4799+
});
4800+
4801+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
4802+
await server.connect(serverTransport);
4803+
await client.connect(clientTransport);
4804+
4805+
// Test with input that has leading/trailing whitespace
4806+
const result = await client.callTool({
4807+
name: 'preprocess-test',
4808+
arguments: { name: ' World ' }
4809+
});
4810+
4811+
expect(result.content).toEqual([
4812+
{
4813+
type: 'text',
4814+
text: 'Hello, World!'
4815+
}
4816+
]);
4817+
});
4818+
4819+
test('should support z.transform() schemas', async () => {
4820+
const server = new McpServer({
4821+
name: 'test',
4822+
version: '1.0.0'
4823+
});
4824+
4825+
const client = new Client({
4826+
name: 'test-client',
4827+
version: '1.0.0'
4828+
});
4829+
4830+
// z.transform() allows transforming validated output
4831+
const transformSchema = z
4832+
.object({
4833+
firstName: z.string(),
4834+
lastName: z.string()
4835+
})
4836+
.transform(data => ({
4837+
...data,
4838+
fullName: `${data.firstName} ${data.lastName}`
4839+
}));
4840+
4841+
server.registerTool('transform-test', { inputSchema: transformSchema }, async args => {
4842+
return {
4843+
content: [{ type: 'text' as const, text: `Full name: ${args.fullName}` }]
4844+
};
4845+
});
4846+
4847+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
4848+
await server.connect(serverTransport);
4849+
await client.connect(clientTransport);
4850+
4851+
const result = await client.callTool({
4852+
name: 'transform-test',
4853+
arguments: { firstName: 'John', lastName: 'Doe' }
4854+
});
4855+
4856+
expect(result.content).toEqual([
4857+
{
4858+
type: 'text',
4859+
text: 'Full name: John Doe'
4860+
}
4861+
]);
4862+
});
4863+
4864+
test('should support z.pipe() schemas', async () => {
4865+
const server = new McpServer({
4866+
name: 'test',
4867+
version: '1.0.0'
4868+
});
4869+
4870+
const client = new Client({
4871+
name: 'test-client',
4872+
version: '1.0.0'
4873+
});
4874+
4875+
// z.pipe() chains multiple schemas together
4876+
const pipeSchema = z
4877+
.object({ value: z.string() })
4878+
.transform(data => ({ ...data, processed: true }))
4879+
.pipe(z.object({ value: z.string(), processed: z.boolean() }));
4880+
4881+
server.registerTool('pipe-test', { inputSchema: pipeSchema }, async args => {
4882+
return {
4883+
content: [{ type: 'text' as const, text: `Value: ${args.value}, Processed: ${args.processed}` }]
4884+
};
4885+
});
4886+
4887+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
4888+
await server.connect(serverTransport);
4889+
await client.connect(clientTransport);
4890+
4891+
const result = await client.callTool({
4892+
name: 'pipe-test',
4893+
arguments: { value: 'test' }
4894+
});
4895+
4896+
expect(result.content).toEqual([
4897+
{
4898+
type: 'text',
4899+
text: 'Value: test, Processed: true'
4900+
}
4901+
]);
4902+
});
4903+
4904+
test('should support nested transformation schemas', async () => {
4905+
const server = new McpServer({
4906+
name: 'test',
4907+
version: '1.0.0'
4908+
});
4909+
4910+
const client = new Client({
4911+
name: 'test-client',
4912+
version: '1.0.0'
4913+
});
4914+
4915+
// Complex schema with both preprocess and transform
4916+
const complexSchema = z.preprocess(
4917+
input => {
4918+
if (typeof input === 'object' && input !== null) {
4919+
const obj = input as Record<string, unknown>;
4920+
// Convert string numbers to actual numbers
4921+
if (typeof obj.count === 'string') {
4922+
return { ...obj, count: parseInt(obj.count, 10) };
4923+
}
4924+
}
4925+
return input;
4926+
},
4927+
z
4928+
.object({
4929+
name: z.string(),
4930+
count: z.number()
4931+
})
4932+
.transform(data => ({
4933+
...data,
4934+
doubled: data.count * 2
4935+
}))
4936+
);
4937+
4938+
server.registerTool('complex-transform', { inputSchema: complexSchema }, async args => {
4939+
return {
4940+
content: [{ type: 'text' as const, text: `${args.name}: ${args.count} -> ${args.doubled}` }]
4941+
};
4942+
});
4943+
4944+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
4945+
await server.connect(serverTransport);
4946+
await client.connect(clientTransport);
4947+
4948+
// Pass count as string, preprocess will convert it
4949+
const result = await client.callTool({
4950+
name: 'complex-transform',
4951+
arguments: { name: 'items', count: '5' }
4952+
});
4953+
4954+
expect(result.content).toEqual([
4955+
{
4956+
type: 'text',
4957+
text: 'items: 5 -> 10'
4958+
}
4959+
]);
4960+
});
4961+
});
4962+
47684963
describe('resource()', () => {
47694964
/***
47704965
* Test: Resource Registration with URI and Read Callback

src/server/mcp.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,17 +1312,9 @@ const EMPTY_OBJECT_JSON_SCHEMA = {
13121312
properties: {}
13131313
};
13141314

1315-
// Helper to check if an object is a Zod schema (ZodRawShapeCompat)
1316-
function isZodRawShapeCompat(obj: unknown): obj is ZodRawShapeCompat {
1317-
if (typeof obj !== 'object' || obj === null) return false;
1318-
1319-
const isEmptyObject = Object.keys(obj).length === 0;
1320-
1321-
// Check if object is empty or at least one property is a ZodType instance
1322-
// Note: use heuristic check to avoid instanceof failure across different Zod versions
1323-
return isEmptyObject || Object.values(obj as object).some(isZodTypeLike);
1324-
}
1325-
1315+
/**
1316+
* Checks if a value looks like a Zod schema by checking for parse/safeParse methods.
1317+
*/
13261318
function isZodTypeLike(value: unknown): value is AnySchema {
13271319
return (
13281320
value !== null &&
@@ -1334,6 +1326,46 @@ function isZodTypeLike(value: unknown): value is AnySchema {
13341326
);
13351327
}
13361328

1329+
/**
1330+
* Checks if an object is a Zod schema instance (v3 or v4).
1331+
*
1332+
* Zod schemas have internal markers:
1333+
* - v3: `_def` property
1334+
* - v4: `_zod` property
1335+
*
1336+
* This includes transformed schemas like z.preprocess(), z.transform(), z.pipe().
1337+
*/
1338+
function isZodSchemaInstance(obj: object): boolean {
1339+
return '_def' in obj || '_zod' in obj || isZodTypeLike(obj);
1340+
}
1341+
1342+
/**
1343+
* Checks if an object is a "raw shape" - a plain object where values are Zod schemas.
1344+
*
1345+
* Raw shapes are used as shorthand: `{ name: z.string() }` instead of `z.object({ name: z.string() })`.
1346+
*
1347+
* IMPORTANT: This must NOT match actual Zod schema instances (like z.preprocess, z.pipe),
1348+
* which have internal properties that could be mistaken for schema values.
1349+
*/
1350+
function isZodRawShapeCompat(obj: unknown): obj is ZodRawShapeCompat {
1351+
if (typeof obj !== 'object' || obj === null) {
1352+
return false;
1353+
}
1354+
1355+
// If it's already a Zod schema instance, it's NOT a raw shape
1356+
if (isZodSchemaInstance(obj)) {
1357+
return false;
1358+
}
1359+
1360+
// Empty objects are valid raw shapes (tools with no parameters)
1361+
if (Object.keys(obj).length === 0) {
1362+
return true;
1363+
}
1364+
1365+
// A raw shape has at least one property that is a Zod schema
1366+
return Object.values(obj).some(isZodTypeLike);
1367+
}
1368+
13371369
/**
13381370
* Converts a provided Zod schema to a Zod object if it is a ZodRawShapeCompat,
13391371
* otherwise returns the schema as is.

0 commit comments

Comments
 (0)