# @FrontMcp

> Declare an MCP server, the apps it hosts, and the settings that apply to all of them.

Source: https://frontmcp.dev/reference/sdk/frontmcp

`@FrontMcp` declares the server: its name and version, the [apps](https://frontmcp.dev/reference/sdk/app) 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.

```ts
@FrontMcp(options)
export default class Server {}
```

---

## Reference

### `@FrontMcp(options)`

Apply `@FrontMcp` to an empty class in your server's entry file.

```ts main.ts
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 {}
```

[See more examples below.](#usage)

#### Options

Required:

| Option | Type | Description |
| --- | --- | --- |
| `info` | `ServerInfoOptions` | Who the server is. Sent to clients in the `_meta` of its responses. See [`info`](#info). |
| `apps` | `AppType[]` | The apps to serve: `@App` classes, `app()` results, [`App.remote()` and `App.esm()`](https://frontmcp.dev/reference/sdk/app#apps-from-outside-your-code). |

Shared by every app:

| Option | Type | Description |
| --- | --- | --- |
| `providers` | `ProviderType[]` | [Providers](https://frontmcp.dev/reference/sdk/provider) every app can get, as one shared instance. |
| `plugins` | `PluginType[]` | Plugins that apply to every app, such as caching or auditing. See [`@Plugin`](https://frontmcp.dev/reference/sdk/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](#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](https://frontmcp.dev/reference/plugins#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](#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](https://frontmcp.dev/reference/sdk/guard) and [setting defaults for every tool](#setting-defaults-for-every-tool). |
| `fetch` | `{ forwardCallerTokenTo?, forwardCustomHeadersTo?, requestTimeout?, autoInjectTracingHeaders? }` | How [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch#server-options) 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](#starting-the-server). |
| `http` | `HttpOptions` | Port, path, CORS and custom routes. See [`http`](#http). |
| `transport` | `TransportOptions` | Protocols, sessions and where sessions are stored. See [`transport`](#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`](#transport), 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`](#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](#authorities-configuration-required-or-invalid-authorities-rule). |
| `splitByApp` | `boolean` | Serve each app on its own path, `/<app id>`, each with its own `auth`. Defaults to `false`. See [Apps, discovery and splitting](https://frontmcp.dev/reference/server/apps). |
| `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](https://frontmcp.dev/reference/server/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](https://frontmcp.dev/reference/server/observability#health). |
| `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](https://frontmcp.dev/reference/server/observability#metrics). |
| `observability` | `boolean` or `ObservabilityOptions` | OpenTelemetry tracing and structured logs. Needs `@frontmcp/observability` installed. See [Observability and telemetry](https://frontmcp.dev/reference/server/observability). |

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`](https://frontmcp.dev/reference/sdk/skill). Its `audit` options keep a signed record of what skill scripts run: see [Audit log](https://frontmcp.dev/reference/plugins/skilled-openapi#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](https://frontmcp.dev/reference/server/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`](https://frontmcp.dev/reference/sdk/job#frontmcp-jobs-). |
| `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`](https://frontmcp.dev/reference/sdk/channel). |
| `loader` | `{ url?, registryUrl?, token?, tokenEnvVar? }` | Where `App.esm()` fetches packages. Defaults to the npm registry and esm.sh. See [the loader](https://frontmcp.dev/reference/server/esm#the-loader). |
| `ui` | `{ cdnOverrides?, escapeStringResults?, servingMode? }` | Defaults for the tools' [UI widgets](https://frontmcp.dev/reference/ui): where they load their libraries from, whether a template's plain string is [escaped](https://frontmcp.dev/reference/ui#escaping-string-results), and the [serving mode](https://frontmcp.dev/reference/ui#serving-modes) 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](https://frontmcp.dev/reference/ui#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`

```ts main.ts
@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](#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](https://frontmcp.dev/reference/server/config-files#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](#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](https://frontmcp.dev/reference/server/protocol-versions#transportdefaultprotocolversion). |
| `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](https://frontmcp.dev/reference/deployment/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](https://frontmcp.dev/reference/auth/modes#auth-for-one-app). [Auth modes](https://frontmcp.dev/reference/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](https://frontmcp.dev/reference/auth/local). |
| `"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](https://frontmcp.dev/reference/auth/local).

```ts main.ts
@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](https://frontmcp.dev/learn/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:

```ts main.ts
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`](https://frontmcp.dev/reference/sdk/frontmcp-instance) also has `runStdio(options)`, `createHandler(options)` for serverless platforms, and [`createFetchHandler(options)`](https://frontmcp.dev/reference/sdk/create-fetch-handler), which is what the Playground uses. To call a server in your own process, see [`create()` and `createDirect()`](https://frontmcp.dev/reference/sdk/create) and [`connect()`](https://frontmcp.dev/reference/sdk/connect).

#### Caveats

- Declare **one** `@FrontMcp` per server. The class body is ignored.
- Options FrontMCP doesn't know are **dropped without an error**, including `info.description` and a top-level `prompts`. 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 minimal `AsyncContext` so that calls overlap as they do on Node (FrontMCP's browser build serves [one request at a time](https://frontmcp.dev/reference/sdk/create-fetch-handler#in-a-browser) without one). It opens no port, and `serve`, `http`, `transport` and the storage options have no effect there. `auth` does apply, but the Playground's client never sends credentials: a mode that requires them (such as `static`) refuses every request, and one that allows anonymous callers treats the Playground as anonymous. See [Authenticating Clients](https://frontmcp.dev/learn/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.

```ts main.ts active
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 {}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in" },
  { id: "T-2", title: "Invoice total is wrong" },
];

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}

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

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

test("clients see the server's info", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" });
  expect(response.result).toMatchObject({
    _meta: {
      "io.modelcontextprotocol/serverInfo": {
        name: "help-desk",
        title: "Help Desk",
        version: "1.4.0",
        websiteUrl: "https://desk.example.com",
      },
    },
  });
});
```

### 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:

```ts main.ts active
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 {}
```

```ts shared.ts
import { Resource, ResourceContext, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "ping", description: "Check that the server is up", inputSchema: {} })
export class Ping extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

@Resource({ name: "status", uri: "desk://status", mimeType: "application/json", description: "Whether every service is up" })
export class ServerStatus extends ResourceContext {
  async execute() {
    return { services: { tickets: "up", billing: "up" } };
  }
}
```

```ts apps.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "search_tickets", description: "Search support tickets", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [{ id: "T-1", title: "Cannot log in" }].filter((t) => t.title.toLowerCase().includes(query)) };
  }
}

@Tool({ name: "get_invoice", description: "Get an invoice", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, amount: "49 EUR" };
  }
}

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

@App({ id: "billing", name: "Billing", tools: [GetInvoice] })
export class BillingApp {}
```

```ts shared.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcp, FrontMcpInstance, Tool, ToolContext } from "@frontmcp/sdk";
import { SearchTickets } from "./apps";

test("the server's tool and resource are listed once, next to the apps'", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name).sort()).toEqual(["get_invoice", "ping", "search_tickets"]);
  expect((await mcp.resources.list()).map((r) => r.uri)).toEqual(["desk://status"]);
  expect((await mcp.tools.call("ping", {})).json()).toEqual({ ok: true });
});

test("a name an app's tool has too gets a prefix on both", async () => {
  @Tool({ name: "search_tickets", description: "Search every app's tickets", inputSchema: {} })
  class SearchEverything extends ToolContext {
    async execute() {
      return { from: "server" };
    }
  }
  @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] })
  class HelpDesk {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], tools: [SearchEverything] });
  try {
    expect((await server.listTools()).tools.map((t) => t.name).sort()).toEqual(["help-desk:search_tickets", "server:search_tickets"]);
    expect((await server.callTool("server:search_tickets", {})).structuredContent).toEqual({ from: "server" });
  } finally {
    await server.dispose();
  }
});
```

`@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.

```ts main.ts active
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 {}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [], query };
  }
}

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed" };
  }
}

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

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

test("server/discover carries the instructions", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" });
  expect(response.result).toMatchObject({ instructions: expect.stringContaining("Find tickets with search_tickets") });
});
```

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

```ts main.ts active
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 {}
```

```ts help-desk.app.ts
import { App, 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() }));
    if (answer.status !== "accept" || !answer.content?.confirm) return { id, status: "open" };
    return { id, status: "closed" };
  }
}

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

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

test("the tool closes the ticket once the user confirms", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } }));
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result.json()).toEqual({ id: "T-1", status: "closed" });
});
```

Without `elicitation: { enabled: true }`, the same call fails. See [the troubleshooting entry](#elicitation-is-disabled-in-server-configuration).

### 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:

```ts main.ts active
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 {}
```

```ts help-desk.app.ts
import { App, tool } from "@frontmcp/sdk";

const names = ["search_tickets", "get_ticket", "create_ticket", "assign_ticket", "close_ticket"];
const tools = names.map((name) =>
  tool({ name, description: name.replace("_", " "), inputSchema: {} })(async () => ({ ok: true })),
);

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

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

type Page = { tools: { name: string }[]; nextCursor?: string };

test("each page has two tools and a cursor to the next", async ({ mcp }) => {
  const page = async (cursor?: string) =>
    (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor ? { cursor } : {} })).result as Page;

  const first = await page();
  expect(first.tools).toHaveLength(2);
  const second = await page(first.nextCursor);
  expect(second.tools).toHaveLength(2);
  const third = await page(second.nextCursor);
  expect(third.tools).toHaveLength(1);
  expect(third.nextCursor).toBeUndefined();
});
```

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()`](https://frontmcp.dev/reference/sdk/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](https://frontmcp.dev/reference/sdk/create#listing-more-than-one-page-of-tools), 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.

<Examples title="Server-wide defaults">

#### Example: 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.

```ts main.ts active
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 {}
```

#### Example: Rate limit
`throttle` sets limits for every tool. Here each tool may run twice a minute; the third call fails with a message that says when to retry.

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [], query };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  throttle: { enabled: true, defaultRateLimit: { maxRequests: 2, windowMs: 60_000 } },
})
export default class Server {}
```

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

test("the third call in a minute is refused", async ({ mcp }) => {
  await mcp.tools.call("search_tickets", { query: "a" });
  await mcp.tools.call("search_tickets", { query: "b" });
  const third = await mcp.tools.call("search_tickets", { query: "c" });
  expect(third).toBeError();
  expect(third.raw._meta?.code).toBe("RATE_LIMIT_EXCEEDED");
  expect(third.text()).toContain("Rate limit exceeded");
});
```

`throttle` also takes `global` (one limit shared by all requests), `defaultConcurrency` and `globalConcurrency` (`{ maxConcurrent }`), `defaultTimeout` (`{ executeMs }`), `ipFilter`, and `storage` to share counters between processes through Redis. A tool's own `rateLimit`, `concurrency` and `timeout` override the defaults.

`ui.servingMode` sets a default the same way, for the [serving mode](https://frontmcp.dev/reference/ui#serving-modes) 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](https://frontmcp.dev/reference/ui#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:

```ts main.ts
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 `401` with the challenge the MCP endpoint sends. With an identity provider (`transparent` mode), 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.requiredScopes` gets `403` `{"error":"Forbidden"}`, with `error="insufficient_scope"` and the scopes it needs in the challenge.
- Otherwise the handler runs, and `req.authSession` says who called: `user` holds the token's claims (`sub`, `scope`, and for a JWT the rest of them), and `token` the token itself. A static key's `user.sub` is `static:` and a hash of the key.
- A server that lets anonymous callers in lets them into these routes too. In `public` mode, the default, and with `allowAnonymous`, a request without a token runs the handler as a new anonymous caller each time, whose `req.authSession.user.sub` starts with `anon:`. 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 `/sse` and `/message` paths next to it: `/`, `/sse` and `/message` by default, or `/mcp`, `/mcp/sse` and `/mcp/message` with `entryPath: "/mcp"`;
- `/health` and `/metrics`;
- anything under `/oauth/` or `/.well-known/`.

Such a route stops the server from starting: see [the troubleshooting entry](#custom-httproute--collides-with-the-reserved-frontmcp-path). `/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()`](https://frontmcp.dev/reference/sdk/frontmcp-instance#frontmcpinstancecreatehandlerconfig). [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) 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.

```ts main.ts active
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 {}
```

```ts store.ts
const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  { id: "T-3", title: "Export times out", status: "open" },
];

// A real export would stream thousands of rows from the database
export function ticketsCsv(status: string) {
  const rows = tickets.filter((t) => t.status === status).map((t) => `${t.id},${t.title}`);
  return ["id,title", ...rows].join("\n") + "\n";
}
```

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

test("the result is a link to the file, not the file", async ({ mcp }) => {
  const result = await mcp.tools.call("export_tickets", { status: "open" });
  expect(result).toBeSuccessful();
  expect(result.raw.content).toEqual([
    { type: "resource_link", uri: "https://desk.example.com/exports/open", name: "open-tickets.csv", mimeType: "text/csv" },
  ]);
});

test("createFetchHandler(), which runs this Playground, doesn't serve the route", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(new Request("https://desk.example.com/exports/open"));
  expect(response.status).toBe(404);
  expect(await response.json()).toEqual({ error: "Not Found", entryPaths: ["/"] });
});
```

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()`](https://frontmcp.dev/reference/sdk/frontmcp-instance#frontmcpinstancecreatehandlerconfig) 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:

```ts node-host.ts
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);
}
```

```ts main.ts
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:

```ts
// 🚩 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:

```ts
// 🚩 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:

```ts main.ts active
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 {}
```

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 `@FrontMcp` has no `authorities` option to check it with.
- A rule checks nothing: `{}`, an empty list, an unknown field like `role:`, or a profile name inside `anyOf`. 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 `authorities` option 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.

```ts startup.test.ts active
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");
});
```

```ts apps.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a ticket", inputSchema: { id: z.string() }, authorities: "lead" })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, closed: true };
  }
}

// 🚩 checks nothing
@Tool({ name: "reopen_ticket", description: "Reopen a ticket", inputSchema: { id: z.string() }, authorities: {} })
class ReopenTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, reopened: true };
  }
}

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

@App({ id: "archive", name: "Archive", tools: [ReopenTicket] })
export class ArchiveApp {}
```

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./apps";

export const authorities = { profiles: { lead: { roles: { any: ["lead"] } } } };

// ✅ The option is set, and the app's rule checks a role
@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], authorities })
export default class Server {}
```

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

```ts startup.test.ts active
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();
});
```

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  // ✅ A path of its own, not /health
  http: { routes: [{ method: "GET", path: "/status/queue", handler: (_req, res) => res.json({ waiting: 4 }) }] },
})
export default class Server {}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [], query };
  }
}

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

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](https://frontmcp.dev/reference/server/observability#health).

### 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()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), 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](#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:

```ts
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`](#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`](#auth) in front of anything you expose.
