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:
- 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. - 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. - 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.