# Escape Hatches

> Stepping outside FrontMCP's declarative model when you have to. Reading the request behind a call, calling other services from a tool, and running FrontMCP without its built-in server.

Source: https://frontmcp.dev/learn/escape-hatches

Most of FrontMCP is declarative. You describe a tool's name, schema and handler, and FrontMCP does the rest: it validates arguments, shapes results, and speaks the protocol over HTTP. Now and then a server has to step outside that model: to look at the request itself, to reach a service on the other side of the network, or to run somewhere FrontMCP's own server doesn't. Most servers never need these. When yours does, this chapter shows how, and what to watch out for.

**In this chapter**
- [How to read the request behind a call](https://frontmcp.dev/learn/reading-the-request)
- [How to call other services from a tool](https://frontmcp.dev/learn/calling-other-services)
- [How to run FrontMCP without its built-in server](https://frontmcp.dev/learn/running-frontmcp-anywhere)

## Reading the request

A tool gets its arguments in `execute()`. Everything else about the call is on `this`: `this.context` holds an id for the request and its trace, and `this.auth` the caller your authentication established. The request id is what ties a failure the user reports to the line in your logs:

```ts describe-call.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "describe_call",
  description: "Describe the request this call arrived in. For debugging.",
  inputSchema: {},
})
export class DescribeCall extends ToolContext {
  async execute() {
    return {
      requestId: this.context.requestId,
      traceId: this.context.traceContext.traceId,
      caller: this.auth.user.sub,
    };
  }
}
```

Call it again and all three change: the Playground has no authentication, so every request is a new anonymous caller. Only the caller comes from something your server checked. The arguments, the client's name and its headers are whatever the client chose to send.

Read more: [Reading the Request](https://frontmcp.dev/learn/reading-the-request)
Learn what `this.context` and `this.auth` hold, how to tag logs and errors with the request id, where the caller's identity should come from, and why client info and arguments prove nothing.

## Calling other services

`this.fetch()` is `fetch` with the current request attached: it tells the service which request is calling, and gives up after 30 seconds. The rest is up to the tool. It sends the service's own credentials rather than the caller's, and passes on only what 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}`, {
      headers: { Authorization: "Bearer bk_test_7Hq2" }, // billing's key, from your secrets in a real server
      signal: AbortSignal.timeout(2_000),
    });
    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);
};
```

The billing service here is played by `billing.example.ts`, because the Playground can't reach the internet. Try `INV-9` or `INV-7`: this version still passes billing's errors off as invoices, which is one of the things the lesson fixes.

Read more: [Calling Other Services](https://frontmcp.dev/learn/calling-other-services)
Learn what `this.fetch()` adds to a request, why it keeps the caller's token to itself, and how to turn failures and timeouts into errors the model can act on.

## Running FrontMCP anywhere

`@FrontMcp` normally starts an HTTP server for you. Underneath it, `FrontMcpInstance.createFetchHandler()` turns your server into a function from `Request` to `Response`, for Cloudflare Workers and any other runtime that speaks the web's `fetch` API. It's what runs this Playground. Open the **Tests** tab:

```ts worker.ts active
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

const handler = FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] });

export default {
  async fetch(request: Request): Promise<Response> {
    return (await handler)(request);
  },
};
```

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

test("the worker answers health checks", async () => {
  const res = await worker.fetch(new Request("https://desk.example.com/healthz"));
  expect(await res.json()).toMatchObject({ status: "ok", server: { name: "help-desk" } });
});

test("the worker answers MCP requests", async () => {
  const res = await worker.fetch(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  expect((await res.json()).result.tools).toContainTool("get_ticket");
});
```

```ts help-desk.app.ts
import { App, 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: "open" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).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}.`, "TICKET_NOT_FOUND"));
    return ticket;
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] })
export class HelpDesk {}
```

Two more entry points skip HTTP altogether: `connect()` gives your own code an MCP client connected in memory, and `createDirect()` runs tools with no client at all, for scripts and jobs.

Read more: [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere)
Learn when to take over from `@FrontMcp`, how to serve MCP from any web-standard runtime, and how `connect()` and `createDirect()` differ from a real client in identity, errors and formatting.

## What's next?

Start the chapter with [Reading the Request](https://frontmcp.dev/learn/reading-the-request). This is the last chapter of Learn. From here, the [Reference](https://frontmcp.dev/reference) covers each API in detail.
