@HttpRoute
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.
@HttpRoute({ method, path, auth?, sse? })
class BoardPage extends HttpRouteContext {
async handle() { return { open: 2 }; }
}
Reference
@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, a person signed in for one MCP caller (the question inbox), 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(), 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.
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.
Caveats
- Reserved paths. The MCP endpoint's paths (with its
/sse,/messageand/server-card),/oauth/*,/.well-known/*,/health,/metricsand the health probe paths stop the server withReservedRouteCollisionError, as they do forhttp.routes. One exception: aGETroute 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 anMcp-Session-Id, stays the endpoint's. Ansseroute can't use that path. - Built-in routes. With
http.auth.peopleset, 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 withInvalidHttpRouteError. - Same origin.
POST,PUT,PATCHandDELETEare refused with403when the request comes from another site's page: anOriginthat isn't the server's own, or aSec-Fetch-Siteother thansame-originornone. A request with neither header, from a client that isn't a browser, is allowed. The explicit origins inhttp.corsand inhttp.security.dnsRebindingProtection.allowedOriginsare allowed too. - Redirects need an absolute URL. Given a path, such as
httpRespond.redirect("/board"), the route answers200with 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.
HttpRouteHookhooks the stagescheckIpFilter,findRoute,acquireQuota,checkSameOrigin,authenticatePersonandhandle.
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.
- A local process trades the token for a one-time code:
POST /_frontmcp/people/codewithAuthorization: Bearer <token>. The answer hascode,expiresAtandloginPath. - A browser opens
loginPath,/_frontmcp/people/login?code=…, and submits its form. That spends the code for anHttpOnly,SameSite=Strictsession 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, 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. |
Usage
Serving JSON without opening a port
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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() publishes its name on every write, so a tool's write reaches the page.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Troubleshooting
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), and a bad literal path fails when the class is declared, with INVALID_DECORATOR_METADATA (below).
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"',
);
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 and http.routes. Choose another path.
@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.
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. A session made from a createCode({ subject }) code doesn't open a "people" route, and a "people" session doesn't open a "subject" route.
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.