Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/fix-tools-list-wrapped-schemas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/sdk': patch
---

Fix `tools/list` emitting `inputSchema: {}` (and silently dropping `outputSchema`) for tools whose schema is wrapped in `ZodEffects` / `ZodPipeline` (`.refine`, `.superRefine`, `.transform`, `.pipe`). The emission sites now fall back to the original schema when
`normalizeObjectSchema` can't unwrap, mirroring what `validateToolInput` already does — the converter libraries walk those wrappers natively.
24 changes: 12 additions & 12 deletions src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,15 @@ export class McpServer {
title: tool.title,
description: tool.description,
inputSchema: (() => {
if (!tool.inputSchema) return EMPTY_OBJECT_JSON_SCHEMA;
// Fallback to the original schema when normalizeObjectSchema can't unwrap
// it (e.g. .refine/.superRefine/.transform/.pipe). The converter walks
// those wrappers natively and emits the underlying object's properties.
const obj = normalizeObjectSchema(tool.inputSchema);
return obj
? (toJsonSchemaCompat(obj, {
strictUnions: true,
pipeStrategy: 'input'
}) as Tool['inputSchema'])
: EMPTY_OBJECT_JSON_SCHEMA;
return toJsonSchemaCompat(obj ?? (tool.inputSchema as AnySchema), {
strictUnions: true,
pipeStrategy: 'input'
}) as Tool['inputSchema'];
})(),
annotations: tool.annotations,
execution: tool.execution,
Expand All @@ -161,12 +163,10 @@ export class McpServer {

if (tool.outputSchema) {
const obj = normalizeObjectSchema(tool.outputSchema);
if (obj) {
toolDefinition.outputSchema = toJsonSchemaCompat(obj, {
strictUnions: true,
pipeStrategy: 'output'
}) as Tool['outputSchema'];
}
toolDefinition.outputSchema = toJsonSchemaCompat(obj ?? (tool.outputSchema as AnySchema), {
strictUnions: true,
pipeStrategy: 'output'
}) as Tool['outputSchema'];
}

return toolDefinition;
Expand Down
2 changes: 1 addition & 1 deletion src/server/zod-json-schema-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function mapMiniTarget(t: CommonOpts['target'] | undefined): 'draft-7' | 'draft-
return 'draft-7'; // fallback
}

export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts): JsonSchema {
export function toJsonSchemaCompat(schema: AnySchema, opts?: CommonOpts): JsonSchema {
if (isZ4Schema(schema)) {
// v4 branch — use Mini's built-in toJSONSchema
return z4mini.toJSONSchema(schema as z4c.$ZodType, {
Expand Down
130 changes: 130 additions & 0 deletions test/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '../../src/types.js';
import { completable } from '../../src/server/completable.js';
import { McpServer, ResourceTemplate } from '../../src/server/mcp.js';
import type { AnySchema } from '../../src/server/zod-compat.js';
import { InMemoryTaskStore } from '../../src/experimental/tasks/stores/in-memory.js';
import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js';

Expand Down Expand Up @@ -5220,6 +5221,135 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
});
});

describe('tools/list with wrapped inputSchema (issue #2145)', () => {
// Each entry wraps the same plain object shape:
// z.object({ prompt: z.string().min(1), count: z.number().int().positive().default(1) })
// The wrapper (.refine / .superRefine / .transform / .pipe) hides `.shape` from the
// top-level schema, but tools/list should still emit the underlying object's
// `properties` instead of falling back to {}.
const buildBase = () =>
z.object({
prompt: z.string().min(1),
count: z.number().int().positive().default(1)
});

const wrappers: Array<{ name: string; build: () => AnySchema; rejectsLargeCount: boolean }> = [
{
name: '.refine',
build: () =>
(buildBase() as { refine: (...args: unknown[]) => AnySchema }).refine((v: { count: number }) => v.count <= 100, {
message: 'count must be <= 100'
}),
rejectsLargeCount: true
},
{
name: '.superRefine',
build: () =>
(buildBase() as { superRefine: (...args: unknown[]) => AnySchema }).superRefine(
(v: { count: number }, ctx: { addIssue: (issue: Record<string, unknown>) => void }) => {
if (v.count > 100) {
ctx.addIssue({ code: 'custom', path: ['count'], message: 'count must be <= 100' });
}
}
),
rejectsLargeCount: true
},
{
name: '.transform',
build: () => (buildBase() as { transform: (...args: unknown[]) => AnySchema }).transform((v: Record<string, unknown>) => v),
rejectsLargeCount: false
},
{
name: '.pipe',
build: () =>
(buildBase() as { pipe: (...args: unknown[]) => AnySchema }).pipe(
z.object({
prompt: z.string(),
count: z.number()
})
),
rejectsLargeCount: false
}
];

test.each(wrappers)('$name: tools/list emits properties from the underlying object', async ({ build }) => {
const server = new McpServer({ name: 'test', version: '1.0.0' });
const client = new Client({ name: 'test', version: '1.0.0' });

server.registerTool('wrapped', { inputSchema: build() }, async () => ({ content: [{ type: 'text' as const, text: 'ok' }] }));

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
const wrapped = result.tools.find(t => t.name === 'wrapped');
expect(wrapped).toBeDefined();
expect(wrapped!.inputSchema.type).toBe('object');
const properties = wrapped!.inputSchema.properties as Record<string, unknown> | undefined;
expect(properties).toBeDefined();
expect(properties!.prompt).toBeDefined();
expect(properties!.count).toBeDefined();
});

test.each(wrappers)('$name: tools/call still validates against the wrapped schema', async ({ build, rejectsLargeCount }) => {
const server = new McpServer({ name: 'test', version: '1.0.0' });
const client = new Client({ name: 'test', version: '1.0.0' });

server.registerTool('wrapped', { inputSchema: build() }, async () => ({ content: [{ type: 'text' as const, text: 'ok' }] }));

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const okResult = await client.callTool({ name: 'wrapped', arguments: { prompt: 'hi', count: 5 } });
expect(okResult.isError).toBeFalsy();

if (rejectsLargeCount) {
const badResult = await client.callTool({ name: 'wrapped', arguments: { prompt: 'hi', count: 200 } });
expect(badResult.isError).toBe(true);
expect(JSON.stringify(badResult.content)).toContain('count');
}
});

test('regression: tool without inputSchema still emits EMPTY_OBJECT_JSON_SCHEMA', async () => {
const server = new McpServer({ name: 'test', version: '1.0.0' });
const client = new Client({ name: 'test', version: '1.0.0' });

server.registerTool('no-args', {}, async () => ({ content: [{ type: 'text' as const, text: 'ok' }] }));

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
const tool = result.tools.find(t => t.name === 'no-args');
expect(tool).toBeDefined();
expect(tool!.inputSchema).toEqual({ type: 'object', properties: {} });
});

test('wrapped outputSchema is also emitted in tools/list', async () => {
const server = new McpServer({ name: 'test', version: '1.0.0' });
const client = new Client({ name: 'test', version: '1.0.0' });

const wrappedOutput = (z.object({ result: z.string() }) as { refine: (...args: unknown[]) => AnySchema }).refine(() => true);

server.registerTool('with-wrapped-output', { outputSchema: wrappedOutput }, async () => ({
content: [{ type: 'text' as const, text: 'ok' }],
structuredContent: { result: 'ok' }
}));

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
const tool = result.tools.find(t => t.name === 'with-wrapped-output');
expect(tool).toBeDefined();
expect(tool!.outputSchema).toBeDefined();
expect(tool!.outputSchema!.type).toBe('object');
const properties = tool!.outputSchema!.properties as Record<string, unknown> | undefined;
expect(properties).toBeDefined();
expect(properties!.result).toBeDefined();
});
});

describe('resource()', () => {
/***
* Test: Resource Registration with URI and Read Callback
Expand Down
Loading