Errors
A tool error is a successful JSON-RPC result with isError: true that the model reads and recovers from. A protocol error is a JSON-RPC error response the model never sees.
Return a tool error with isError
Return isError: true from a tool handler to report a failure the model should see.
import { McpServer, ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const notes = new Map([['welcome', 'Read tools.md first.']]);
const server = new McpServer({ name: 'notes', version: '1.0.0' });
server.registerTool(
'read-note',
{
description: 'Read a note by its id',
inputSchema: z.object({ id: z.string() })
},
async ({ id }) => {
const note = notes.get(id);
if (!note) {
return {
content: [{ type: 'text', text: `No note with id "${id}". Known ids: ${[...notes.keys()].join(', ')}` }],
isError: true
};
}
return { content: [{ type: 'text', text: note }] };
}
);Every call on this page comes from an in-memory Client connected to the server above — Test a server shows that wiring. Call read-note with an id that does not exist.
const missing = await client.callTool({ name: 'read-note', arguments: { id: 'drafts' } });
console.log(missing);The tools/call response is an ordinary result:
{
content: [
{
type: 'text',
text: 'No note with id "drafts". Known ids: welcome'
}
],
isError: true
}The model reads the message, sees welcome in it, and retries with an id that exists. Put the recovery hint in text — it is the only thing the model has to work with.
Let a thrown exception become a tool error
Throw instead: the SDK catches anything a tool handler throws and converts it to the same isError: true shape.
server.registerTool(
'delete-note',
{
description: 'Delete a note by its id',
inputSchema: z.object({ id: z.string() })
},
async ({ id }) => {
if (!notes.delete(id)) {
throw new Error(`Cannot delete "${id}": no such note`);
}
return { content: [{ type: 'text', text: `Deleted "${id}"` }] };
}
);Call delete-note with the same missing id.
const thrown = await client.callTool({ name: 'delete-note', arguments: { id: 'drafts' } });
console.log(thrown);The exception's message becomes the result's content text:
{
content: [ { type: 'text', text: 'Cannot delete "drafts": no such note' } ],
isError: true
}A throw and an explicit isError: true produce the same shape; returning explicitly gives you control over content. The SDK skips outputSchema validation on any isError result.
Throw a protocol error
Resource, prompt, and completion callbacks have no isError channel. Throw ProtocolError(code, message, data?) when the request itself is wrong.
server.registerResource(
'note',
new ResourceTemplate('note://{id}', { list: undefined }),
{ description: 'A note by its id' },
async (uri, { id }) => {
const noteId = String(id);
if (!/^[a-z]+$/.test(noteId)) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Note ids are lowercase letters, got "${noteId}"`);
}
const note = notes.get(noteId);
if (!note) throw new ResourceNotFoundError(uri.href);
return { contents: [{ uri: uri.href, text: note }] };
}
);Coming from v1?
ProtocolError and ProtocolErrorCode replace v1's McpError and ErrorCode — run the codemod, then see the upgrade guide.
Read the resource with an id the callback rejects.
try {
await client.readResource({ uri: 'note://42' });
} catch (error) {
const { code, message } = error as ProtocolError;
console.log({ code, message });
}readResource rejects with a ProtocolError carrying the wire fields:
{ code: -32602, message: 'Note ids are lowercase letters, got "42"' }On the wire this is a JSON-RPC error response — { code, message, data? } instead of a result — and the host's MCP client handles it; the model never sees it. A non-ProtocolError exception thrown from one of these callbacks surfaces as -32603 Internal Error with the exception's message.
Choose between tool error and protocol error
Pick by audience. The model drives tools/call, so a failure it can recover from — a missing record, a bad argument, a transient upstream fault — belongs in isError: true with a message that names the fix. The host application drives resources/read, prompts/get, and completion/complete, so failures there are protocol errors addressed to the caller's code.
The handler decides which channel exists:
- A tool handler produces only tool errors. The SDK converts every exception it throws — including a thrown
ProtocolError— into anisError: trueresult.UrlElicitationRequiredErroris the one exception; it propagates as a JSON-RPC error so the host can open the URL — see Elicitation. - A resource, prompt, or completion callback produces only protocol errors. Throw a
ProtocolError.
Use the typed error subclasses
Each subclass picks the right ProtocolErrorCode and packs structured data for you. ResourceNotFoundError takes the missing URI — the read callback above already throws it for a well-formed id with no note.
try {
await client.readResource({ uri: 'note://archived' });
} catch (error) {
const { code, message, data } = error as ResourceNotFoundError;
console.log({ code, message, data });
}The error carries the requested URI in data and the code the spec mandates for a resources/read miss:
{
code: -32602,
message: 'Resource not found: note://archived',
data: { uri: 'note://archived' }
}Three more subclasses cover the other structured protocol errors:
UrlElicitationRequiredError(elicitations)—-32042; the only error a tool handler can propagate. See Elicitation.UnsupportedProtocolVersionError({ supported, requested })—-32022;data.supportedlets the peer pick a version and retry.MissingRequiredClientCapabilityError({ requiredCapabilities })—-32021;data.requiredCapabilitiesnames exactly what the client must declare.
Match these by code and data shape, not by instanceof — instanceof fails across separately bundled copies of the SDK.
Look up a protocol error code
ProtocolErrorCode is the complete vocabulary of wire codes the SDK sends and recognizes.
| Member | Code | Meaning |
|---|---|---|
ParseError | -32700 | The message was not valid JSON. |
InvalidRequest | -32600 | The message was not a valid JSON-RPC request. |
MethodNotFound | -32601 | No handler is registered for the method. |
InvalidParams | -32602 | The params are wrong — also the code for a resources/read miss. |
InternalError | -32603 | The handler threw something other than a ProtocolError. |
ResourceNotFound | -32002 | Receive-tolerated only: the SDK answers a resources/read miss with -32602 and never emits -32002. Throw ResourceNotFoundError instead. |
MissingRequiredClientCapability | -32021 | The request needs a capability the client did not declare. |
UnsupportedProtocolVersion | -32022 | The requested protocol version is unknown to the receiver or unsupported by it. |
UrlElicitationRequired | -32042 | The tool needs the user to visit a URL before it can complete. |
-32021 and -32022 are new in protocol revision 2026-07-28 — see Protocol versions.
Recap
isError: trueis a successful JSON-RPC result carrying a tool failure the model reads and acts on.- A tool handler that throws produces the same
isError: trueresult; the exception'smessagebecomes thecontenttext. - A tool handler cannot produce a protocol error — only
UrlElicitationRequiredErrorescapes. ProtocolErrorand its subclasses, thrown from resource, prompt, and completion callbacks, become JSON-RPC error responses the model never sees.ResourceNotFoundErrorand the other subclasses pick the code and pack structureddata; match them bycodeanddata, notinstanceof.- The table above lists every
ProtocolErrorCodemember.