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 (allowance-exhausted) from the plan-resolved daily cap (quota-exceeded). The per-minute rejections carry no data.
Per-minute throttle — HTTP 200. Sliding-window limiter 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. The per-user threshold is plan-resolved from planLimits.mcpBurstRequestsPerMinute: 60 / minute on Pro, Pro Business and API Starter, 300 on API Business, 1,000 on Enterprise. It falls back to 60 whenever that value is missing or is not a finite number of at least 1, so an unreadable entitlement lands on the lower ceiling rather than the higher one. Metadata and free-tier methods are the one case where a paid caller does not get their plan rate: they are served before the entitlement pre-check runs, so the limiter has no plan in hand and evaluates them at 60 (api/mcp/handler.ts:842 calls applyPerMinuteLimit without a limit). It is the same per-user pool either way, so on API Business a burst of tools/list rejects at 60 while tools/call still runs to 300. The operator-key and anonymous-discovery limiters stay at a flat 60 / minute. 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:
<limit> in the per-user string is your own plan’s burst, not a constant. It is 60 on Pro, Pro Business and API Starter, 300 on API Business, 1,000 on Enterprise, and 60 when the entitlement cannot be read. Parse the number out of the message if you want it, or read it from the plans table. The other two strings are genuinely fixed at 60, because neither the legacy operator-key limiter nor the anonymous discovery limiter is plan-resolved.
Example payload (per-user variant, API Business):
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 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. The cap is plan-resolved and identical on both the OAuth and wm_… doors: Pro 50/day and Pro Business 250/day on a dedicated counter at one unit per call, while API Starter and API Business spend the same allowance as their REST requests (1,000 and 10,000 units/day) at a per-tool weight of 1, 2, or 3. Enterprise can be unlimited. The message quotes the budget that actually rejected, so it will not always read 50. 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.)
data.limit is the budget that actually rejected — the same number the message quotes, so branch on it rather than parsing the prose. data.sharedWithRestApi is the fact the message cannot carry: on an API plan with REST enforcement on, this budget is the REST meter, so the exhaustion may be REST traffic your client never sent. It is true only in that case; Pro, Pro Business and shadow-mode API tiers all read false because they are metered on MCP’s own counter. When it is true, nextStep says so as well.
Free-account allowance — HTTP 429 + 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.
The code is also reused at HTTP 413 for a request body over the 256 KiB cap (MAX_JSON_RPC_BODY_BYTES). That case is not an encoder bug — a perfectly well-formed client hits it by sending an oversized tools/call argument, and because the body is rejected before parsing the server cannot echo your id (it is always null). Apart from the 413, this is strictly a client encoder bug that well-formed JSON-RPC clients will not see in production.
tools/call argument, or page the work into smaller calls; do not retry the same payload. On 200/400/406, audit the request encoder: the body must be a JSON object with a string method and (for any method besides notifications/*) an id field. If you see a non-413 -32600 from a known-good client library, file an issue against this server — that case 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.
