DEV Community

Jeff
Jeff

Posted on Originally published at powerduck.com

When an MCP Tool Errors: Structured Tool Errors vs a Raw Exception

Quick answer: An MCP tool should return a structured, machine-readable error when something goes wrong, not let a raw exception bubble up. The agent calling the tool has to decide whether to retry, fix its parameters, or ask the user, and it can only do that with a stable error code and message. A thrown stack trace is for your logs; a structured error is the contract your tool exposes to the model.

That distinction became concrete when an internal tool that looked up a customer hit a rate limit from the upstream service. The first implementation let the HTTP client throw, so the MCP server returned a generic internal error with a stack trace. The agent, seeing nothing it could reason about, apologized to the user and gave up, even though a rate limit is plainly retryable. After we converted failures into structured results with explicit codes, the same agent recognized the retryable case, waited, and recovered without involving the user.

What the MCP error model expects

The protocol distinguishes between a successful tool result and an error result, and it also separates protocol-level errors from tool-level errors. A tool that runs but cannot fulfill the request should return a result flagged as an error with content the model can read. That is different from crashing the request with a JSON-RPC error. The former keeps the agent in control; the latter treats every failure as a transport or server fault.

Here is the shape of a tool-level error result.

The human-readable text helps the model phrase anything it tells the user, while the structured block gives deterministic fields the agent can branch on without parsing prose.

Why raw exceptions are a poor contract

When an exception escapes, the server typically serializes a generic message and possibly internal details. That fails on two axes at once: it leaks information you may not want exposed, and it gives the model no vocabulary for the failure's category. The table below maps how the two approaches change the caller's behavior.

Failure Raw exception Structured error
Rate limit Agent gives up; may expose internals Code marked retryable; agent waits and retries
Invalid parameters Generic 500-style error VALIDATION_FAILED with the offending field
Not found Looks like a server crash NOT_FOUND; agent reports absence correctly
Permission denied Unclear whether to retry FORBIDDEN; agent asks the user, never retries
Upstream outage Stack trace with no guidance UPSTREAM_UNAVAILABLE, retryable flag set

Mapping internal failures to stable codes

Define a small set of error codes that represent the decisions the caller can make, and translate every internal exception into one of them at the boundary. Keep the exception detail in server logs, not in the tool response.

Inside the handler, throw the specific ToolError at the point where you know what went wrong, and wrap the whole call so unknown exceptions become a safe generic error rather than a leak.

Use the input schema for the preventable failures

Not every problem belongs in an error result. Bad input that a strict input schema can reject should be caught before the handler runs, through schema validation, rather than returned as a tool error. Reserve structured tool errors for failures that only become meaningful at execution time: a missing upstream record, a permission scope, a conflict, or a retryable service limit. The combination of a tight schema and a small error vocabulary keeps the agent from ever having to guess.

Write the retry decision into the contract

The single most valuable field is the retryable flag. Models cannot reliably infer whether a timeout or a 409 is safe to retry from prose alone. Set it explicitly and, where you know a duration, include it. Then test the agent's behavior against each code: a retryable limit should trigger a bounded retry with backoff, a validation error should change the next request, and a forbidden error should stop and surface to the user. Those tests pin the contract the same way a client test pins an HTTP API.

FAQ

Should a tool failure be a JSON-RPC error instead?

Usually no. JSON-RPC errors are for protocol or transport problems. If the tool ran but could not complete the task, return a result with isError set to true and structured content, so the agent stays in control and can reason about the failure.

Is it safe to include the exception message in the response?

Avoid raw exception text, which can leak internal paths, hostnames, or library details. Put a stable, user-safe message in the tool result and keep the full exception in your server logs.

How does the agent know whether to retry?

Do not rely on it to infer retryability from prose. Provide an explicit retryable boolean and, when available, a retry duration. Test that retryable codes trigger bounded retries and non-retryable codes stop immediately.

What about invalid arguments?

Catch those with a strict input schema before the handler runs whenever possible. Keep structured tool errors for execution-time failures such as not found, forbidden, conflict, and upstream limits.

This is part of the MCP Server Guide — start there for the full set of decisions that make a tool reliable for an agent.

Top comments (0)