# this.fail

> End a tool call with an error the model can read, and choose the code it carries.

Source: https://frontmcp.dev/reference/sdk/fail

`this.fail()` ends a tool call with an error result instead of a return value. The model reads that result like any other, so a clear message lets it recover: try another id, ask the user, or stop. Pass a `PublicMcpError`, and its message reaches the model in development and in production alike.

```ts
this.fail(error)
```

---

## Reference

### `this.fail(error)`

Call `this.fail()` inside a tool's `execute()`. Nothing after it runs.

```ts close-ticket.tool.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = this.get(TicketStore).find(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    if (ticket.status === "closed") this.fail(new PublicMcpError(`Ticket ${id} is already closed.`, "TICKET_CLOSED"));
    return this.get(TicketStore).close(id);
  }
}
```

[See more examples below.](#usage)

#### Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| `error` | `Error` | What went wrong. Use a `PublicMcpError`, or one of [its subclasses](#error-classes), for a message the model should read. Any other error is [hidden in production](#what-the-client-receives). |

#### Returns

`never`. `this.fail()` throws to end `execute()`, and TypeScript knows it: after `if (!ticket) this.fail(…)`, `ticket` is no longer `undefined`.

#### What the client receives

In a tool, `this.fail()` produces a normal `tools/call` result, with `isError: true`:

```json
{
  "content": [{ "type": "text", "text": "There's no ticket T-9." }],
  "isError": true,
  "_meta": {
    "errorId": "err_4418a34f3368d9dc",
    "code": "PUBLIC_ERROR",
    "timestamp": "2026-09-24T09:12:03.512Z",
    "stack": "PublicMcpError: There's no ticket T-9.\n    at GetTicket.execute (…)"
  }
}
```

`errorId` is new for every error, and `stack` is only sent in development. (Under MCP 2026-07-28, `_meta` also carries the server's `io.modelcontextprotocol/serverInfo`.) The text and the code depend on what you pass:

| You pass | `_meta.code` | Text in development | Text in production |
| --- | --- | --- | --- |
| `new PublicMcpError(message)` | `PUBLIC_ERROR` | `message` | `message` |
| `new PublicMcpError(message, code)` | `code` | `message` | `message` |
| Another [public error class](#error-classes) | its code | its message | its message |
| `new InternalMcpError(message, code?)` | `INTERNAL_ERROR`, or `code` | `message` | `Internal FrontMCP error. Please contact support with error ID: err_…` |
| Any other error, like `new Error(message)` | `SERVER_ERROR` | `message`, then `Original error:` and the stack | `Internal FrontMCP error. Please contact support with error ID: err_…` |

Development means `NODE_ENV` isn't `production`. The Playground always runs in development; [see what production sends](#what-production-hides).

#### Error classes

These are exported from `@frontmcp/sdk`. All but `InternalMcpError` are public: their message reaches the model in production. They're the ones you'll use with `this.fail()`; [Error classes](https://frontmcp.dev/reference/sdk/error-classes) lists every class FrontMCP exports, and what each one looks like to a client.

| Class | `_meta.code` | Text |
| --- | --- | --- |
| `PublicMcpError(message, code?, statusCode?)` | `PUBLIC_ERROR`, or `code` | `message`. `statusCode` (default `400`) isn't part of a tool result. |
| `InvalidInputError(message?, details?)` | `INVALID_INPUT` | `message` (default "Invalid input: validation failed"), then `Details:` and `details` as JSON, if given. It's the code FrontMCP uses when arguments don't match `inputSchema`. |
| `RateLimitError(retryAfter?)` | `RATE_LIMIT_EXCEEDED` | "Rate limit exceeded", then ". Retry after 60 seconds" when `retryAfter` is `60`. |
| `QuotaExceededError(quotaType?)` | `QUOTA_EXCEEDED` | "usage quota exceeded", with `quotaType` in place of "usage". |
| `UnauthorizedError(message?)` | `UNAUTHORIZED` | `message`, default "Unauthorized". |
| `ResourceNotFoundError(uri)` | `RESOURCE_NOT_FOUND` | "Resource not found: " and the URI. |
| `InternalMcpError(message, code?)` | `INTERNAL_ERROR`, or `code` | `message` in development only. |

Every one of them has an `errorId` and a `code`, and is an `instanceof McpError`.

#### In resources and prompts

`ResourceContext` and `PromptContext` have `this.fail()` too, and it works the same way: a public error keeps its message and code, and any other error is hidden in production. What changes is the shape. `resources/read` and `prompts/get` answer with a JSON-RPC error instead of a result, and the code that a tool puts in `_meta.code` is in `error.data.code`:

```json
{
  "code": -32602,
  "message": "There's no ticket T-9.",
  "data": { "errorId": "err_fe0e63b17b92dc8f", "code": "PUBLIC_ERROR" }
}
```

| You pass | `code` | `message` | `data.code` |
| --- | --- | --- | --- |
| `new PublicMcpError(message, code?)` | `-32602` | `message` | `PUBLIC_ERROR`, or `code` |
| `new UnauthorizedError(message?)` | `-32001` | `message` | `UNAUTHORIZED` |
| Any other error, like `new Error(message)` | `-32603` | `message` in development, `Internal FrontMCP error. Please contact support with error ID: err_…` in production | `SERVER_ERROR` |

Throwing a public error gives the same result. A thrown error that isn't public is wrapped: its message follows `Resource "tickets://T-9" read failed: ` or `Prompt execution failed: `, and `data.code` is `RESOURCE_READ_ERROR` or `PROMPT_EXECUTION_FAILED`.

#### Caveats

- **`this.fail()` works by throwing.** A `try`/`catch` around it catches it, as a `FlowControl`, and the call carries on. See [A tool call succeeds after `this.fail()`](#a-tool-call-succeeds-after-thisfail).
- **Throwing works too.** A `PublicMcpError` thrown from `execute()`, or from a provider it calls, reaches the client as if you had passed it to `this.fail()`. Only errors that aren't public come out differently: `TOOL_EXECUTION_ERROR` when thrown, `SERVER_ERROR` through `this.fail()`. See [Failing vs throwing](#failing-vs-throwing).
- **Every failure is in the [server log](https://frontmcp.dev/reference/server/logging#finding-the-line-for-an-error-id), with its error ID.** A public error is logged as a warning with the tool's name, the error ID and the code; any other error as an error, with its original message and stack. So an ID a user reports from production can be looked up.
- `this.fail()` is `protected`: call it from inside the tool class. Code elsewhere, like a provider, [throws a `PublicMcpError` instead](#errors-from-providers-and-other-code).
- In a resource or a prompt, [`this.respond()`](https://frontmcp.dev/reference/sdk/respond#ending-early-everywhere-else) ends the call with a result, as `this.fail()` ends it with an error: the value is sent as a returned one would be, an object as JSON text and a string as text. (Changed in 1.9.3: before, it failed with `Prompt output not found`, or `Resource output not found`.) An `execute()` that returns nothing still fails that way.

---

## Usage

### Failing with a message the model can read

Say what went wrong and what to do instead. The call ends there, and the model gets your message as the result.

```ts get-ticket.tool.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by id",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}. Search by title with search_tickets.`));
    return ticket;
  }
}
```

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

test("an unknown id fails with a public error", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-9" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("PUBLIC_ERROR");
  expect(result.text()).toBe("There's no ticket T-9. Search by title with search_tickets.");
});

test("a known id returns the ticket", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
});
```

### Choosing an error code

`_meta.code` lets a client, or your tests, tell failures apart without parsing the text. Give `PublicMcpError` a code of your own, or use a class that has one.

<Examples title="Error codes">

#### Example: Your own code
```ts close-ticket.tool.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND"));
    if (ticket.status === "closed") this.fail(new PublicMcpError(`Ticket ${id} is already closed.`, "TICKET_CLOSED"));
    ticket.status = "closed";
    return ticket;
  }
}
```

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

test("closing a closed ticket fails with TICKET_CLOSED", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-2" });
  expect(result.raw._meta?.code).toBe("TICKET_CLOSED");
  expect(result.text()).toBe("Ticket T-2 is already closed.");
});

test("an unknown ticket fails with TICKET_NOT_FOUND", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-9" });
  expect(result.raw._meta?.code).toBe("TICKET_NOT_FOUND");
});
```

#### Example: Invalid input
For a rule the schema can't express, like "in the future", fail with `InvalidInputError`: the same code as a schema failure, so the model treats it the same way. The second argument is added to the message as JSON.

```ts schedule-callback.tool.ts active
import { InvalidInputError, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "schedule_callback",
  description: "Schedule a phone call back to the customer who opened a ticket",
  inputSchema: { id: z.string(), at: z.string().datetime().describe("When to call, as an ISO date-time") },
})
export class ScheduleCallback extends ToolContext {
  async execute({ id, at }: { id: string; at: string }) {
    if (Date.parse(at) <= Date.now()) {
      this.fail(new InvalidInputError("`at` must be in the future.", { field: "at", received: at }));
    }
    return { id, callbackAt: at };
  }
}
```

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

test("a time in the past is INVALID_INPUT, with the details", async ({ mcp }) => {
  const result = await mcp.tools.call("schedule_callback", { id: "T-1", at: "2020-01-06T09:00:00Z" });
  expect(result.raw._meta?.code).toBe("INVALID_INPUT");
  expect(result.text()).toBe('`at` must be in the future.\nDetails: {\n  "field": "at",\n  "received": "2020-01-06T09:00:00Z"\n}');
});
```

#### Example: Rate limit
`RateLimitError` writes the message for you, including when to try again.

```ts escalate-ticket.tool.ts active
import { RateLimitError, Tool, ToolContext, z } from "@frontmcp/sdk";

const escalations = new Map<string, number>();

@Tool({ name: "escalate_ticket", description: "Escalate a ticket to the on-call engineer", inputSchema: { id: z.string() } })
export class EscalateTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const count = escalations.get(id) ?? 0;
    if (count >= 2) this.fail(new RateLimitError(3600));
    escalations.set(id, count + 1);
    return { id, escalation: count + 1 };
  }
}
```

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

test("the third escalation of a ticket is rate limited", async ({ mcp }) => {
  await mcp.tools.call("escalate_ticket", { id: "T-1" });
  await mcp.tools.call("escalate_ticket", { id: "T-1" });
  const third = await mcp.tools.call("escalate_ticket", { id: "T-1" });
  expect(third.raw._meta?.code).toBe("RATE_LIMIT_EXCEEDED");
  expect(third.text()).toBe("Rate limit exceeded. Retry after 3600 seconds");
});
```

### Failing vs throwing

For a `PublicMcpError`, `throw` and `this.fail()` give the same result: `find_ticket` throws, `get_ticket` fails, and the model reads the same message and code from both. They differ for errors that aren't public. `sync_ticket` throws a plain `Error`, which arrives as `TOOL_EXECUTION_ERROR`, with `Tool "sync_ticket" execution failed:` before the message; passed to `this.fail()`, the same error would be `SERVER_ERROR`. Production hides both.

```ts tickets.tool.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }];

@Tool({ name: "find_ticket", description: "Get one support ticket by id", inputSchema: { id: z.string() } })
export class FindTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) throw new PublicMcpError(`There's no ticket ${id}.`);
    return ticket;
  }
}

@Tool({ name: "get_ticket", description: "Get one support ticket by id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

@Tool({ name: "sync_ticket", description: "Copy a ticket to the CRM", inputSchema: { id: z.string() } })
export class SyncTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    throw new Error(`connect ECONNREFUSED 10.0.4.12:5432 while syncing ${id}`); // 🚩 not public
  }
}
```

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

test("throwing a public error keeps its message and code", async ({ mcp }) => {
  const result = await mcp.tools.call("find_ticket", { id: "T-9" });
  expect(result.raw._meta?.code).toBe("PUBLIC_ERROR");
  expect(result.text()).toBe("There's no ticket T-9.");
});

test("failing with it gives the same result", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-9" });
  expect(result.raw._meta?.code).toBe("PUBLIC_ERROR");
  expect(result.text()).toBe("There's no ticket T-9.");
});

test("throwing a plain Error gives TOOL_EXECUTION_ERROR", async ({ mcp }) => {
  const result = await mcp.tools.call("sync_ticket", { id: "T-1" });
  expect(result.raw._meta?.code).toBe("TOOL_EXECUTION_ERROR");
  expect(result.text()).toMatch(/^Tool "sync_ticket" execution failed: connect ECONNREFUSED/);
});
```

### Errors from providers and other code

Providers and helpers can't call `this.fail()`, so they throw. A `PublicMcpError` they throw passes through the tool unchanged, and a subclass of it can carry its own code, so the tool doesn't need a `try`/`catch`:

```ts reopen-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@Tool({ name: "reopen_ticket", description: "Reopen a closed support ticket", inputSchema: { id: z.string() } })
export class ReopenTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return this.get(TicketStore).reopen(id);
  }
}
```

```ts ticket-store.ts
import { Provider, PublicMcpError } from "@frontmcp/sdk";

export class TicketNotFoundError extends PublicMcpError {
  constructor(id: string) {
    super(`There's no ticket ${id}.`, "TICKET_NOT_FOUND", 404);
  }
}

@Provider({ name: "TicketStore" })
export class TicketStore {
  private tickets = new Map([["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }]]);

  reopen(id: string) {
    const ticket = this.tickets.get(id);
    if (!ticket) throw new TicketNotFoundError(id);
    ticket.status = "open";
    return ticket;
  }
}
```

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

test("the provider's error reaches the client", async ({ mcp }) => {
  const result = await mcp.tools.call("reopen_ticket", { id: "T-9" });
  expect(result.raw._meta?.code).toBe("TICKET_NOT_FOUND");
  expect(result.text()).toBe("There's no ticket T-9.");
});

test("a known ticket is reopened", async ({ mcp }) => {
  const result = await mcp.tools.call("reopen_ticket", { id: "T-2" });
  expect(result.json()).toMatchObject({ id: "T-2", status: "open" });
});
```

Anything else a provider throws, like a database driver's error, is wrapped as `TOOL_EXECUTION_ERROR` and hidden in production, like the plain `Error` in [Failing vs throwing](#failing-vs-throwing).

### What production hides

The Call tab shows what a plain `Error` gives in development: its message and stack, which can include hostnames, queries and secrets. With `NODE_ENV=production`, FrontMCP sends an error ID instead. The Playground can't switch to production, so the test formats the same errors with `createErrorHandler()`. Its `handle()` uses the function that formats `tools/call` errors, and `isDevelopment: false` is what `NODE_ENV=production` means to it.

```ts sync-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "sync_ticket", description: "Copy a ticket to the CRM", inputSchema: { id: z.string() } })
export class SyncTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    this.fail(new Error(`connect ECONNREFUSED 10.0.4.12:5432 while syncing ${id}`)); // 🚩 not public
  }
}
```

```ts production.test.ts
import { test, expect } from "@frontmcp/testing";
import { createErrorHandler, PublicMcpError } from "@frontmcp/sdk";

const production = createErrorHandler({ isDevelopment: false });
const development = createErrorHandler({ isDevelopment: true });

test("a plain Error becomes an error ID in production", () => {
  const error = new Error("connect ECONNREFUSED 10.0.4.12:5432");
  const result = production.handle(error);
  expect(result._meta?.code).toBe("SERVER_ERROR");
  expect(result.content[0].text).toBe(`Internal FrontMCP error. Please contact support with error ID: ${result._meta?.errorId}`);
  expect(development.handle(error).content[0].text).toContain("ECONNREFUSED 10.0.4.12:5432");
});

test("a PublicMcpError reads the same in production", () => {
  const error = new PublicMcpError("The CRM is down for maintenance. Try again after 18:00 UTC.");
  expect(production.handle(error).content[0].text).toBe("The CRM is down for maintenance. Try again after 18:00 UTC.");
});

test("only development sends the stack", () => {
  const error = new PublicMcpError("The CRM is down.");
  expect(production.handle(error)._meta).not.toHaveProperty("stack");
  expect(development.handle(error)._meta).toHaveProperty("stack");
});
```

Say what the model can do about it in a `PublicMcpError`, and keep the details for your logs.

### Failing in a resource or a prompt

`this.fail()` works in resources and prompts too. The client gets a JSON-RPC error with your message, and your code in `data.code` ([see the table](#in-resources-and-prompts)).

<Examples title="Other entries">

#### Example: Resource
```ts ticket.resource.ts active
import { PublicMcpError, ResourceContext, ResourceTemplate } from "@frontmcp/sdk";

const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }];

@ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" })
export class Ticket extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND"));
    return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(ticket) }] };
  }
}
```

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

test("the error has the message, and the code in data", async ({ mcp }) => {
  const result = await mcp.resources.read("tickets://T-9");
  expect(result.error).toMatchObject({ code: -32602, message: "There's no ticket T-9.", data: { code: "TICKET_NOT_FOUND" } });
});
```

#### Example: Prompt
```ts summarize-ticket.prompt.ts active
import { Prompt, PromptContext, PublicMcpError, type GetPromptResult } from "@frontmcp/sdk";

const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }];

@Prompt({
  name: "summarize_ticket",
  description: "Summarize a ticket for a handover",
  arguments: [{ name: "id", description: "Ticket id, like T-1", required: true }],
})
export class SummarizeTicket extends PromptContext {
  async execute({ id }: Record<string, string>): Promise<GetPromptResult> {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND"));
    return { messages: [{ role: "user", content: { type: "text", text: `Summarize: ${ticket.title} (${ticket.status})` } }] };
  }
}
```

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

test("the error has the message, and the code in data", async ({ mcp }) => {
  const result = await mcp.prompts.get("summarize_ticket", { id: "T-9" });
  expect(result.error).toMatchObject({ code: -32602, message: "There's no ticket T-9.", data: { code: "TICKET_NOT_FOUND" } });
});
```

---

## Troubleshooting

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

The server runs in production, and the call failed with an error that isn't public: a plain `Error` passed to `this.fail()` (`_meta.code` `SERVER_ERROR`), an `InternalMcpError`, or any other error thrown from `execute()` (`TOOL_EXECUTION_ERROR`). Look the ID up in the server log: FrontMCP logs every failure with its ID, and for these errors, the original message and stack. Fail with a [`PublicMcpError`](#failing-with-a-message-the-model-can-read) for anything the model should read.

### `Tool "get_ticket" execution failed: …`, with `_meta.code` `TOOL_EXECUTION_ERROR`

`execute()` threw an error that isn't public, like a plain `Error`, a `TypeError` from a library, or an `InternalMcpError`, so FrontMCP wrapped it. If the message is for the model, throw or fail with a `PublicMcpError` instead. See [Failing vs throwing](#failing-vs-throwing). If the error comes from a provider, [throw a `PublicMcpError` there](#errors-from-providers-and-other-code).

### `_meta.code` is `SERVER_ERROR`

The tool called `this.fail()` with an error that isn't an `McpError`, like `new Error(…)` or a `TypeError` from a library. FrontMCP wraps it as a server error, which shows the stack in development and hides everything in production. Use `PublicMcpError` if the message is for the model.

### A tool call succeeds after `this.fail()`

`this.fail()` throws to end `execute()`, and a `try`/`catch` around it caught that. Here the tool reports the store as unavailable, although the real problem is an unknown ticket:

```ts close-ticket.tool.ts active
import { FlowControl, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }];

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    try {
      const ticket = tickets.find((t) => t.id === id);
      if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`)); // 🚩 caught below
      ticket.status = "closed";
      return { closed: true, id };
    } catch (e) {
      return { closed: false, reason: "The ticket store is unavailable.", caught: e instanceof FlowControl };
    }
  }
}
```

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

test("the catch swallowed the failure", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-9" });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ closed: false, reason: "The ticket store is unavailable.", caught: true });
});
```

Call `this.fail()` outside the `try`, or let `FlowControl` through:

```ts
} catch (e) {
  if (e instanceof FlowControl) throw e; // ✅ this.fail() ends the call
  return { closed: false, reason: "The ticket store is unavailable." };
}
```

### `Resource "…" read failed: …` or `Prompt execution failed: …`

A resource or a prompt threw an error that isn't public, so FrontMCP wrapped it: code `-32603`, with `data.code` `RESOURCE_READ_ERROR` or `PROMPT_EXECUTION_FAILED`. In production the message is only "Internal FrontMCP error" and an error ID.

```ts ticket.resource.ts
import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk";

@ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" })
export class Ticket extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }): Promise<{ contents: { uri: string; text: string }[] }> {
    throw new Error(`connect ECONNREFUSED 10.0.4.12:5432 while reading ${id}`); // 🚩 not public
  }
}
```

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

test("the error is wrapped as a read failure", async ({ mcp }) => {
  const result = await mcp.resources.read("tickets://T-1");
  expect(result.error).toMatchObject({
    code: -32603,
    message: 'Resource "tickets://T-1" read failed: connect ECONNREFUSED 10.0.4.12:5432 while reading T-1',
    data: { code: "RESOURCE_READ_ERROR" },
  });
});
```

If the client should read the message, fail with a `PublicMcpError`, as in [Failing in a resource or a prompt](#failing-in-a-resource-or-a-prompt).

### TypeScript says `Property 'fail' is protected`

`this.fail()` was called from outside the tool class, for example from a provider that was handed the tool. Throw a `PublicMcpError` there instead: it [reaches the client as it is](#errors-from-providers-and-other-code).
