Skip to content

Error responses ​

The gateway serves two protocols, so it has two error vocabularies: HTTP with a JSON body on the management API and the OAuth server, JSON-RPC on the MCP edge. A client that treats them as one shape will mis-parse the other.

The four body shapes ​

ShapeEmitted byExample
{"detail": "<message>"}A domain rule on the management API{"detail": "server 'echo' not found"}
{"detail": [{"loc": …, "msg": …, "type": …}]}Request validation, always 422{"detail":[{"loc":["body","url"],"msg":"invalid URL","type":"value_error"}]}
{"error": "<code>", "error_description": "…"}The OAuth server (RFC 6749 §5.2) and auth-failure lockouts{"error":"invalid_grant","error_description":"code expired"}
{"jsonrpc":"2.0","id":…,"error":{"code":…,"message":…,"data":…}}The MCP edge{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"insufficient_credits"}}

Note that detail is a string on a domain error and a list of objects on a validation error. Branch on the type, not on the key.

Two MCP-edge responses are HTTP-shaped rather than JSON-RPC, because they are refused before any JSON-RPC message is parsed: an unknown endpoint answers 404 {"error": "not found"}, and a dead or foreign session id answers 404 {"error": "session not found"}. A session is foreign when it was opened on another endpoint or by another caller: only the user or API key that opened a session can use or end it, and the answer does not reveal that it exists. A body over the size cap (max_request_bytes, 4 MiB by default) answers 413 with a JSON-RPC error, id: null and code -32600, before anything else is checked. A browser request from an origin the endpoint does not allow answers 403 with the same JSON-RPC shape, before its credential is checked; on /api/v1 the same 413 carries the usual {"detail": …} object.

Every path under /oauth/ has its own fixed body cap of 64 KiB, whatever max_request_bytes says, because those routes answer before anyone signs in. A larger body, declared or streamed, answers 413 in the OAuth shape before it is parsed: {"error": "invalid_request", "error_description": "request body exceeds the 65536-byte limit"}.

Management API status codes ​

StatusMeaning
400A precondition of the operation does not hold.
401No credential, or an invalid or expired one.
403A signed-in user who is not an administrator, an endpoint-kind API key, or a cookie-authenticated mutation that failed the Origin/Referer check.
404The named resource does not exist.
409The write conflicts with an existing resource, such as a name already in use.
422Request validation, including a body name that differs from the path name.
429An auth-failure lockout, with Retry-After.
503A dependency the operation needs is unavailable, with Retry-After when it is the live-state store.

A signed-in user who is not an administrator receives 403, so a browser client can tell "session expired" (401, sign in again) from "not an administrator" (403) by status alone. GET /api/v1/me answers for any signed-in user and says whether they are one.

JSON-RPC codes on the MCP edge ​

CodeNameCauseHTTP
-32700parse errorThe body is not JSON, or not a JSON-RPC object400
-32600invalid requestA stateless client sent a JSON-RPC response400
-32602invalid paramsA missing or malformed params._meta envelope, an unknown log level, or bad method parameters400
-32601method not foundThe method is not registered404
-32020header mismatchA mirrored header disagrees with the body — see Request envelope400
-32022unsupported protocol versiondata carries supported and requested400
-32000upstream failureThe upstream call failed, was denied by the metering hook, or needs an upstream connection the caller has not made200
-32800cancelledThe call was cancelled: by the client's notifications/cancelled (a modern client sends it in a request of its own, with the same credentials as the call), by the client closing the connection, or by the total-timeout ceiling200

-32000 and -32800 arrive with HTTP 200 because they are the result of a successfully routed call, and on a streamed tools/call they are the last SSE event rather than the response body.

-32000 carries no discriminator ​

The message is free text: your own reason verbatim from a metering denial that carries no user_message (one that does is answered as a tool result with isError: true instead), or a connect hint naming the URL when a per_user upstream is not connected. There is no data field and no machine-readable code, so a client that must distinguish these cases matches on the message. Two facts make that more robust than it sounds:

  • A metering denial is audited as status: denied with denied_reason set to the same string.
  • A missing upstream connection is audited as status: error with error_message: upstream_not_connected.

So the audit log disambiguates after the fact even where the wire response does not.

Retrying ​

ResponseRetry?
429Yes, after Retry-After.
503Yes, with backoff.
-32000 upstream failureOnce, with backoff. The gateway already retried an expired upstream token internally.
-32800 cancelledNo. Cancellation was deliberate or the ceiling was reached.
-32020, -32022, -32602, 422No. Fix the request.
401No. Get a new credential first.

Next steps ​