error.code, and soft-behavior envelopes inside result.content[0].text — and a single failure can touch one, two, or all three. Triage from the outside in: HTTP status → JSON-RPC code → soft envelope.
For the projection grammar itself, see the JMESPath guide. For per-tool parameters and freshness budgets, see the Tools Reference.
Quick orientation
- HTTP status is the transport-layer answer. Most JSON-RPC replies — successes AND errors — come back as HTTP 200, per the JSON-RPC 2.0 convention. The handler only escalates the status when the failure is something a generic HTTP client must react to (auth, daily cap, service unavailable) and benefits from a
Retry-After/WWW-Authenticateheader. - JSON-RPC
error.codeis the application-layer answer. Nine codes are in use:-32001,-32002,-32003,-32004,-32029,-32600,-32601,-32602,-32603. The handler never emits another code — if you see one, treat it as a wire bug and file an issue. - Soft-behavior envelopes are the high-volume failure mode.
tools/callsucceeds at the JSON-RPC layer (HTTP 200, noerrorfield), but the JSON sitting insideresult.content[0].textcarries a_budget_exceededor_jmespath_errordiscriminator. Clients that only inspect the JSON-RPC envelope will silently treat these as successes — parseresult.content[0].textand check for a leading_key before consuming the payload as data. - Executed calls stay charged.
_budget_exceeded,_jmespath_error, and tool-execution errors (-32603) all happen after the tool has run, so they consume the Pro daily-quota slot. Only pre-dispatch failures such as daily-cap rejection or quota-reservation service failure avoid charging the slot. - Every 401 sets
WWW-Authenticatewithrealm="worldmonitor"and aresource_metadatapointer at/.well-known/oauth-protected-resource. RFC 9728-aware clients (Claude Desktop, MCP Inspector) bounce through the OAuth flow on this header without further intervention.
JSON-RPC error codes
Subsections below give the literal payload, the trigger site, and what to do for each code.
-32001 — Unauthenticated / invalid credentials
Fires from the api/mcp/auth.ts emission sites, always paired with HTTP 401 and a WWW-Authenticate header. Those sites collapse to four user-visible triggers, in order of how clients hit them:
- No
Authorizationbearer AND noX-WorldMonitor-Key— the client called/mcpwith no credentials. Authorization: Bearer <token>but<token>is invalid or expired — the token didn’t resolve to a context (revoked / TTL expired / never minted by/api/oauth/token).X-WorldMonitor-Key: <key>but<key>isn’t in the valid set — the API key is wrong.- OAuth token resolves but the Pro MCP token row is missing or cross-bound — the
mcpTokenIdno longer maps to the userId. Typically a revoke from Settings → Connected MCP clients. Not in this list, deliberately: a caller whose subscription is inactive. A confirmed free account (a configured no-row result or an internally consistent tier-0 row) is admitted onto the free-account allowance (see-32029and-32002below). A provider-confirmed lapse follows the same free-account path: the shared entitlement gate treats the ended coverage as a confirmed free state. An expired or disabled paid entitlement without that lapse returns terminal-32002at HTTP 403 because re-authenticating cannot fix it. An entitlement lookup that fails or cannot be verified returns a retryable-32603at HTTP 503 — it is an availability failure, not a verdict about the caller.
-32001 from these auth-resolution sites carries a machine-readable data payload: reason is always no-account, nextStep is one sentence of guidance, and upgradeUrl is where to send the user. Branch on error.data.reason, not on the message text. (One defensive fail-closed guard in api/mcp/handler.ts emits a bare -32001 / 401 with no data — it should be unreachable in practice, so treat a data-less -32001 like case 1.)
Example wire payload (case 1):
/api/oauth/token with a fresh authorization code, OR refresh-grant with a valid refresh token). For API-key clients, double-check the X-WorldMonitor-Key header — user-issued wm_ keys and operator-issued enterprise keys must go in that header, NOT as Authorization: Bearer. The WWW-Authenticate header’s resource_metadata pointer is the canonical place to start the discovery flow from scratch.
-32002 — Terminal entitlement denial
The entitlement denials are HTTP 403, Cache-Control: no-store, and no WWW-Authenticate header — the credential is valid, the entitlement is not, and re-authenticating cannot change that. Two triggers, distinguished by data.reason:
reason: "lapsed-subscription" — the rare race where a lapse lands after the entitlement pre-check but before a Pro tool’s downstream fetch. A provider-confirmed lapse already present at the pre-check is reclassified onto the restricted free-account path, so it does not emit this denial there. A later downstream BillingDenialError is re-emitted by api/mcp/dispatch.ts with X-Billing-Verification: subscription_lapsed because that in-flight Pro operation can no longer complete.
reason: "upgrade-required" — a signed-in account on the free allowance called a tool outside it. The free allowance covers free-account (direct cache-read) tools only; every subscription tool — anything with server-side execute logic, live-fetch or not — stays Pro-only. Fires from api/mcp/dispatch.ts before any allowance slot is charged, so a refused call costs the caller nothing. No X-Billing-Verification header (nothing is being verified). The same reason: "upgrade-required" also fires from the auth pre-check in api/mcp/auth.ts when a non-free entitlement is insufficient (an expired or disabled paid row) — there the message is Subscription not active. instead.
-32002 emission is different: resources/read of worldmonitor://account/mcp-allowance with a credential that is not user-bound returns -32002 with message Account allowance status requires a user-bound credential. inside HTTP 200 and with no data payload — it is a plain JSON-RPC error, not an entitlement denial.
What to do. Do not retry and do not re-run OAuth — both will reproduce the same denial. Surface the state to the user: the subscription must be renewed (worldmonitor.app → Pricing) before MCP access resumes. Renewal takes effect within seconds of the provider webhook; no re-authentication is needed afterwards. While the same subscription is still being verified (provider re-check in flight), the server instead returns a retryable -32603 at HTTP 503 with X-Billing-Verification: renewal_verification_pending|renewal_verification_failed and a dynamic Retry-After — see -32603 below.
-32003 — Required data inputs unavailable
A tool ran but the upstream seeds it needs could not be read (a Redis blip, or a seeder that has not yet published). Returned inside HTTP 200 with a structured data payload — the only code that names its unavailable inputs:
retryable: true is the contract. If a specific tool returns -32003 consistently, the seeder behind the named input is down; check status.worldmonitor.app.
-32004 — SSE replay cursor not found
GET /mcp with Last-Event-ID asked to resume a stream this edge instance does not hold — the bounded in-memory replay buffer expired, or the reconnect landed on a different instance. Returned at HTTP 404. Re-issue the original POST instead of resuming; treat replay as loss-tolerant transport recovery, not durable storage. (A replay GET missing Accept: text/event-stream gets HTTP 406, and one missing a valid Mcp-Session-Id gets HTTP 400 with -32600, before this check is reached.)
-32029 — Rate limited (per-minute, daily cap, or free allowance)
All the rate-limit triggers share this code; the HTTP status disambiguates the per-minute case, and error.data.reason disambiguates the free allowance from the Pro daily cap.
Per-minute throttle — HTTP 200. Sliding-window limiter at 60 requests / minute keyed per legacy operator (env_) API key, per user (combined across a user’s OAuth tokens AND dashboard wm_… keys — one shared budget, not stackable), or per IP for anonymous public discovery. Credentialed requests are limited after auth. Credential-less public discovery methods (initialize, notifications/initialized, ping, tools/list, prompts/list, prompts/get, resources/list, resources/templates/list, logging/setLevel) and anonymous public-resource reads are served without auth but still pass through the anonymous discovery limiter. Credential-less data/quota methods, or metadata methods outside that public set, do not use anonymous discovery — they fail closed with -32001 / HTTP 401. Comes back as a JSON-RPC error inside HTTP 200 because the limiter is upstream of any per-id correlation. Fails OPEN on Upstash transient errors — single spikes in limiter-backend latency won’t take the API down.
When the per-minute limiter rejects, the handler emits a durable mcp.rate_limit_hit telemetry event with an allowlisted identity shape. The plan-limit scanner uses that event for sustained-burst notices; it does not infer customer-facing MCP burst notices from raw Upstash limiter internals.
The message text identifies which limiter fired. Three distinct strings:
Example payload (per-user variant):
wm_…-key validation is capped at 60 checks / 60 s per IP (a flood of invalid keys gets -32029 Too many requests at HTTP 429 before any account lookup), and the anonymous get_sources free-tier path has its own fail-closed 10 calls / minute / IP ceiling — its rejection is -32029 at HTTP 429 with message Free-tier rate limit. Max 10 unauthenticated tool calls per minute per IP. plus IETF RateLimit/RateLimit-Policy and Retry-After headers. Fail-closed means an unreachable limiter backend refuses the call (-32603 / 503, Rate-limit service temporarily unavailable. Try again.) rather than serving it unmetered.
Daily cap — HTTP 429 + Retry-After. A hard daily cap (default 50 quota-consuming calls / UTC day) is enforced by an atomic Redis reservation BEFORE the tool runs, so the exact call that crosses the boundary rejects. Only tools/call and resources/read of a data-bearing URI-template instantiation (the auth-symmetric resources path) count. Dashboard-issued wm_… API-key callers use the 50/day default. OAuth allowances are plan-resolved; API Starter and API Business currently use that same default, while enterprise OAuth can be unlimited. Legacy deployment-allowlisted operator keys are the only authenticated class outside the daily reservation path. Exempt from the daily cap: describe_tool, get_sources, tools/list, prompts/list, prompts/get, resources/list, resources/templates/list, logging/setLevel, initialize, notifications/initialized, ping, resources/read of a public resource such as worldmonitor://seed-meta/freshness, and the authenticated status read worldmonitor://account/mcp-allowance. (These methods still count toward the authenticated per-minute limit, except anonymous get_sources, which uses its separate 10/minute/IP fail-closed limit.)
Retry-After. A signed-in account without a subscription gets a small free taste of the cached-data tools, metered by two fail-closed counters: 3 idle-gap request windows per UTC day and an absolute ceiling of 5 calls per UTC day. A new request window opens after 15 minutes of inactivity — MCP sessions have no task boundary, so wall-clock idleness is the only honest one. Whichever counter is exhausted first produces the same denial. Live-fetch tools are not covered at all and return -32002 / 403 reason: "upgrade-required" (above) without spending a slot.
-32001 / 401. An exhausted quota is not an authentication failure, and answering it with the re-authenticate envelope sends RFC 9728-aware clients into a loop: OAuth succeeds, the retry 401s again, forever. Honour Retry-After or upgrade.
Retry-After (the value is seconds-until-UTC-midnight). If the MCP daily cap is the binding constraint for batch work, use the REST/API path where appropriate, or contact Enterprise for a custom MCP limit.
Paid-plan customers also receive account notices and bounded-cadence email when sustained usage crosses a plan threshold. These notices never imply an automatic upgrade, automatic overage charge, or automatic move into API Business; support or checkout action is explicit.
-32600 — Invalid request envelope
Fires when the request body isn’t valid JSON, isn’t a JSON object, lacks a string method field, or carries an invalid id (a string id longer than 256 UTF-8 bytes is rejected with Invalid request: invalid id). The SSE-replay preconditions reuse the code at non-200 statuses: a replay GET without Accept: text/event-stream gets -32600 at HTTP 406, and one without Mcp-Session-Id gets it at HTTP 400. Strictly a client encoder bug — well-formed JSON-RPC clients will never see this in production.
method and (for any method besides notifications/*) an id field. If you see -32600 from a known-good client library, file an issue against this server — it should never reach you.
-32601 — Method not found
The method field was a string but didn’t match any handler. Methods this server speaks: initialize, notifications/initialized, ping, tools/list, tools/call, prompts/list, prompts/get, resources/list, resources/templates/list, resources/read, logging/setLevel.
capabilities block of your initialize response. Note that resources/subscribe is not implemented (the initialize handshake advertises resources.subscribe: false explicitly) — clients that try it get -32601.
-32602 — Invalid params
The most common error code, shared across tools/call, prompts/get, resources/read, and logging/setLevel. The main triggers:
message — it always tells you what was missing or wrong. For tools, names are in tools/list. For prompts, names + argument schemas are in prompts/list. For resources, the concrete URIs are in resources/list and the parameterised URI templates are in resources/templates/list. For logging/setLevel, valid levels are the RFC 5424 subset listed above.
-32603 — Internal error
Four distinct conditions share this code; the HTTP status (and, for billing verification, the X-Billing-Verification header) disambiguates whether retry is reasonable.
HTTP 200 — tool-execution failure. A tool dispatcher threw. Most commonly: every Redis key the tool reads returned null (cache_all_null — transient Redis blip or a still-warming seeder), or a sibling internal fetch failed mid-call. Pro quota is not rolled back: the tool already executed, so retrying consumes another slot.
Retry-After: 5 — service unavailable. Either the OAuth resolution service threw (Convex transient blip), or MCP_INTERNAL_HMAC_SECRET is unset on the deploy (a misconfig — Pro tool calls cannot sign their downstream fetches without it), or the Pro daily-quota reservation Redis pipeline failed with something other than cap-exceeded.
message text identifies the trigger. The distinct strings:
Recovery for the first three is identical (honour
Retry-After: 5). The billing-verification rows differ: they carry an X-Billing-Verification header (renewal_verification_pending|renewal_verification_failed, or entitlement_verification_unavailable for the backend-unreachable row), a data.code mirroring it, and — for the two renewal-verification codes — a dynamic Retry-After between 1 and 60 seconds sized to the actual provider re-check; honour the header value rather than assuming 5 (the backend-unreachable row uses a fixed Retry-After: 5). The renewal-verification codes mean the subscription recently expired locally and the server is re-confirming it with the billing provider before denying; a renewed subscription typically recovers within one or two retries.
HTTP 200 — resources/read payload was empty or unparseable. Defensive guard inside resources/read for the never-should-happen case where the inner tools/call dispatcher returned no content[0].text or non-JSON text.
What to do. For HTTP 200 tool errors: retry once after ~1 second; if a specific tool returns -32603 consistently, check status.worldmonitor.app for the relevant seeder. For HTTP 503: honour Retry-After. For the resources/read defensive case: file an issue — it indicates a dispatcher contract violation upstream of your call.
HTTP statuses
Every status the MCP handler can return. Most JSON-RPC replies — including most errors — are HTTP 200 by convention; the table calls out the cases where the handler escalates.
One HTTP status appears that isn’t a JSON-RPC error:
- 405 with an empty body comes from method-validation BEFORE JSON-RPC. The handler accepts
POST(the JSON-RPC path),GET(theLast-Event-IDSSE replay channel, or — with no SSEAccept— the 200 markdown server guide),HEAD(same routing as GET: replay ack, guide headers, or a JSON 200 ack on non-/mcppathnames used by uptime probes), andOPTIONS(CORS preflight). An SSE-flavouredGETwith noLast-Event-IDreturns 405 (no standalone stream is offered). Anything else gets 405 +Allow: POST, GET, HEAD, OPTIONS. The endpoint enforces noOriginallowlist: it advertises wildcard CORS and authenticates by explicitAuthorization/X-WorldMonitor-Keyheader, so browser-origin clients (any origin) are accepted.
Soft-behavior envelopes
Soft envelopes are the high-volume failure mode and the single most common parsing bug for clients that only inspect the JSON-RPC layer. Thetools/call returns HTTP 200 with no error field, the result.content[0].text parses as JSON, and the resulting object has a leading-underscore discriminator key. Always:
- Parse
result.content[0].textas JSON. - Check whether the parsed object has a
_budget_exceededor_jmespath_errorkey at its top level. If yes, treat as an error and do not consume sibling fields as data. - Otherwise, treat the parsed object as the tool’s normal response (cache tools wrap it as
{ cached_at, stale, data }; RPC tools return their declared shape).
_budget_exceeded — response too big for the per-tool budget
Every tool declares a per-tool output budget (_outputBudgetBytes) sized to keep responses inside the typical agent context window. When the serialised response exceeds that budget after all per-tool filters, summary, and JMESPath have been applied, the dispatcher swaps the oversized payload for this envelope — still inside the normal MCP result, still HTTP 200, still no isError:
text payload:
_budget_exceeded: true— discriminator. Always literallytrue; never present on success responses.budget_bytes: number— the per-tool budget the response was checked against.actual_bytes: number— UTF-8 byte length of the serialised response after all narrowing.hint: string— recovery advice. The text varies based on whether the caller already passed ajmespathargument; both phrasings tell you to narrow the result.
country, since, limit), or both. The JMESPath guide has worked examples for projection. The summary: true flag (every cache tool accepts it) returns a server-built counts-and-samples digest that is always under budget.
_jmespath_error — projection failed
Three failure kinds, all returned with the same envelope shape. The _jmespath_error value is a string (not an object); its content is <kind>: <details>. The discriminator is the leading kind token before the first :.
original_keys is the top-level keys of the unprojected response (bounded at 50 entries, with a ...<N more> sentinel when truncated). It is included specifically so the LLM can self-correct on its next tools/call without refetching — the projection failed, but the tool fetch itself succeeded.
Quota. The Pro daily-quota slot is NOT rolled back. The tool fetch succeeded; the user-supplied projection is what failed. A bad expression consumes one quota slot per attempt, which is why original_keys exists — to make the retry self-correcting in one extra call rather than guesswork over N.
The three kinds:
expression_too_long
The JMESPath expression itself exceeds 1024 UTF-8 bytes (JMESPATH_MAX_EXPR_BYTES). The cap is intentionally generous — typical real expressions are 50–200 bytes — and a 1024+ byte expression almost always indicates accidental copy/paste of a full payload into the argument.
invalid_expression
The expression parsed by the JMESPath engine threw — bad syntax, unclosed bracket, unknown function. The details after the kind token is the parser’s error message verbatim.
[?country == "Iraq"]) when JMESPath wants single quotes ([?country == 'Iraq']), and (b) using bare numeric literals ([?deathsBest > 0]) when JMESPath wants backticks ([?deathsBest > \0`]`). The JMESPath guide covers both pitfalls.
projection_too_large
The expression parsed and ran, but the projected output exceeded 256 KB (JMESPATH_MAX_OUTPUT_BYTES) after stringification. Almost always indicates a runaway multiselect-hash or multiselect-list duplicating fields across a large array.
[?...]), or slice the result ([0:N]). Pipe combinators (see example 12 in the JMESPath guide) compose well here.
Other tool-specific envelopes
A handful of tools return their own application-level error envelopes insidecontent[0].text rather than via JSON-RPC -32602. These are documented per-tool in the Tools Reference — the catalog calls them out so a client can recognise the pattern:
describe_toolreturns{ "error": "missing_tool_name", "hint": "..." }or{ "error": "unknown_tool", "requested": "...", "available": [...] }. Quota-exempt — bad input does not consume a quota slot. See Tools Reference →describe_tool.
_budget_exceeded, _jmespath_error) for the catalog-class envelopes and off a top-level error: string for per-tool envelopes.
Roadmap
- Auto-summarize on budget exceed. A future protocol revision may have
_budget_exceededresponses ship a server-built summary inline (one annotated content block in addition to the envelope) for the subset of tools where a summary is well-defined. Deferred until production telemetry justifies the per-tool tradeoff.
See also
- MCP Server overview — endpoints, auth modes, OAuth setup, plans and quotas.
- JMESPath Projection Guide — projection grammar + 12 worked examples; the right place to learn how to fix
_jmespath_errorand recover from_budget_exceeded. - MCP Tools Reference — per-tool parameters, response shapes, and per-tool soft envelopes (e.g.
describe_tool). - MCP Quickstart — five-minute zero-to-first-call onboarding.
- JSON-RPC 2.0 spec — the wire envelope shape the catalog references throughout.
- RFC 9728 — OAuth 2.0 Protected Resource Metadata — what the
WWW-Authenticateresource_metadatapointer means.
