isError is one optional boolean on an MCP tool result. In our gateway it has been behind three separate production bugs, and not one of them showed up as an error anywhere: no red log line, no failed request, no exception. In two of the three the assistant confidently told the user that something had worked.
Here is what the flag really means, and the three places we got it wrong.
A tool call has two independent ways to fail
The protocol separates them on purpose. A malformed request, an unknown method or a dead transport is a JSON-RPC error. A tool that ran and failed is a successful JSON-RPC response whose result carries isError: true and a human-readable message in its content blocks. The model is meant to read that message and react, which it cannot do if the host swallows the whole response as a transport failure.
Which means a host has two booleans to track, not one. Our first version tracked one. The call did not throw, so it was green. The error text went into the transcript as ordinary tool output, and a model that reads {"content":[{"text":"403 Forbidden"}]} as a result rather than a failure will summarise it as if the work got done. We had an assistant report a ticket update that the upstream API had refused.
const isErr = !!(ok && result && typeof result === "object" && result.isError);
approvedOutcomes.push({
name: callName,
ok: ok && !isErr,
error: isErr ? textOf(result).slice(0, 300) : undefined,
});ok means the call came back. isErr means it came back with a failure inside. The interesting state is ok && isErr, and it is the one a naive host has no name for.
This is also how a write gets fabricated
HTTP 200 with an errors array
Our first large customer connector was a GraphQL API turned into 84 MCP tools. The obvious mapping is isError: !response.ok, and for REST it is almost right. For GraphQL it is wrong in a way that is hard to notice: GraphQL answers 200 for field-level errors. A query whose every field blew up still comes back 200 with an errors array and data: null. Under that mapping, every GraphQL failure we had was a success.
The fix that suggests itself is worse. If you flag any response with a non-empty errors array as an error, you throw away partial results, which GraphQL produces constantly: one unauthorised field in a large selection set nulls that field and errors on it while the rest of the payload resolves fine. Discard it and the assistant tells the user it found nothing, when it was holding nine tenths of the answer.
So the branch is on the data, not on the errors array:
export function graphqlOutcome(payload: any, httpOk: boolean): [unknown, boolean] {
if (!httpOk) return [payload, false];
const errors = Array.isArray(payload?.errors) ? payload.errors : null;
if (!errors || errors.length === 0) return [payload, true];
const data = payload?.data;
const hasData =
data != null &&
typeof data === "object" &&
Object.values(data).some((v) => v !== null && v !== undefined);
if (!hasData) return [payload, false];
return [
{
...payload,
_warning: `Partial result: ${errors.length} field error(s) — some fields are missing or null; see \`errors\`.`,
},
true,
];
}Four outcomes. Transport failure is an error regardless of body. A clean payload passes through untouched. Errors with no surviving data is an error. Errors with surviving data is a success carrying a _warning string, which matters more than it looks: the model reads the warning and says which fields it could not see instead of inventing values for the nulls. The errors array stays in the payload either way, so the specific field names are there if the model needs them.
The result that is not an error and is still a failure
The third one took the longest. Identity tools (me, get_current_user, viewer) are how an embedded assistant learns who it is acting for, and we cache the answer for the conversation so we are not re-resolving it every turn.
An expired token on some APIs does not produce a 401 and does not produce an errors array. It produces 200 with { "data": { "viewer": null } }. Nothing about that response is an error. isError is false, correctly. And we cached it as a resolved identity, which meant every later turn in that conversation believed it knew the user and quietly skipped the reconnect prompt.
export function isIdentityToolResolved(toolName: string, result: unknown): boolean {
if (!isIdentityToolName(toolName)) return false;
if (result == null) return false;
if (typeof result === "object" && (result as any).isError) return false;
return !isIdentityToolNullResult(toolName, result);
}Two gates. isError rules out the loud failures. A per-tool notion of an empty result rules out the quiet ones, including the case where the null is buried in a JSON string inside a text content block, which is where it usually is.
The same duality shows up in our annotation probe, which calls each read-only tool once to generate a description of what it returns. A try/catch around the call catches exactly half the failures. The other half come back as a perfectly well-formed result with isError set, and without an explicit check the probe records a failure message as a sample of the tool's output.
The rule we ended up with
- Did the call return? That is the transport axis. Exceptions and JSON-RPC errors live here.
- Did the tool succeed? That is
isError. It is only meaningful when the call returned, and it is the axis a host has to surface to the model in words rather than as payload. - Is the payload actually an answer? The protocol has nothing to say here. Whoever wrote the tool knows what its empty result looks like, and that knowledge has to live somewhere.
- Is a partial answer better than none? Usually yes, if you label it. Silent truncation is what turns a degraded result into a wrong one.
What to test
All of this sits at the gateway level, so every MCP hosted on Command+K gets the GraphQL outcome rules and the failure surfacing without writing them. If you are converting a GraphQL API yourself, the conversion docs cover the selection-set side of it, and the three client bugs post covers what breaks one layer below this.
FAQ
- What does isError actually mean in MCP?
- It is an optional boolean on CallToolResult that means the tool ran and failed. It arrives inside a successful JSON-RPC response, not a JSON-RPC error. Protocol failures (bad method, malformed params, transport dead) go in the JSON-RPC error object. Tool failures (upstream 403, missing record, expired token) go in the result with isError: true, because the model is supposed to read the message and decide what to do next.
- Should my MCP server throw or return isError?
- Return isError for anything the model could reasonably act on, and put a readable message in the content blocks. Throw only when the request itself was invalid or the server cannot produce a result at all. If you throw for an expired token, the host sees a transport failure and the model never learns that the user needs to reconnect.
- My GraphQL-backed MCP tool returns HTTP 200 with an errors array. Is that a failure?
- It depends on whether any data survived. GraphQL returns 200 for field-level errors and still fills in the fields that resolved. If some non-null data came back, treat it as a success and tell the model that fields are missing. If data is empty or every field is null, that is a real failure and isError belongs on it.
- Can a tool fail with isError set to false?
- Yes, and it is the hardest case to catch. An identity tool that returns 200, no errors and a null viewer is not an error by any measure the protocol offers, but the assistant now believes it knows who it is talking to. You need a per-tool notion of an empty result on top of isError.