# Error classes

> The error classes of @frontmcp/sdk, grouped by area, with the code a client receives, whether its message survives production, and whether you throw it or FrontMCP does.

Source: https://frontmcp.dev/reference/sdk/error-classes

`@frontmcp/sdk` exports an error class for most things that can go wrong, from a model sending bad arguments to a provider that can't be built. This page groups them by area; a few other exported classes aren't listed. Each one has a string `code`, which clients receive, and is either **public**, so its message reaches the client in production, or **internal**, so production replaces it with an error ID. A handful are for you to fail with. Most are what FrontMCP throws itself, and knowing them tells you what a client saw and why. For how to fail a call, see [`this.fail()`](https://frontmcp.dev/reference/sdk/fail); for every code a client can receive, see [Error codes](https://frontmcp.dev/reference/errors).

```ts
this.fail(new PublicMcpError(message, code?, statusCode?))
throw new InvalidInputError(message?, details?)
```

---

## Reference

### `McpError`

Every FrontMCP error extends the abstract `McpError`, itself an `Error`, except the guard's errors and `@frontmcp/auth`'s internal ones (see [below](#errors-that-arent-mcperrors)).

| Member | Type | Description |
| --- | --- | --- |
| `code` | `string` | What kind of error it is, like `"INVALID_INPUT"`. Clients get it as a tool result's `_meta.code`, or as `data.code` in a JSON-RPC error. |
| `isPublic` | `boolean` | Whether the message may reach clients in production. |
| `statusCode` | `number` | An HTTP-style status. It picks the JSON-RPC code for [resources and prompts](#from-a-resource-or-a-prompt); a tool result is always HTTP `200`. |
| `errorId` | `string` | `err_` and 16 hex digits, new for every error. Clients get it in `_meta.errorId` or `data.errorId`, and FrontMCP logs it with the error, so a user's report can be found in the log. |
| `message` | `string` | The message as written, for your logs. |
| `getPublicMessage()` | `string` | What a production client reads. |
| `getInternalMessage()` | `string` | What a development client reads in a tool result. |
| `toMcpError(isDevelopment?)` | `CallToolResult` | The `isError` tool result for this error. |

#### `PublicMcpError(message, code?, statusCode?, wwwAuthenticate?)`

The error to fail with when the model should read the message. `code` defaults to `"PUBLIC_ERROR"`, and `statusCode` to `400`. `wwwAuthenticate` is only used on FrontMCP's own HTTP endpoints. Subclass it for errors of your own:

```ts
export class TicketArchivedError extends PublicMcpError {
  constructor(id: string) {
    super(`Ticket ${id} is archived. Ask a lead to restore it.`, "TICKET_ARCHIVED", 409);
  }
}
```

#### `InternalMcpError(message, code?)`

An error whose message must not leave the server. `code` defaults to `"INTERNAL_ERROR"`, and `statusCode` is `500`. Production clients get `Internal FrontMCP error. Please contact support with error ID: err_…`; the message goes to the log.

### What the client receives

#### From a tool

A tool that fails, with `this.fail()` or by throwing, answers with a normal result: `isError: true`, the text, and `_meta` with `errorId`, `code`, `timestamp`, and in development, `stack`.

- **`_meta.code` is the error's `code`.** `this.fail()` passes every `McpError` through as it is. A thrown error keeps its code too if it's public.
- **A thrown error that isn't public is wrapped** as `ToolExecutionError`: code `TOOL_EXECUTION_ERROR`, text `Tool "…" execution failed: ` and the message. Passed to `this.fail()`, the same `InternalMcpError` keeps its code, and a plain `Error` becomes `SERVER_ERROR`. [See it run.](#failing-vs-throwing)
- **The text** is `getInternalMessage()` in development and `getPublicMessage()` in production. For most public errors they're the same; the few that differ are marked below.
- **A few refusals are JSON-RPC errors instead**, all before `execute()` runs: a thrown `ToolCredentialsRequiredError` (`-32001`, with `data.authUrl`); for clients before MCP 2026-07-28, `TaskAugmentationNotSupportedError` and `TaskAugmentationRequiredError` (`-32601`); and under 2026-07-28, a call to a tool with `execution.taskSupport: "required"` from a client that didn't declare the tasks extension (`-32602`, `Tool "…" runs as a task; declare the io.modelcontextprotocol/tasks extension in clientCapabilities`).

#### From a resource or a prompt

`resources/read` and `prompts/get` answer with a JSON-RPC error. Its `message` is `getPublicMessage()`, except that an internal error's message is kept in development, and `data` holds `errorId` and `code`. The JSON-RPC code comes from the class:

| Error | JSON-RPC code |
| --- | --- |
| A class with its own JSON-RPC mapping (marked "own" below) | Its own code, and its own `data`, without `code` |
| Public, `statusCode` 401 | `-32001` |
| Public, `statusCode` 403 | `-32003` |
| Public, `statusCode` below 500 | `-32602` |
| Public, `statusCode` 500 or more | `-32603`, with the message kept |
| Internal | `-32603` |

A thrown error that isn't public is wrapped, as `ResourceReadError` (`Resource "…" read failed: `) or `PromptExecutionError` (`Prompt execution failed: `). [See it run.](#failing-in-a-resource-or-a-prompt)

#### Before any handler

A few refusals happen before FrontMCP knows what the request is for. They're HTTP errors with a JSON-RPC body, and no error class of yours is involved: `throttle.global` gives HTTP `429` and `-32029`, `throttle.ipFilter` HTTP `403` and `-32001` (see [Guard options](https://frontmcp.dev/reference/sdk/guard#what-a-caller-gets-back)), and, on the Node HTTP server, a body over `http.bodyLimit` HTTP `413` and `-32600`, `Payload Too Large`.

#### In production

With `NODE_ENV=production`, a public error's message is sent, and an internal error's is replaced with `Internal FrontMCP error. Please contact support with error ID: err_…`; `InvalidOutputError` says `Output validation failed. Please contact support.` instead. No stack is sent. The Playground runs in development, so the examples below [format errors the way production does](#what-production-sends) with `createErrorHandler({ isDevelopment: false })`.

#### Errors that aren't `McpError`s

- **Any other `Error`**, like a `TypeError` or a driver's error, is internal. Passed to `this.fail()`, it becomes a `GenericServerError`, code `SERVER_ERROR`; in development the text adds `Original error:` and the stack.
- **The guard's errors** (`ExecutionTimeoutError`, `ConcurrencyLimitError` and the rest) extend `GuardError`, not `McpError`. Passed to `this.fail()`, they keep their code and message, as public errors. Thrown, only `ExecutionTimeoutError` keeps its code in a tool; the others are wrapped. In a resource or prompt, all of them are wrapped when thrown, like any error that isn't public.
- **`@frontmcp/auth`'s internal errors** (`JwtSecretRequiredError`, `SessionSecretRequiredError` and the rest) extend `AuthInternalError`. Their own code is lost: clients get `SERVER_ERROR`.
- **`AuthorityDeniedError`**, from `@frontmcp/auth`, is how [`authorities`](https://frontmcp.dev/learn/authorizing-calls) refuses a call. Clients get code `AUTHORITY_DENIED`, and its message is kept in production.

### Helper functions

| Function | What it does |
| --- | --- |
| `isPublicError(error)` | `true` for an `McpError` with `isPublic`. `false` for guard errors and `AuthorityDeniedError`, which FrontMCP still sends as public. |
| `isClientFacingError(error)` | `isPublicError()`, or an `AuthorityDeniedError`. |
| `toMcpError(error)` | The `McpError` FrontMCP would send for `error`: an `McpError` as it is, a guard error as a public `GuardLimitMcpError` with the same code, anything else as a `GenericServerError`. |
| `formatMcpErrorResponse(error, isDevelopment?)` | The `isError` tool result for `error`. `isDevelopment` defaults to `NODE_ENV !== "production"`. |
| `createErrorHandler({ isDevelopment?, logger?, errorTransformer? })` | An `ErrorHandler`: `handle(error, context?)` formats a tool result as `tools/call` does (and logs it with `logger`); `wrap(fn)` makes a function throw `McpError`s only. |
| `extractPublicMessage(error)` | The most specific message in an error's `cause` chain. FrontMCP doesn't use it. |
| `shouldStopExecution(error)` | Whether an error ends a flow: `true` except for a `FlowControl` that responds or continues. |
| `MCP_ERROR_CODES` | The JSON-RPC codes: `UNAUTHORIZED` `-32001`, `RESOURCE_NOT_FOUND` `-32002`, `FORBIDDEN` `-32003`, `INVALID_REQUEST` `-32600`, `METHOD_NOT_FOUND` `-32601`, `INVALID_PARAMS` `-32602`, `INTERNAL_ERROR` `-32603`, `PARSE_ERROR` `-32700`. |

### The classes

The tables give each class's `code`, the JSON-RPC code a resource or prompt gets for it (a tool gets the `code` in `_meta.code`, [as above](#from-a-tool)), whether its message is kept in production, and who throws it. Unless a table says otherwise, a class's constructor takes what its message needs, in the order the message uses it.

#### Base classes

| Class | `code` | JSON-RPC | Production | Who throws it |
| --- | --- | --- | --- | --- |
| `PublicMcpError(message, code?, statusCode?)` | `PUBLIC_ERROR`, or `code` | by `statusCode` (`-32602` by default) | Kept | **You**, for anything the model should read. FrontMCP, for skill and channel requests it can't serve. |
| `InternalMcpError(message, code?)` | `INTERNAL_ERROR`, or `code` | `-32603` | Hidden | You, for failures to keep in the log. FrontMCP, for its own failures. |
| `GenericServerError(message, originalError?)` | `SERVER_ERROR` | `-32603` | Hidden | FrontMCP, for any other `Error` passed to `this.fail()`. |

#### Tools and prompts

| Class | `code` | JSON-RPC | Production | Who throws it |
| --- | --- | --- | --- | --- |
| `ToolNotFoundError(name)` | `TOOL_NOT_FOUND` | `-32602` | Kept: `Tool "x" not found` | FrontMCP, for a name no tool has, or an `internal` tool called by a client. |
| `ToolExecutionError(name, cause?)` | `TOOL_EXECUTION_ERROR` | `-32603` | Hidden | FrontMCP, around an error thrown from `execute()` that isn't public. |
| `EntryUnavailableError(type, name, …)` | `ENTRY_UNAVAILABLE` | `-32003` (own) | Kept. It describes the server's environment: OS, runtime, deployment. | FrontMCP, for a tool, resource, resource template or prompt whose [`availableWhen`](https://frontmcp.dev/reference/server/environment) doesn't match where the server runs. One whose `surface` leaves out `"mcp"` gets the not-found error instead. |
| `PromptNotFoundError(name)` | `PROMPT_NOT_FOUND` | `-32602` | Kept: `Prompt not found: x` | FrontMCP, for an unknown prompt. |
| `PromptExecutionError(name, cause?)` | `PROMPT_EXECUTION_FAILED` | `-32603` | Hidden | FrontMCP, around an error thrown from a prompt that isn't public. |
| `MissingPromptArgumentError(name)` | `MISSING_PROMPT_ARGUMENT` | `-32602` | Kept: `Missing required argument: id` | FrontMCP, for a required prompt argument that wasn't sent. |
| `ToolCallError(toolName, result)` | `TOOL_CALL_ERROR` | | Kept | FrontMCP's in-process clients with an LLM format, like [`connectClaude()`](https://frontmcp.dev/reference/sdk/connect#the-adapters): their `callTool()` rejects with it when the tool fails. `message` is the tool's error text, and `result` the MCP result, `isError` and `_meta.code` included. It never reaches a client over MCP. New in 1.9.2. |

#### Resources

| Class | `code` | JSON-RPC | Production | Who throws it |
| --- | --- | --- | --- | --- |
| `ResourceNotFoundError(uri)` | `RESOURCE_NOT_FOUND` | `-32602` (own, `data: { uri }`). `-32002` for clients before MCP 2026-07-28. | Kept: `Resource not found: tickets://T-9` | FrontMCP, for a URI nothing serves. You, for a template whose item doesn't exist, so the client gets the same error as for an unknown URI. |
| `ResourceReadError(uri, cause?)` | `RESOURCE_READ_ERROR` | `-32603` | Hidden | FrontMCP, around an error thrown from a resource that isn't public. |
| `InvalidResourceUriError(uri, reason?)` | `INVALID_RESOURCE_URI` | `-32602` | Kept | You. FrontMCP never throws it. |

#### Validation

| Class | `code` | JSON-RPC | Production | Who throws it |
| --- | --- | --- | --- | --- |
| `InvalidInputError(message?, details?)` | `INVALID_INPUT` | `-32602` | Kept, with `details` appended as `Details:` and JSON | FrontMCP, for arguments that don't match `inputSchema` (`Invalid tool input`, then the Zod issues). **You**, for rules a schema can't express; see [`this.fail`](https://frontmcp.dev/reference/sdk/fail#choosing-an-error-code). |
| `InvalidOutputError(details?)` | `INVALID_OUTPUT` | `-32603` | Hidden: `Output validation failed. Please contact support.` | FrontMCP, for a result that doesn't match `outputSchema`, or holds `NaN`, and since 1.8.7 for an [agent's](https://frontmcp.dev/reference/sdk/agent#returning-structured-output) answer that doesn't match. In development the text is `Tool output validation failed (output does not match outputSchema at status)`, naming the first field that didn't match. |
| `InvalidMethodError(method, expected)` | `INVALID_METHOD` | `-32602` | Kept | FrontMCP, inside its own request handling. Clients don't normally see it. |

#### Auth

| Class | `code` | JSON-RPC | Production | Who throws it |
| --- | --- | --- | --- | --- |
| `UnauthorizedError(message?, wwwAuthenticate?)` | `UNAUTHORIZED` | `-32001` | Kept (default `Unauthorized`) | **You**, when the caller must sign in again. FrontMCP, for an older client's session that can't be restored from its token. |
| `AuthorizationRequiredError({ appId, toolId, authUrl?, requiredScopes?, … })` | `AUTHORIZATION_REQUIRED` | `-32003` | Kept; production adds `To authorize, click: ` and `authUrl` | FrontMCP, when the caller hasn't authorized the tool's app yet. See [Progressive auth](https://frontmcp.dev/reference/auth/progressive). |
| `ToolNotConsentedError(name)` | `TOOL_NOT_CONSENTED` | `-32003` (own) | Kept | FrontMCP, for a tool the user didn't select when consenting. See [Tools: consent](https://frontmcp.dev/reference/auth/progressive#tools-consent). |
| `ToolCredentialsRequiredError({ toolId, providers, authUrl? })` | `TOOL_CREDENTIALS_REQUIRED` | `-32001` (own), also in `tools/call` | Kept | FrontMCP, for a tool whose required [`authProviders`](https://frontmcp.dev/reference/sdk/tool) credential isn't connected. See [Progressive auth](https://frontmcp.dev/reference/auth/progressive#asking-for-a-credential-when-a-tool-needs-it). |
| `AuthConfigurationError(message, { errors?, suggestion? })` | `AUTH_CONFIGURATION_ERROR` | `-32603` | Kept; production adds `To fix this issue:` and `suggestion` | FrontMCP, at startup, for an `auth` or `authorities` configuration that can't work, including [an `authorities` rule that checks nothing](https://frontmcp.dev/reference/sdk/frontmcp#authorities-configuration-required-or-invalid-authorities-rule). |
| `UnsupportedClientVersionError(version)` | `UNSUPPORTED_CLIENT_VERSION` | | Kept | FrontMCP, for an older client's `initialize` whose protocol version isn't a date. |
| `SessionMissingError(message?)` | `SESSION_MISSING` | `-32001` | Kept | Nobody: FrontMCP 1.9.3 never throws it. |
| `PublicAccessDeniedError(kind, name)` | `PUBLIC_ACCESS_DENIED` | `-32003` (own) | Kept: `The tool "help-desk:close_ticket" is not available to anonymous callers` | FrontMCP, for an anonymous caller's call to a tool or prompt that the server's [`publicAccess`](https://frontmcp.dev/reference/auth/modes#publicaccess) doesn't list. New in 1.9.2. |
| `SessionIdentityRequiredError(what?)` | `SESSION_IDENTITY_REQUIRED` | `-32003` | Kept: `The secure store's session scope needs a verified session or a signed-in caller. Authenticate the request, or use a session transport (an MCP revision before 2026-07-28).` | FrontMCP, when [`this.secureStore`](https://frontmcp.dev/reference/auth/local#thissecurestore) with `scope: "session"` is used by a caller with neither a verified session nor a sign-in, like an anonymous MCP 2026-07-28 caller. New in 1.8.5. |
| `AuthorityDeniedError` (`@frontmcp/auth`) | `AUTHORITY_DENIED` | `-32003` (own, `data: { entryType, entryName, deniedBy, denial }`) | Kept: `Access denied to Tool "help-desk:close_ticket": …` | FrontMCP, when a caller fails an [`authorities`](https://frontmcp.dev/learn/authorizing-calls) rule. Not exported by `@frontmcp/sdk`. |

`@frontmcp/sdk` also re-exports `@frontmcp/auth`'s internal errors, which FrontMCP throws while starting up or handling tokens. Clients get `SERVER_ERROR` for all of them, hidden in production; the server log has the real code. The ones you're most likely to meet: `JwtSecretRequiredError` (`JWT_SECRET_REQUIRED`: production with `auth.mode: "local"` and no `JWT_SECRET`), `JwtSecretWeakError` (`JWT_SECRET_INVALID`: a secret under 32 bytes), `SessionSecretRequiredError` (`SESSION_SECRET_REQUIRED`), `ElicitationSecretRequiredError`, `TokenNotAvailableError`, `TokenStoreRequiredError`, `TokenLeakDetectedError`, `EncryptionKeyNotConfiguredError`, `EncryptionContextNotSetError`, `VaultLoadError`, `VaultNotFoundError`, `SessionIdEmptyError`, `CredentialProviderAlreadyRegisteredError`, `OrchestratedAuthNotAvailableError`, `OrchestratorJwksNotAvailableError` and `NoProviderIdError`. `AuthProvidersNotConfiguredError`, `ScopeDeniedError` and `InMemoryStoreRequiredError` are never thrown.

#### Rate limits and the guard

| Class | `code` | JSON-RPC | Production | Who throws it |
| --- | --- | --- | --- | --- |
| `RateLimitError(retryAfter?)` | `RATE_LIMIT_EXCEEDED` | `-32602` | Kept: `Rate limit exceeded. Retry after 30 seconds` | FrontMCP, for a tool's [`rateLimit`](https://frontmcp.dev/reference/sdk/guard#ratelimit). **You**, for limits of your own. |
| `QuotaExceededError(quotaType?)` | `QUOTA_EXCEEDED` | `-32602` | Kept: `export quota exceeded` (`usage` by default) | You. FrontMCP never throws it. |
| `ExecutionTimeoutError(name, ms)` | `EXECUTION_TIMEOUT` | `-32602` through `this.fail()` | Kept | FrontMCP, for a tool's [`timeout`](https://frontmcp.dev/reference/sdk/guard#timeout). |
| `ConcurrencyLimitError(name, max)` | `CONCURRENCY_LIMIT` | `-32602` through `this.fail()` | Kept | FrontMCP, for [`concurrency`](https://frontmcp.dev/reference/sdk/guard#concurrency) and `globalConcurrency`. |
| `QueueTimeoutError(name, ms)` | `QUEUE_TIMEOUT` | `-32602` through `this.fail()` | Kept | FrontMCP, when a queued call waits longer than `queueTimeoutMs`. |
| `IpBlockedError(ip)`, `IpNotAllowedError(ip)` | `IP_BLOCKED`, `IP_NOT_ALLOWED` | `-32003` through `this.fail()` | Kept | Nobody: `ipFilter` answers the request itself. |
| `GuardStorageUnavailableError(storageType, cause?)` | `GUARD_STORAGE_UNAVAILABLE` | | Replaced, since 1.9: `Service temporarily unavailable: the rate-limit store cannot be reached. Retry shortly.`, with status `503` | FrontMCP, when `throttle.storage` can't be reached and its `fallback` isn't `"memory"`: while the server starts, where it stops the server (new in 1.8.6), and since 1.8.7 on a call whose limit check or concurrency slot the store can't serve, where the client gets it. See [Guard troubleshooting](https://frontmcp.dev/reference/sdk/guard#the-server-doesnt-start-throttlestorage-redis-is-unavailable) and [while the server runs](https://frontmcp.dev/reference/sdk/guard#calls-fail-with-guard_storage_unavailable-while-the-server-runs). |
| `GuardError(message, code, statusCode)` | as given | | | The base of the six above. |
| `GuardLimitMcpError(guardError)` | the guard error's | by status | Kept | FrontMCP, when it turns a guard error into an `McpError`. |

See [Guard options](https://frontmcp.dev/reference/sdk/guard#what-a-caller-gets-back) for when each limit trips.

#### Agents

| Class | `code` | Production | Who throws it |
| --- | --- | --- | --- |
| `AgentNotFoundError(id)` | `AGENT_NOT_FOUND` | Kept | FrontMCP, for an unknown agent, like a name [`this.invokeAgent()`](https://frontmcp.dev/reference/sdk/agent#agentcontext) doesn't know: `Agent not found: …`. |
| `AgentVisibilityError` | `AGENT_VISIBILITY_DENIED` | Kept | FrontMCP, when `invokeAgent()` names an agent this one can't see, like one with `swarm.isVisible: false`. New in 1.9. |
| `AgentCallDepthExceededError` | `AGENT_CALL_DEPTH_EXCEEDED` | Kept | FrontMCP, for a call from one agent to another past [`swarm.maxCallDepth`](https://frontmcp.dev/reference/sdk/agent#swarm). New in 1.9. |
| `AgentConfigurationError` | `AGENT_CONFIGURATION_ERROR` | | FrontMCP, at startup, for an agent's `exports` that names something the agent doesn't have. New in 1.9. |
| `AgentExecutionError(id, cause?)` | `AGENT_EXECUTION_FAILED` | Hidden | FrontMCP, around an agent's failures. |
| `AgentNotConfiguredError(name)`, `AgentToolNotFoundError(agent, tool, available)` | `AGENT_NOT_CONFIGURED`, `AGENT_TOOL_NOT_FOUND` | Hidden | FrontMCP, for an agent without an LLM adapter, or asking for a tool it doesn't have. |
| `AgentLoopExceededError`, `AgentTimeoutError`, `AgentLlmError` | `AGENT_LOOP_EXCEEDED`, `AGENT_TIMEOUT`, `AGENT_LLM_ERROR` | | Nobody: FrontMCP 1.9.3 never throws them. |

A client calling an agent gets `TOOL_EXECUTION_ERROR` when its loop runs out of turns or time, or its model fails; [`@Agent`](https://frontmcp.dev/reference/sdk/agent#errors) lists the texts. The exceptions are an answer that doesn't match the agent's `outputSchema`, which is `InvalidOutputError`, code `INVALID_OUTPUT`, as for a tool (in 1.8.5 and 1.8.6 it was `TOOL_EXECUTION_ERROR` too, with a stack trace in development), and the three agent-to-agent errors above, which keep their codes.

#### Elicitation and multi-round-trip requests

| Class | `code` | JSON-RPC | Production | Who throws it |
| --- | --- | --- | --- | --- |
| `ElicitationDisabledError()` | `ELICITATION_DISABLED` | `-32602` | Kept, shorter: `Elicitation is disabled on this server.` Development: `Elicitation is disabled in server configuration. Enable it via @FrontMcp({ elicitation: { enabled: true } })` | FrontMCP, for [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) on a server without `elicitation.enabled`. |
| `ElicitationNotSupportedError(message?)` | `ELICITATION_NOT_SUPPORTED` | `-32602` | Kept | FrontMCP, when an older client has no session or transport to ask through. |
| `ElicitationNotOwnedError(elicitId)` | `ELICITATION_NOT_OWNED` | `-32003` (own, `data: { elicitId }`) | Kept: `Elicitation "elicit-…" was not requested by this caller` | FrontMCP, when `sendElicitationResult` answers a question that another caller was asked. See [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit#the-model-is-asked-to-call-sendelicitationresult). |
| `ElicitationTimeoutError(id, ttl)` | `ELICITATION_TIMEOUT` | `-32602` | Kept, reworded: `… The user did not respond within the allowed time (300 seconds).` | FrontMCP, when an older client's user doesn't answer in time. |
| `ElicitationFallbackRequired(…)` | `ELICITATION_FALLBACK` | | | FrontMCP, internally, for clients that can't show forms. The call answers with instructions instead of an error. |
| `ElicitationStoreNotInitializedError`, `ElicitationEncryptionError`, `ElicitationSubscriptionError` | `ELICITATION_STORE_NOT_INITIALIZED`, `ELICITATION_ENCRYPTION_ERROR`, `ELICITATION_SUBSCRIPTION_ERROR` | `-32603` | Hidden | FrontMCP, for failures of its elicitation store. |
| `SamplingNotAvailableError(feature?)` | `MRTR_REQUIRED` | `-32602` | Kept | FrontMCP, for sampling or `roots/list` from a client older than MCP 2026-07-28. |
| `InputRequiredSignal`, `MissingClientCapabilityError` | `INPUT_REQUIRED`, `MISSING_REQUIRED_CLIENT_CAPABILITY` | | | FrontMCP, under MCP 2026-07-28: the first becomes an `input_required` result, the second HTTP `400` with `-32021`. `isMrtrSignal(error)` recognizes both. |

#### Tasks

| Class | `code` | JSON-RPC | Who throws it |
| --- | --- | --- | --- |
| `TaskNotFoundError(taskId)` | `TASK_NOT_FOUND` | `-32602` (own) | FrontMCP, for an unknown task id: `Failed to retrieve task: Task not found`. |
| `TaskAlreadyTerminalError(status)` | `TASK_ALREADY_TERMINAL` | `-32602` (own) | FrontMCP, when cancelling a task that has `completed`, `failed` or been `cancelled`. |
| `TaskAugmentationNotSupportedError(tool)`, `TaskAugmentationRequiredError(tool)` | `TASK_AUGMENTATION_NOT_SUPPORTED`, `TASK_AUGMENTATION_REQUIRED` | `-32601` (own), also in `tools/call` | FrontMCP, for a client before MCP 2026-07-28 that asks for a task on a tool with `taskSupport: "forbidden"`, or doesn't on one with `"required"`. [Under 2026-07-28](#from-a-tool) the second is a `-32602`. |
| `TaskStoreNotInitializedError()` | `TASK_STORE_NOT_INITIALIZED` | `-32603` | FrontMCP, for its own task store. Hidden in production. |

All but the last are public.

#### Jobs and workflows

| Class | `code` | Production | Who throws it |
| --- | --- | --- | --- |
| `JobNotAuthorizedError(name)` | `JOB_NOT_AUTHORIZED` | Kept: `Job or workflow "x" not found or not permitted` | FrontMCP's job tools, for a job that doesn't exist or that the caller may not run. See [`@Job`](https://frontmcp.dev/reference/sdk/job#restricting-who-can-run-a-job). |
| `DynamicJobRegistrationDisabledError(kind?)` | `DYNAMIC_REGISTRATION_DISABLED` | Kept | FrontMCP's `register_job` and `register_workflow`, without `jobs.allowDynamicRegistration`. |
| `WorkflowTimeoutError`, `WorkflowJobTimeoutError`, `WorkflowStepNotFoundError`, `WorkflowDagValidationError` | `WORKFLOW_TIMEOUT`, `WORKFLOW_JOB_TIMEOUT`, `WORKFLOW_STEP_NOT_FOUND`, `WORKFLOW_DAG_VALIDATION` | Hidden | FrontMCP, while running a workflow. Clients get `TOOL_EXECUTION_ERROR` from `execute_workflow`; see [`@Workflow`](https://frontmcp.dev/reference/sdk/workflow). |
| `DynamicJobDirectExecutionError(name?)` | `DYNAMIC_JOB_DIRECT_EXECUTION` | Hidden | FrontMCP, for a dynamic job run outside its sandbox. |

#### Remote apps

[Remote servers](https://frontmcp.dev/reference/server/remote) covers how a remote app fails, and what each failure looks like.

For [`App.remote()`](https://frontmcp.dev/reference/sdk/app#apps-from-outside-your-code). The public ones tell the model which remote server failed.

| Class | `code` | Production | Who throws it |
| --- | --- | --- | --- |
| `RemoteTimeoutError(app, operation, ms)` | `REMOTE_TIMEOUT_ERROR` | Kept | FrontMCP, when a remote tool call takes too long (30 seconds by default) on every try. |
| `RemoteToolNotFoundError`, `RemoteResourceNotFoundError`, `RemotePromptNotFoundError` | `REMOTE_TOOL_NOT_FOUND`, `REMOTE_RESOURCE_NOT_FOUND`, `REMOTE_PROMPT_NOT_FOUND` | Kept | FrontMCP, when the remote server doesn't have it. |
| `RemoteToolExecutionError`, `RemoteResourceReadError`, `RemotePromptGetError` | `REMOTE_TOOL_EXECUTION_ERROR`, `REMOTE_RESOURCE_READ_ERROR`, `REMOTE_PROMPT_GET_ERROR` | Kept, with the remote error's message | FrontMCP, when the remote call fails. |
| `RemoteAuthError(app, details?)` | `REMOTE_AUTH_ERROR` | Kept: `Authentication failed for remote server "status": the remote server refused the credentials (HTTP 401)` | FrontMCP, when the remote answers a tool call, resource read or prompt request with `401`, or a [`remoteAuth: { mode: "mapped" }`](https://frontmcp.dev/reference/server/remote#credentials-for-each-caller) mapper throws. A remote that refuses the connection while the server starts is a `RemoteConnectionError` instead. (Changed in 1.9.3: before, it was effectively never thrown.) |
| `RemoteNotConnectedError(app)` | `REMOTE_NOT_CONNECTED` | Kept | FrontMCP, before the remote app has connected. |
| `RemoteConnectionError`, `RemoteCapabilityDiscoveryError` | `REMOTE_CONNECTION_ERROR`, `REMOTE_CAPABILITY_DISCOVERY_ERROR` | Hidden | FrontMCP, when connecting or listing the remote server's capabilities fails. |
| `InvalidRetryOptionsError` | `INVALID_RETRY_OPTIONS` | Hidden | FrontMCP, for bad retry options. |
| `RemoteDisconnectError`, `RemoteAuthorizationError`, `RemoteTransportError`, `RemoteUnsupportedTransportError`, `RemoteCapabilityNotSupportedError`, `RemoteConfigurationError` | | | Nobody: never thrown in 1.9.3. |

The remote errors also have an `mcpErrorCode` field. FrontMCP doesn't use it for `tools/call`, `resources/read` or `prompts/get`: the JSON-RPC code follows `statusCode` like any other public error, so a remote "not found" is `-32602`.

#### Providers

All internal (`-32603`, hidden in production), thrown by FrontMCP while building or looking up [providers](https://frontmcp.dev/reference/sdk/provider): `ProviderNotAvailableError` (`PROVIDER_NOT_AVAILABLE`: what [`this.get()`](https://frontmcp.dev/reference/sdk/get#provider-directory-is-not-available-not-found-in-local-or-parent-registries) throws for a provider it can't find), `ProviderNotRegisteredError`, `ProviderNotInstantiatedError`, `ProviderScopeMismatchError`, `ProviderScopedAccessError`, `ProviderConstructionError` (`Failed to construct provider "…"`), `ProviderDependencyError`, `DependencyCycleError` (`Circular dependency detected: A -> B -> A`), `InvalidDependencyScopeError`, `PluginDependencyError`, `InvalidPluginScopeError` and `DependencyNotFoundError`.

#### Registries, decorators and normalization

All internal, thrown by FrontMCP, most of them while the server starts:

- **Registries**: `DuplicateAppIdError` (`DUPLICATE_APP_ID`, `Two apps share the id "status": …`, for two [`App.remote()`](https://frontmcp.dev/reference/server/remote#two-apps-share-the-id-status) or `App.esm()` apps with one id; new in 1.9.3), `RegistryDefinitionNotFoundError`, `RegistryGraphEntryNotFoundError`, `RegistryDependencyNotRegisteredError` (`Tool "X" depends on "Db", which is not registered`), `InvalidRegistryKindError`, `NameDisambiguationError`, `EntryValidationError`, `FlowNotRegisteredError`, `UnsupportedHookOwnerKindError`, `InvalidHookFlowError` and `RegistryNotInitializedError`.
- **Decorators**: `InvalidDecoratorMetadataError` (`@FrontMcp invalid metadata for "apps": …`, see [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp#frontmcp-invalid-metadata-for-apps)) and `HookTargetNotDefinedError`.
- **Normalization**, for an entry in a `providers`, `plugins`, `tools` or other list that isn't what FrontMCP expects: `MissingProvideError`, `InvalidUseClassError`, `InvalidUseFactoryError`, `InvalidUseValueError` and `InvalidEntityError` (`Invalid Tool 'X'. Expected a class or function.`).

#### Transport

Internal, thrown by FrontMCP's transports: `MethodNotImplementedError`, `UnsupportedTransportTypeError`, `InvalidTransportSessionError`, `TransportNotConnectedError`, `TransportAlreadyStartedError`, `TransportServiceNotAvailableError` and `SessionClaimConflictError`. `TransportBusRequiredError` is never thrown. Two are public: `UnsupportedContentTypeError` (`UNSUPPORTED_CONTENT_TYPE`, for an older client's SSE message with the wrong content type) and `PayloadTooLargeError` (`PAYLOAD_TOO_LARGE`, HTTP `413`).

#### ESM packages

[Loading apps from npm](https://frontmcp.dev/reference/server/esm) covers how a package fails to load. For an [`App.esm()`](https://frontmcp.dev/reference/sdk/app#apps-from-outside-your-code) package, FrontMCP throws these while the server starts, so each one stops it:

| Class | `code` | When |
| --- | --- | --- |
| `EsmVersionResolutionError` | `ESM_VERSION_RESOLUTION_ERROR` | No version matches the range, or the registry doesn't have the package: `Failed to resolve version for "@acme/nope@1.0.0": Package "@acme/nope" not found in registry at …`. |
| `EsmRegistryAuthError` | `ESM_REGISTRY_AUTH_ERROR` | The registry answered `401` or `403`. |
| `EsmPackageLoadError` | `ESM_PACKAGE_LOAD_ERROR` | The package's bundle couldn't be fetched or run: `Failed to load ESM package "@acme/status-tools"@1.0.0: …`. |
| `EsmManifestInvalidError` | `ESM_MANIFEST_INVALID` | The package loaded, but doesn't export a FrontMCP package: `Invalid manifest in ESM package "…": ESM module does not export a valid FrontMcpPackageManifest. …` |
| `EsmCacheError` | `ESM_CACHE_ERROR` | The bundle couldn't be cached. |
| `EsmInvalidSpecifierError` | `ESM_INVALID_SPECIFIER` | Thrown by `App.esm()` itself, as soon as it is called, for a specifier that isn't a package name with an optional range. |

Changed in 1.9.2: before, these were exported but never thrown, and a package that failed to load failed with a plain `Error`.

Since 1.9.1, for a single tool, resource or prompt loaded with `Tool.esm()`, `Tool.remote()` and the like: `ExternalEntryLoadError`, when its package doesn't load or its server can't be reached, `ExternalEntryNotFoundError`, when the package or server has no entry by that name, and `ExternalEntryNotSupportedError`, for an agent, skill or job loaded that way, which FrontMCP refuses. All three stop the server from starting.

#### Everything else

Internal, thrown by FrontMCP: `FlowExitedWithoutOutputError` (`Flow exited without producing output`: a tool's `execute()` returned `undefined`), `RequestContextNotAvailableError` (`this.context` used outside a request), `ContextExtensionNotAvailableError`, `ServerNotFoundError`, `ConfigNotFoundError`, `RequiredConfigUndefinedError`, `SessionVerificationFailedError`, `ScopeConfigurationError`, `HaConfigurationError`, `InvokeStateMissingKeyError`, `FlowInputMissingError`, `DynamicAdapterNameError`, `ServerlessHandlerNotInitializedError`, `VercelKvNotSupportedError`, the skill errors `InvalidSkillError`, `SkillInstructionFetchError`, `InvalidInstructionSourceError`, `SkillSessionError` and `SkillContextNotImplementedError`, the agent errors `AgentConfigKeyNotFoundError`, `AgentToolExecutionError` and `AgentMethodNotAvailableError`, and `ToolNameConflictError`, for a tool added with [`registerTool()`](https://frontmcp.dev/reference/sdk/register-tool) under a name the server already has (new in 1.9). `SkillValidationError` is public: it stops the server from starting when a skill with `toolValidation: "strict"` names a tool the server doesn't have (`Skill '…' failed tool validation: missing tools […]`, since 1.9; see [`@Skill`](https://frontmcp.dev/reference/sdk/skill#skill--failed-tool-validation-missing-tools-)). `GlobalConfigNotFoundError` (`Plugin "cache" requires global "redis" configuration`) is public, and so is `UnenforcedMetadataError` (`UNENFORCED_METADATA`), which stops the server from starting when an entry declares a plugin's option, like `approval`, and no plugin that enforces it covers the entry: see [`@Plugin`](https://frontmcp.dev/reference/sdk/plugin#unenforced-metadata-tool--declares-). `VercelKvAsyncInitRequiredError` and `EnclaveExecutionError` are never thrown.

`FlowControl` isn't an error to report: it's how `this.fail()` and `this.respond()` end a call, and its `message` is empty. See [A tool call succeeds after `this.fail()`](https://frontmcp.dev/reference/sdk/fail#a-tool-call-succeeds-after-thisfail).

#### Caveats

- **Fail with a public class for anything the model should read.** `PublicMcpError`, or one of `InvalidInputError`, `RateLimitError`, `QuotaExceededError`, `UnauthorizedError` and `ResourceNotFoundError`, or a subclass of your own.
- **Throwing and failing differ only for errors that aren't public**: `TOOL_EXECUTION_ERROR` when thrown, the error's own code through `this.fail()`. Production hides both.
- **In a resource or prompt, `statusCode` picks the JSON-RPC code.** A `PublicMcpError` with status `404` is `-32602`, like any other client error; `401` and `403` are the ones that change it.
- **A development client reads more than a production one**: the stack, an internal error's message, and for a few classes, a longer developer message. Test what production sends with `createErrorHandler({ isDevelopment: false })`.
- **Many exported classes are never thrown in 1.9.3**, as the tables say. Don't wait for them in a client.

---

## Usage

### Failing with a built-in class

Each sample below is a class you might fail a tool with. `fail_with` fails with the one you name; the test checks what a client reads for each, in development. Try other samples in the Call tab.

```ts samples.ts active
import {
  ConcurrencyLimitError,
  ElicitationDisabledError,
  InternalMcpError,
  InvalidInputError,
  PublicMcpError,
  QuotaExceededError,
  RateLimitError,
  SessionSecretRequiredError,
  UnauthorizedError,
} from "@frontmcp/sdk";

export const samples: Record<string, Error> = {
  public: new PublicMcpError("There's no ticket T-9."),
  publicWithCode: new PublicMcpError("Ticket T-2 is archived. Ask a lead to restore it.", "TICKET_ARCHIVED", 409),
  invalidInput: new InvalidInputError("`due` must be in the future.", { field: "due" }),
  rateLimit: new RateLimitError(3600),
  quota: new QuotaExceededError("export"),
  unauthorized: new UnauthorizedError("Sign in again to export tickets."),
  internal: new InternalMcpError("replica lag 12s on tickets-db-2", "DB_LAG"),
  plain: new Error("connect ECONNREFUSED 10.0.4.12:5432"),
  guard: new ConcurrencyLimitError("export_tickets", 1),
  elicitationOff: new ElicitationDisabledError(),
  authInternal: new SessionSecretRequiredError("sessions"),
};
```

```ts fail-with.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { samples } from "./samples";

@Tool({
  name: "fail_with",
  description: "Fail with one of the sample errors",
  inputSchema: { sample: z.string().describe("A key of `samples`, like invalidInput") },
})
export class FailWith extends ToolContext {
  async execute({ sample }: { sample: string }) {
    this.fail(samples[sample]);
  }
}
```

```ts development.test.ts
import { test, expect } from "@frontmcp/testing";

const expected: Record<string, [code: string, text: string | RegExp]> = {
  public: ["PUBLIC_ERROR", "There's no ticket T-9."],
  publicWithCode: ["TICKET_ARCHIVED", "Ticket T-2 is archived. Ask a lead to restore it."],
  invalidInput: ["INVALID_INPUT", '`due` must be in the future.\nDetails: {\n  "field": "due"\n}'],
  rateLimit: ["RATE_LIMIT_EXCEEDED", "Rate limit exceeded. Retry after 3600 seconds"],
  quota: ["QUOTA_EXCEEDED", "export quota exceeded"],
  unauthorized: ["UNAUTHORIZED", "Sign in again to export tickets."],
  internal: ["DB_LAG", "replica lag 12s on tickets-db-2"],
  plain: ["SERVER_ERROR", /^connect ECONNREFUSED 10\.0\.4\.12:5432\n\nOriginal error:\nError: connect ECONNREFUSED/],
  guard: ["CONCURRENCY_LIMIT", 'Concurrency limit reached for "export_tickets" (max: 1)'],
  elicitationOff: ["ELICITATION_DISABLED", "Elicitation is disabled in server configuration. Enable it via @FrontMcp({ elicitation: { enabled: true } })"],
  authInternal: ["SERVER_ERROR", /^Session secret is required for sessions/],
};

for (const [sample, [code, text]] of Object.entries(expected)) {
  test(`${sample}: ${code}`, async ({ mcp }) => {
    const result = await mcp.tools.call("fail_with", { sample });
    expect(result).toBeError(code);
    if (typeof text === "string") expect(result.text()).toBe(text);
    else expect(result.text()).toMatch(text);
    expect(result.raw._meta).toHaveProperty("stack");
  });
}
```

### What production sends

The same samples, formatted by an `ErrorHandler` with `isDevelopment: false`, which is what `tools/call` uses when `NODE_ENV=production`. Public errors keep their message; internal ones, plain errors and `@frontmcp/auth`'s errors become an error ID; `ElicitationDisabledError` switches to its shorter message:

```ts production.test.ts active
import { test, expect } from "@frontmcp/testing";
import { createErrorHandler, isPublicError, toMcpError } from "@frontmcp/sdk";
import { samples } from "./samples";

const production = createErrorHandler({ isDevelopment: false });
const hidden = /^Internal FrontMCP error\. Please contact support with error ID: err_[0-9a-f]{16}$/;

const expected: Record<string, [code: string, text: string | RegExp]> = {
  public: ["PUBLIC_ERROR", "There's no ticket T-9."],
  invalidInput: ["INVALID_INPUT", '`due` must be in the future.\nDetails: {\n  "field": "due"\n}'],
  rateLimit: ["RATE_LIMIT_EXCEEDED", "Rate limit exceeded. Retry after 3600 seconds"],
  guard: ["CONCURRENCY_LIMIT", 'Concurrency limit reached for "export_tickets" (max: 1)'],
  elicitationOff: ["ELICITATION_DISABLED", "Elicitation is disabled on this server."],
  internal: ["DB_LAG", hidden],
  plain: ["SERVER_ERROR", hidden],
  authInternal: ["SERVER_ERROR", hidden],
};

for (const [sample, [code, text]] of Object.entries(expected)) {
  test(`${sample} in production`, () => {
    const result = production.handle(samples[sample]);
    expect(result._meta?.code).toBe(code);
    if (typeof text === "string") expect(result.content[0].text).toBe(text);
    else expect(result.content[0].text).toMatch(text);
    expect(result._meta).not.toHaveProperty("stack");
  });
}

test("the error ID is the one in the server's log", () => {
  const result = production.handle(samples.internal);
  expect(result._meta?.errorId).toBe((samples.internal as { errorId?: string }).errorId);
});

test("guard errors aren't McpErrors, but are sent as public ones", () => {
  expect(isPublicError(samples.guard)).toBe(false);
  expect(toMcpError(samples.guard)).toMatchObject({ code: "CONCURRENCY_LIMIT", isPublic: true });
});
```

```ts samples.ts
import {
  ConcurrencyLimitError,
  ElicitationDisabledError,
  InternalMcpError,
  InvalidInputError,
  PublicMcpError,
  QuotaExceededError,
  RateLimitError,
  SessionSecretRequiredError,
  UnauthorizedError,
} from "@frontmcp/sdk";

export const samples: Record<string, Error> = {
  public: new PublicMcpError("There's no ticket T-9."),
  publicWithCode: new PublicMcpError("Ticket T-2 is archived. Ask a lead to restore it.", "TICKET_ARCHIVED", 409),
  invalidInput: new InvalidInputError("`due` must be in the future.", { field: "due" }),
  rateLimit: new RateLimitError(3600),
  quota: new QuotaExceededError("export"),
  unauthorized: new UnauthorizedError("Sign in again to export tickets."),
  internal: new InternalMcpError("replica lag 12s on tickets-db-2", "DB_LAG"),
  plain: new Error("connect ECONNREFUSED 10.0.4.12:5432"),
  guard: new ConcurrencyLimitError("export_tickets", 1),
  elicitationOff: new ElicitationDisabledError(),
  authInternal: new SessionSecretRequiredError("sessions"),
};
```

```ts fail-with.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { samples } from "./samples";

@Tool({
  name: "fail_with",
  description: "Fail with one of the sample errors",
  inputSchema: { sample: z.string().describe("A key of `samples`, like invalidInput") },
})
export class FailWith extends ToolContext {
  async execute({ sample }: { sample: string }) {
    this.fail(samples[sample]);
  }
}
```

### Failing vs throwing

A public error reads the same thrown or failed. The others don't: thrown from a tool's `execute()`, an `InternalMcpError` or a guard error other than a timeout is wrapped as `TOOL_EXECUTION_ERROR`, and a `ToolCredentialsRequiredError` becomes a JSON-RPC error rather than a tool result. Thrown from a resource, every guard error is wrapped, the timeout too:

```ts tools.ts active
import {
  ConcurrencyLimitError,
  ExecutionTimeoutError,
  InternalMcpError,
  PublicMcpError,
  ResourceContext,
  ResourceTemplate,
  Tool,
  ToolContext,
  ToolCredentialsRequiredError,
  z,
} from "@frontmcp/sdk";

const make: Record<string, () => Error> = {
  public: () => new PublicMcpError("There's no ticket T-9.", "TICKET_NOT_FOUND", 404),
  internal: () => new InternalMcpError("replica lag 12s", "DB_LAG"),
  concurrency: () => new ConcurrencyLimitError("export", 1),
  timeout: () => new ExecutionTimeoutError("export", 500),
  credentials: () => new ToolCredentialsRequiredError({ toolId: "sync_crm", providers: ["crm"], authUrl: "https://desk.example.com/connect/crm" }),
};

const input = { sample: z.enum(["public", "internal", "concurrency", "timeout", "credentials"]) };
type Input = { sample: "public" | "internal" | "concurrency" | "timeout" | "credentials" };

@Tool({ name: "throw_it", description: "Throw a sample error", inputSchema: input })
export class ThrowIt extends ToolContext {
  async execute({ sample }: Input): Promise<{ ok: boolean }> {
    throw make[sample]();
  }
}

@Tool({ name: "fail_it", description: "Fail with a sample error", inputSchema: input })
export class FailIt extends ToolContext {
  async execute({ sample }: Input): Promise<{ ok: boolean }> {
    this.fail(make[sample]());
  }
}

@ResourceTemplate({ name: "thrown", uriTemplate: "thrown://{sample}", mimeType: "text/plain" })
export class Thrown extends ResourceContext<{ sample: string }> {
  async execute(uri: string, { sample }: { sample: string }): Promise<{ contents: { uri: string; text: string }[] }> {
    throw make[sample]();
  }
}
```

```ts throw-vs-fail.test.ts
import { test, expect } from "@frontmcp/testing";

const codes = async (mcp: any, sample: string) => {
  const thrown = await mcp.tools.call("throw_it", { sample });
  const failed = await mcp.tools.call("fail_it", { sample });
  const code = (r: any) => (r.error ? r.error.code : r.raw._meta?.code);
  return [code(thrown), code(failed)];
};

test("a public error: the same either way", async ({ mcp }) => {
  expect(await codes(mcp, "public")).toEqual(["TICKET_NOT_FOUND", "TICKET_NOT_FOUND"]);
});

test("an internal error: wrapped when thrown", async ({ mcp }) => {
  expect(await codes(mcp, "internal")).toEqual(["TOOL_EXECUTION_ERROR", "DB_LAG"]);
  expect((await mcp.tools.call("throw_it", { sample: "internal" })).text()).toMatch(/^Tool "throw_it" execution failed: replica lag 12s/);
});

test("guard errors: only a timeout keeps its code when thrown", async ({ mcp }) => {
  expect(await codes(mcp, "concurrency")).toEqual(["TOOL_EXECUTION_ERROR", "CONCURRENCY_LIMIT"]);
  expect(await codes(mcp, "timeout")).toEqual(["EXECUTION_TIMEOUT", "EXECUTION_TIMEOUT"]);
});

test("in a resource, a thrown guard error is wrapped too, even a timeout", async ({ mcp }) => {
  expect((await mcp.resources.read("thrown://public")).error).toMatchObject({ code: -32602, data: { code: "TICKET_NOT_FOUND" } });
  expect((await mcp.resources.read("thrown://timeout")).error).toMatchObject({
    code: -32603,
    message: 'Resource "thrown://timeout" read failed: Execution of "export" timed out after 500ms',
    data: { code: "RESOURCE_READ_ERROR" },
  });
});

test("missing credentials: a JSON-RPC error when thrown", async ({ mcp }) => {
  const thrown = await mcp.tools.call("throw_it", { sample: "credentials" });
  expect(thrown.error).toMatchObject({ code: -32001, data: { tool: "sync_crm", providers: ["crm"], authUrl: "https://desk.example.com/connect/crm" } });
  expect(await mcp.tools.call("fail_it", { sample: "credentials" })).toBeError("TOOL_CREDENTIALS_REQUIRED");
});
```

### Failing in a resource or a prompt

In a resource or a prompt, the class's `statusCode` picks the JSON-RPC code. `TicketLockedError` is a public error of this server's own, with status `403`:

```ts ticket.resource.ts active
import { InternalMcpError, PublicMcpError, ResourceContext, ResourceNotFoundError, ResourceTemplate, UnauthorizedError } from "@frontmcp/sdk";

export class TicketLockedError extends PublicMcpError {
  constructor(id: string) {
    super(`Ticket ${id} is under legal hold.`, "TICKET_LOCKED", 403);
  }
}

@ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" })
export class Ticket extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    if (id === "T-3") this.fail(new TicketLockedError(id));
    if (id === "T-4") this.fail(new UnauthorizedError("Sign in to read archived tickets."));
    if (id === "T-5") this.fail(new PublicMcpError("The ticket store is down for maintenance.", "STORE_DOWN", 503));
    if (id === "T-6") this.fail(new InternalMcpError("replica lag 12s", "DB_LAG"));
    if (id !== "T-1") this.fail(new ResourceNotFoundError(uri));
    return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ id, title: "Cannot log in" }) }] };
  }
}
```

```ts resource-errors.test.ts
import { test, expect } from "@frontmcp/testing";

test("status 403 is -32003", async ({ mcp }) => {
  const result = await mcp.resources.read("tickets://T-3");
  expect(result.error).toMatchObject({ code: -32003, message: "Ticket T-3 is under legal hold.", data: { code: "TICKET_LOCKED" } });
});

test("status 401 is -32001", async ({ mcp }) => {
  expect((await mcp.resources.read("tickets://T-4")).error).toMatchObject({ code: -32001, data: { code: "UNAUTHORIZED" } });
});

test("a public error with status 503 is -32603, message kept", async ({ mcp }) => {
  const result = await mcp.resources.read("tickets://T-5");
  expect(result.error).toMatchObject({ code: -32603, message: "The ticket store is down for maintenance.", data: { code: "STORE_DOWN" } });
});

test("an internal error is -32603", async ({ mcp }) => {
  expect((await mcp.resources.read("tickets://T-6")).error).toMatchObject({ code: -32603, data: { code: "DB_LAG" } });
});

test("ResourceNotFoundError reads like an unknown URI", async ({ mcp }) => {
  const missing = await mcp.resources.read("tickets://T-9");
  const unknown = await mcp.resources.read("invoices://1");
  expect(missing.error).toMatchObject({ code: -32602, message: "Resource not found: tickets://T-9", data: { uri: "tickets://T-9" } });
  expect(unknown.error).toMatchObject({ code: -32602, message: "Resource not found: invoices://1", data: { uri: "invoices://1" } });
});
```

### Errors FrontMCP raises for you

Most errors a client sees come from FrontMCP, before or around your code. Here each test makes FrontMCP raise one:

```ts main.ts active
import { App, FrontMcp, Job, JobContext, Prompt, PromptContext, Resource, ResourceContext, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string().min(1) } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { query, tickets: [] };
  }
}

@Tool({ name: "close_ticket", description: "Close a ticket", inputSchema: { id: z.string() }, authorities: "admin" })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, closed: true };
  }
}

@Tool({ name: "run_on_deno", description: "Only on Deno", inputSchema: {}, availableWhen: { runtime: ["deno"] } })
class RunOnDeno extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

@Tool({ name: "export_archive", description: "Export the ticket archive", inputSchema: {}, execution: { taskSupport: "required" } })
class ExportArchive extends ToolContext {
  async execute() {
    return { rows: 18_000 };
  }
}

@Prompt({ name: "summarize_ticket", description: "Summarize a ticket", arguments: [{ name: "id", required: true }] })
class SummarizeTicket extends PromptContext {
  async execute({ id }: Record<string, string>) {
    return { messages: [{ role: "user" as const, content: { type: "text" as const, text: `Summarize ticket ${id}.` } }] };
  }
}

@Resource({ name: "audit-log", uri: "desk://audit-log", mimeType: "text/plain", authorities: "admin" })
class AuditLog extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, text: "T-1 closed by nour" }] };
  }
}

@Resource({ name: "deploy-notes", uri: "desk://deploy-notes", mimeType: "text/plain", availableWhen: { runtime: ["deno"] } })
class DeployNotes extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, text: "Deploy with deployctl." }] };
  }
}

@Job({ name: "nightly_cleanup", description: "Close stale tickets", inputSchema: {}, outputSchema: {} })
class NightlyCleanup extends JobContext {
  async execute() {
    return {};
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [SearchTickets, CloseTicket, RunOnDeno, ExportArchive],
  resources: [AuditLog, DeployNotes],
  prompts: [SummarizeTicket],
  jobs: [NightlyCleanup],
})
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  authorities: { profiles: { admin: { roles: { any: ["admin"] } } } },
})
export default class Server {}
```

```ts frontmcp-errors.test.ts
import { test, expect } from "@frontmcp/testing";

test("an unknown tool: TOOL_NOT_FOUND", async ({ mcp }) => {
  const result = await mcp.tools.call("delete_everything", {});
  expect(result).toBeError("TOOL_NOT_FOUND");
  expect(result.text()).toBe('Tool "delete_everything" not found');
});

test("arguments that don't match the schema: INVALID_INPUT", async ({ mcp }) => {
  const result = await mcp.tools.call("search_tickets", { query: "" });
  expect(result).toBeError("INVALID_INPUT");
  expect(result.text()).toMatch(/^Invalid tool input\nDetails: \[/);
});

test("a caller an authorities rule refuses: AUTHORITY_DENIED", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result).toBeError("AUTHORITY_DENIED");
  expect(result.text()).toBe(`Access denied to Tool "help-desk:close_ticket": profile:admin: roles.any: user has none of 'admin'`);
});

test("a resource an authorities rule refuses: -32003", async ({ mcp }) => {
  const result = await mcp.resources.read("desk://audit-log");
  expect(result.error).toMatchObject({
    code: -32003,
    message: `Access denied to Resource "help-desk:audit-log": profile:admin: roles.any: user has none of 'admin'`,
    data: { entryType: "Resource", deniedBy: "profile:admin: roles.any: user has none of 'admin'" },
  });
});

test("a tool whose availableWhen doesn't match: ENTRY_UNAVAILABLE", async ({ mcp }) => {
  const result = await mcp.tools.call("run_on_deno", {});
  expect(result).toBeError("ENTRY_UNAVAILABLE");
  expect(result.text()).toMatch(/^Tool "run_on_deno" is not available in the current environment \(requires: \{"runtime":\["deno"\]\}\) \(current: \{"os":/);
});

test("a resource whose availableWhen doesn't match: -32003", async ({ mcp }) => {
  const result = await mcp.resources.read("desk://deploy-notes");
  expect(result.error).toMatchObject({ code: -32003, data: { entryType: "Resource", missingAxes: ["runtime"] } });
  expect(result.error?.message).toMatch(/^Resource "desk:\/\/deploy-notes" is not available in the current environment/);
});

test("a job that doesn't exist: JOB_NOT_AUTHORIZED", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "delete_everything", input: {} });
  expect(result).toBeError("JOB_NOT_AUTHORIZED");
  expect(result.text()).toBe('Job or workflow "delete_everything" not found or not permitted');
});

test("a task-only tool, called without the tasks extension: -32602", async ({ mcp }) => {
  const result = await mcp.tools.call("export_archive", {});
  expect(result.error).toEqual({
    code: -32602,
    message: 'Tool "export_archive" runs as a task; declare the io.modelcontextprotocol/tasks extension in clientCapabilities',
  });
});

test("an unknown prompt, and a missing argument: -32602", async ({ mcp }) => {
  expect((await mcp.prompts.get("escalate", {})).error).toMatchObject({ code: -32602, message: "Prompt not found: escalate", data: { code: "PROMPT_NOT_FOUND" } });
  expect((await mcp.prompts.get("summarize_ticket", {})).error).toMatchObject({
    code: -32602,
    message: "Missing required argument: id",
    data: { code: "MISSING_PROMPT_ARGUMENT" },
  });
});
```

`ENTRY_UNAVAILABLE`'s message lists the server's OS, runtime and deployment, in production too, for tools, resources and prompts alike. If that's more than callers should know, leave the entry out of the server instead of relying on `availableWhen`.

---

## Troubleshooting

### `Internal FrontMCP error. Please contact support with error ID: err_…`

The server runs with `NODE_ENV=production`, and the call failed with an internal error: an `InternalMcpError` or one of FrontMCP's, a plain `Error`, or one of `@frontmcp/auth`'s. Search the server's log for the error ID: FrontMCP logs the original message and stack with it. If the model should read the message, fail with a [public class](#failing-with-a-built-in-class). See [`this.fail`](https://frontmcp.dev/reference/sdk/fail#internal-frontmcp-error-please-contact-support-with-error-id-err_).

### My error's code arrives as `TOOL_EXECUTION_ERROR`

It was thrown from `execute()` and isn't public: an `InternalMcpError`, a guard error, or an `Error` with a `code` property of its own. Throw a `PublicMcpError` subclass, or pass the error to `this.fail()`, which keeps an `McpError`'s code. See [Failing vs throwing](#failing-vs-throwing).

### My error's code arrives as `SERVER_ERROR`

It isn't an `McpError`: a plain `Error`, or one of `@frontmcp/auth`'s internal errors, passed to `this.fail()`. Only `McpError`s and guard errors keep their code. Wrap it: `this.fail(new InternalMcpError(error.message, "CRM_SYNC_FAILED"))` keeps the message out of production, with a code clients can tell apart.

### A resource error arrives as `-32602` when I meant "not found"

Under MCP 2026-07-28, every client error from a resource is `-32602`, including `ResourceNotFoundError`; `-32002` is only for older clients. Tell errors apart by `data.code`, which is your class's `code`. `ResourceNotFoundError` is the exception: its `data` is `{ uri }`, so check the message.

### The client sees a long developer message, like `Elicitation is disabled in server configuration. Enable it via …`

In development, a tool's error text is the internal message, written for you. Production sends the public one: here, `Elicitation is disabled on this server.` Resources and prompts send the public one in both. See [What production sends](#what-production-sends).

### I'm waiting for `AgentLoopExceededError` (or `QuotaExceededError`, `SessionMissingError`, an ESM error…) and it never comes

FrontMCP 1.9.3 exports these classes but never throws them. The [tables](#the-classes) say which, and what's thrown instead, like `TOOL_EXECUTION_ERROR` for an agent that runs out of turns.
