Skip to content

client.callTool validates structuredContent against outputSchema even when isError is true (asymmetric with server) #1943

Description

@bokelley

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions