@FrontMcp
@FrontMcp declares the server: its name and version, the apps it hosts, and the settings that apply to all of them, from how it asks users for input to which port it listens on. A project has one, in its entry file, and importing that file starts the server.
@FrontMcp(options)
export default class Server {}
Reference
@FrontMcp(options)
Apply @FrontMcp to an empty class in your server's entry file.
import "reflect-metadata";
import { FrontMcp, LogLevel } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
http: { port: 3000 },
logging: { level: LogLevel.Info },
})
export default class Server {}Options
Required:
| Option | Type | Description |
|---|---|---|
info | ServerInfoOptions | Who the server is. Sent to clients in the _meta of its responses. See info. |
apps | AppType[] | The apps to serve: @App classes, app() results, App.remote() and App.esm(). |
Shared by every app:
| Option | Type | Description |
|---|---|---|
providers | ProviderType[] | Providers every app can get, as one shared instance. |
plugins | PluginType[] | Plugins that apply to every app, such as caching or auditing. See @Plugin. |
skills | SkillType[] | Skills available to every app. |
tools, resources | ToolType[], ResourceType[] | Tools and resources served with every app's, once. See Tools every app shares. (Changed in 1.9.1: before, they were accepted and never registered.) |
adapters | AdapterType[] | Adapters, like an OpenAPI adapter, whose tools are served with every app's. See Registering an adapter. (Changed in 1.9: before, @FrontMcp dropped the option.) |
How the server behaves toward clients. These all work in the Playground:
| Option | Type | Description |
|---|---|---|
instructions | string | Tells the model how to use the server as a whole. Sent with server/discover, and in initialize to older clients, through every entry point. (Changed in 1.9.3: before, initialize through createFetchHandler() and connect() left them out.) See giving the model instructions. |
elicitation | { enabled?, redis? } | Lets tools ask the user for input with this.elicit(). Off by default. Turning it on also adds a sendElicitationResult tool for clients on protocol versions before 2026-07-28 that can't show forms; 2026-07-28 clients never see it. redis stores pending questions for servers that run as several processes; it defaults to the top-level redis. |
pagination | { tools?: { mode?, pageSize?, autoThreshold? } } | How tools/list is split into pages. By default (mode: "auto") it pages once there are more than autoThreshold tools, pageSize per page; both default to 40. mode: true always pages, false never does. The TypeScript type requires all three fields. |
output | OutputPolicy | Defaults for every tool's result: schemaMode ("definition", the default, "description", "both" or "none"), schemaDescriptionFormat ("summary" or "jsonSchema") and allowNonFinite (default false: a NaN or Infinity in a result is an error). An app's or tool's output overrides it. |
throttle | GuardConfig | Server-wide rate limits, concurrency caps and timeouts. enabled is required: enabled: false also turns off every tool's own rateLimit and concurrency. A tool's own limits work without throttle. See Guard options and setting defaults for every tool. |
fetch | { forwardCallerTokenTo?, forwardCustomHeadersTo?, requestTimeout?, autoInjectTracingHeaders? } | How this.fetch() behaves: which origins get the caller's token and x-frontmcp-* headers (none by default), the timeout (30 seconds), and whether to send tracing headers. |
How the server is deployed. These configure a real process, so the Playground ignores them:
| Option | Type | Description |
|---|---|---|
serve | boolean | Defaults to true: importing the decorated class starts the server. See starting the server. |
http | HttpOptions | Port, path, CORS and custom routes. See http. |
transport | TransportOptions | Protocols, sessions and where sessions are stored. See transport. |
session | { sessionMode?, platformDetection? } | Deprecated: the pre-1.0 option that transport replaced, and not read. A sessionMode other than "stateful" logs the same warning as transport.sessionMode, and a platformDetection logs session.platformDetection is ignored and will be removed in the next major, with the rest of `session`: move it to `transport.platformDetection`. (Changed in 1.9.4: before, it was dropped without a warning.) |
auth | AuthOptions | Who may connect. Defaults to public. See auth. |
authorities | AuthoritiesConfig | Named role and attribute rules, and where to find roles in a token, for the authorities option of tools, resources, templates, prompts, agents and skills. A server whose entries declare authorities doesn't start without it, and neither does one with a rule that checks nothing: see Troubleshooting. |
splitByApp | boolean | Serve each app on its own path, /<app id>, each with its own auth. Defaults to false. See Apps, discovery and splitting. |
redis | RedisOptions | Shared storage for sessions, tokens and pending questions: { host, port?, password?, db?, tls?, keyPrefix?, defaultTtlMs? }, or { provider: "vercel-kv", url?, token? }. |
pubsub | RedisOptions | Redis for resource-subscription events, needed when redis is Vercel KV. Defaults to redis. |
sqlite | { path?, encryption?, walMode?, ttlCleanupIntervalMs? } | Local storage for sessions, pending questions and events on a single machine, instead of Redis. |
logging | { level?, enableConsole?, prefix?, transports? } | level is a LogLevel: Debug, Verbose, Info (the default), Warn, Error or Off. transports adds your own log destinations. See Logging. |
health | HealthOptions | Health endpoints, on by default: /healthz (also /health) and /readyz. probes adds your own checks, like a database ping. See Health checks. |
metrics | MetricsOptions | A /metrics endpoint in Prometheus or JSON format. Off by default. auth: "token" protects it with a token from FRONTMCP_METRICS_TOKEN. See Metrics. |
observability | boolean or ObservabilityOptions | OpenTelemetry tracing and structured logs. Needs @frontmcp/observability installed. See Observability and telemetry. |
Other features:
| Option | Type | Description |
|---|---|---|
skillsConfig | SkillsConfigOptions | Serve skills over HTTP (/llm.txt, /skills), and choose how the skill catalog joins instructions with injectInstructions: "append" (the default), "prepend", "replace" or "off". See @Skill. Its audit options keep a signed record of what skill scripts run: see Audit log. |
extApps | ExtAppsOptions | What MCP Apps widgets may do: call tools, log, open links, update the model's context. |
tasks | TasksOptions | Where background tasks are kept, for how long, and where they run. Tasks are on by default; a tool runs as one when it sets execution.taskSupport. See Background tasks. |
jobs | { enabled, allowDynamicRegistration?, store? } | The jobs and workflows system. On when an app declares jobs or workflows, with runs kept in memory; set it to keep runs in Redis or to turn the system off. See @Job. |
channels | { enabled, defaultMeta? } | Push events to Claude Code sessions through channels. Off by default. A client asks for them with experimental: { "claude/channel": {} } in its capabilities: in initialize, before 2026-07-28, or in a 2026-07-28 subscriptions/listen request, whose stream then carries the events (since 1.9.2; before, 2026-07-28 clients received none). See @Channel. |
loader | { url?, registryUrl?, token?, tokenEnvVar? } | Where App.esm() fetches packages. Defaults to the npm registry and esm.sh. See the loader. |
ui | { cdnOverrides?, escapeStringResults?, servingMode? } | Defaults for the tools' UI widgets: where they load their libraries from, whether a template's plain string is escaped, and the serving mode of every tool that doesn't set one. An app's @App({ ui: { servingMode } }) overrides it for that app's tools, and a tool's own ui.servingMode overrides both: see Defaults for the server and an app. (servingMode is new in 1.9.4.) |
info
| Field | Type | Description |
|---|---|---|
name | string | Required. The server's name, like "help-desk". |
version | string | Required. The server's version, like "1.0.0". |
title | string | A readable name for clients to display. |
websiteUrl | string | A URL for the server's documentation or home page. |
icons | Icon[] | Icons for clients to show. Each has a src, and optionally mimeType, sizes and theme. |
info has no description field. One is silently dropped: use instructions to describe the server to the model.
http
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
http: {
port: 8080,
entryPath: "/mcp",
cors: { origin: ["https://desk.example.com"], credentials: true },
routes: [{ method: "GET", path: "/exports", handler: (req, res) => res.json({ format: req.query.format ?? "csv" }) }],
},
})
export default class Server {}| Field | Default | Description |
|---|---|---|
port | PORT, or 3000 | The TCP port to listen on. |
entryPath | "" | The path of the MCP endpoint. With the default, clients connect to the root: http://localhost:3000. |
cors | none | CORS for browser clients: { origin, credentials?, maxAge? }. Without it, FrontMCP sends no CORS headers, so browser pages on other origins can't read responses. origin: true allows any origin. |
security | loopback | bindAddress: "loopback" (the default, 127.0.0.1), "all", or an address. The FRONTMCP_BIND_ADDRESS environment variable does the same. dnsRebindingProtection checks Host and Origin headers. strict: true turns on the hardening. |
routes | none | Extra HTTP handlers on the same port: { method, path, handler, auth? }. With auth: true, a route requires the same credentials as the MCP endpoint. Paths used by FrontMCP, like /health and /.well-known/*, are rejected at startup. createFetchHandler() doesn't serve routes. See Serving HTTP routes next to MCP. |
bodyLimit | "4mb" | The largest JSON request body accepted. urlencodedLimit does the same for form bodies. |
securityHeaders | nosniff and X-Frame-Options: DENY | The security headers on every response: { hsts?, contentTypeOptions?, frameOptions?, custom?, csp? }. hsts, contentTypeOptions and frameOptions take the header's value, or false to leave it out; custom adds headers as they are; csp is { enabled?, directives?, reportUri?, reportOnly? }. Each option wins over its FRONTMCP_* variable (Response headers). Since 1.8.7; before, FrontMCP sent neither default header and sent X-Powered-By: Express. |
socketPath | none | Listen on a Unix socket instead of a port. |
hostFactory | Express | Your own HTTP server instead of Express: a FrontMcpServer, or a function that returns one. It then applies cors, bodyLimit, securityHeaders and the Host check itself. See Using your own HTTP server. |
transport
| Field | Default | Description |
|---|---|---|
protocol | "legacy" | Which HTTP transports clients may use, for protocol versions before 2026-07-28: "legacy" (Streamable HTTP and the old SSE transport), "modern" (Streamable HTTP with sessions), "stateless-api" (no sessions), "full" (everything), or an object of sse, streamable, json, stateless, legacy and strictSession flags. |
defaultProtocolVersion | "legacy" on the Node server, "2026-07-28" behind createFetchHandler() | How to serve a bare JSON-RPC request that names no protocol version. See Protocol versions. |
persistence | on when redis or sqlite is set | Store sessions so they survive restarts: { redis?, sqlite?, defaultTtlMs?, sessionCheckTimeoutMs? }. sessionCheckTimeoutMs (500 by default, new in 1.9.4) is how long a request waits for the store to confirm its session before it's served from memory: see High availability. Without it, FrontMCP uses the top-level redis, or else sqlite, if one is set, and memory otherwise. false keeps sessions in memory. |
distributedMode | false | true or "auto" for servers that run as many instances behind a load balancer or as serverless functions. |
providerCaching | true | When false, CONTEXT providers are rebuilt on every request, even within a session: those registered on @FrontMcp and, since 1.9.2, on apps. |
eventStore | off | Let SSE clients resume missed messages after reconnecting: { enabled, provider?, maxEvents?, ttlMs?, redis? }. |
sessionMode | "stateful" | Deprecated, not read, and removed in the next major: with "stateless", older clients still get a session id and must send it. Sessions follow protocol: "stateless-api" serves without them. A value other than "stateful" logs a warning at startup: transport.sessionMode ('stateless') is ignored and will be removed in the next major: sessions follow `transport.protocol`. To serve without sessions, set `transport.protocol: 'stateless-api'` and remove `sessionMode`. (Since 1.9; the message changed in 1.9.4.) |
platformDetection | built-in | Rules for recognizing the client's platform from its client info. |
Clients that speak MCP 2026-07-28 don't use sessions, so most of these only matter for older clients. A session id is encrypted with MCP_SESSION_SECRET. Since 1.8.6, a request whose id was minted under another secret gets 404 invalid session id (before 1.8.6, session not initialized), and the client starts over with initialize, so changing the secret ends every open session once. This was checked on a Node server, since the Playground has no sessions.
auth
auth sets who may connect, and apps inherit it. An app's own auth doesn't change who may call its tools on the shared endpoint: see Auth for one app. Auth modes covers every mode and its options.
mode | Who may connect |
|---|---|
"public" | Anyone. The default. Callers get anonymous identities. |
"static" | Callers that send one of the shared secrets in tokens, as Authorization: Bearer … by default. |
"transparent" | Callers with a valid token from your identity provider, provider. FrontMCP checks tokens but doesn't issue them. |
"local" | Callers who sign in through FrontMCP's own OAuth server. See Local auth. |
"remote" | Callers who sign in through an external OAuth provider, provider, with FrontMCP handling the flow. |
In local and remote mode, only registered clients can sign in, since requireRegisteredClients defaults to true, and a client is granted only the scopes listed in allowedScopes. See Local auth.
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
auth: { mode: "static", tokens: [process.env.DESK_API_KEY!] },
})
export default class Server {}The Playground's client can't send credentials, so this runs in a project, not in the Playground. Authenticating Clients walks through each mode.
Starting the server
With the default serve: true, importing the class starts an HTTP server. With the environment variable FRONTMCP_STDIO=1 (or true), it connects over stdio instead and opens no port, for clients that launch the server as a subprocess.
To decide when the server starts, set serve: false and start it yourself:
import "reflect-metadata";
import { FrontMcp, FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
const options = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] };
@FrontMcp({ ...options, serve: false })
export default class Server {}
await connectToDatabase();
await FrontMcpInstance.bootstrap(options);FrontMcpInstance also has runStdio(options), createHandler(options) for serverless platforms, and createFetchHandler(options), which is what the Playground uses. To call a server in your own process, see create() and createDirect() and connect().
Caveats
- Declare one
@FrontMcpper server. The class body is ignored. - Options FrontMCP doesn't know are dropped without an error, including
info.descriptionand a top-levelprompts. Check the spelling of anything that seems to have no effect. - The MCP endpoint is at the root path unless you set
http.entryPath. - The Playground runs your options through
FrontMcpInstance.createFetchHandler(), in your browser, with a minimalAsyncContextso that calls overlap as they do on Node (FrontMCP's browser build serves one request at a time without one). It opens no port, andserve,http,transportand the storage options have no effect there.authdoes apply, but the Playground's client never sends credentials: a mode that requires them (such asstatic) refuses every request, and one that allows anonymous callers treats the Playground as anonymous. See Authenticating Clients.
Usage
Declaring a server
info and apps are all a server needs. Clients receive info with every response: open the Wire tab and look at _meta in any response.
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
@FrontMcp({
info: { name: "help-desk", title: "Help Desk", version: "1.4.0", websiteUrl: "https://desk.example.com" },
apps: [HelpDeskApp],
})
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Tools every app shares
tools and resources on @FrontMcp belong to no app: every app serves them, and they're listed once, with their own names. Use them for what isn't any app's, like a health check:
import { FrontMcp } from "@frontmcp/sdk";
import { BillingApp, HelpDeskApp } from "./apps";
import { Ping, ServerStatus } from "./shared";
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp, BillingApp],
tools: [Ping],
resources: [ServerStatus],
})
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
@FrontMcp has no prompts: put prompts in an app. Changed in 1.9.1: before, FrontMCP accepted tools and resources here but never registered them, so clients couldn't see or call them.
Giving the model instructions
Tool descriptions explain each tool. instructions explains the server: which tool to start with, how the tools fit together, what not to do. Clients read it from server/discover and usually add it to the model's context, so keep it short.
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
instructions:
"Support ticket tools. Find tickets with search_tickets before acting on them. " +
"Never close a ticket the customer hasn't confirmed is solved.",
})
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Asking the user for input
this.elicit() pauses a tool to ask the user a question, and it only works when the server turns elicitation on. The Call tab shows the form a client would show; answer it to finish the call.
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
elicitation: { enabled: true },
})
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Without elicitation: { enabled: true }, the same call fails. See the troubleshooting entry.
Paging long tool lists
Once a server has more than 40 tools, tools/list returns them 40 at a time, with a nextCursor for the next page. Change both numbers with pagination. Here the threshold is four tools, and there are five, so they come two per page:
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
pagination: { tools: { mode: "auto", autoThreshold: 4, pageSize: 2 } },
})
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A client reads every tool by following nextCursor until there isn't one. mcp.tools.list() does that for you since 1.8.6; before, it returned only the first page. A DirectClient from connect() or server.connect() does it too: its list methods follow every page, listResources(), listResourceTemplates() and listPrompts() since 1.8.7. So does listTools() on the server itself, since 1.9; before, it returned only the first page. This test sends the requests itself, to show each page.
Setting defaults for every tool
Some options set a default for every tool on the server. An app's own setting overrides the server's, and a tool's overrides both.
Server-wide defaults
Example 1 of 2
Output
output controls how tool results are checked and described. With allowNonFinite: true, a NaN in a result becomes null instead of failing the call. Change it to false to see the error.
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";
@Tool({ name: "average_reply_hours", description: "Average hours to first reply this week", inputSchema: {} })
class AverageReplyHours extends ToolContext {
async execute() {
const replies: number[] = [];
return { hours: replies.reduce((a, b) => a + b, 0) / replies.length }; // 0 / 0 is NaN
}
}
@App({ id: "reports", name: "Reports", tools: [AverageReplyHours] })
class ReportsApp {}
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [ReportsApp],
output: { allowNonFinite: true },
})
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
ui.servingMode sets a default the same way, for the serving mode of every tool with a widget that doesn't set its own: @FrontMcp({ ui: { servingMode: "static" } }), overridden by @App({ ui: { servingMode } }) for an app's tools, and by a tool's own ui.servingMode. New in 1.9.4. Defaults for the server and an app shows it.
Serving HTTP routes next to MCP
Not everything a help desk serves is MCP. A spreadsheet wants a CSV export, and the billing system calls a webhook when an invoice is paid. http.routes adds plain HTTP endpoints on the MCP endpoint's port, so they don't need a second server:
import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
import { markPaid, ticketsCsv } from "./store";
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
auth: { mode: "static", tokens: [process.env.DESK_API_KEY!] },
http: {
routes: [
{
method: "GET",
path: "/exports/:status",
auth: true, // the same credentials as the MCP endpoint
handler: (req, res) => {
res.setHeader("Content-Type", "text/csv");
res.send(ticketsCsv(req.params?.status ?? "open"));
},
},
{
method: "POST",
path: "/webhooks/billing",
handler: (req, res) => {
markPaid(req.body.invoice);
res.status(202).json({ received: req.body.invoice });
},
},
],
},
})
export default class Server {}A handler is Express-style, (req, res, next). req.params holds the path's :status, req.query the query string, req.headers the headers, and req.body a JSON or form body, already parsed. res has status(), json(), send() and setHeader(). This server answers:
| Request | Answer |
|---|---|
GET /exports/open with no Authorization header | 401 {"error":"Unauthorized"}, with WWW-Authenticate: Bearer realm="mcp" |
GET /exports/open with a key that isn't in tokens | 401, with WWW-Authenticate: Bearer realm="mcp", error="invalid_token", error_description="The access token is invalid" |
GET /exports/open with Authorization: Bearer and the key | 200, the CSV, as text/csv; charset=utf-8 |
POST /webhooks/billing with no credentials | 202 {"received":"INV-7"}. A route without auth: true is open to anyone, whatever the server's auth, so check that the request comes from billing in the handler. |
auth: true runs the check the MCP endpoint runs, before the handler:
- Without valid credentials, the route answers
401with the challenge the MCP endpoint sends. With an identity provider (transparentmode), it points at the resource metadata:Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource". - A valid token that lacks one of
auth.requiredScopesgets403{"error":"Forbidden"}, witherror="insufficient_scope"and the scopes it needs in the challenge. - Otherwise the handler runs, and
req.authSessionsays who called:userholds the token's claims (sub,scope, and for a JWT the rest of them), andtokenthe token itself. A static key'suser.subisstatic:and a hash of the key. - A server that lets anonymous callers in lets them into these routes too. In
publicmode, the default, and withallowAnonymous, a request without a token runs the handler as a new anonymous caller each time, whosereq.authSession.user.substarts withanon:. If a route needs a real caller, refuse those in the handler.
throttle.ipFilter applies to every route, before auth: a refused address gets 403 {"error":"forbidden","message":"Client IP rejected by ipFilter"}.
The content type. FrontMCP's Express server sets Content-Type: application/json; charset=utf-8 on every response before a handler runs. That suits res.json(), but a CSV string sent with res.send() goes out labelled as JSON. Set the type first, as the export does.
Reserved paths. A route can't take a path FrontMCP serves itself:
- the MCP endpoint and the
/sseand/messagepaths next to it:/,/sseand/messageby default, or/mcp,/mcp/sseand/mcp/messagewithentryPath: "/mcp"; /healthand/metrics;- anything under
/oauth/or/.well-known/.
Such a route stops the server from starting: see the troubleshooting entry. /healthz and /readyz aren't on the list, and your routes are registered before FrontMCP's health checks, so a route on either replaces the health check.
Where routes are served. FrontMCP's Node server serves them, whether you start it by importing the class or with bootstrap(), and so does createHandler(). createFetchHandler() doesn't: on Cloudflare Workers, Deno and Bun, and in the Playground, a request to a route gets 404. It still refuses reserved paths. Everything else in this section was checked on a Node server.
Large files. A tool's result, like a resource's contents, travels inside one JSON-RPC response. For an export of many megabytes, serve the file from a route and have the tool return a resource_link to it: the result carries only the link, and the file goes out on a request of its own when someone follows it. With auth: true on the route, that request needs the same credentials as the MCP endpoint.
import { App, FrontMcp, Tool, ToolContext, z, type ServerRequest, type ServerResponse } from "@frontmcp/sdk";
import { ticketsCsv } from "./store";
const PUBLIC_URL = "https://desk.example.com"; // where clients reach this server
@Tool({
name: "export_tickets",
description: "Export the tickets with a status as a CSV file, and return a link to it",
inputSchema: { status: z.enum(["open", "closed"]) },
outputSchema: "resource_link",
})
class ExportTickets extends ToolContext {
async execute({ status }: { status: "open" | "closed" }) {
return { type: "resource_link" as const, uri: `${PUBLIC_URL}/exports/${status}`, name: `${status}-tickets.csv`, mimeType: "text/csv" };
}
}
@App({ id: "help-desk", name: "Help Desk", tools: [ExportTickets] })
class HelpDeskApp {}
export const config = {
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
http: {
routes: [
{
method: "GET" as const,
path: "/exports/:status",
handler: (req: ServerRequest, res: ServerResponse) => {
res.setHeader("Content-Type", "text/csv");
res.send(ticketsCsv(req.params?.status ?? "open"));
},
},
],
},
};
@FrontMcp(config)
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
On a Node server, following the link returns the two open tickets as text/csv.
Using your own HTTP server
FrontMCP's Node server is built on Express. http.hostFactory replaces Express with a server of your own: a FrontMcpServer subclass, or a function that returns one. FrontMCP still decides what to serve, the MCP endpoint, discovery documents, OAuth, health checks and your routes, and registers each with your server. Your server decides how requests reach them.
To add MCP to a Node server you already run, you don't need one: createHandler() returns FrontMCP's Express app as a (req, res) handler, to call with the requests MCP should answer.
FrontMCP calls these methods, in this order:
| Method | Called |
|---|---|
registerMiddleware(path, handler) | Once for each of FrontMCP's endpoints, while the server starts: the MCP endpoint with "/", discovery documents with "", and OAuth with paths like "/oauth/token" and "/oauth/provider/:providerId/callback". Run handler for every request at or under path, in the order they were registered. A handler that doesn't serve the request calls next(). |
registerRoute(method, path, handler) | For each of http.routes, then for FrontMCP's health checks, /healthz, /health and /readyz. Run handler for that method and path. :name parts of the path go in req.params. |
prepare() | Once everything is registered. |
start(portOrSocketPath, bindAddress) | When the server starts: with http.socketPath if it's set, else http.port, and the bind address http.security resolves to, "127.0.0.1" by default. |
getHandler() | By createHandler(), instead of start(). createHandler() returns what it returns. |
stop() | On SIGTERM or SIGINT. The base class's does nothing. |
enhancedHandler(handler) | Never, in 1.9.4. The class declares it, so return handler. |
The handlers expect Express's request and response, so a host gives them what they use. On the request: path, query, params, headers, and body, parsed from JSON or a form. On the response: status(), which returns the response, json(), send() and redirect(), besides Node's own setHeader(), writeHead(), write() and end(), which streamed answers use. This host does it with node:http alone:
import http from "node:http";
import { FrontMcpServer, type HttpMethod, type ServerRequest, type ServerRequestHandler, type ServerResponse } from "@frontmcp/sdk";
type Layer = { method?: HttpMethod; path: string; prefix: boolean; handler: ServerRequestHandler };
export class NodeHost extends FrontMcpServer {
private layers: Layer[] = [];
private server?: http.Server;
registerMiddleware(path: string, handler: ServerRequestHandler) {
this.layers.push({ path, prefix: true, handler });
}
registerRoute(method: HttpMethod, path: string, handler: ServerRequestHandler) {
this.layers.push({ method, path, prefix: false, handler });
}
enhancedHandler(handler: ServerRequestHandler) {
return handler;
}
prepare() {}
getHandler() {
return (req: http.IncomingMessage, res: http.ServerResponse) =>
this.handle(req, res).catch(() => {
if (!res.headersSent) res.writeHead(500).end();
});
}
async start(portOrSocketPath?: number | string, bindAddress?: string) {
this.server = http.createServer(this.getHandler());
await new Promise<void>((resolve) => {
if (typeof portOrSocketPath === "string") this.server!.listen(portOrSocketPath, resolve);
else this.server!.listen(portOrSocketPath, bindAddress, resolve);
});
}
override async stop() {
await new Promise((resolve) => this.server?.close(resolve));
}
private async handle(req: http.IncomingMessage, res: http.ServerResponse) {
const url = new URL(req.url ?? "/", "http://localhost");
let body: unknown;
try {
body = await readBody(req);
} catch {
return void res.writeHead(400).end(); // not JSON
}
// What FrontMCP's handlers read and call, as on Express
const request = Object.assign(req, { path: url.pathname, query: Object.fromEntries(url.searchParams), body });
const response = Object.assign(res, {
status: (code: number) => {
res.statusCode = code;
return res;
},
json: (payload: unknown) => {
if (!res.hasHeader("content-type")) res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify(payload));
},
send: (payload: unknown) => res.end(typeof payload === "string" || payload instanceof Uint8Array ? payload : JSON.stringify(payload)),
redirect: (status: number | string, location?: string) => {
res.writeHead(typeof status === "number" ? status : 302, { location: typeof status === "number" ? location : status }).end();
},
});
// Offer the request to each handler in the order FrontMCP registered them, until one answers
const layers = this.layers.filter((layer) => !layer.method || layer.method === req.method);
const next = async (i: number): Promise<void> => {
if (i === layers.length) return void res.writeHead(404).end();
const params = matchPath(layers[i], url.pathname);
if (!params) return next(i + 1);
Object.assign(request, { params });
await layers[i].handler(request as unknown as ServerRequest, response as unknown as ServerResponse, () => next(i + 1));
};
await next(0);
}
}
// "/exports/:status" matches "/exports/open" with { status: "open" }; a middleware's path also matches what's below it
function matchPath(layer: Layer, pathname: string): Record<string, string> | undefined {
const want = layer.path.split("/").filter(Boolean);
const got = pathname.split("/").filter(Boolean);
if (layer.prefix ? got.length < want.length : got.length !== want.length) return undefined;
const params: Record<string, string> = {};
for (const [i, part] of want.entries()) {
if (part.startsWith(":")) params[part.slice(1)] = decodeURIComponent(got[i]);
else if (part !== got[i]) return undefined;
}
return params;
}
async function readBody(req: http.IncomingMessage) {
const type = req.headers["content-type"] ?? "";
const form = type.includes("application/x-www-form-urlencoded");
if (!form && !type.includes("application/json")) return undefined;
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const text = Buffer.concat(chunks).toString("utf8");
if (!text) return undefined;
return form ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text);
}import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
import { NodeHost } from "./node-host";
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
http: { port: 3000, hostFactory: () => new NodeHost() },
})
export default class Server {}On a Node server, this host served server/discover and tools/call to a 2026-07-28 client, an initialize session and a streamed tools/call to a 2025-06-18 client, /healthz, /readyz, the resource metadata document, and an http.routes route with auth: true. The Playground runs no HTTP server, so it never calls a host.
Some of what the Express server does, a host of your own has to do itself: cors, bodyLimit, securityHeaders with its nosniff and X-Frame-Options defaults, the Host check of security.dnsRebindingProtection, and the JSON Content-Type default. With this host, a request from an origin listed in cors gets no CORS headers. The function form of hostFactory is called once, with the http options except hostFactory, defaults filled in (port, entryPath, bodyLimit), so your server can read them. An instance, hostFactory: new NodeHost(), works the same way, without them.
FrontMcpServer, HttpMethod, ServerRequestHandler, ServerRequest and ServerResponse come from @frontmcp/sdk. FrontMCP's Express host class isn't exported, so a host extends FrontMcpServer directly.
Troubleshooting
@FrontMcp invalid metadata for "apps"
The full message goes on: apps items must be annotated with @App() | @FrontMcpApp() or be a valid remote app configuration. An entry in apps is a class without @App. Often it's a tool or a provider listed directly on the server:
// 🚩 A tool is not an app
@FrontMcp({ info, apps: [SearchTickets] })
// ✅ Put the tool in an app, and the app on the server
@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] })
class HelpDeskApp {}
@FrontMcp({ info, apps: [HelpDeskApp] })
Invalid option: expected one of 0|1|2|3|4|100
The error's path is logging.level. The level is a LogLevel enum value, not a string:
// 🚩 A string
logging: { level: "info" }
// ✅ The enum
import { LogLevel } from "@frontmcp/sdk";
logging: { level: LogLevel.Info }
Elicitation is disabled in server configuration
A tool called this.elicit(), but the server doesn't have elicitation turned on, so the call failed with this message:
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
return { id, status: answer.content?.confirm ? "closed" : "open" };
}
}
@App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] })
class HelpDeskApp {}
// 🚩 No `elicitation: { enabled: true }`
@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] })
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Add elicitation: { enabled: true } to @FrontMcp. If the server runs as several processes, also give it a redis, so an answer can reach whichever process asked.
Authorities configuration required, or Invalid authorities rule
The server didn't start, for one of three reasons:
- An entry declares
authorities, a tool, resource, template, prompt, agent, a tool inside an agent, or a skill, and@FrontMcphas noauthoritiesoption to check it with. - A rule checks nothing:
{}, an empty list, an unknown field likerole:, or a profile name insideanyOf. The message names the entry. Such a rule used to let every caller in; since FrontMCP 1.8.3 the server refuses it instead. - A rule names a profile the
authoritiesoption doesn't define, like a typo:Invalid authorities rule: Tool "close_ticket": authorities names an unknown profile "lead". Changed in 1.8.4: before, such a server started and then refused every caller.
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { ArchiveApp, HelpDeskApp } from "./apps";
import { authorities } from "./main";
const info = { name: "help-desk", version: "1.0.0" };
test("an entry with authorities, and no authorities option", async () => {
await expect(FrontMcpInstance.createDirect({ info, apps: [HelpDeskApp] })).rejects.toThrow('Authorities configuration required: Tool "close_ticket"');
});
test("a rule that checks nothing", async () => {
await expect(FrontMcpInstance.createDirect({ info, apps: [HelpDeskApp, ArchiveApp], authorities })).rejects.toThrow(
'Invalid authorities rule: Tool "reopen_ticket": authorities checks nothing',
);
});
test("a profile name the option doesn't define", async () => {
const typo = { profiles: { leads: { roles: { any: ["lead"] } } } };
await expect(FrontMcpInstance.createDirect({ info, apps: [HelpDeskApp], authorities: typo })).rejects.toThrow(
'Invalid authorities rule: Tool "close_ticket": authorities names an unknown profile "lead"',
);
});
test("with the option, and a rule that checks a role, the server starts", async ({ mcp }) => {
expect(await mcp.tools.call("close_ticket", { id: "T-1" })).toBeError("AUTHORITY_DENIED");
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Add the authorities option, and fix the rule. To leave an entry open to everyone, remove its authorities.
Custom http.route … collides with the reserved FrontMCP path
The server didn't start, because a route in http.routes takes a path FrontMCP serves itself. The full message names both paths and the rule: Custom http.route "GET /health" collides with the reserved FrontMCP path "/health". Reserved prefixes are the MCP entry path (and its /sse + /message siblings), /oauth/*, /.well-known/*, /health, and /metrics. Choose a different path.
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance, type ServerRequestHandler } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
const info = { name: "help-desk", version: "1.0.0" };
const ok: ServerRequestHandler = (_req, res) => res.json({ ok: true });
const serve = (path: string, entryPath = "") =>
FrontMcpInstance.createFetchHandler({ info, apps: [HelpDeskApp], http: { entryPath, routes: [{ method: "GET", path, handler: ok }] } });
test("a route on /health", async () => {
await expect(serve("/health")).rejects.toThrow('Custom http.route "GET /health" collides with the reserved FrontMCP path "/health".');
});
test("a route under /.well-known", async () => {
await expect(serve("/.well-known/desk.json")).rejects.toThrow('collides with the reserved FrontMCP path "/.well-known"');
});
test("with entryPath /mcp, /mcp/sse is taken and / is free", async () => {
await expect(serve("/mcp/sse", "/mcp")).rejects.toThrow('collides with the reserved FrontMCP path "/mcp/sse"');
await expect(serve("/", "/mcp")).resolves.toBeDefined();
});
test("/healthz isn't reserved", async () => {
await expect(serve("/healthz")).resolves.toBeDefined();
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Move the route to a path of its own. To add a check to FrontMCP's health endpoints, use health.probes instead: see Health checks.
My client gets 404 or Cannot POST /mcp
The client is using a different path from the server. The MCP endpoint is at the root, http://localhost:3000, unless http.entryPath says otherwise. Either point the client at the root, or set http: { entryPath: "/mcp" }. If apps are standalone or the server uses splitByApp, each app has its own path under the entry path, like /mcp/billing.
A route answers 404 {"error":"Not Found","entryPaths":["/"]}
The server runs through createFetchHandler(), as on Cloudflare Workers, Deno and Bun, and it doesn't serve http.routes. The 404 lists the paths it serves MCP on. Answer the route's path in your own fetch code before calling the handler, or run the server on Node: see Serving HTTP routes next to MCP.
A web page can't read the server's responses
Browsers block cross-origin reads unless the server sends CORS headers, and since FrontMCP 1.7 it sends none by default. The request still reaches the server; the browser hides the response. List the origins that need access:
http: { cors: { origin: ["https://desk.example.com"] } }
FrontMCP then also exposes the Mcp-Session-Id header, which clients using sessions need. CORS only affects browsers. It isn't access control; use auth for that.
Clients on other machines can't connect
The server listens on 127.0.0.1 by default, so only the same machine can reach it. Set http: { security: { bindAddress: "all" } }, or the FRONTMCP_BIND_ADDRESS=all environment variable (handy in a Dockerfile), and put auth in front of anything you expose.