Skip to content

Commit b0e4b04

Browse files
committed
fix(server): preserve inputSchema for z.discriminatedUnion / z.union (#1643)
normalizeObjectSchema returned undefined for any schema whose root was not z.object(...), so registerTool silently advertised an empty schema in tools/list when given z.discriminatedUnion(...). Tool calls still validated correctly via the fallback in validateToolInput, which masked the bug from end-to-end tests. Pass discriminated unions and unions through unchanged in normalizeObjectSchema; let toJsonSchemaCompat handle the actual conversion (zod-to-json-schema for v3, Mini's z.toJSONSchema for v4). Both emit oneOf/anyOf branches. Default a top-level type: "object" on the emitted JSON Schema in mcp.ts so the wire payload is spec compliant when the converter does not include one (the discriminated-union and union case). Adds three regression tests under "Tools with union and intersection schemas" exercising tools/list, callTool, and outputSchema for v3 and v4.
1 parent bf1e022 commit b0e4b04

4 files changed

Lines changed: 179 additions & 14 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@modelcontextprotocol/sdk': patch
3+
---
4+
5+
Fix `registerTool` / `registerPrompt` silently dropping `inputSchema` and `outputSchema` when given a `z.discriminatedUnion(...)` or `z.union(...)` of objects.
6+
7+
`normalizeObjectSchema` previously returned `undefined` for any schema whose root was not `z.object(...)`, so the schema never reached `toJsonSchemaCompat` and `tools/list` advertised an empty schema. Tool calls still validated correctly via the fallback in `validateToolInput`,
8+
which masked the bug.
9+
10+
`normalizeObjectSchema` now passes discriminated unions and unions through unchanged. The `tools/list` payload is also given a top-level `type: "object"` when missing so the emitted JSON Schema satisfies the MCP spec for tool input/output schemas (Zod emits `oneOf` / `anyOf`
11+
without a root type for these cases).
12+
13+
Closes #1643.

src/server/mcp.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,17 @@ export class McpServer {
147147
description: tool.description,
148148
inputSchema: (() => {
149149
const obj = normalizeObjectSchema(tool.inputSchema);
150-
return obj
151-
? (toJsonSchemaCompat(obj, {
152-
strictUnions: true,
153-
pipeStrategy: 'input'
154-
}) as Tool['inputSchema'])
155-
: EMPTY_OBJECT_JSON_SCHEMA;
150+
if (!obj) return EMPTY_OBJECT_JSON_SCHEMA;
151+
const json = toJsonSchemaCompat(obj, {
152+
strictUnions: true,
153+
pipeStrategy: 'input'
154+
});
155+
// MCP requires `type: "object"` at the root of
156+
// tool inputSchema. Discriminated unions and
157+
// unions of objects produce `oneOf` / `anyOf`
158+
// without a top-level `type`; default it so the
159+
// emitted schema is spec compliant.
160+
return ensureObjectRoot(json) as Tool['inputSchema'];
156161
})(),
157162
annotations: tool.annotations,
158163
execution: tool.execution,
@@ -162,10 +167,11 @@ export class McpServer {
162167
if (tool.outputSchema) {
163168
const obj = normalizeObjectSchema(tool.outputSchema);
164169
if (obj) {
165-
toolDefinition.outputSchema = toJsonSchemaCompat(obj, {
170+
const json = toJsonSchemaCompat(obj, {
166171
strictUnions: true,
167172
pipeStrategy: 'output'
168-
}) as Tool['outputSchema'];
173+
});
174+
toolDefinition.outputSchema = ensureObjectRoot(json) as Tool['outputSchema'];
169175
}
170176
}
171177

@@ -1331,6 +1337,22 @@ const EMPTY_OBJECT_JSON_SCHEMA = {
13311337
properties: {}
13321338
};
13331339

1340+
/**
1341+
* Ensures a JSON Schema produced from a Zod schema has a top-level
1342+
* `type: "object"` per the MCP spec for tool input/output schemas.
1343+
*
1344+
* Plain `z.object(...)` already emits `type: "object"`, so this is a
1345+
* no-op for the common case. Discriminated unions and unions of objects
1346+
* emit `oneOf` / `anyOf` without a root `type`; we default it here so
1347+
* the wire payload is spec compliant. Schemas with an explicit non-object
1348+
* root `type` are left alone (they will fail downstream validation, which
1349+
* is the right signal for the user).
1350+
*/
1351+
function ensureObjectRoot(json: Record<string, unknown>): Record<string, unknown> {
1352+
if (json.type !== undefined) return json;
1353+
return { type: 'object', ...json };
1354+
}
1355+
13341356
/**
13351357
* Checks if a value looks like a Zod schema by checking for parse/safeParse methods.
13361358
*/

src/server/zod-compat.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,18 @@ export function getObjectShape(schema: AnyObjectSchema | undefined): Record<stri
133133

134134
// --- Schema normalization ---
135135
/**
136-
* Normalizes a schema to an object schema. Handles both:
136+
* Normalizes a schema for use as an object-shaped tool/prompt input or output.
137+
* Handles:
137138
* - Already-constructed object schemas (v3 or v4)
139+
* - Discriminated unions and unions whose branches are object schemas
140+
* (e.g. `z.discriminatedUnion('action', [...])`); these convert to a
141+
* valid JSON Schema (`oneOf` / `anyOf`) via `toJsonSchemaCompat` and
142+
* parse correctly via `safeParse`, so they pass through unchanged.
138143
* - Raw shapes that need to be wrapped into object schemas
144+
*
145+
* Returns `undefined` for schemas whose root is not object-shaped (e.g.
146+
* `z.string()`, `z.array(...)`), since those cannot satisfy the MCP spec's
147+
* requirement that tool input/output schemas describe objects.
139148
*/
140149
export function normalizeObjectSchema(schema: AnySchema | ZodRawShapeCompat | undefined): AnyObjectSchema | undefined {
141150
if (!schema) return undefined;
@@ -169,20 +178,35 @@ export function normalizeObjectSchema(schema: AnySchema | ZodRawShapeCompat | un
169178
}
170179

171180
// If we get here, it should be an AnySchema (not a raw shape)
172-
// Check if it's already an object schema
181+
// Check if it's already an object schema, a discriminated union, or a
182+
// union — all three convert cleanly to a JSON Schema with type:object
183+
// (via `toJsonSchemaCompat`) and validate via `safeParse`.
173184
if (isZ4Schema(schema as AnySchema)) {
174-
// Check if it's a v4 object
175185
const v4Schema = schema as unknown as ZodV4Internal;
176186
const def = v4Schema._zod?.def;
177-
if (def && (def.type === 'object' || def.shape !== undefined)) {
178-
return schema as AnyObjectSchema;
187+
if (def) {
188+
if (def.type === 'object' || def.shape !== undefined) {
189+
return schema as AnyObjectSchema;
190+
}
191+
// v4 reports both `z.union(...)` and `z.discriminatedUnion(...)`
192+
// as `def.type === 'union'`. Pass them through; downstream
193+
// `toJsonSchemaCompat` (Mini's `z.toJSONSchema`) emits a valid
194+
// `oneOf` / `anyOf` JSON Schema and `safeParse` handles them.
195+
if (def.type === 'union') {
196+
return schema as AnyObjectSchema;
197+
}
179198
}
180199
} else {
181-
// Check if it's a v3 object
182200
const v3Schema = schema as unknown as ZodV3Internal;
183201
if (v3Schema.shape !== undefined) {
184202
return schema as AnyObjectSchema;
185203
}
204+
// v3 distinguishes the two; both serialise to a JSON Schema with
205+
// `oneOf` / `anyOf` via the vendored `zodToJsonSchema` converter.
206+
const typeName = v3Schema._def?.typeName;
207+
if (typeName === 'ZodDiscriminatedUnion' || typeName === 'ZodUnion') {
208+
return schema as AnyObjectSchema;
209+
}
186210
}
187211

188212
return undefined;

test/server/mcp.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5023,6 +5023,112 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
50235023
])
50245024
);
50255025
});
5026+
5027+
// Regression for https://github.com/modelcontextprotocol/typescript-sdk/issues/1643
5028+
// Before the fix, normalizeObjectSchema returned undefined for
5029+
// discriminated unions and unions, so registerTool silently dropped
5030+
// the schema in tools/list and emitted EMPTY_OBJECT_JSON_SCHEMA. Tool
5031+
// calls still validated correctly via the fallback in validateToolInput.
5032+
test('should expose a discriminated union inputSchema in tools/list', async () => {
5033+
const server = new McpServer({ name: 'test', version: '1.0.0' });
5034+
const client = new Client({ name: 'test-client', version: '1.0.0' });
5035+
5036+
const inputSchema = z.discriminatedUnion('action', [
5037+
z.object({ action: z.literal('create'), name: z.string() }),
5038+
z.object({ action: z.literal('delete'), id: z.string() })
5039+
]);
5040+
5041+
server.registerTool('mutate', { inputSchema }, async args => ({
5042+
content: [{ type: 'text' as const, text: JSON.stringify(args) }]
5043+
}));
5044+
5045+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
5046+
await server.connect(serverTransport);
5047+
await client.connect(clientTransport);
5048+
5049+
const list = await client.listTools();
5050+
expect(list.tools).toHaveLength(1);
5051+
const advertised = list.tools[0].inputSchema as Record<string, unknown>;
5052+
expect(advertised.type).toBe('object');
5053+
// Both v3 (zod-to-json-schema) and v4 (Mini) emit oneOf or anyOf.
5054+
const branches = (advertised.oneOf ?? advertised.anyOf) as Array<Record<string, unknown>> | undefined;
5055+
expect(branches).toBeDefined();
5056+
expect(branches).toHaveLength(2);
5057+
5058+
// Tool calls keep working.
5059+
const ok = await client.callTool({
5060+
name: 'mutate',
5061+
arguments: { action: 'create', name: 'foo' }
5062+
});
5063+
expect(ok.isError).toBeFalsy();
5064+
5065+
const bad = await client.callTool({
5066+
name: 'mutate',
5067+
arguments: { action: 'create' }
5068+
});
5069+
expect(bad.isError).toBe(true);
5070+
});
5071+
5072+
test('should expose a union of objects inputSchema in tools/list', async () => {
5073+
const server = new McpServer({ name: 'test', version: '1.0.0' });
5074+
const client = new Client({ name: 'test-client', version: '1.0.0' });
5075+
5076+
const inputSchema = z.union([
5077+
z.object({ kind: z.literal('a'), x: z.string() }),
5078+
z.object({ kind: z.literal('b'), y: z.number() })
5079+
]);
5080+
5081+
server.registerTool('pick', { inputSchema }, async () => ({
5082+
content: [{ type: 'text' as const, text: 'ok' }]
5083+
}));
5084+
5085+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
5086+
await server.connect(serverTransport);
5087+
await client.connect(clientTransport);
5088+
5089+
const list = await client.listTools();
5090+
const advertised = list.tools[0].inputSchema as Record<string, unknown>;
5091+
expect(advertised.type).toBe('object');
5092+
expect(advertised.oneOf ?? advertised.anyOf).toBeDefined();
5093+
});
5094+
5095+
test('should expose a discriminated union outputSchema in tools/list', async () => {
5096+
const server = new McpServer({ name: 'test', version: '1.0.0' });
5097+
const client = new Client({ name: 'test-client', version: '1.0.0' });
5098+
5099+
const outputSchema = z.discriminatedUnion('kind', [
5100+
z.object({ kind: z.literal('ok'), data: z.string() }),
5101+
z.object({ kind: z.literal('err'), message: z.string() })
5102+
]);
5103+
5104+
server.registerTool(
5105+
'maybe',
5106+
{
5107+
inputSchema: z.object({ should_fail: z.boolean() }),
5108+
outputSchema
5109+
},
5110+
async ({ should_fail }) => {
5111+
const structured = should_fail ? { kind: 'err' as const, message: 'oops' } : { kind: 'ok' as const, data: 'fine' };
5112+
return {
5113+
content: [{ type: 'text' as const, text: JSON.stringify(structured) }],
5114+
structuredContent: structured
5115+
};
5116+
}
5117+
);
5118+
5119+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
5120+
await server.connect(serverTransport);
5121+
await client.connect(clientTransport);
5122+
5123+
const list = await client.listTools();
5124+
const advertised = list.tools[0].outputSchema as Record<string, unknown> | undefined;
5125+
expect(advertised).toBeDefined();
5126+
expect(advertised!.type).toBe('object');
5127+
expect(advertised!.oneOf ?? advertised!.anyOf).toBeDefined();
5128+
5129+
const ok = await client.callTool({ name: 'maybe', arguments: { should_fail: false } });
5130+
expect(ok.isError).toBeFalsy();
5131+
});
50265132
});
50275133

50285134
describe('Tools with transformation schemas', () => {

0 commit comments

Comments
 (0)