Response shape
A tool call that runs returns an object with exactly one of result or
error — the same split as the JSON-RPC envelope one level up, applied to the
tool payload. But a refusal can also arrive on a second channel, so the unwrapper
that works everywhere checks both, in this order:
function unwrap(result) {
if (result.isError) {
// the call never completed — the text is prose, not JSON
throw Object.assign(new Error(result.content?.[0]?.text ?? "call failed"), {
channel: "isError",
});
}
const body = result.structuredContent;
if (body.error) throw Object.assign(new Error(body.error.message), body.error);
return body.result;
}
Only the in-band throw carries the structured error — code included, which
is what a per-code retry decision is made on. The isError channel has no
structured detail to carry: that throw is tagged channel: "isError" so a
handler can tell the two apart, and its only message is the prose text — which
is why it gets one blind retry rather than a decision. See
Two channels.
error is {code, message, field?, details?}. Branch on code; message is
for humans and its wording is not a contract.
code | Meaning | Retry? |
|---|---|---|
invalid_input | Your arguments were refused. Often with field naming the one to change — market-data sets it consistently, the research and messaging tools only sometimes. | No — fix the request |
not_entitled | The backend refused your subscription's access to this data. Distinct from an unrecognised key (HTTP 401) and from a backend your key cannot reach at all (-32003) — see Operations → Errors. | No |
upstream_unavailable | A dependency is failing or slow. | Yes, with backoff |
internal | Our side is wrong: a malformed record, or a guard we can't satisfy. A retry returns the identical error, which is why it isn't worth one — see Operations → Errors. | No |
Two channels, and which one you get
This is the one a shared response handler gets wrong. A refusal reaches you on one of two channels, and which one depends on where it was raised, not on which tool you called:
| How the call was refused | Channel |
|---|---|
| The tool ran and refused — a bad date range, an unanalysable query, an entitlement check | In-band: isError: false, payload {error: {code, …}} |
An argument failed the tool's inputSchema before the body ran | isError: true with structuredContent: null and a plain-text message — except on the market-data tools, which map these in-band as invalid_input |
| An internal guard raised an unclassified error (e.g. an unsupported filter-operator combination) | isError: true, plain text — it is indistinguishable from a bug at the boundary, so it is not dressed up as a refusal |
| A failure raised below the tool's own error handling — including a transient one, like a timeout reaching a search index | isError: true, plain text. Not every isError is a bug, which is why it is worth one retry — see Operations → Errors |
The in-band case is the one that surprises people, because isError is false:
{
"isError": false,
"structuredContent": {
"error": {
"code": "invalid_input",
"message": "the comparison window this request implies falls outside …",
"field": "compare"
}
}
}
A client that branches only on isError reads that as a success and then finds
no data where it expected some. A client that branches only on the payload
crashes on the isError cases, because structuredContent is null there.
Handle both and you do not need to know which channel any given refusal uses —
which is why the unwrap above leads with isError.
An empty answer is a success
An unknown id returns a null or empty payload; a filter matching nothing returns
zero rows. Both arrive on the result branch.
Treating that as a failure — or reading it as "EDITED has no data for this retailer" — is the specific mistake this contract exists to prevent. It is worth being blunt about because it has already cost real debugging time: a fixed-shape client that unwraps at the wrong level returns an empty collection rather than raising, so the call looks like a legitimate "no data" answer when in fact the data was there and the reader was wrong.
content vs structuredContent
They serve different readers and deliberately differ.
structuredContentis the machine contract: always the envelope, always matching the publishedoutputSchema.content[0].textis what an LLM sees. On success it is the payload serialized without the envelope. On a refusal it is the plain message — prose, not JSON.
Read structuredContent for anything your code branches on.
Do not parse content as the payload when structuredContent is missing.
The shapes differ, so the fallback silently yields a different answer instead of
failing. The one legitimate read of content is on the isError path, where
structuredContent is null and the text is the only message there is — which
is exactly what unwrap does above, and no further.
Two consequences worth knowing if you have existing code reading content:
- On a successful call to a tool returning a list or object,
JSON.parse(result.content[0].text)still yields the payload — so such code does not break on the happy path. It breaks on refusals, on both channels: the text is prose, soJSON.parsethrows, and on the in-band channel it does so whileisErrorisfalse. - For a tool whose payload is a plain string,
content[0].textis that string, soJSON.parsethrows even on success.
Both are reasons to read structuredContent.result rather than to patch the
parse.
Python: read .structured_content, not .data
If you use the fastmcp client, prefer .structured_content. Every tool's
schema carries x-fastmcp-wrap-result and the client unwraps against it — which
works on success and yields None on a refusal, with the parse error swallowed
and is_error still False. So .data turns a refusal into a silent None.
def unwrap(result):
"""The `.data` equivalent that does not swallow a refusal."""
body = result.structured_content
if body is None: # schema violation caught above the tool body
raise RuntimeError(result.content[0].text if result.content else "no payload")
if "error" in body:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
return body["result"]
See also
- Operations → Errors — HTTP statuses, JSON-RPC codes, and retry guidance
- Concepts → Tools — discovery, calling, and batch queries
- Each tool's page in the Reference publishes its
outputSchema, which describes both branches