Error handling in custom MCP servers

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

Almost every MCP error-handling mistake comes from confusing two mechanisms that look similar and behave in opposite ways. Tool execution errors ride inside a normal successful result with isError: true for anything that goes wrong once a call reaches your tool: an API failure, a bad input value, a business rule that said no. Protocol errors are standard JSON-RPC errors for problems at the protocol layer, like a call to a method the server doesn't support or a request the transport can't parse (MCP spec, Tools: Error Handling).

The difference isn't cosmetic. A protocol error fails the JSON-RPC request: the client's SDK sees an error object and typically raises an exception, so the model never receives the content. A tool execution error is a result, so the failure text flows back to the model, which can read it, apologize, retry with different arguments, or ask the user for help. Get this backwards and you either hide recoverable failures from the model or crash requests that should have been graceful. That single choice is what this guide is about. Here's the whole model at a glance:

The failure is...Report it as...The model...
The tool ran and failed (API down, bad input, business rule)A normal result with isError: trueSees the error text and can react
Bad arguments that fail the tool's own inputSchemaNothing to do: the high-level SDK returns an isError result before your handler runsSees it
An unknown or unregistered tool nameNothing to do: the SDK raises a real JSON-RPC error (-32602, "Unknown tool: ...") before your handler runsNever sees it — the request itself fails
Your handler threw an unexpected exceptionNothing to do: the SDK catches it and returns isError: true for youSees the message

Tool execution errors: isError on the result

When a tool runs but can't do its job, return the failure as content and set isError: true. It's still a valid result, so the model receives the text. The spec's own example is an API failure returned this way (MCP spec, Tools: Error Handling). It looks like this:

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "resultType": "complete",
    "content": [{ "type": "text", "text": "Failed to fetch weather: API rate limit exceeded" }],
    "isError": true
  }
}

The resultType is new in the 2026-07-28 revision and required on every result; "complete" is what an ordinary tool result carries, error or not. On the current SDK release line — the 2.0.0 TypeScript packages and Python's mcp 2.0.0 — it's filled in for you. The snippets below are pinned to the pre-2.0.0 SDK line (@modelcontextprotocol/sdk v1.29.0 and mcp v1.28.1), which predates resultType and negotiates an earlier protocol revision; they illustrate the throw-vs-isError pattern itself, which carries over unchanged, not the resultType plumbing.

You rarely build that object by hand. In the TypeScript SDK, a tool registered with registerTool can either return a result with isError: true or just throw: the server's tool dispatch catches any thrown error and wraps it as a result with isError: true and the error message as text (typescript-sdk src/server/mcp.ts, createToolError).

server.registerTool(
  "get_weather",
  { description: "Get current weather", inputSchema: { location: z.string() } },
  async ({ location }) => {
    const res = await fetch(`https://api.example.com/weather?q=${location}`);
    if (res.ok) return { content: [{ type: "text", text: await res.text() }] };

    // The API failed: hand that back as an error result, not a thrown exception.
    return { isError: true, content: [{ type: "text", text: `Weather API returned ${res.status}` }] };
  },
);

The Python SDK behaves the same way. With the in-SDK FastMCP (from mcp.server.fastmcp import FastMCP), just raise: the low-level server catches the exception and returns a CallToolResult with content set to the message and isError=True (python-sdk src/mcp/server/lowlevel/server.py, _make_error_result).

@mcp.tool()
async def get_weather(location: str) -> str:
    res = await client.get(f"https://api.example.com/weather?q={location}")
    if res.status_code != 200:
        # Raising is enough: the server converts it to isError=True.
        raise RuntimeError(f"Weather API returned {res.status_code}")
    return res.text

The practical rule: let ordinary failures raise, and only construct an isError result yourself when you want to control the exact message the model sees (for example, to strip internal details). Don't catch an exception just to re-raise it, and don't turn a tool failure into a JSON-RPC error unless the request itself was invalid.

There's one case that looks like a third kind of error and isn't an error at all. When a tool can't finish because it needs something from the user or the client's model, a form to fill in, a URL to visit, a sampling call, it returns a result with resultType: "input_required": the requests it needs fulfilled go in inputRequests, plus an opaque requestState string. The client gathers the input and retries the original call under a new JSON-RPC id, passing inputResponses and echoing requestState back untouched. This is the Multi Round-Trip Requests pattern the 2026-07-28 revision introduced, and it's how elicitation (form and URL modes), sampling, and roots all reach the client now (MCP spec, Multi Round-Trip Requests).

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "github_login": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Please provide your GitHub username",
          "requestedSchema": {
            "type": "object",
            "properties": { "name": { "type": "string" } },
            "required": ["name"]
          }
        }
      }
    },
    "requestState": "AEAD-protected blob"
  }
}

Three constraints matter if you write one of these by hand. Only tools/call, resources/read, and prompts/get may return an InputRequiredResult. A server must not include an inputRequests entry for a capability the client didn't declare in that request's _meta. And requestState comes back through the client, so treat it as attacker-controlled and integrity-protect it (HMAC or AEAD) if it influences authorization or business logic.

Through 2025-11-25 this worked differently for URL-mode elicitation: the server raised a real JSON-RPC error with code -32042 to tell the client to run an elicitation flow first. That code is retired, and implementations of the current revision must not emit it.

Protocol errors: real JSON-RPC error codes

Protocol errors are for when the request can't be honored at all. MCP doesn't invent its own numbering scheme here; it uses JSON-RPC 2.0. The full set of predefined codes is short and defined by the JSON-RPC 2.0 spec. They are:

CodeNameMeaning
-32700Parse errorInvalid JSON was received.
-32600Invalid RequestThe JSON sent is not a valid Request object.
-32601Method not foundThe method does not exist or is not available.
-32602Invalid paramsInvalid method parameter(s).
-32603Internal errorInternal JSON-RPC error.
-32000 to -32099(Server error)Reserved for implementation-defined server errors.

That's the entire predefined set. Application failures use isError, not a numeric code, so ignore any "MCP-specific" code like -32800 you might see elsewhere.

The implementation-defined range in that last row is the one place non-standard codes legitimately live, and 2026-07-28 split it in two. -32000 to -32019 is a legacy sub-range: it holds codes implementations allocated before there was a policy, which is where the SDK-defined ConnectionClosed (-32000) and RequestTimeout (-32001) sit (typescript-sdk types.ts, ErrorCode). Those keep working, but nothing new should be allocated there. -32020 to -32099 is reserved for the specification, which currently defines exactly three codes, all of them about the per-request metadata: -32020 HeaderMismatch, -32021 MissingRequiredClientCapability, and -32022 UnsupportedProtocolVersion. If you need a code of your own, allocate it outside JSON-RPC's reserved -32768 to -32000 band entirely.

Raising a protocol error deliberately

You rarely need to. The SDK raises a real -32601 when a client calls a JSON-RPC method your server doesn't support, and it turns bad tool arguments into an isError result for you rather than a raised error. Reach for a raised McpError only when you genuinely want to fail the request at the protocol layer instead of returning a tool result.

In TypeScript, throw McpError with an ErrorCode (typescript-sdk types.ts, McpError). For example:

import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";

throw new McpError(ErrorCode.InvalidParams, "start must be before end");

In Python, raise McpError with an ErrorData payload (python-sdk shared/exceptions.py, McpError). For example:

from mcp import McpError
from mcp.types import ErrorData, INVALID_PARAMS

raise McpError(ErrorData(code=INVALID_PARAMS, message="start must be before end"))

But remember the default: inside a tool handler, raising McpError (like any exception) gets caught and returned as an isError result. Protocol errors are usually the right layer for request routing and argument validation, not for reporting that a tool's real work failed.

Import the result types from mcp.types

A common copy-paste failure in Python is importing the tool-result types from the wrong module. CallToolResult (which carries the isError field, defaulting to False) and TextContent both live in mcp.types, not in mcp.server.models (python-sdk types.py, CallToolResult).

from mcp.types import CallToolResult, TextContent

If you use the FastMCP decorator style shown above, you usually don't touch these types at all; you return a plain string or raise. You only need them when working with the low-level Server API and constructing results explicitly.

Server-side logging

Returning a safe message to the model and recording the full failure for yourself are two separate jobs. On stdio you don't get to blur them: the server can't write anything to stdout that isn't a valid MCP message, and the client is free to capture, forward, or ignore whatever the server writes to stderr (MCP spec, stdio transport). So send logs to stderr (or a file), never to stdout, or you corrupt the message stream.

import logging, sys
logging.basicConfig(stream=sys.stderr, level=logging.INFO)

Log the request context you'll actually need to debug (tool name, a request identifier, the exception) and keep secrets and full payloads out of it. The message you put in the isError result is what the model and user see; the stderr log is what you see. They shouldn't be the same string when the real error mentions internal hosts, tokens, or stack traces.

If you'd rather not stand up and grep this logging yourself, AgentCat records tool calls, their arguments, and errors per session for production MCP servers, so you can see which tool actually failed and with what input without piecing it together from stderr.

Recovery patterns are ordinary application code

Retries with backoff, circuit breakers, and cached-fallback responses all earn their keep against flaky external dependencies, but none of them are MCP concepts. They're the same patterns you'd apply to any HTTP client, and MCP doesn't define or require them. The one part MCP does put on you is what to do when recovery fails: convert the final failure into an isError result with a message the model can act on ("the upstream service is unavailable, try again shortly"), rather than letting it surface as a raw stack trace. For a runnable server you can adapt, the SDK ships example servers (typescript-sdk examples, pinned to v1.29.0).

Common questions

Why do I see "Method not found" at startup?

Clients probe optional capabilities such as prompts/list and resources/list. If your server doesn't declare those capabilities, that probe legitimately returns -32601. It's not a bug in your tool handling. Declare and implement the capability only if you actually offer prompts or resources.

Should a validation failure be isError or a JSON-RPC error?

Both reach the model as isError, just by different routes. If the arguments are structurally wrong (missing a required field, wrong type), the high-level SDK catches it during schema validation before your handler runs and returns an isError result for you (in the TypeScript SDK the text includes Input validation error: ...).

If the arguments are well-formed but semantically bad (a date range where start is after end, a record that doesn't exist), you return isError: true yourself. Either way the model sees the message and can correct itself.

Do I need a try/except around every tool?

No. Both SDKs already catch uncaught exceptions from tool handlers and return them as isError results. Add your own handling only to control the exact message (for example, to hide internal details) or to implement retries. A bare try/except that catches and re-raises adds nothing.

What HTTP status code should a Streamable HTTP server return on error?

The JSON-RPC error travels in the response body regardless, and for a tool that failed the HTTP response is still 200 with an isError result or a JSON-RPC error in the body. Don't try to map every JSON-RPC error onto an HTTP status.

A handful of transport-level cases do have a status the 2026-07-28 revision pins down. Auth uses the OAuth statuses: 401 for unauthorized, 403 for forbidden or an invalid Origin. The three request-metadata errors all pair with 400 Bad Request, and they're the ones you'll actually hit while bringing a server up: -32020 HeaderMismatch when an HTTP header disagrees with the matching _meta value in the body, -32021 MissingRequiredClientCapability when the request needs a capability the client didn't declare, and -32022 UnsupportedProtocolVersion when the server doesn't implement the version the request asked for. A request missing a required _meta field is malformed, so 400 with -32602. An unknown method is 404 Not Found with -32601.