MCP for outboundReference

Three Kinds of MCP Failure Collapse Into One isError Flag

A tools/call probe of our own MCP server: unknown tool, bad args, and a thrown exception all resolve with isError:true — and how an agent tells them apart.

What we ranAn in-process tools/call probe of Kairon's own MCP server at commit 047df22e on 2026-07-22, provoking three failure classes (unknown tool, schema-invalid args, thrown exception) plus the live 401/405 transport refusals — captured against @modelcontextprotocol/sdk 1.29.0.

When a tools/call to our own MCP server fails, @modelcontextprotocol/sdk 1.29.0 collapses three different failures into one envelope: a resolved result with isError: true and a human-readable string. client.callTool does not reject. Unknown tool, bad arguments, and a thrown exception all arrive the same way; which one it was lives in prose.

Three failures, one shape

The probe below provokes each failure against the real per-request server and prints the raw tools/call result. Here is what SDK 1.29.0 returned, verbatim.

A — an unknown tool. Call lead_delete, which is not in the catalogue. callTool resolves:

{
  "content": [
    { "type": "text", "text": "MCP error -32602: Tool lead_delete not found" }
  ],
  "isError": true
}

B — schema-invalid arguments. Call lead_get with {}; the required id is missing, and Zod rejects it. callTool resolves:

{
  "content": [
    {
      "type": "text",
      "text": "MCP error -32602: Input validation error: Invalid arguments for tool lead_get: [\n  {\n    \"expected\": \"string\",\n    \"code\": \"invalid_type\",\n    \"path\": [\n      \"id\"\n    ],\n    \"message\": \"Invalid input: expected string, received undefined\"\n  }\n]"
    }
  ],
  "isError": true
}

C — a real exception. Call lead_get with a valid id, and make the service throw new Error('lead not found in this organization'). callTool resolves:

{
  "content": [
    { "type": "text", "text": "lead not found in this organization" }
  ],
  "isError": true
}

Three unrelated failures — a typo'd tool name, a malformed argument, a backend exception — arrive as the same thing: a resolved result with isError: true and a string. The JSON-RPC code -32602 that distinguishes A and B is inside the text, not a field. C carries no code at all; its text is the raw error.message.

Only the success omits isError.

D — icp_list succeeds. callTool resolves, with no isError key:

{
  "content": [
    { "type": "text", "text": "{\n  \"ok\": true\n}" }
  ]
}

Across the four envelopes we captured, the presence of the isError key was the only structured signal that a call had failed. Everything about why it failed was in prose an agent would have to parse.

Reproduce it

Same method as the probe-your-own-server-over-InMemoryTransport check — build the real server in-process, connect an SDK Client, and read what actually comes back. Drop this in your API package and run it with tsx:

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createMcpServer, type McpToolDeps } from '../src/mcp/mcp-server.js';

const principal = { organizationId: 'org_probe', ownerUserId: 'user_probe' };

// Every domain module answers a benign { ok: true }, EXCEPT leads.get, which
// throws a real domain error so case C captures a genuine thrown exception.
const okModule = new Proxy({}, { get: () => async () => ({ ok: true }) });
const leads = new Proxy(
  {},
  {
    get: (_t, prop) =>
      prop === 'get'
        ? async () => {
            throw new Error('lead not found in this organization');
          }
        : async () => ({ ok: true }),
  },
);
const deps = new Proxy(
  { leads },
  { get: (t, prop) => (prop in t ? (t as Record<string, unknown>)[prop] : okModule) },
) as unknown as McpToolDeps;

async function main(): Promise<void> {
  const server = createMcpServer(deps, principal);
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  const client = new Client({ name: 'error-probe', version: '1.0.0' });
  await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

  const cases = [
    { label: 'A unknown tool', name: 'lead_delete', args: {} },
    { label: 'B bad args', name: 'lead_get', args: {} },
    { label: 'C thrown', name: 'lead_get', args: { id: 'lead_123' } },
    { label: 'D success', name: 'icp_list', args: {} },
  ];
  for (const c of cases) {
    const res = await client.callTool({ name: c.name, arguments: c.args });
    console.log(c.label, JSON.stringify(res));
  }
  await client.close();
}
await main();

The deps are a Proxy returning benign results, with leads.get rigged to throw so case C is a genuine exception rather than a mock TypeError. Swap in your own tool names and the four cases reproduce.

The transport fails earlier, and differently

Two failures never reach the tool layer, so they are not isError results. An unauthenticated POST /mcp is refused with HTTP 401 and a WWW-Authenticate pointer — the same challenge that starts the OAuth discovery chain:

$ curl -s -i -X POST https://app.heykairon.com/mcp -H 'content-type: application/json' \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
HTTP/2 401
www-authenticate: Bearer resource_metadata="https://app.heykairon.com/.well-known/oauth-protected-resource"
content-type: application/json; charset=utf-8

{"error":"unauthorized"}

That body is plain {"error":"unauthorized"}, not JSON-RPC and not an isError result. A GET /mcp (the server is stateless, POST-only) returns HTTP 405 with a JSON-RPC -32000 frame:

$ curl -s https://app.heykairon.com/mcp
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed — /mcp is stateless (ADR-0017); use POST."},"id":null}

Both surface to an agent's HTTP/transport layer, before callTool is in play.

Where isError comes from

Our registrar never sets isError. apps/api/src/mcp/tool-catalogue.ts:40-52 wraps every result as { content: [{ type: 'text', text: JSON.stringify(await tool.call(input), null, 2) }] } and nothing else. So isError: true on one of our results is always the SDK reacting to a thrown or internal exception — the not-found throw, the Zod-validation throw, or a real exception in the tool body — never a value our code chose.

The transport refusals are written by hand, higher up. apps/api/src/mcp/mount-mcp.ts:176-238 writes the 401 {error:'unauthorized'} + WWW-Authenticate, and the two 403 bodies, straight to the HTTP response before any JSON-RPC parsing; mount-mcp.ts:332-345 returns the 405.

What an agent has to handle

Talking to this server, failure shows up in three places, and they need different code:

  1. HTTP status, before JSON-RPC. 401 → re-authenticate and retry. 403 → surface it: the token is valid but the org is behind the paywall (subscription_required) or has no connected LinkedIn account (channel_action.not_connected), so retrying the same call will not help.
  2. A rejected callTool. We saw no rejection from these four cases on 1.29.0, but a JSON-RPC error frame would reject the promise — handle it as a thrown error, not a result.
  3. A resolved result with isError: true. This is the common case, and the flag alone does not tell you what to do. Read the text: -32602: Tool ... not found → the tool name is wrong, give up or pick another; -32602: Input validation error → fix the arguments and retry; anything else → a backend error you may be able to retry transiently.

An agent that checks isError and treats it as one condition mis-handles the two most common cases — it retries an unknown-tool call that will never succeed, or gives up on a bad-argument call it could have fixed.

Scope and caveats

This is SDK 1.29.0's high-level McpServer, verified only for this version and this server. It catches the not-found and the Zod-validation McpError at the same boundary it catches a thrown tool body, and returns all three as isError results. A different SDK, a different version, or a hand-rolled low-level server could return JSON-RPC error frames instead — which would reject callTool. Check your own before assuming.

Reporting tool execution errors via isError is by spec (2025-06-18): execution errors are reported that way so the model can see and react to them. The narrow finding here is that protocol misuse — an unknown tool, bad arguments — lands in the same channel, with the distinguishing -32602 code only in prose. That mirrors the sibling audit of this server's tool annotations, where 0 of 40 tools carry the machine-readable hints a client would gate on: same theme, the field an agent needs is missing and the information sits in free text instead.

Only our own server was probed, in-process — no customer account, no LinkedIn call. Run the probe against yours and read the four envelopes.