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.types import McpError

@server.call_tool()
async def call_tool(name: str, arguments: dict):
    if name == "get_user":
        user_id = arguments.get("user_id")

        if not user_id:
            raise McpError(
                code=-32602,
                message="user_id is required"
            )

        user = await db.get_user(user_id)
        if not user:
            raise McpError(
                code=-32603,
                message=f"User {user_id} not found"
            )

        return [TextContent(type="text", text=user.to_json())]

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:

@server.call_tool()
async def call_tool(name: str, arguments: dict):
    try:
        # Validation errors
        validate_arguments(name, arguments)

        # Business logic errors
        result = await execute_tool(name, arguments)

        return result

    except ValidationError as e:
        # Client's fault - bad parameters
        raise McpError(code=-32602, message=str(e))

    except NotFoundError as e:
        # Resource not found
        raise McpError(code=-32603, message=str(e))

    except ExternalAPIError as e:
        # External service failure
        raise McpError(
            code=-32603,
            message=f"External service unavailable: {e}"
        )

    except Exception as e:
        # Unexpected error - log it
        logger.exception("Unexpected error in tool call")
        raise McpError(
            code=-32603,
            message="An unexpected error occurred"
        )

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")

@server.call_tool()
async def call_tool(name: str, arguments: dict):
    logger.info(f"Tool called: {name}", extra={"arguments": arguments})

    try:
        result = await execute_tool(name, arguments)
        logger.info(f"Tool succeeded: {name}")
        return result

    except Exception as e:
        logger.error(
            f"Tool failed: {name}",
            extra={"arguments": arguments, "error": str(e)},
            exc_info=True
        )
        raise

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