# @HttpRoute

> Serve pages, JSON and live SSE views from an app's providers, with route hooks and people sign-in.

Source: https://frontmcp.dev/reference/sdk/http-route

[Checked against FrontMCP 1.11.0](https://frontmcp.dev/versions#how-pages-are-checked) on November 4, 2026

A server can offer a page or a JSON endpoint alongside MCP. `@HttpRoute` puts that route in an app, where it uses the same providers as its tools. Requests run through the `http:route` flow for IP filtering, rate limits, same-origin checks and people sign-in. New in 1.11.0.

```ts
@HttpRoute({ method, path, auth?, sse? })
class BoardPage extends HttpRouteContext {
  async handle() { return { open: 2 }; }
}
```



---

<a id="reference"></a>

## Reference

<a id="httprouteoptions"></a>

### `@HttpRoute(options)`

Register the class in `@App({ routes: [BoardPage] })`.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `method` | `"GET" \| "POST" \| "PUT" \| "PATCH" \| "DELETE"` | required | Request method. An `sse` route must use `"GET"`. |
| `path` | `string \| (() => string)` | required | Literal path segments and `:name` parameters, such as `/board/:id`. No wildcards or groups (`*`, `()`, `{}`), query, fragment or whitespace. A function resolves at startup. |
| `auth` | `"people" \| "subject" \| "public"` | `"people"` when people auth is configured, else `"public"` | Who may call the route: a person signed in through [people sign-in](https://frontmcp.dev/reference/sdk/http-route#people-sign-in), a person signed in for one MCP caller (the [question inbox](https://frontmcp.dev/reference/sdk/elicit#persistent-questions)), or anyone. Each kind of session opens only its own kind of route. `"people"` and `"subject"` need `http.auth.people`. |
| `sse` | `{ store, topics, coalesceMs?, heartbeatMs? }` | none | Serve `text/event-stream`: `handle()`'s view is sent at once, and again after each burst of publishes on `topics`. `store` is anything with `subscribe(topic, listener)`, such as a [`resourceStore()`](https://frontmcp.dev/reference/sdk/resource#resourcestoreoptions), whose keys publish their name as a topic. `topics` names at least one. Defaults: coalesce 50 ms, heartbeat 15000 ms. |

`HttpRouteContext` exposes `request` (method, path, headers, query and parsed body), path `params`, optional `person` (`person.subject` on a `"subject"` route), and `get()` for the app's providers.

<a id="what-handle-returns"></a>

#### What `handle()` returns

| `handle()` returns | Response |
| --- | --- |
| An object or array | `200`, as JSON |
| A string | `200`, as `text/plain` |
| Nothing | `204` |
| An `httpRespond` output | As built: `httpRespond.json(body, { status })`, `httpRespond.html(markup, status?, headers?)`, `httpRespond.redirect(url)` (`302`), and the others |

On an `sse` route, the return value is the view: one `data:` event, with an object as JSON. Returning nothing sends no event.

<a id="caveats"></a>

#### Caveats

- **Reserved paths.** The MCP endpoint's paths (with its `/sse`, `/message` and `/server-card`), `/oauth/*`, `/.well-known/*`, `/health`, `/metrics` and the health probe paths stop the server with `ReservedRouteCollisionError`, as they do for [`http.routes`](https://frontmcp.dev/reference/sdk/frontmcp#custom-httproute--collides-with-the-reserved-frontmcp-path). One exception: a `GET` route may use the MCP endpoint's own path. A page load there reaches the route, and a request for an event stream, or one with an `Mcp-Session-Id`, stays the endpoint's. An `sse` route can't use that path.
- **Built-in routes.** With `http.auth.people` set, the sign-in routes under `/_frontmcp/people/`, and the question inbox's, are routes of the server too: a route with the same method and path stops the server with `InvalidHttpRouteError`.
- **Same origin.** `POST`, `PUT`, `PATCH` and `DELETE` are refused with `403` when the request comes from another site's page: an `Origin` that isn't the server's own, or a `Sec-Fetch-Site` other than `same-origin` or `none`. A request with neither header, from a client that isn't a browser, is allowed. The explicit origins in `http.cors` and in `http.security.dnsRebindingProtection.allowedOrigins` are allowed too.
- **Redirects need an absolute URL.** Given a path, such as `httpRespond.redirect("/board")`, the route answers `200` with the redirect object as JSON.
- **SSE.** Only the latest view waits for a slow reader. On Node, the next event waits for the socket to drain, and the stream stops when the client leaves.
- **Hooks.** `HttpRouteHook` hooks the stages `checkIpFilter`, `findRoute`, `acquireQuota`, `checkSameOrigin`, `authenticatePerson` and `handle`.

<a id="people-sign-in"></a>

### People sign-in

`http.auth.people: "loopback-token"` turns on sign-in for people, under `/_frontmcp/people/`, and routes default to `auth: "people"`. When the server starts, it prints a sign-in token on stderr, once.

1. A local process trades the token for a one-time code: `POST /_frontmcp/people/code` with `Authorization: Bearer <token>`. The answer has `code`, `expiresAt` and `loginPath`.
2. A browser opens `loginPath`, `/_frontmcp/people/login?code=…`, and submits its form. That spends the code for an `HttpOnly`, `SameSite=Strict` session cookie, which opens `"people"` routes.

A tool can hand a person a code too. `this.get(LoopbackTokenAuth).createCode()` returns `{ code, expiresAt }`, a code for `"people"` routes. `createCode({ subject })` makes one that opens only that MCP caller's `"subject"` routes, such as its [question inbox](https://frontmcp.dev/reference/sdk/elicit#persistent-questions), and no `"people"` route.

The object form takes `mode: "loopback-token"` and these settings:

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `codeTtlMs` | `number` | `60000` | One-time code lifetime, at most 600000 ms. |
| `sessionTtlMs` | `number` | `43200000` | Signed-in browser session lifetime. |
| `maxFailedAttempts` | `number` | `5` | Failed attempts before lockout. |
| `lockoutMs` | `number` | `60000` | Lockout and failed-attempt retention. |
| `cookiePath` | `string` | `"/"` | Session cookie path. |

> **Note**
>
> People sign-in has no Playground: it starts from the token the server prints on stderr when it starts. Run the server on Node to try it.

---

<a id="usage"></a>

## Usage

<a id="serving-json-without-opening-a-port"></a>

### Serving JSON without opening a port

```ts main.ts active
import { App, HttpRoute, HttpRouteContext } from "@frontmcp/sdk";

@HttpRoute({ method: "GET", path: "/board/:id", auth: "public" })
class Board extends HttpRouteContext {
  async handle() { return { id: this.params.id, open: 2 }; }
}
@App({ name: "Desk", routes: [Board] })
export class Desk {}
```

```ts route.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { Desk } from "./main";

test("a fetch handler serves an app route", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "desk", version: "1.0.0" }, apps: [Desk] });
  const response = await handler(new Request("http://localhost/board/support"));
  expect(response.status).toBe(200);
  expect(await response.json()).toEqual({ id: "support", open: 2 });
});
```

<a id="accepting-changes-from-the-servers-own-pages"></a>

### Accepting changes from the server's own pages

A `POST` route answers with `httpRespond` for a status of its own. A page of another site can't call it, and a redirect goes to an absolute URL.

```ts tickets.ts active
import { App, HttpRoute, HttpRouteContext, httpRespond } from "@frontmcp/sdk";

@HttpRoute({ method: "POST", path: "/tickets", auth: "public" })
class CreateTicket extends HttpRouteContext {
  async handle() {
    const { title } = this.request.body as { title: string };
    return httpRespond.json({ id: "T-3", title }, { status: 201 });
  }
}

@HttpRoute({ method: "GET", path: "/help", auth: "public" })
class Help extends HttpRouteContext {
  handle() {
    return httpRespond.redirect("https://frontmcp.dev/reference/sdk/http-route");
  }
}

@App({ name: "Desk", routes: [CreateTicket, Help] })
export class Desk {}
```

```ts tickets.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, HttpRoute, HttpRouteContext, httpRespond } from "@frontmcp/sdk";
import { Desk } from "./tickets";

const info = { name: "desk", version: "1.0.0" };
const createTicket = async (headers: Record<string, string>) => {
  const handler = await FrontMcpInstance.createFetchHandler({ info, apps: [Desk] });
  return handler(
    new Request("http://localhost/tickets", {
      method: "POST",
      headers: { "content-type": "application/json", ...headers },
      body: JSON.stringify({ title: "Printer jam" }),
    }),
  );
};

test("the server's own page creates a ticket", async () => {
  const response = await createTicket({ origin: "http://localhost" });
  expect(response.status).toBe(201);
  expect(await response.json()).toEqual({ id: "T-3", title: "Printer jam" });
});

test("another site's page is refused", async () => {
  const fromOrigin = await createTicket({ origin: "https://elsewhere.example" });
  expect(fromOrigin.status).toBe(403);
  expect(await fromOrigin.json()).toEqual({ error: "Cross-origin requests may not change anything here." });
  expect((await createTicket({ "sec-fetch-site": "cross-site" })).status).toBe(403);
});

test("a client that isn't a browser sends neither header, and is allowed", async () => {
  expect((await createTicket({})).status).toBe(201);
});

test("a redirect to an absolute URL is a 302", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ info, apps: [Desk] });
  const response = await handler(new Request("http://localhost/help", { redirect: "manual" }));
  expect(response.status).toBe(302);
  expect(response.headers.get("location")).toBe("https://frontmcp.dev/reference/sdk/http-route");
});

test("a redirect to a path is sent as JSON instead", async () => {
  @HttpRoute({ method: "GET", path: "/home", auth: "public" })
  class Home extends HttpRouteContext {
    handle() {
      return httpRespond.redirect("/board");
    }
  }
  @App({ name: "Home", routes: [Home] })
  class HomeApp {}

  const handler = await FrontMcpInstance.createFetchHandler({ info, apps: [HomeApp] });
  const response = await handler(new Request("http://localhost/home", { redirect: "manual" }));
  expect(response.status).toBe(200);
  expect(await response.json()).toMatchObject({ kind: "redirect", location: "/board" });
});
```

<a id="streaming-a-live-view"></a>

### Streaming a live view

An `sse` route sends `handle()`'s view when the page connects, and again whenever its topics are published. A key of a [`resourceStore()`](https://frontmcp.dev/reference/sdk/resource#serving-a-stored-value) publishes its name on every write, so a tool's write reaches the page.

```ts board.ts active
import { App, HttpRoute, HttpRouteContext, resourceStore, Tool, ToolContext } from "@frontmcp/sdk";

export const store = resourceStore();
export const board = store.key("board", { initial: { open: 2 } });

@HttpRoute({ method: "GET", path: "/board/events", auth: "public", sse: { store, topics: ["board"] } })
class BoardEvents extends HttpRouteContext {
  async handle() {
    return (await board.get()).value;
  }
}

@Tool({ name: "close_ticket", description: "Close one open ticket", inputSchema: {} })
class CloseTicket extends ToolContext {
  async execute() {
    return (await board.update((current) => ({ open: current.open - 1 }))).value;
  }
}

@App({ name: "Desk", tools: [CloseTicket], routes: [BoardEvents] })
export class Desk {}
```

```ts board.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { board, Desk } from "./board";

test("the page gets the board now, and again after a tool's write", async ({ mcp }) => {
  const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "desk", version: "1.0.0" }, apps: [Desk] });
  const response = await handler(new Request("http://localhost/board/events"));
  expect(response.status).toBe(200);
  expect(response.headers.get("content-type")).toBe("text/event-stream");

  const events = response.body!.getReader();
  const decoder = new TextDecoder();
  const nextEvent = async () => decoder.decode((await events.read()).value);
  try {
    const { open } = (await board.get()).value;
    expect(await nextEvent()).toBe(`data: ${JSON.stringify({ open })}\n\n`);
    await mcp.tools.call("close_ticket", {});
    expect(await nextEvent()).toBe(`data: ${JSON.stringify({ open: open - 1 })}\n\n`);
  } finally {
    await events.cancel();
  }
});
```

<a id="troubleshooting"></a>

## Troubleshooting

<a id="invalid_http_route"></a>

### `INVALID_HTTP_ROUTE`

The server didn't start. `InvalidHttpRouteError` (`HTTP route <method> <path>: <reason>`) names a route with the same method and path as another, a `path` function that returned an unusable path, an `sse` route on the MCP endpoint's path, a class in `routes` that isn't an `@HttpRoute`, or a `"people"` or `"subject"` route on a server without `http.auth.people`. Give each route its own method and path, keep SSE off the MCP endpoint's path, and set `http.auth.people` or `auth: "public"`.

A reserved path fails with `ReservedRouteCollisionError` instead ([below](https://frontmcp.dev/reference/sdk/http-route#custom-httproute--collides-with-the-reserved-frontmcp-path)), and a bad literal `path` fails when the class is declared, with `INVALID_DECORATOR_METADATA` ([below](https://frontmcp.dev/reference/sdk/http-route#httproute-invalid-metadata-for-path)).

```ts startup.test.ts active
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, HttpRoute, HttpRouteContext, type HttpRouteType } from "@frontmcp/sdk";

const info = { name: "desk", version: "1.0.0" };
const start = (routes: HttpRouteType[]) => {
  @App({ name: "Desk", routes })
  class Desk {}
  return FrontMcpInstance.createFetchHandler({ info, apps: [Desk] });
};

@HttpRoute({ method: "GET", path: "/board", auth: "public" })
class Board extends HttpRouteContext {
  handle() { return { open: 2 }; }
}
@HttpRoute({ method: "GET", path: "/board/", auth: "public" })
class BoardAgain extends HttpRouteContext {
  handle() { return { open: 2 }; }
}
@HttpRoute({ method: "GET", path: "/team", auth: "people" })
class Team extends HttpRouteContext {
  handle() { return { people: 3 }; }
}
@HttpRoute({ method: "GET", path: "/health", auth: "public" })
class Health extends HttpRouteContext {
  handle() { return { ok: true }; }
}

test("two routes with one method and path", async () => {
  await expect(start([Board, BoardAgain])).rejects.toThrow("HTTP route GET /board/: another route already serves this method and path");
});

test("a people route without http.auth.people", async () => {
  await expect(start([Team])).rejects.toThrow("HTTP route GET /team: it needs a signed-in person, but http.auth.people is not set");
});

test("a reserved path is a ReservedRouteCollisionError", async () => {
  await expect(start([Health])).rejects.toThrow('Custom http.route "GET /health" collides with the reserved FrontMCP path "/health".');
});

test("a bad literal path fails at the decorator", () => {
  expect(() => HttpRoute({ method: "GET", path: "/files/*", auth: "public" })(Board)).toThrow(
    '@HttpRoute invalid metadata for "path"',
  );
});
```

```ts main.ts
import { App, HttpRoute, HttpRouteContext } from "@frontmcp/sdk";

// ✅ Its own method and path, a public route, no wildcard
@HttpRoute({ method: "GET", path: "/board/:id", auth: "public" })
class Board extends HttpRouteContext {
  handle() { return { id: this.params.id, open: 2 }; }
}
@App({ name: "Desk", routes: [Board] })
export class Desk {}
```

<a id="custom-httproute--collides-with-the-reserved-frontmcp-path"></a>

### `Custom http.route … collides with the reserved FrontMCP path`

The server didn't start, because a route takes a path FrontMCP serves itself. `ReservedRouteCollisionError` names both paths and lists the reserved ones: see [Caveats](https://frontmcp.dev/reference/sdk/http-route#caveats) and [`http.routes`](https://frontmcp.dev/reference/sdk/frontmcp#custom-httproute--collides-with-the-reserved-frontmcp-path). Choose another path.

<a id="httproute-invalid-metadata-for-path"></a>

### `@HttpRoute invalid metadata for "path"`

The class failed where it's declared, with `INVALID_DECORATOR_METADATA`: its `path` has a wildcard, a group, a query, a fragment or whitespace, or doesn't start with `/`. Use literal segments and `:name` parameters. The same check on the other options names them: an `sse` route that isn't `GET`, or an empty `topics`.

<a id="person_not_signed_in"></a>

### `PERSON_NOT_SIGNED_IN`

`401`: `Please sign in: open the one-time sign-in link the server gives you (POST /_frontmcp/people/code with its token).` Sign in with a code for the route's kind of `auth`: see [People sign-in](https://frontmcp.dev/reference/sdk/http-route#people-sign-in). A session made from a `createCode({ subject })` code doesn't open a `"people"` route, and a `"people"` session doesn't open a `"subject"` route.

<a id="cross-origin-requests-may-not-change-anything-here"></a>

### `Cross-origin requests may not change anything here.`

`403`, code `CROSS_ORIGIN_REQUEST`: a `POST`, `PUT`, `PATCH` or `DELETE` came from another site's page. Send it from the server's own pages, or list the page's origin in `http.cors`. See [Accepting changes from the server's own pages](https://frontmcp.dev/reference/sdk/http-route#accepting-changes-from-the-servers-own-pages).
