Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions src/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 43 additions & 11 deletions src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -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.
Expand Down
Loading