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(); for every code a client can receive, see Error codes.
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).
| 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; 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:
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.codeis the error'scode.this.fail()passes everyMcpErrorthrough 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: codeTOOL_EXECUTION_ERROR, textTool "…" execution failed:and the message. Passed tothis.fail(), the sameInternalMcpErrorkeeps its code, and a plainErrorbecomesSERVER_ERROR. See it run. - The text is
getInternalMessage()in development andgetPublicMessage()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 thrownToolCredentialsRequiredError(-32001, withdata.authUrl); for clients before MCP 2026-07-28,TaskAugmentationNotSupportedErrorandTaskAugmentationRequiredError(-32601); and under 2026-07-28, a call to a tool withexecution.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.
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), 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 with createErrorHandler({ isDevelopment: false }).
Errors that aren't McpErrors
- Any other
Error, like aTypeErroror a driver's error, is internal. Passed tothis.fail(), it becomes aGenericServerError, codeSERVER_ERROR; in development the text addsOriginal error:and the stack. - The guard's errors (
ExecutionTimeoutError,ConcurrencyLimitErrorand the rest) extendGuardError, notMcpError. Passed tothis.fail(), they keep their code and message, as public errors. Thrown, onlyExecutionTimeoutErrorkeeps 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,SessionSecretRequiredErrorand the rest) extendAuthInternalError. Their own code is lost: clients getSERVER_ERROR.AuthorityDeniedError, from@frontmcp/auth, is howauthoritiesrefuses a call. Clients get codeAUTHORITY_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 McpErrors 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), 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 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(): 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. |
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 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. |
ToolNotConsentedError(name) | TOOL_NOT_CONSENTED | -32003 (own) | Kept | FrontMCP, for a tool the user didn't select when consenting. See Tools: consent. |
ToolCredentialsRequiredError({ toolId, providers, authUrl? }) | TOOL_CREDENTIALS_REQUIRED | -32001 (own), also in tools/call | Kept | FrontMCP, for a tool whose required authProviders credential isn't connected. See Progressive auth. |
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. |
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 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 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 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. 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. |
ConcurrencyLimitError(name, max) | CONCURRENCY_LIMIT | -32602 through this.fail() | Kept | FrontMCP, for 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 and 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 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() 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. 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 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() 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(). |
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 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. |
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. |
DynamicJobDirectExecutionError(name?) | DYNAMIC_JOB_DIRECT_EXECUTION | Hidden | FrontMCP, for a dynamic job run outside its sandbox. |
Remote apps
Remote servers covers how a remote app fails, and what each failure looks like.
For App.remote(). 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" } 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: ProviderNotAvailableError (PROVIDER_NOT_AVAILABLE: what this.get() 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 twoApp.remote()orApp.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,InvalidHookFlowErrorandRegistryNotInitializedError. - Decorators:
InvalidDecoratorMetadataError(@FrontMcp invalid metadata for "apps": …, see@FrontMcp) andHookTargetNotDefinedError. - Normalization, for an entry in a
providers,plugins,toolsor other list that isn't what FrontMCP expects:MissingProvideError,InvalidUseClassError,InvalidUseFactoryError,InvalidUseValueErrorandInvalidEntityError(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 covers how a package fails to load. For an App.esm() 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() 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). 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. 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().
Caveats
- Fail with a public class for anything the model should read.
PublicMcpError, or one ofInvalidInputError,RateLimitError,QuotaExceededError,UnauthorizedErrorandResourceNotFoundError, or a subclass of your own. - Throwing and failing differ only for errors that aren't public:
TOOL_EXECUTION_ERRORwhen thrown, the error's own code throughthis.fail(). Production hides both. - In a resource or prompt,
statusCodepicks the JSON-RPC code. APublicMcpErrorwith status404is-32602, like any other client error;401and403are 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.
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"),
};Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 });
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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]();
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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" }) }] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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. See this.fail.
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.
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 McpErrors 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.
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 say which, and what's thrown instead, like TOOL_EXECUTION_ERROR for an agent that runs out of turns.