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-non-object-output-schema-roots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/sdk': patch
---

Fix the `tools/call` crash for non-object `outputSchema` roots (`.optional()`, `.nullable()`, `.nullish()`, `z.union(...)`): output validation now parses the schema directly when it cannot be normalized to an object, mirroring the input-validation path. `tools/list` still cannot
advertise such schemas (the wire schema requires `type: "object"` at the root, per the MCP specification), but registration now warns about the omission instead of dropping it silently.
24 changes: 19 additions & 5 deletions src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { RequestHandlerExtra } from '../shared/protocol.js';
import { Transport } from '../shared/transport.js';

import { validateAndWarnToolName } from '../shared/toolNameValidation.js';
import { issueNonObjectOutputSchemaWarning } from '../shared/outputSchemaWarning.js';
import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcp-server.js';
import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js';
import { ZodOptional } from 'zod';
Expand Down Expand Up @@ -373,9 +374,13 @@ export class McpServer {
);
}

// if the tool has an output schema, validate structured content
const outputObj = normalizeObjectSchema(tool.outputSchema) as AnyObjectSchema;
const parseResult = await safeParseAsync(outputObj, result.structuredContent);
// if the tool has an output schema, validate structured content.
// Try to normalize to object schema first (for raw shapes and object schemas)
// If that fails, use the schema directly (for union/intersection/etc), mirroring
// the input-validation path — non-object roots must validate, not crash (#1308)
const outputObj = normalizeObjectSchema(tool.outputSchema);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the !result.structuredContent check above also take this fallback into account? With an .optional() or .nullish() root, a tool that returns only content still gets has an output schema but no structured content was provided before this parse runs (I tried a quick local test at this commit with both roots), so the optional part of the schema never applies. If that's intended, since the spec expects structured content whenever an output schema is set, it might be worth a sentence in the changeset so people don't expect .optional() to make structuredContent optional. Otherwise a case where the handler omits structuredContent could go into the new test.

const schemaToParse = outputObj ?? (tool.outputSchema as AnySchema);
const parseResult = await safeParseAsync(schemaToParse, result.structuredContent);
if (!parseResult.success) {
const error = 'error' in parseResult ? parseResult.error : 'Unknown error';
const errorMessage = getParseErrorMessage(error);
Expand Down Expand Up @@ -950,11 +955,20 @@ export class McpServer {
// Validate tool name according to SEP specification
validateAndWarnToolName(name);

const normalizedInputSchema = getZodSchemaObject(inputSchema);
const normalizedOutputSchema = getZodSchemaObject(outputSchema);

// Warn (rather than refuse) when the output schema cannot be advertised in
// tools/list: the call-time validation still applies, so registration proceeds.
if (normalizedOutputSchema && !normalizeObjectSchema(normalizedOutputSchema)) {
issueNonObjectOutputSchemaWarning(name);
}

const registeredTool: RegisteredTool = {
title,
description,
inputSchema: getZodSchemaObject(inputSchema),
outputSchema: getZodSchemaObject(outputSchema),
inputSchema: normalizedInputSchema,
outputSchema: normalizedOutputSchema,
annotations,
execution,
_meta,
Expand Down
18 changes: 18 additions & 0 deletions src/shared/outputSchemaWarning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Registration-time warning for non-object output schemas.
*
* The MCP specification requires `Tool.outputSchema` to be an object schema
* (type: "object" at its root), so a non-object root (union, optional, …) cannot be
* serialized into `tools/list` — clients would never see it and would not validate
* the tool's output. `tools/call` still validates against the schema itself. This
* warning makes the listing omission visible instead of silent (#1308).
*/

export function issueNonObjectOutputSchemaWarning(name: string): void {
console.warn(`Tool outputSchema warning for "${name}":`);
console.warn(
' - The outputSchema is not an object schema, so tools/list cannot advertise it (the MCP specification requires type "object" at the root).'
);
console.warn(" - tools/call still validates results against it, but clients will not validate this tool's output.");
console.warn('Consider wrapping the schema in z.object(...) — e.g. merging an error envelope in as an optional `error` property.');
}
68 changes: 68 additions & 0 deletions test/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,74 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(JSON.parse(textContent.text)).toEqual(result.structuredContent);
});

/***
* Test: Non-object outputSchema roots validate instead of crashing (#1308)
*
* optional/nullable/nullish/union output schemas previously crashed every
* tools/call with "Cannot read properties of undefined (reading '_zod')" and
* were silently omitted from tools/list. The call must now validate (mirroring
* the input path's fallback), and the listing omission — unavoidable, since the
* wire schema requires type "object" at the outputSchema root — must be warned
* about at registration rather than silent.
*/
test('should validate output for non-object outputSchema roots and warn about the listing omission', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

const mcpServer = new McpServer({
name: 'test server',
version: '1.0'
});
const client = new Client({
name: 'test client',
version: '1.0'
});

const cases = [
{ name: 'optional-root', schema: z.object({ data: z.string() }).optional() },
{ name: 'nullable-root', schema: z.object({ data: z.string() }).nullable() },
{ name: 'nullish-root', schema: z.object({ data: z.string() }).nullish() },
{
name: 'union-root',
schema: z.union([z.object({ data: z.string() }), z.object({ value: z.string() })])
}
];

for (const { name, schema } of cases) {
mcpServer.registerTool(name, { description: name, outputSchema: schema }, async () => ({
structuredContent: { data: 'hello' },
content: []
}));
}

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

const listResult = await client.request({ method: 'tools/list' }, ListToolsResultSchema);
expect(listResult.tools).toHaveLength(cases.length);
// Pinned: the wire schema (Tool.outputSchema requires type "object" at the
// root) means these schemas cannot be advertised. The pin keeps the omission
// a documented behaviour; the registration warning keeps it non-silent.
for (const tool of listResult.tools) {
expect(tool.outputSchema).toBeUndefined();
}

// Every root that previously crashed the call now validates cleanly.
for (const { name } of cases) {
const result = await client.request({ method: 'tools/call', params: { name, arguments: {} } }, CallToolResultSchema);
expect(result.isError).toBeFalsy();
expect(result.structuredContent).toEqual({ data: 'hello' });
}

// The listing omission is warned about once per tool at registration.
const warnings = warnSpy.mock.calls.map(call => call.join(' ')).join('\n');
expect(warnings).toContain('outputSchema warning');
expect(warnings).toContain('type "object"');
for (const { name } of cases) {
expect(warnings).toContain(`"${name}"`);
}
warnSpy.mockRestore();
});

/***
* Test: Tool with Output Schema Must Provide Structured Content
*/
Expand Down
Loading