Skip to content

Error handling

Every error the server surfaces to the client is a structured object with four guaranteed fields: a stable code, a retriable boolean, a human-readable message, and a recovery_hint that tells the agent what to do next. This page is the map of where those errors come from.

DiscordMcpError (base — all custom errors extend this)
├─ DiscordClientError (caller's fault — 4xx-equivalent)
│ ├─ DiscordPermissionError (DISCORD_PERMISSION_DENIED)
│ ├─ DiscordRateLimitError (DISCORD_RATE_LIMITED)
│ ├─ DiscordNotFoundError (DISCORD_NOT_FOUND)
│ ├─ ValidationError (VALIDATION_FAILED)
│ ├─ DiscordAuthError (DISCORD_AUTH_INVALID)
│ ├─ DiscordCloudflareBlocked (DISCORD_CLOUDFLARE_BLOCKED)
│ ├─ ScopeRejectedError (SCOPE_REJECTED)
│ ├─ GuildNotAllowedError (GUILD_NOT_ALLOWED)
│ ├─ DryRunPreview (DRY_RUN_PREVIEW)
│ └─ CancelledError (CANCELLED)
└─ DiscordServerError (server's fault — 5xx-equivalent)
├─ CircuitOpenError (CIRCUIT_OPEN)
├─ BulkheadFullError (BULKHEAD_SATURATED)
├─ DiscordTimeoutError (DISCORD_TIMEOUT)
└─ DiscordRestUnavailable (DISCORD_REST_UNAVAILABLE)

Source: errors/base.ts, errors/client.ts, errors/server.ts.

The split mirrors HTTP semantics: client errors are deterministic (the same input will fail the same way; the agent must change the input or the config); server errors are transient (retry might work).

Source: errors/format.ts.

The single conversion point that turns any thrown error into the wire shape returned to the MCP client:

{
code: string, // stable machine-readable identifier
retriable: boolean, // whether a naive retry might succeed
message: string, // human-readable summary
recovery_hint: string, // imperative next step
// optional, error-specific:
suggested_tool?: string,
retry_after_ms?: number,
missing_permissions?: string[],
validation_issues?: { path: string, message: string, code: string }[],
}

Any DiscordMcpError formats trivially via this helper. Unknown errors (e.g. a programmer mistake that throws a TypeError) are wrapped into a generic INTERNAL_ERROR shape with retriable: false and a hint that mentions filing an issue — we never leak stack traces to the client.

The contract: every recovery_hint is imperative + actionable for the agent.

CodeRecovery hint shape
DISCORD_PERMISSION_DENIED”Grant <perm> to bot’s role in Server Settings → Roles.”
DISCORD_RATE_LIMITED”Wait <ms>ms then retry” (+ optional batch alternative).
DISCORD_NOT_FOUND”Verify: 1) <resource> exists 2) bot has VIEW permission 3) ID is correct”
VALIDATION_FAILED”Fix <path>: <zod issue>
DRY_RUN_PREVIEW”Set MCP_DRY_RUN=false AND pass __confirm:true to actually execute”
CIRCUIT_OPEN”Discord upstream failing; circuit auto-recovers in <ms>ms”
BULKHEAD_SATURATED”Too many in-flight calls; retry after current batch completes”

The hints encode the fix, not the diagnosis. The agent doesn’t need to know what a bulkhead is to act on BULKHEAD_SATURATED — it just needs to know “retry later.”

The DryRunPreview error is the load-bearing piece of the safe-by-default posture. When a destructive tool is called without confirmation, the precondition throws DryRunPreview carrying the redacted preview of what would have run:

{
"code": "DRY_RUN_PREVIEW",
"retriable": false,
"message": "Dry-run: would call messages_send with the given args",
"recovery_hint": "Set MCP_DRY_RUN=false AND pass __confirm:true (or use elicitation flow) to actually execute",
"preview": { "channel_id": "111122223333444455", "content": "..." }
}

The agent sees the preview, optionally surfaces it to the human, then either:

  1. Re-issues the call with __confirm:true (and the operator has MCP_DRY_RUN=false in env), or
  2. Surfaces the preview as “this is what I would do, are you sure?” via elicitation if the client supports it.

Either way, no Discord call ever fires until both halves of the contract are satisfied. See Confirmation for the contract details.

The resilience layer uses Cockatiel for retry, circuit breaker, bulkhead, and timeout. Cockatiel throws its own error types; we map them to discord-mcp errors so the client never sees a leaky implementation detail:

Cockatiel errordiscord-mcp errorCode
BrokenCircuitErrorCircuitOpenErrorCIRCUIT_OPEN
BulkheadRejectedErrorBulkheadFullErrorBULKHEAD_SATURATED
TaskCancelledError (timeout)DiscordTimeoutErrorDISCORD_TIMEOUT

Mapping happens in rest/errors.ts inside the resilient REST adapter, so all REST callers see the canonical codes.

This was added in Plan 8 Phase D — before that, callers occasionally got BrokenCircuitError thrown straight through, which broke the formatErrorForUser contract because Cockatiel’s error class isn’t a DiscordMcpError.

Errors don’t always cross the wire as JSON-RPC errors. By design, the tool result envelope can carry isError: true with structured content, preserving the request as a successful protocol exchange while marking the application result as failed:

{
"isError": true,
"structuredContent": {
"code": "DISCORD_NOT_FOUND",
"retriable": false,
"recovery_hint": "Verify: 1) ...",
"message": "Channel 999... not found"
}
}

Why: an MCP-level JSON-RPC error implies “the protocol exchange itself broke.” A 404 from Discord is not a protocol break — it’s a perfectly ordinary outcome the agent needs to handle. Returning it as isError: true with structured content keeps the trace clean (the call succeeded; the result said no) and lets the agent branch on the code field.

ConcernFile
Base classeserrors/base.ts
Client errorserrors/client.ts
Server errorserrors/server.ts
Wire formattererrors/format.ts
Cockatiel mappingsrest/errors.ts