In MCP, a tool execution error and a protocol error use different channels. If a tool received a valid call but could not find a record, query an API, or apply a business rule, return a result with isError: true and a message the model can use. If the JSON-RPC request is malformed or the protocol state cannot serve it, return a protocol error to the host.
The decision comes down to one question: who can fix the problem? The model can adjust arguments, choose another tool, or explain that a record does not exist. The host must repair a method, a protocol version, a JSON-RPC message, or the connection setup. Mixing the channels makes an agent retry something the model cannot fix, or turns a recoverable tool failure into a generic host error.
This article extends the TypeScript MCP server for coding agents. That tutorial shows how to register and connect tools. This page focuses on the failure contract after the call reaches the server.
Key Takeaways
- Use
isError: truefor execution failures the model can understand and correct.- Use a JSON-RPC error for a problem in the request itself or in protocol state.
- Write the
isErrormessage as a short observation: what failed and what next step is valid.- Keep stack traces, tokens, sensitive arguments, and internal IDs out of model-facing text.
- Test visibility: a tool failure should be a result, while a protocol failure should reach the host as an error.
isError and protocol errors reach different consumers
The Model Context Protocol error-handling specification separates two families. Protocol errors use the error field in a JSON-RPC response for problems such as malformed requests, unknown methods, and server failures. Execution errors appear inside the tool result with isError: true; the message can help the model correct its parameters or change course.
| Failure | Channel | Who should act | Typical next step |
|---|---|---|---|
| A date has the wrong format | result.isError: true |
Model and runtime | Correct the argument and retry within the run budget |
| A record does not exist | result.isError: true |
Model | Choose another identifier or explain the absence |
| An external API rejected the operation | result.isError: true |
Runtime and model | Classify a retry, use an alternative, or stop |
| Invalid JSON-RPC | error in JSON-RPC |
MCP host | Repair the message or client |
| Unsupported method or version | error in JSON-RPC |
MCP host | Adjust negotiation or configuration |
| The server cannot serve requests | JSON-RPC error or host policy | Host and operation | Restore the server before continuing |
This table describes the wire-level contract. SDKs may normalize some paths differently. The current MCP TypeScript SDK error guide shows, for example, that an exception thrown inside a tool handler becomes an isError: true result, while resource and prompt callbacks can throw ProtocolError to produce a JSON-RPC response. Read the behavior of the version you installed and test the shape your client actually receives.
Visibility is the important detail. A result with isError: true is still a well-formed response to a tool call. The model receives the text and can make the next decision. A JSON-RPC error has no result; the MCP client or host that coordinates the session receives the failure.
How should you write an error message an agent can use?
A model-facing message should contain the observable fact and the allowed correction. It does not need the whole exception. The SDK documentation uses a missing record and includes known identifiers so the model can choose a valid value. The message works because it describes a change the next call can make.
Prefer this shape:
No note with id "drafts". Use one of: welcome, release-notes.
Avoid these two:
Error: ENOTFOUND db.internal:5432 at QueryRunner.execute (query-runner.ts:81)
The operation failed. Please try again.
The first exposes infrastructure without saying whether a retry can help. The second gives the model no new observation. A useful message answers four questions in a few words: which tool failed, what condition was observed, what may change on the next call, and when the agent must stop.
Error text is still unstructured content. An open MCP repository issue about schema-governed tool errors discusses the risk of stack traces, internal identifiers, and personal data crossing this boundary. The issue is implementer discussion, not proof that every server leaks data. The practical response is still clear: redact the text before returning it and treat it as model context.
How do you return a tool error in TypeScript?
The small shape below adapts the current TypeScript SDK example. The type deliberately avoids an SDK import so that the response contract remains visible. Registration method names vary between versions.
type TextContent = {
type: "text";
text: string;
};
type ToolResult = {
content: TextContent[];
isError?: true;
};
const notes = new Map([
["welcome", "Read tools.md first."],
["release-notes", "The latest release is staged."],
]);
export async function readNote(id: string): Promise<ToolResult> {
const note = notes.get(id);
if (!note) {
return {
content: [{
type: "text",
text: `No note with id "${id}". Choose another id.`,
}],
isError: true,
};
}
return { content: [{ type: "text", text: note }] };
}
In the real server, connect this result to the tool-registration method of the SDK you installed. A known tool failure can also throw the tool exception that the SDK converts to isError: true, but an explicit return gives you control over the text and keeps the policy visible in the code. Do not assume that throwing any ProtocolError inside a tool handler will produce a JSON-RPC error: the TypeScript SDK error page says that tool handlers use the result channel.
Validate arguments before the external effect. If id must be a string, the input schema should reject other types. If the string is valid but no record matches it, execution reached the tool and can return an observation with isError: true. That distinction keeps a transport validation problem separate from a domain absence.
When is a JSON-RPC error the right choice?
Use the protocol channel when the message is not a valid tool execution request or when the host must repair the session. Examples include invalid JSON, an unsupported method, a message that fails the tools/call schema, and a protocol version the server does not support.
A protocol response has this conceptual shape:
{
"jsonrpc": "2.0",
"id": 7,
"error": {
"code": -32602,
"message": "Invalid tool call request"
}
}
The error code must follow the version and library contract. Do not use -32602 as a universal label for every application failure. A business rule such as “an order cannot be canceled after it was sent” belongs in the tool result, even if the runtime uses an exception internally to produce it.
The MCP Python SDK error guide makes the same distinction in another language: a tool exception appears as is_error=True and the model reads the message, while an MCP exception produces a JSON-RPC error that the client handles. The implementation changes, but the question stays the same: did the model's chosen tool fail, or did the host send an invalid protocol request?
How do you test that the agent receives the right failure?
An error test should check more than whether some exception occurred. It should prove the shape observed by the consumer. The fixture below does not open an MCP connection; it locks down the local contract so the integration layer can be tested against the real SDK.
import assert from "node:assert/strict";
const result = await readNote("missing");
assert.equal(result.isError, true);
assert.match(result.content[0].text, /Choose another id/);
assert.equal("error" in result, false);
const protocolFailure = {
jsonrpc: "2.0",
id: 7,
error: { code: -32602, message: "Invalid tool call request" },
};
assert.equal("result" in protocolFailure, false);
assert.equal(protocolFailure.error.code, -32602);
In a suite that calls the real MCP server, add four checks:
- Call a tool with a valid domain argument that does not exist and confirm
isError: true. - Confirm that the text contains no stack trace, absolute path, token, or secret payload.
- Send a malformed JSON-RPC request and confirm that the client receives an error, not a
CallToolResult. - Trigger a known upstream failure and confirm that no mutation happened before the error result.
If the client turns a case into isError: true when you expected a JSON-RPC rejection, do not change the test to hide the difference. Check the SDK version, the handler path, and the wire log. The contract-testing guide for TypeScript MCP servers covers connection and schema checks; add this case as a focused extension for failure semantics.
How do you avoid an infinite retry after isError?
isError: true makes a failure visible to the model, but it does not decide whether the model should try again. The runtime still needs to distinguish argument correction, a dependency retry, a tool switch, and a stop. A domain error may be correctable once. The same fingerprint repeated several times is a reason to stop or ask for intervention.
Do not return a message that encourages a retry for an operation that may already have produced an effect. If a write response was lost, query external state or use an idempotency key before exposing the action as repeatable. The MCP error result should describe what was observed, not invent that the mutation did not happen.
Also, do not use isError as access control. Authorization, allowlists, human confirmation, and server scope must block the effect before or inside the service policy. The error channel reports the decision; it does not make a dangerous tool safe.
MCP error-handling checklist
- Did the call arrive as a valid
tools/callrequest? - Did the failure happen during tool execution or before it?
- Can the model correct the input, choose another tool, or use the observation?
- Does a model-facing failure use
isError: true? - Is JSON-RPC error reserved for the host to repair the request or protocol?
- Is the message short, actionable, and free of sensitive data?
- Does the runtime limit retries, record the reason, and avoid repeating an uncertain mutation?
- Does the test check the shape received by the model and the shape received by the host?
- Does observed behavior match the installed SDK version?
The goal is not to send every error to the model. Each consumer should receive the information it can use. An isError: true result is an observation from tool execution. A JSON-RPC error is a failure in the client-server contract. When that boundary appears in both code and tests, an agent is less likely to treat structural unavailability as an invitation to try again.
How this article was produced
Samuel Fajreldines is responsible for this article. The research compared the MCP specification, the current TypeScript SDK error documentation, the Python SDK error guide, and documented model-visible error behavior in an agent SDK. Public discussions were used only for vocabulary, concerns, and test ideas. The diagram is an original SVG, and the result-shape fixture was checked locally with Node.js without starting an MCP server, calling a model, or accessing an external API. AI assistance supported discovery, organization, drafting, localization, and consistency review. The author uses RemoteCode as a work tool, but it did not run production tests or provide operational experience.
Sources consulted
- Model Context Protocol, “Error Handling”, retrieved November 14, 2026.
- MCP TypeScript SDK, “Errors”, retrieved November 14, 2026.
- MCP Python SDK, “Handling errors”, retrieved November 14, 2026.
- OpenAI Agents SDK for Python, “Running agents”, retrieved November 14, 2026.