# Calling Other Services

> How a tool calls an HTTP API with this.fetch(), what it adds to each request, whose credentials it sends, and how to turn a slow or failing service into errors the model can act on.

Source: https://frontmcp.dev/learn/calling-other-services

Sooner or later a tool needs data that lives somewhere else: a billing API, a CRM, another team's service. `this.fetch()` is the web's `fetch` with the current request attached. Making the call is the easy part. This lesson is about everything around it: a service that's down or slow, a response full of data the model shouldn't see, and credentials that end up where they shouldn't.

**You will learn**
- What `this.fetch()` adds to a request
- Which credentials to send, and why not the caller's
- How to turn failures and timeouts into errors the model can act on
- How to keep upstream secrets and raw responses away from the model

## Calling a service from a tool

Support agents often need a customer's invoice, and invoices live in the billing service. Here's a first version of a tool that fetches one. (If the service has an OpenAPI description, the [OpenAPI adapter](https://frontmcp.dev/learn/wrapping-an-openapi-service) can generate a tool for each operation instead; this lesson writes the tool by hand, which is what you do when you want to shape what the model sees.)

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

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await fetch(`https://billing.example/invoices/${id}`);
    return await res.json();
  }
}
```

```ts billing.example.ts
// Stands in for the billing service at https://billing.example, which the
// Playground can't reach from your browser. It answers requests for that host
// in-process, and passes everything else to the real fetch. A real server
// doesn't need this file.
//
// INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers.

const invoices: Record<string, object> = {
  "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." },
  "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." },
};

/** Every request the billing service received, oldest first. */
export const received: { path: string; headers: Record<string, string> }[] = [];

async function billing(request: Request): Promise<Response> {
  const path = new URL(request.url).pathname;
  received.push({ path, headers: Object.fromEntries(request.headers) });
  switch (path) {
    case "/invoices/INV-6":
      return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } });
    case "/invoices/INV-7":
      return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 });
    case "/invoices/INV-8": // a connection that stays open and never answers
      return new Promise((_, reject) => {
        const open = setInterval(() => {}, 1_000);
        request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason)));
      });
  }
  const invoice = invoices[path.replace("/invoices/", "")];
  return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
  return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init);
};
```

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

test("🚩 the model gets the customer's card token", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-1" });
  expect(result.json()).toHaveProperty("card_token");
});

test("🚩 a missing invoice looks like a result", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-9" });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ error: "not_found" });
});
```

> **Note**
The Playground runs in your browser and can't reach a real billing service, so every example on this page has a `billing.example.ts` file that plays one. It answers requests for `https://billing.example` in-process, and records what it received so tests can check it. You won't need it in a real server.

The tool works, and it has four problems, which the rest of this lesson fixes one at a time:

1. **The model gets everything the service sent**, including a card token and internal notes it should never see.
2. **Failures look like answers.** A 404 comes back as a successful result that says `{"error":"not_found"}`.
3. **Nothing limits how long it waits.** If billing hangs, so does the call.
4. **Billing can't tell who is calling.** Nothing links its logs to yours.

## Using `this.fetch()`

Start with the last problem, and the first. Switch `fetch` to `this.fetch()`, and build the result from the fields the model needs:

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

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await this.fetch(`https://billing.example/invoices/${id}`);
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}
```

```ts billing.example.ts
// Stands in for the billing service at https://billing.example, which the
// Playground can't reach from your browser. It answers requests for that host
// in-process, and passes everything else to the real fetch. A real server
// doesn't need this file.
//
// INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers.

const invoices: Record<string, object> = {
  "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." },
  "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." },
};

/** Every request the billing service received, oldest first. */
export const received: { path: string; headers: Record<string, string> }[] = [];

async function billing(request: Request): Promise<Response> {
  const path = new URL(request.url).pathname;
  received.push({ path, headers: Object.fromEntries(request.headers) });
  switch (path) {
    case "/invoices/INV-6":
      return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } });
    case "/invoices/INV-7":
      return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 });
    case "/invoices/INV-8": // a connection that stays open and never answers
      return new Promise((_, reject) => {
        const open = setInterval(() => {}, 1_000);
        request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason)));
      });
  }
  const invoice = invoices[path.replace("/invoices/", "")];
  return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
  return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init);
};
```

```ts get-invoice.test.ts
import { test, expect } from "@frontmcp/testing";
import { received } from "./billing.example";

test("the model gets only what it needs", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-1" });
  expect(result.json()).toEqual({ id: "INV-1", status: "paid", total: "120.00 EUR", due: "2026-09-01" });
});

test("billing is told which request is calling", async ({ mcp }) => {
  await mcp.tools.call("get_invoice", { id: "INV-1" });
  const { headers } = received[received.length - 1];
  expect(headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
  expect(headers["traceparent"]).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/);
});
```

`this.fetch()` takes the same arguments as `fetch` and returns the same `Response`. What it adds is the request you're in. Each of these is added only if you haven't set it yourself:

| Added | Value |
| --- | --- |
| `x-request-id` header | [`this.context.requestId`](https://frontmcp.dev/learn/reading-the-request), so billing's logs can be matched with yours. |
| `traceparent` header | The request's trace context, so a tracing system can join your server's work and billing's into one trace. |
| A timeout | 30 seconds, when you don't pass a `signal` of your own. |

What it doesn't add matters as much: the caller's token and their `x-frontmcp-*` headers stay on your server, as the next section shows.

The other change is the result: the tool picks four fields and formats the total, and everything else the service sent stays in the tool. The service's JSON is a lot like a database row. It carries whatever billing needed, it can grow new fields without telling you, and [Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results) explains why the model should get a result you built on purpose.

## Sending your own credentials

Your server authenticates its clients, usually with an OAuth access token. That token was issued for your MCP server, not for billing, and `this.fetch()` doesn't send it on. The Playground has no authentication, so this test uses `create()` to call the tool as a signed-in user whose token is `mcp-token-for-nour`, and looks at what billing received:

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

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await this.fetch(`https://billing.example/invoices/${id}`);
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}
```

```ts billing.example.ts
// Stands in for the billing service at https://billing.example, which the
// Playground can't reach from your browser. It answers requests for that host
// in-process, and passes everything else to the real fetch. A real server
// doesn't need this file.
//
// INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers.

const invoices: Record<string, object> = {
  "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." },
  "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." },
};

/** Every request the billing service received, oldest first. */
export const received: { path: string; headers: Record<string, string> }[] = [];

async function billing(request: Request): Promise<Response> {
  const path = new URL(request.url).pathname;
  received.push({ path, headers: Object.fromEntries(request.headers) });
  switch (path) {
    case "/invoices/INV-6":
      return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } });
    case "/invoices/INV-7":
      return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 });
    case "/invoices/INV-8": // a connection that stays open and never answers
      return new Promise((_, reject) => {
        const open = setInterval(() => {}, 1_000);
        request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason)));
      });
  }
  const invoice = invoices[path.replace("/invoices/", "")];
  return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
  return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init);
};
```

```ts credentials.test.ts
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { received } from "./billing.example";
import { GetInvoice } from "./get-invoice.tool";

test("the caller's token stays on your server", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [GetInvoice] });
  await server.callTool("get_invoice", { id: "INV-1" }, { authContext: { token: "mcp-token-for-nour", user: { sub: "nour" } } });
  await server.dispose();
  expect(received[received.length - 1].headers).not.toHaveProperty("authorization");
});
```

Forwarding it would hand the user's credential to another service, and if that service accepted it, the model could reach it with the user's rights, which nobody agreed to. The MCP specification calls this token passthrough and forbids it. (FrontMCP 1.8.0 did send the caller's token to every host. If you're still on it, set `Authorization` yourself on every call.)

So billing gets no credentials at all. Give each service its own. `this.fetch()` never replaces an `Authorization` header you set:

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

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await this.fetch(`https://billing.example/invoices/${id}`, {
      headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
    });
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}
```

```ts secrets.ts
// In a real server, read this from the environment or a secret store.
export const BILLING_API_KEY = "bk_test_7Hq2";
```

```ts billing.example.ts
// Stands in for the billing service at https://billing.example, which the
// Playground can't reach from your browser. It answers requests for that host
// in-process, and passes everything else to the real fetch. A real server
// doesn't need this file.
//
// INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers.

const invoices: Record<string, object> = {
  "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." },
  "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." },
};

/** Every request the billing service received, oldest first. */
export const received: { path: string; headers: Record<string, string> }[] = [];

async function billing(request: Request): Promise<Response> {
  const path = new URL(request.url).pathname;
  received.push({ path, headers: Object.fromEntries(request.headers) });
  switch (path) {
    case "/invoices/INV-6":
      return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } });
    case "/invoices/INV-7":
      return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 });
    case "/invoices/INV-8": // a connection that stays open and never answers
      return new Promise((_, reject) => {
        const open = setInterval(() => {}, 1_000);
        request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason)));
      });
  }
  const invoice = invoices[path.replace("/invoices/", "")];
  return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
  return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init);
};
```

```ts credentials.test.ts
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { received } from "./billing.example";
import { GetInvoice } from "./get-invoice.tool";

test("billing gets its own key, not the caller's token", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [GetInvoice] });
  await server.callTool("get_invoice", { id: "INV-1" }, { authContext: { token: "mcp-token-for-nour", user: { sub: "nour" } } });
  await server.dispose();
  expect(received[received.length - 1].headers["authorization"]).toBe("Bearer bk_test_7Hq2");
});
```

Keep the key where the model can't see it: out of results, out of error messages, and out of URLs, which tend to end up in logs. The same goes for anything the service sends back about itself.

> **Note**
Sometimes a service of your own accepts the same tokens as your MCP server, because both trust the same identity provider. Then you can let the caller's token through to that service, and only to it, by listing its origin in `@FrontMcp({ fetch: { forwardCallerTokenTo: ["https://tickets-api.example.com"] } })`. [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch#forwarding-the-callers-token-to-your-own-service) shows how. For a service where each user has an account of their own, the types accept `credentials: { provider }`, but FrontMCP 1.9.3 registers no provider registry for it to read from, so on its own it adds nothing and logs a warning.

## When the service fails

Now the second problem. A response isn't an answer until you've checked its status. The obvious fix is to pass billing's error on:

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

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await this.fetch(`https://billing.example/invoices/${id}`, {
      headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
    });
    if (!res.ok) {
      this.fail(new PublicMcpError(`Billing error ${res.status}: ${await res.text()}`));
    }
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}
```

```ts secrets.ts
// In a real server, read this from the environment or a secret store.
export const BILLING_API_KEY = "bk_test_7Hq2";
```

```ts billing.example.ts
// Stands in for the billing service at https://billing.example, which the
// Playground can't reach from your browser. It answers requests for that host
// in-process, and passes everything else to the real fetch. A real server
// doesn't need this file.
//
// INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers.

const invoices: Record<string, object> = {
  "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." },
  "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." },
};

/** Every request the billing service received, oldest first. */
export const received: { path: string; headers: Record<string, string> }[] = [];

async function billing(request: Request): Promise<Response> {
  const path = new URL(request.url).pathname;
  received.push({ path, headers: Object.fromEntries(request.headers) });
  switch (path) {
    case "/invoices/INV-6":
      return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } });
    case "/invoices/INV-7":
      return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 });
    case "/invoices/INV-8": // a connection that stays open and never answers
      return new Promise((_, reject) => {
        const open = setInterval(() => {}, 1_000);
        request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason)));
      });
  }
  const invoice = invoices[path.replace("/invoices/", "")];
  return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
  return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init);
};
```

The model now learns the name and port of billing's database server, and nothing it can do about it. And because the message is in a `PublicMcpError`, it gets it word for word in production too. The model needs to know one of two things: that it asked for something that doesn't exist, so it should fix its request, or that billing is having trouble, so it should wait or carry on without the invoice. Say exactly that, and log the details for yourself:

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

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

const unavailable = () =>
  new PublicMcpError(
    "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.",
    "BILLING_UNAVAILABLE",
  );

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    let res: Response;
    try {
      res = await this.fetch(`https://billing.example/invoices/${id}`, {
        headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
      });
    } catch (err) {
      this.contextLogger.error(`billing request failed: ${String(err)}`);
      this.fail(unavailable());
    }
    if (res.status === 404) {
      this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND"));
    }
    if (!res.ok) {
      this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`);
      this.fail(unavailable());
    }
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}
```

```ts secrets.ts
// In a real server, read this from the environment or a secret store.
export const BILLING_API_KEY = "bk_test_7Hq2";
```

```ts billing.example.ts
// Stands in for the billing service at https://billing.example, which the
// Playground can't reach from your browser. It answers requests for that host
// in-process, and passes everything else to the real fetch. A real server
// doesn't need this file.
//
// INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers.

const invoices: Record<string, object> = {
  "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." },
  "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." },
};

/** Every request the billing service received, oldest first. */
export const received: { path: string; headers: Record<string, string> }[] = [];

async function billing(request: Request): Promise<Response> {
  const path = new URL(request.url).pathname;
  received.push({ path, headers: Object.fromEntries(request.headers) });
  switch (path) {
    case "/invoices/INV-6":
      return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } });
    case "/invoices/INV-7":
      return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 });
    case "/invoices/INV-8": // a connection that stays open and never answers
      return new Promise((_, reject) => {
        const open = setInterval(() => {}, 1_000);
        request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason)));
      });
  }
  const invoice = invoices[path.replace("/invoices/", "")];
  return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
  return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init);
};
```

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

test("a missing invoice is INVOICE_NOT_FOUND", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-9" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("INVOICE_NOT_FOUND");
});

test("an outage is BILLING_UNAVAILABLE, without billing's details", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-7" });
  expect(result.raw._meta?.code).toBe("BILLING_UNAVAILABLE");
  expect(result.text()).not.toContain("pg-billing");
});

test("a real invoice still comes back", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-1" });
  expect(result).toBeSuccessful();
});
```

Each failure gets a code, so programs and tests can tell them apart, and a message that says what to do next. The `catch` handles the other way a call fails: `this.fetch()` throws, rather than returning a response, when the service can't be reached at all. The details go to the log, tagged with the request id, as in [Reading the Request](https://frontmcp.dev/learn/reading-the-request).

> **Pitfall: this.fail() throws, and catch catches it**
`this.fail()` ends the call by throwing. Call it inside a `try`, and your own `catch` receives it. A "no such invoice" error would turn into "billing isn't answering" before the model ever saw it. Keep the `try` around the `this.fetch()` call only, as above, and fail after it. The second challenge shows the bug.

## When the service is slow

The last problem left is waiting. `INV-8` never answers. With no `signal`, `this.fetch()` waits 30 seconds before it gives up, and the user waits with it. Decide how long this service may take, pass that as `signal`, and tell the model what happened when the time runs out:

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

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

const unavailable = () =>
  new PublicMcpError(
    "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.",
    "BILLING_UNAVAILABLE",
  );

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    let res: Response;
    try {
      res = await this.fetch(`https://billing.example/invoices/${id}`, {
        headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
        signal: AbortSignal.timeout(2_000),
      });
    } catch (err) {
      if ((err as Error).name === "TimeoutError") {
        this.fail(new PublicMcpError("The billing service didn't answer within 2 seconds. Try again later, or carry on without the invoice.", "BILLING_TIMEOUT"));
      }
      this.contextLogger.error(`billing request failed: ${String(err)}`);
      this.fail(unavailable());
    }
    if (res.status === 404) {
      this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND"));
    }
    if (!res.ok) {
      this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`);
      this.fail(unavailable());
    }
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}
```

```ts secrets.ts
// In a real server, read this from the environment or a secret store.
export const BILLING_API_KEY = "bk_test_7Hq2";
```

```ts billing.example.ts
// Stands in for the billing service at https://billing.example, which the
// Playground can't reach from your browser. It answers requests for that host
// in-process, and passes everything else to the real fetch. A real server
// doesn't need this file.
//
// INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers.

const invoices: Record<string, object> = {
  "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." },
  "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." },
};

/** Every request the billing service received, oldest first. */
export const received: { path: string; headers: Record<string, string> }[] = [];

async function billing(request: Request): Promise<Response> {
  const path = new URL(request.url).pathname;
  received.push({ path, headers: Object.fromEntries(request.headers) });
  switch (path) {
    case "/invoices/INV-6":
      return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } });
    case "/invoices/INV-7":
      return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 });
    case "/invoices/INV-8": // a connection that stays open and never answers
      return new Promise((_, reject) => {
        const open = setInterval(() => {}, 1_000);
        request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason)));
      });
  }
  const invoice = invoices[path.replace("/invoices/", "")];
  return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
  return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init);
};
```

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

test("a hung service is BILLING_TIMEOUT after 2 seconds", async ({ mcp }) => {
  const started = Date.now();
  const result = await mcp.tools.call("get_invoice", { id: "INV-8" });
  expect(result.raw._meta?.code).toBe("BILLING_TIMEOUT");
  expect(Date.now() - started).toBeLessThan(4_000);
});
```

`AbortSignal.timeout()` aborts the request, and `this.fetch()` rejects with an error named `TimeoutError`. Passing your own `signal` replaces the 30-second default, so the limit is yours. Keep it well under what a client will wait for a whole tool call, or the client gives up first and the model never sees your message.

**Deep dive: A time limit for the whole call**
`@Tool` also takes `timeout: { executeMs }`, a limit on the whole of `execute()`, however many requests it makes:

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

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  timeout: { executeMs: 1_000 },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await this.fetch(`https://billing.example/invoices/${id}`);
    return { status: res.status };
  }
}
```

```ts billing.example.ts
// Stands in for the billing service at https://billing.example, which the
// Playground can't reach from your browser. It answers requests for that host
// in-process, and passes everything else to the real fetch. A real server
// doesn't need this file.
//
// INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers.

const invoices: Record<string, object> = {
  "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." },
  "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." },
};

/** Every request the billing service received, oldest first. */
export const received: { path: string; headers: Record<string, string> }[] = [];

async function billing(request: Request): Promise<Response> {
  const path = new URL(request.url).pathname;
  received.push({ path, headers: Object.fromEntries(request.headers) });
  switch (path) {
    case "/invoices/INV-6":
      return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } });
    case "/invoices/INV-7":
      return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 });
    case "/invoices/INV-8": // a connection that stays open and never answers
      return new Promise((_, reject) => {
        const open = setInterval(() => {}, 1_000);
        request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason)));
      });
  }
  const invoice = invoices[path.replace("/invoices/", "")];
  return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
  return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init);
};
```

After a second the call fails with the code `EXECUTION_TIMEOUT` and `Execution of "get_invoice" timed out after 1000ms`, in production too. It makes a good backstop, but it doesn't say which service was slow or what to do instead. A timeout on the request, with a message you wrote, tells the model what to do.

## Recap

- `this.fetch()` is `fetch` plus the request: it sends `x-request-id` and `traceparent`, and gives up after 30 seconds unless you pass a `signal`.
- `this.fetch()` keeps the caller's token and `x-frontmcp-*` headers on your server. Send each service its own credentials in `Authorization`.
- Check `res.ok`. Map "doesn't exist" and "not available right now" to different codes and messages that tell the model what to do.
- Keep upstream error bodies, host names and keys out of results and error messages. Log them with `this.contextLogger` instead.
- Set a timeout per request with `signal: AbortSignal.timeout(ms)`, and catch the `TimeoutError`.
- Keep `this.fail()` out of `try` blocks whose `catch` would swallow it.

## Try some challenges

### Challenge: Tell the model how long to wait
When billing is busy it answers `429 Too Many Requests` with a `Retry-After` header, the number of seconds to wait. `get_invoice` treats that like an outage. Fail with the code `BILLING_BUSY` instead, and a message that says how many seconds to wait. Try it with `INV-6`.

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

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

const unavailable = () =>
  new PublicMcpError(
    "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.",
    "BILLING_UNAVAILABLE",
  );

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    let res: Response;
    try {
      res = await this.fetch(`https://billing.example/invoices/${id}`, {
        headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
        signal: AbortSignal.timeout(2_000),
      });
    } catch (err) {
      this.contextLogger.error(`billing request failed: ${String(err)}`);
      this.fail(unavailable());
    }
    if (res.status === 404) {
      this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND"));
    }
    if (!res.ok) {
      this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`);
      this.fail(unavailable());
    }
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}
```

```ts get-invoice.tool.ts solution
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { BILLING_API_KEY } from "./secrets";

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

const unavailable = () =>
  new PublicMcpError(
    "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.",
    "BILLING_UNAVAILABLE",
  );

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    let res: Response;
    try {
      res = await this.fetch(`https://billing.example/invoices/${id}`, {
        headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
        signal: AbortSignal.timeout(2_000),
      });
    } catch (err) {
      this.contextLogger.error(`billing request failed: ${String(err)}`);
      this.fail(unavailable());
    }
    if (res.status === 404) {
      this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND"));
    }
    if (res.status === 429) {
      const seconds = Number(res.headers.get("retry-after")) || 60;
      this.fail(new PublicMcpError(`The billing service is busy. Try again in ${seconds} seconds.`, "BILLING_BUSY"));
    }
    if (!res.ok) {
      this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`);
      this.fail(unavailable());
    }
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}
```

```ts secrets.ts
// In a real server, read this from the environment or a secret store.
export const BILLING_API_KEY = "bk_test_7Hq2";
```

```ts billing.example.ts
// Stands in for the billing service at https://billing.example, which the
// Playground can't reach from your browser. It answers requests for that host
// in-process, and passes everything else to the real fetch. A real server
// doesn't need this file.
//
// INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers.

const invoices: Record<string, object> = {
  "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." },
  "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." },
};

/** Every request the billing service received, oldest first. */
export const received: { path: string; headers: Record<string, string> }[] = [];

async function billing(request: Request): Promise<Response> {
  const path = new URL(request.url).pathname;
  received.push({ path, headers: Object.fromEntries(request.headers) });
  switch (path) {
    case "/invoices/INV-6":
      return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } });
    case "/invoices/INV-7":
      return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 });
    case "/invoices/INV-8": // a connection that stays open and never answers
      return new Promise((_, reject) => {
        const open = setInterval(() => {}, 1_000);
        request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason)));
      });
  }
  const invoice = invoices[path.replace("/invoices/", "")];
  return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
  return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init);
};
```

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

test("a 429 fails with `BILLING_BUSY`", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-6" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("BILLING_BUSY");
});

test("the message says to wait 30 seconds, as billing asked", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-6" });
  expect(result.text()).toContain("30 seconds");
});

test("an outage is still `BILLING_UNAVAILABLE`", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-7" });
  expect(result.raw._meta?.code).toBe("BILLING_UNAVAILABLE");
});
```

**Hint:**
Check for `429` before the general `!res.ok` branch. `res.headers.get("retry-after")` returns the header as a string, or `null` if it's missing.

**Solution:**
A rate limit isn't an outage: billing is fine, and it has said exactly when to come back. Passing that on lets the model wait and retry, or tell the user when to try again, instead of giving up. The fallback of 60 seconds covers a service that forgets the header.

### Challenge: Let the not-found error through
This version of `get_invoice` wraps everything in one `try`. Ask it for `INV-9`, which doesn't exist, and the model hears that billing is down. Make a missing invoice fail with `INVOICE_NOT_FOUND` again, without changing how outages are reported.

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

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    try {
      const res = await this.fetch(`https://billing.example/invoices/${id}`, {
        headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
        signal: AbortSignal.timeout(2_000),
      });
      if (res.status === 404) {
        this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND"));
      }
      if (!res.ok) throw new Error(`billing answered ${res.status}: ${await res.text()}`);
      const invoice = (await res.json()) as Invoice;
      return {
        id: invoice.id,
        status: invoice.status,
        total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
        due: invoice.due_date,
      };
    } catch (err) {
      this.contextLogger.error(`billing request failed: ${String(err)}`);
      this.fail(
        new PublicMcpError(
          "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.",
          "BILLING_UNAVAILABLE",
        ),
      );
    }
  }
}
```

```ts get-invoice.tool.ts solution
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { BILLING_API_KEY } from "./secrets";

type Invoice = { id: string; status: string; total_cents: number; currency: string; due_date: string };

const unavailable = () =>
  new PublicMcpError(
    "The billing service isn't answering right now. Try again in a few minutes, or carry on without the invoice.",
    "BILLING_UNAVAILABLE",
  );

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status, total and due date.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  annotations: { readOnlyHint: true, openWorldHint: true },
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    let res: Response;
    try {
      res = await this.fetch(`https://billing.example/invoices/${id}`, {
        headers: { Authorization: `Bearer ${BILLING_API_KEY}` },
        signal: AbortSignal.timeout(2_000),
      });
    } catch (err) {
      this.contextLogger.error(`billing request failed: ${String(err)}`);
      this.fail(unavailable());
    }
    if (res.status === 404) {
      this.fail(new PublicMcpError(`There's no invoice ${id}. Check the id with the user.`, "INVOICE_NOT_FOUND"));
    }
    if (!res.ok) {
      this.contextLogger.error(`billing answered ${res.status}: ${await res.text()}`);
      this.fail(unavailable());
    }
    const invoice = (await res.json()) as Invoice;
    return {
      id: invoice.id,
      status: invoice.status,
      total: `${(invoice.total_cents / 100).toFixed(2)} ${invoice.currency}`,
      due: invoice.due_date,
    };
  }
}
```

```ts secrets.ts
// In a real server, read this from the environment or a secret store.
export const BILLING_API_KEY = "bk_test_7Hq2";
```

```ts billing.example.ts
// Stands in for the billing service at https://billing.example, which the
// Playground can't reach from your browser. It answers requests for that host
// in-process, and passes everything else to the real fetch. A real server
// doesn't need this file.
//
// INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers.

const invoices: Record<string, object> = {
  "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." },
  "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." },
};

/** Every request the billing service received, oldest first. */
export const received: { path: string; headers: Record<string, string> }[] = [];

async function billing(request: Request): Promise<Response> {
  const path = new URL(request.url).pathname;
  received.push({ path, headers: Object.fromEntries(request.headers) });
  switch (path) {
    case "/invoices/INV-6":
      return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } });
    case "/invoices/INV-7":
      return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 });
    case "/invoices/INV-8": // a connection that stays open and never answers
      return new Promise((_, reject) => {
        const open = setInterval(() => {}, 1_000);
        request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason)));
      });
  }
  const invoice = invoices[path.replace("/invoices/", "")];
  return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
  return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init);
};
```

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

test("a missing invoice fails with `INVOICE_NOT_FOUND`", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-9" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("INVOICE_NOT_FOUND");
});

test("an outage is still `BILLING_UNAVAILABLE`", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-7" });
  expect(result.raw._meta?.code).toBe("BILLING_UNAVAILABLE");
});

test("a real invoice still comes back", async ({ mcp }) => {
  const result = await mcp.tools.call("get_invoice", { id: "INV-1" });
  expect(result.json()).toEqual({ id: "INV-1", status: "paid", total: "120.00 EUR", due: "2026-09-01" });
});
```

**Hint:**
Call it with `INV-9` and open the Logs tab. The log says only `billing request failed: Error`. What did the `catch` catch?

**Solution:**
`this.fail()` throws to end the call, so inside the `try` it was caught and replaced. The fix shrinks the `try` to the one line that can fail to reach billing, and checks the response after it, where `this.fail()` goes straight to the client. If you do need a wide `try`, rethrow what you didn't expect rather than turning everything into one error.

### Challenge: Keep up with a new status
This `get_invoice` returns billing's JSON as it is, and relies on `outputSchema` to drop the fields the model shouldn't see. It works for `INV-1`. Call it with `INV-2`, then fix it so that every invoice comes back with exactly the four fields in the schema, and `status` may also be `"overdue"`.

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

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status and total.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  outputSchema: z.object({
    id: z.string(),
    status: z.enum(["paid", "open"]),
    total_cents: z.number(),
    currency: z.string(),
  }),
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await this.fetch(`https://billing.example/invoices/${id}`);
    return await res.json(); // outputSchema drops the rest
  }
}
```

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

type Invoice = { id: string; status: "paid" | "open" | "overdue"; total_cents: number; currency: string };

@Tool({
  name: "get_invoice",
  description: "Get an invoice from the billing service: its status and total.",
  inputSchema: { id: z.string().regex(/^INV-\d+$/).describe("Invoice id, like INV-1") },
  outputSchema: z.object({
    id: z.string(),
    status: z.enum(["paid", "open", "overdue"]),
    total_cents: z.number(),
    currency: z.string(),
  }),
})
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    const res = await this.fetch(`https://billing.example/invoices/${id}`);
    const invoice = (await res.json()) as Invoice;
    return { id: invoice.id, status: invoice.status, total_cents: invoice.total_cents, currency: invoice.currency };
  }
}
```

```ts billing.example.ts
// Stands in for the billing service at https://billing.example, which the
// Playground can't reach from your browser. It answers requests for that host
// in-process, and passes everything else to the real fetch. A real server
// doesn't need this file.
//
// INV-1 and INV-2 exist. INV-6 is rate-limited, INV-7 fails, INV-8 never answers.

const invoices: Record<string, object> = {
  "INV-1": { id: "INV-1", customer_id: "C-881", status: "paid", total_cents: 12000, currency: "EUR", due_date: "2026-09-01", card_token: "tok_live_4242_9f8e", internal_notes: "Refunded once in 2025." },
  "INV-2": { id: "INV-2", customer_id: "C-881", status: "overdue", total_cents: 4900, currency: "EUR", due_date: "2026-08-15", card_token: "tok_live_4242_9f8e", internal_notes: "Customer disputes the late fee." },
};

/** Every request the billing service received, oldest first. */
export const received: { path: string; headers: Record<string, string> }[] = [];

async function billing(request: Request): Promise<Response> {
  const path = new URL(request.url).pathname;
  received.push({ path, headers: Object.fromEntries(request.headers) });
  switch (path) {
    case "/invoices/INV-6":
      return Response.json({ error: "rate_limited" }, { status: 429, headers: { "Retry-After": "30" } });
    case "/invoices/INV-7":
      return Response.json({ error: "db_unavailable", detail: "connect ECONNREFUSED pg-billing-01.internal:5432" }, { status: 503 });
    case "/invoices/INV-8": // a connection that stays open and never answers
      return new Promise((_, reject) => {
        const open = setInterval(() => {}, 1_000);
        request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason)));
      });
  }
  const invoice = invoices[path.replace("/invoices/", "")];
  return invoice ? Response.json(invoice) : Response.json({ error: "not_found" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
  return url.hostname === "billing.example" ? billing(new Request(input, init)) : realFetch(input, init);
};
```

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

for (const id of ["INV-1", "INV-2"]) {
  test(`${id} comes back with only \`id\`, \`status\`, \`total_cents\` and \`currency\``, async ({ mcp }) => {
    const result = await mcp.tools.call("get_invoice", { id });
    expect(Object.keys(result.raw.structuredContent ?? {}).sort()).toEqual(["currency", "id", "status", "total_cents"]);
    expect(result.text()).not.toContain("tok_live");
  });
}

test("`status` can be \"overdue\"", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "get_invoice");
  expect(tool?.outputSchema?.properties?.status?.enum).toContain("overdue");
});
```

**Hint:**
Read the error `INV-2` gets, then look at its `status` in `billing.example.ts`. FrontMCP checks every result against `outputSchema` before sending it.

**Solution:**
Billing added an `overdue` status, `INV-2` stopped matching the schema, and FrontMCP refused to send it: the call failed with `INVALID_OUTPUT`, which in production reads "Output validation failed. Please contact support.", nothing the model can act on. Adding the status to the enum fixes this one. Building the result field by field in `execute()` means the model gets the fields you chose, whatever billing adds next, and the schema only has to describe your own result. Services change their responses without asking you. [Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results) covers `outputSchema` in more depth.
