Building MCP Servers

Error Handling in MCP

4 min read

An MCP server has an unusual audience for its errors: a language model. It cannot read your logs, it will not open a stack trace, and it will happily invent a plausible answer if your failure message tells it nothing. Error handling here is less about crashing safely and more about writing failures the model can act on.

MCP Error Types

MCP uses the standard JSON-RPC 2.0 codes for general protocol failures:

CodeNameDescription
-32700Parse ErrorInvalid JSON received
-32600Invalid RequestMalformed request
-32601Method Not FoundUnknown method called
-32602Invalid ParamsWrong parameters — including a resource URI that doesn't resolve
-32603Internal ErrorServer-side error

On top of those, the specification defines its own codes and partitions the implementation-defined range:

CodeNameWhen
-32020HeaderMismatchEnvelope metadata disagrees with the request body
-32021MissingRequiredClientCapabilityThe request needs a capability the client didn't declare; data.requiredCapabilities lists them
-32022UnsupportedProtocolVersionThe request's protocol version isn't one you speak

Two rules about the ranges matter when you invent your own codes:

  • -32000 to -32019 is closed. These were handed out before the current policy and new implementations should not use them at all. The notable retiree is -32002 (resource not found), now replaced by -32602 — accept -32002 from older servers, but never emit it.
  • -32020 to -32099 belongs to the spec. Don't allocate in it. Application-specific codes go outside the JSON-RPC reserved range (-32768 to -32000) entirely.

Raising Errors in Tools

When a tool encounters an error, raise it with a descriptive message:

from mcp.shared.exceptions import MCPError


@mcp.tool()
async def get_user(user_id: str) -> str:
    """Look up a user by their id."""
    user = await db.get_user(user_id)
    if not user:
        raise MCPError(
            code=-32603,
            message=f"User {user_id} not found"
        )
    return user.to_json()

MCPError lives in mcp.shared.exceptions, not in mcp.types — it is an exception, not a wire type. The code travels to the client as the JSON-RPC error code and the message travels to the model, which is the part that matters (see below).

Note what is missing: there is no check that user_id was supplied. It is a required parameter with no default, so the schema marks it required and the SDK rejects the call before your function runs. Hand-written presence checks on required parameters are dead code.

Error Categories

Every exception your code can raise needs to land on exactly one of these paths. Decide the mapping once, in one place, and the handler below writes itself:

Routing an exception to the right error code

caller's faultnot foundexternalanything elseException raisedSomewhere inside your tool hand…-32602Bad argumentsMissing field, wrong type, out …-32602Nothing to returnValid shape, but the resource U…-32603Upstream failedDatabase down, API timed out, r…-32603UnexpectedA bug. Log the trace, return so…Model can retryIt has enough information to fi…Model should stopRetrying identically will fail …

The right-hand column is the part that matters. A model that retries a genuinely-missing record burns turns for nothing, and a model that gives up on a transient timeout loses work it could have recovered. Your message is what decides which happens.

Handle errors at the appropriate level:

@mcp.tool()
async def search_orders(query: str) -> str:
    """Search customer orders by free-text query."""
    try:
        return await orders.search(query)

    except NotFoundError as e:
        raise MCPError(code=-32603, message=str(e))

    except ExternalAPIError as e:
        raise MCPError(
            code=-32603,
            message=f"Order service unavailable: {e}"
        )

    except Exception:
        # Log the detail, tell the model only what it can act on
        logger.exception("Unexpected error in search_orders")
        raise MCPError(
            code=-32603,
            message="An unexpected error occurred"
        )

The ValidationError arm is gone on purpose: argument validation now happens in the SDK against your generated schema, and it already returns -32602. Catching it yourself means maintaining a second validator that can disagree with the first.

User-Friendly Error Messages

The model reads your error message and nothing else. Write it for a reader who has no access to your infrastructure and has to decide, right now, whether to try again:

Same failure, two messages

python
Model has to guess
1raise MCPError(
2 code=-32603,
3 message="ERR_DB_CONN_FAIL"
4)
5
6# The model sees an opaque token.
7# Common outcomes: it retries the identical call
8# in a loop, or it apologises to the user and
9# invents an answer from memory.
Model knows what to do
1raise MCPError(
2 code=-32603,
3 message=(
4 "Could not reach the knowledge base (connection "
5 "timed out after 5s). This is usually temporary — "
6 "retrying once is reasonable. If it fails again, "
7 "tell the user the knowledge base is unavailable "
8 "rather than answering from memory."
9 )
10)
11
12# States what broke, whether it is transient,
13# and what to do if the retry also fails.

That last clause — rather than answering from memory — is doing real work. Without an instruction to fall back on, a model that has exhausted its retries tends to fill the gap itself, and a confidently wrong answer is worse than a visible error.

Logging for Debugging

Always log errors with context:

import logging

logger = logging.getLogger("mcp-server")


@mcp.tool()
async def search_orders(query: str) -> str:
    """Search customer orders by free-text query."""
    logger.info("tool called", extra={"tool": "search_orders", "query": query})
    try:
        result = await orders.search(query)
        logger.info("tool succeeded", extra={"tool": "search_orders"})
        return result
    except Exception as e:
        logger.error(
            "tool failed",
            extra={"tool": "search_orders", "query": query, "error": str(e)},
            exc_info=True,
        )
        raise

On stdio, send this to stderr or a file, never stdout — stdout is the protocol channel, and one stray line of log output there corrupts the JSON-RPC stream.

Next, we'll build a complete server lab exercise. :::

Quiz

Module 2 Quiz: Building MCP Servers

Take Quiz
Was this lesson helpful?

Sign in to rate