Summary
The client-side callTool (src/client/index.ts, current dist/esm/client/index.js around lines 496–519) runs outputSchema validation against result.structuredContent whenever structuredContent is present, regardless of result.isError. The server-side validateToolOutput correctly short-circuits when isError is true. The asymmetry makes any tool whose error envelope carries structuredContent unusable with outputSchema.
Reproduction
const server = new McpServer({ name: 'x', version: '1.0.0' });
server.registerTool(
'get_things',
{
inputSchema: {},
outputSchema: { things: z.array(z.string()) },
},
async () => ({
content: [{ type: 'text', text: JSON.stringify({ err: 'NOT_FOUND' }) }],
structuredContent: { err: { code: 'NOT_FOUND', message: 'nope' } },
isError: true,
}),
);
Server-side: validateToolOutput returns early because result.isError is true.
Client-side: callTool sees structuredContent, validates against the things schema, throws McpError(-32602, 'Structured content does not match...'). The buyer can never reach the error payload.
Current behaviour in client/index.js
if (validator) {
if (!result.structuredContent && !result.isError) {
throw new McpError(
ErrorCode.InvalidRequest,
`Tool ${params.name} has an output schema but did not return structured content`,
);
}
// Only validate structured content if present (not when there's an error)
if (result.structuredContent) { // <-- missing !result.isError guard
// ...validate...
}
}
The comment above the second if ("not when there's an error") matches the intent, but the guard itself is missing.
Server-side reference
server/mcp.js validateToolOutput:
if (result.isError) {
return;
}
This is the behaviour that should also apply client-side.
Proposed fix
- if (result.structuredContent) {
+ if (result.structuredContent && !result.isError) {
// validate...
}
Two lines. Matches the existing comment and matches the server-side behaviour.
Impact
Any protocol layering structured error envelopes on top of MCP (AdCP's adcp_error, and likely others that rely on structuredContent for programmatic error extraction) is forced to either:
- skip
outputSchema entirely (losing tools/list discoverability and client-side success validation),
- or inflate
outputSchema into a permissive union that accepts both success and error shapes (defeats the point).
The two-line fix removes that tradeoff and makes client/server output validation consistent.
Happy to open a PR if the fix is welcome.
Environment
@modelcontextprotocol/sdk 1.29.0 (checked against current main; behaviour unchanged)
- Found while migrating
@adcp/client off the deprecated server.tool() overload to registerTool.
Summary
The client-side
callTool(src/client/index.ts, currentdist/esm/client/index.jsaround lines 496–519) runsoutputSchemavalidation againstresult.structuredContentwhenever structuredContent is present, regardless ofresult.isError. The server-sidevalidateToolOutputcorrectly short-circuits whenisErroris true. The asymmetry makes any tool whose error envelope carriesstructuredContentunusable withoutputSchema.Reproduction
Server-side:
validateToolOutputreturns early becauseresult.isErroris true.Client-side:
callToolseesstructuredContent, validates against thethingsschema, throwsMcpError(-32602, 'Structured content does not match...'). The buyer can never reach the error payload.Current behaviour in
client/index.jsThe comment above the second
if("not when there's an error") matches the intent, but the guard itself is missing.Server-side reference
server/mcp.jsvalidateToolOutput:This is the behaviour that should also apply client-side.
Proposed fix
Two lines. Matches the existing comment and matches the server-side behaviour.
Impact
Any protocol layering structured error envelopes on top of MCP (AdCP's
adcp_error, and likely others that rely onstructuredContentfor programmatic error extraction) is forced to either:outputSchemaentirely (losingtools/listdiscoverability and client-side success validation),outputSchemainto a permissive union that accepts both success and error shapes (defeats the point).The two-line fix removes that tradeoff and makes client/server output validation consistent.
Happy to open a PR if the fix is welcome.
Environment
@modelcontextprotocol/sdk1.29.0 (checked against current main; behaviour unchanged)@adcp/clientoff the deprecatedserver.tool()overload toregisterTool.