Error envelope and reason codes#
When the gateway refuses or cannot complete a call, it says why in a machine-readable way. Every error it generates carries the same envelope fields, wherever the protocol puts them (cmd/maqpna-gateway/envelope.go):
| Field | Meaning |
|---|---|
domain |
Always maqpna.com for gateway errors. Agent-to-agent (A2A) protocol errors from a remote agent keep a2a-protocol.org |
reason |
<token>[:<qualifier>]. The token is from the published list below; the qualifier adds machine-readable context: budget_exceeded:agent/day, dlp:github_token, missing_scope:tools:github |
detail |
Free text when the raw reason was not a token, for example a rule's own reason text (destructive) or approval_denied by bob@acme.eu |
policy, rule |
The deciding policy and rule, when a policy decided; otherwise the source: policy revocation/<id> for the kill switch, policy dlp with the profile as rule, policy budget with rule <scope>-<window>, rule pin/<server>/<tool> for tool pinning |
The reason list is append-only: no token is removed or renamed within v1alpha1. A test (TestReasonEnumLiterals) fails the build when a reason literal in the gateway is not in the list.
Where the envelope lives#
| Surface | Shape |
|---|---|
Model Context Protocol (MCP), POST /mcp/{server} |
JSON-RPC error.data = {domain, reason, detail?, policy?, rule?, …}, plus call-specific keys such as approvalId, expiresAt, connectURL |
| A2A JSON-RPC binding | Same as MCP |
| A2A HTTP+JSON binding | google.rpc.Status: {"error": {code, status, message, details: [ErrorInfo]}}; ErrorInfo.reason is the upper-case token (BUDGET_EXCEEDED), domain is maqpna.com, the rest in metadata (including jsonrpcCode) |
Model calls, /llm/{route}/v1/* |
OpenAI style {"error": {message, type, code, reason, domain}}. type is the error class (maqpna_policy_denied, …), code keeps its pre-v0.1.0 value for SDK compatibility, reason is the published token |
Admin API, /v1/* |
{"error": "<message>"}. Adopting the envelope there is Planned for v1beta1 of the admin API |
How a client should read an error#
flowchart TD
E[Error from the gateway] --> D{"domain == maqpna.com?"}
D -- no --> P["Protocol or upstream error:<br/>handle per MCP or A2A spec"]
D -- yes --> T["token = reason up to the first colon"]
T --> G{token group}
G -- "approval_required" --> W["Wait or retry with<br/>X-Maqpna-Approval-Id"]
G -- "auth: unauthorized, missing_token,<br/>token_expired, ..." --> R["Refresh the session token<br/>(re-read MAQPNA_TOKEN_FILE)"]
G -- "rate_limited" --> B["Back off: Retry-After"]
G -- "policy, approvals, routing" --> S["Stop and report:<br/>a retry will get the same answer"]
G -- "availability:<br/>upstream_unavailable, audit_unavailable" --> X["Retry with backoff"]
- Check
domain. Onlymaqpna.comerrors use this list; A2A reuses JSON-RPC codes such as-32001(TaskNotFound) with its own meaning. - Take the token: the text before the first
:. Never match onmessageordetail. - Decide by group: refresh credentials for authentication errors, back off for
rate_limited(honourRetry-After), retry later for availability errors, and stop for policy decisions.
JSON-RPC codes and HTTP status#
| Code | Default reason | Meaning | HTTP (MCP) |
|---|---|---|---|
-32001 |
policy_denied |
Denied by policy, kill switch, DLP, budget, pin, or a denied or expired approval | 200 (in-band); 403 for notifications and streams |
-32002 |
approval_required |
Approval pending (async mode) | 200 (409 ABORTED on A2A HTTP+JSON) |
-32003 |
unauthorized |
Missing or invalid token, missing scope, unknown server | 401, 403 or 404 |
-32020 |
header_mismatch |
MCP 2026-07-28 headers disagree with the body | 400 |
-32021 |
missing_client_capability |
A required client capability was not declared | 400 |
-32022 |
unsupported_protocol_version |
Revision not governed; data.supported lists alternatives |
400 |
-32700, -32600, -32601, -32602, -32603 |
parse_error, invalid_request, method_not_found, invalid_params, internal_error |
Standard JSON-RPC | 400, 413 (body too large), 502 (upstream), 503 (audit unavailable) |
Model-route error types: maqpna_unauthorized (401/403), maqpna_policy_denied (403), maqpna_approval_required (403), maqpna_revoked (403), maqpna_dlp_blocked (403), maqpna_egress_denied (403), maqpna_budget_exceeded (402), maqpna_rate_limited (429), maqpna_not_found (404), maqpna_upstream_error (502), maqpna_audit_unavailable (503) and invalid_request_error (400).
The reason list#
| Group | Tokens |
|---|---|
| Authentication and authorization | unauthorized, missing_token, invalid_token, token_expired, token_signature_invalid, token_audience_mismatch, missing_scope, insufficient_scope, header_mismatch, principal_unverified, svid_invalid, svid_required, svid_trust_domain, svid_mismatch, tenant_mismatch |
| Policy | policy_denied, no_applicable_policy, default_action, rate_limited, revoked, revocation_list_unavailable, budget_exceeded, dlp, guard, guard_unavailable, tool_definition_changed, tool_unpinned, tool_definition_unverified, cedar_unavailable, cedar_error, rego_unavailable, external_policy_error, egress_denied, model_not_allowed_for_data_class |
| Approvals | approval_required, approval_denied, approval_expired, approval_invalid, approval_aborted, approval_required_not_supported_for_egress, approval_required_unsupported_for_models, ciba_rejected, ciba_unavailable, user_confirmation_unavailable |
| Routing and resources | unknown_server, ambiguous_server, cross_tenant_server, route_not_found, route_namespace_not_allowed, route_agent_not_allowed, namespace_not_allowed, agent_not_allowed, consent_required, per_user_unavailable, memory_backend_unavailable |
| A2A | unknown_agent, caller_not_allowed, unknown_skill, unknown_method, delegation_depth, delegation_loop, callee_revoked, peer_card_unverified |
| Availability | audit_unavailable, upstream_unavailable, upstream_credential_unavailable, upstream_error, egress_dial_failed, egress_upstream_failed |
| Protocol | parse_error, invalid_request, invalid_params, method_not_found, internal_error, invalid_body, invalid_json, unsupported_protocol_version, missing_client_capability |
Aliases accepted on input and mapped to the published token: expired → token_expired, bad_signature → token_signature_invalid, audience → token_audience_mismatch, guard_blocked → guard, tokens_per_minute → rate_limited. Audit records use the same vocabulary, plus annotations of allowed calls such as intent, model_override and upstream_status.
What you see#
A budget denial on an MCP call:
{"jsonrpc":"2.0","id":12,"error":{"code":-32001,"message":"denied by policy budget rule agent-day: budget_exceeded:agent/day","data":{"domain":"maqpna.com","reason":"budget_exceeded:agent/day","policy":"budget","rule":"agent-day"}}}
The same class of error on a model call (HTTP 402):
{"error":{"message":"agent budget (day) exhausted for team-a/coder: spent 10.000412 of 10.000000 USD","type":"maqpna_budget_exceeded","code":"budget_exceeded","reason":"budget_exceeded","domain":"maqpna.com"}}
The same on the A2A HTTP+JSON binding (HTTP 403):
{"error":{"code":403,"status":"PERMISSION_DENIED","message":"denied by policy budget rule agent-day: budget_exceeded:agent/day","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"BUDGET_EXCEEDED","domain":"maqpna.com","metadata":{"jsonrpcCode":"-32001","policy":"budget","reason":"budget_exceeded:agent/day","rule":"agent-day"}}]}}
The amounts are illustrative; the field names, codes, tokens and message patterns come from deniedError, budgets.go and a2a_rest.go. In the ledger the rule of this denial is written budget/agent-day. The CLI follows the same pattern for its own errors (errors.golden):
$ maqpna approvals list
✗ --gateway is required
Pass --gateway URL, set MAQPNA_GATEWAY_URL, or save it in a context once:
→ maqpna context set NAME --gateway URL --use