@HttpRoute

Checked against FrontMCP 1.11.0 on

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] }).

OptionTypeDefaultDescription
method"GET" | "POST" | "PUT" | "PATCH" | "DELETE"requiredRequest method. An sse route must use "GET".
pathstring | (() => string)requiredLiteral 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? }noneServe 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() returnsResponse
An object or array200, as JSON
A string200, as text/plain
Nothing204
An httpRespond outputAs 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, /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. 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.

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, and no "people" route.

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

OptionTypeDefaultDescription
codeTtlMsnumber60000One-time code lifetime, at most 600000 ms.
sessionTtlMsnumber43200000Signed-in browser session lifetime.
maxFailedAttemptsnumber5Failed attempts before lockout.
lockoutMsnumber60000Lockout and failed-attempt retention.
cookiePathstring"/"Session cookie path.

Usage

Serving JSON without opening a port

Open
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.

Open
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.

Open
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).

Open
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.

Report a docs issue · Report a security concern privately