# createFetchHandler()

> Serve an MCP server from any runtime that speaks web Request and Response, like Cloudflare Workers, Deno, Bun or a framework's route.

Source: https://frontmcp.dev/reference/sdk/create-fetch-handler

`FrontMcpInstance.createFetchHandler(config)` turns a server into one function: it takes a web-standard `Request` and returns a `Response`. Anything that hands you a `Request` can serve MCP with it: a Cloudflare Worker, `Deno.serve()`, `Bun.serve()`, or a route in a framework like Hono or Next.js. It needs no Node HTTP server and opens no port. This site's Playground runs every example through it.

```ts
const handler = await FrontMcpInstance.createFetchHandler(config)
const response = await handler(request, ctx?, env?)
```

---

## Reference

### `FrontMcpInstance.createFetchHandler(config)`

Create the handler once, at the top of your entry module, and call it for every request:

```ts worker.ts
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./config";

const handler = FrontMcpInstance.createFetchHandler(config);

export default {
  async fetch(request: Request, env: unknown, ctx: { waitUntil(promise: Promise<unknown>): void }) {
    return (await handler)(request, ctx, env);
  },
};
```

[See more examples below.](#usage)

#### Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| `config` | `FrontMcpConfigInput` | The object you'd pass to [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp#options). Not the decorated class, which fails with [`expected object, received undefined`](https://frontmcp.dev/reference/sdk/frontmcp-instance#invalid-input-expected-object-received-undefined). |

#### Returns

A promise of the handler. `createFetchHandler()` builds the server when it's called, and keeps it for every request. That's when the configuration is checked and the startup checks run, as for [`createDirect()`](https://frontmcp.dev/reference/sdk/create), so a server that can't start rejects the promise, and there's no handler: an invalid configuration, an entry whose `approval`, `featureFlag` or `authorities` nothing enforces, or a missing `JWT_SECRET` in production. See [Catching a misconfigured server at startup](#catching-a-misconfigured-server-at-startup).

On an edge isolate, like a Cloudflare Worker, the server can't be built while the module loads, so it's built when the first request arrives instead. `createFetchHandler()` then only runs [`assertStaticStartupConfig(config)`](#assertstaticstartupconfigconfig), and throws what it throws.

Changed in 1.8.4: before, the server was built on the first request everywhere, so a misconfigured server's first request failed instead of `createFetchHandler()`.

Each call to `createFetchHandler()` builds a separate server, with its own providers and state. Create one per process.

### `assertStaticStartupConfig(config)`

Throws the startup error that the configuration's decorators alone show, without building the server: `AuthConfigurationError` for an entry with `authorities` on a server without the `authorities` option, or with a rule that names a profile the option doesn't define, then `UnenforcedMetadataError` for a plugin's option, like `approval`, that no plugin reaching the entry enforces. It returns nothing when it finds none. `createFetchHandler()` calls it on an edge isolate, and so can a test or a build step. (Changed in 1.9.2: before, it missed an unknown profile name, which only the server's build caught.)

It's one-sided: a configuration it accepts can still fail when the server is built, like one with an invalid `info`, because it doesn't validate the configuration itself. On an edge isolate, a failed build answers the request instead:

| The build failed with | Response |
| --- | --- |
| A configuration fault: an invalid configuration (`CONFIG_INVALID`), a failed startup check (`AUTH_CONFIGURATION_ERROR`, `UNENFORCED_METADATA`) or a [missing secret](#production-secrets) | `500` `{ "error": "server_misconfigured", "code", "message" }` |
| Anything else, like a provider whose factory throws | `503` `{ "error": "server_unavailable", "code": "SERVER_START_FAILED", "message" }`, with `Retry-After` |

Later requests try the build again, waiting longer after each failure, from 1 second up to 60. For an edge entry point of your own, `@frontmcp/sdk` exports the same pieces: `createDeferredServerBuild(build)`, whose `get()` builds once and keeps the result or the failure, and `startupFailureResponse(error, retryAfterSeconds?)`, which makes the `500` or `503` above. The Playground isn't an edge isolate, so this was checked in Node with the `EdgeRuntime` global that marks one. Changed in 1.8.5: before, only the recognized faults and secrets got a `500`, any other failure rejected the request's promise, and every request rebuilt at once.

### `createWebFetchHandler(scope, options?)`

The lower-level function under `createFetchHandler()`, also exported from `@frontmcp/sdk`. It takes one endpoint of a server that's already built, a scope, instead of a configuration, and returns the same kind of [handler](#handlerrequest-ctx-env), at once rather than as a promise:

```ts worker.ts
import { FrontMcpInstance, createWebFetchHandler } from "@frontmcp/sdk";
import { config } from "./config";

type Scope = Parameters<typeof createWebFetchHandler>[0];

const instance = await FrontMcpInstance.createForGraph(config);
const handler = createWebFetchHandler(instance.getPrimaryScope() as Scope, { entryPath: ["/mcp", "/v1/mcp"] });
```

`instance.getPrimaryScope()` is the endpoint that serves the server's own apps, and `instance.getAppScope("billing")` a [standalone](https://frontmcp.dev/reference/sdk/app#standalone-apps) app's. `@frontmcp/sdk` doesn't export the `Scope` type, hence the cast. Each request runs through the same flow as with `createFetchHandler()`.

| Option | Default | Description |
| --- | --- | --- |
| `entryPath` | `http.entryPath`, else `/` | Where MCP is served. A list serves it at every path in it. |
| `healthPaths` | none | Paths that answer `200` `{ "status": "ok", "server", "transport": "web-fetch" }`, with no probes run. When it's set, `health`'s own paths, `/healthz` and `/readyz`, aren't answered unless it lists them. |
| `cors` | `http.cors` | CORS for this handler: `origin`, `credentials` and `maxAge`, and also `methods`, `headers` and `exposeHeaders`, the lists of the `Access-Control-Allow-*` and `-Expose-Headers` headers. |
| `sessionRouter` | none | `(request, env, ctx) => Promise<Response \| undefined>`, offered every MCP request before the handler serves it, for sending sessions to a stateful host such as a Durable Object. A `Response` it returns is the answer; `undefined` lets the handler serve the request. Health checks and CORS preflights don't reach it. |
| `metrics` | none | `{ service, config }`, for `/metrics`. `@frontmcp/sdk` doesn't export the service, so in practice this handler serves no `/metrics`. |

Use `createFetchHandler()` unless you need one of these options. Compared with it, `createWebFetchHandler()`:

- **Serves one endpoint.** A server with a standalone app, or with `splitByApp`, has several, and `createFetchHandler()` serves each at its path. This handler serves only the scope you pass, at its `entryPath`: a standalone app's scope too, unless you give it the app's path.
- **Builds nothing.** The scope must already be built, so there's none of `createFetchHandler()`'s deferred build on an edge isolate, with its retries and its `500` and `503` answers.
- **Serves no `/metrics`.**

The handler's type is `WebFetchHandler`, `(request, ctx?, env?) => Promise<Response>`, and its options' are `CreateWebFetchHandlerOptions`, `WebFetchCorsOptions` and `WebFetchSessionRouter`, all exported. See [Building the handler from a scope](#building-the-handler-from-a-scope).

### `handler(request, ctx?, env?)`

| Parameter | Type | Description |
| --- | --- | --- |
| `request` | `Request` | The incoming request. |
| `ctx` | `FetchHandlerCtx` | Optional. What your runtime knows about the request. FrontMCP uses three things from it: `waitUntil(promise)` (Cloudflare Workers), which it calls to finish a streamed response to an older client after the handler returns; `remoteAddr.hostname` (Deno's `info`) and `requestIP(request)` (Bun's `server`), for [the client's address](#the-clients-address). |
| `env` | `unknown` | Optional. Cloudflare's bindings. FrontMCP passes them to its authentication flows, which reach their stores through it, and to your tools as [`this.workerEnv`](#reading-a-workers-bindings). |

Note the order: a Worker's `fetch(request, env, ctx)` takes `env` before `ctx`, and the handler takes `ctx` first.

It returns a promise of a `Response`. MCP errors, bad requests and failed authentication are all responses, with a status, and so are a missing [production secret](#production-secrets) and, on an edge isolate, a server that [can't be built](#assertstaticstartupconfigconfig).

#### What it answers

| Request | Response |
| --- | --- |
| `POST` to the MCP endpoint | The MCP response: JSON, or an event stream when the call streams progress or logs. |
| `GET` to the MCP endpoint | An event stream for older clients. Without `Accept: text/event-stream`, `406` `Not Acceptable: Client must accept text/event-stream`. |
| `DELETE` to the MCP endpoint | `404` `Session not found`: there are no sessions to end. [See below.](#sessions) |
| `OPTIONS`, with `http.cors` set | `204` and [the CORS headers](#cors-and-host-checks), on any path. Without `http.cors`, `405` `Method not allowed.` at the endpoint. |
| `GET /healthz` or `/health` | `200` `{ "status": "ok", "server": { name, version }, "transport": "web-fetch" }`, with `Cache-Control: no-store`. |
| `GET /readyz` | `200` with the readiness report, `{ "status": "ready", "catalog": { … }, "probes": { … }, "transport": "web-fetch" }`, or `503` with `"not_ready"` when a probe fails. See [Health checks](#health-checks). |
| `GET /metrics`, with `metrics` on | The scrape, as on FrontMCP's Node server. See [Metrics](https://frontmcp.dev/reference/server/observability#metrics). (Since 1.9; before, `404`.) |
| A `POST` with a body over `http.bodyLimit` | `413` `{ "jsonrpc": "2.0", "error": { "code": -32600, "message": "Payload Too Large", "data": { limit, length } }, "id": null }`. The default limit is `4mb`. |
| Anything else | `404` `{ "error": "Not Found", "entryPaths": ["/"] }`, with every MCP endpoint's path in `entryPaths`, unless one of FrontMCP's auth routes (`/.well-known/…`, OAuth) claims it. |

The MCP endpoint is `http.entryPath`, or `/` if you don't set one; a trailing slash doesn't matter. A server with one endpoint serves MCP at that path only. A server with more serves each where FrontMCP's Node server does: a [`standalone`](https://frontmcp.dev/reference/sdk/app#standalone-apps) app at `<entryPath>/<app id>`, like `/billing`, and the other apps at the entry path (with no tools, when every app is standalone); with [`splitByApp: true`](https://frontmcp.dev/reference/sdk/frontmcp), each app at `<entryPath>/<app id>`, and nothing at the entry path itself. See [Answering requests that aren't MCP calls](#answering-requests-that-arent-mcp-calls). Each of those endpoints answers its own OAuth discovery documents, like `/.well-known/oauth-protected-resource/billing`. (Changed in 1.9.3: before, with `splitByApp` or only standalone apps, the first endpoint was also served at the entry path, and a 404's `entryPaths` listed one endpoint's paths. Changed in 1.9.2: before, the handler served only the first app, and a standalone app wasn't served at all.)

Every response carries `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY`, and none carries `X-Powered-By`. [Security](https://frontmcp.dev/reference/deployment/security) covers how to change them. (New in 1.8.7.)

#### Request headers

MCP 2026-07-28 clients repeat parts of the request body in headers, so a proxy can route without reading the body. The handler checks them before anything else runs:

| Header | Required | Checked |
| --- | --- | --- |
| `MCP-Protocol-Version` | On every 2026-07-28 request. | Must be there. |
| `Mcp-Method` | On every 2026-07-28 request. | Must be there. |
| `Mcp-Name` | On `tools/call`, `prompts/get` and `resources/read`. | Must equal the body's tool or prompt name, or resource URI. |

A missing or different header gets `400`, with JSON-RPC error `-32020` and a message that says which: `Header mismatch: the mcp-method header is required`, or `Header mismatch: mcp-name header value 'close_ticket' does not match body value 'get_ticket'`. A body that isn't JSON gets `400` with `-32600` `Invalid Request: missing method`.

Other headers reach your tools:

| Header | Becomes |
| --- | --- |
| `Authorization` | The caller, checked by your [`auth`](https://frontmcp.dev/reference/sdk/frontmcp#auth) mode. |
| `traceparent` | [`this.context.traceContext`](https://frontmcp.dev/learn/reading-the-request): the client's trace continues. Without it, each request starts a new trace. |
| `User-Agent`, `Content-Type`, `Accept` | `this.context.metadata.userAgent`, `contentType` and `accept`. |
| `x-frontmcp-*` | `this.context.metadata.customHeaders`, by lowercase name. |

The client's name and version come from each request's `_meta`, as `io.modelcontextprotocol/clientInfo`, and become `this.clientInfo` and `this.platform`. [Reading the Request](https://frontmcp.dev/learn/reading-the-request) tests all of these.

#### Sessions

The handler keeps no sessions. MCP 2026-07-28 has none, so its clients lose nothing: each request carries everything it needs. An anonymous caller is a new `anon:` user on every request.

Clients on older protocol versions work too, one request at a time. Their `initialize` succeeds, but the response has no `Mcp-Session-Id`, and each later request is handled on its own, as an event stream. None of them is in a session (`this.context.verifiedSessionId` is `undefined`), so anything FrontMCP keeps per session for those clients, like a [`CONTEXT` provider](https://frontmcp.dev/reference/sdk/provider#scopes), starts over with every request. They're also the only clients for which a server in production needs `MCP_SESSION_SECRET`. A request that names no protocol version, in its headers or its body, is served as 2026-07-28.

#### In a browser

FrontMCP's browser build has no `AsyncLocalStorage`. Without the TC39 `AsyncContext`, it serves **one request at a time**: a request keeps its turn until it, and everything it started, has finished. Two calls sent at once take turns, a background job runs before the next request is served, and the same goes for [`createDirect()`](https://frontmcp.dev/reference/sdk/create) and [`connect()`](https://frontmcp.dev/reference/sdk/connect) calls. Inside one request, two calls at once, like two `this.callTool()` in a `Promise.all`, fail with `AsyncContextOverlapError`: run them one after the other. Node and Cloudflare Workers keep their own async context, and aren't affected. This site's Playground provides a minimal `AsyncContext`, so its calls overlap as they do on Node, as [Serving calls at the same time](#serving-calls-at-the-same-time) shows; the one-at-a-time mode was checked in a browser worker without it.

Changed in 1.8.4: before, a request that arrived while another was waiting could run with that request's context: its caller, its session and its running tool.

#### The client's address

`this.context.metadata.clientIp`, which [IP filters](https://frontmcp.dev/reference/sdk/frontmcp#setting-defaults-for-every-tool) also use, comes from the runtime when it says who connected, and from proxy headers only when you say the proxy can be trusted:

| Source | Used |
| --- | --- |
| `ctx.requestIP(request).address` (Bun) | Always. |
| `ctx.remoteAddr.hostname` (Deno) | Always. |
| The `CF-Connecting-IP` header | On Cloudflare Workers, where the request has a `cf` property. Elsewhere anyone could send the header, so it's ignored. |
| `X-Forwarded-For`, or else `X-Real-IP` | Only with the environment variable `FRONTMCP_TRUST_PROXY` set to `1`, `true`, `yes` or `on`. The address is the last entry of `X-Forwarded-For`, the one your proxy added; with `FRONTMCP_TRUSTED_PROXY_DEPTH=2` it's the second from the end, for two proxies. |

A value that isn't an IP address is ignored. With none of these, `clientIp` is `undefined`. Set `FRONTMCP_TRUST_PROXY` only when every request comes through a proxy that sets the header, or clients can put any address in it.

#### CORS and host checks

The handler reads the same `http` options as FrontMCP's Node server:

- **`http.cors`**: `{ origin, credentials?, maxAge? }`. `origin` is `true` (reflect the request's origin), `"*"`, one origin, or a list. A preflight from a listed origin gets `204` with `Access-Control-Allow-Origin`, `-Methods` (`GET, POST, OPTIONS, DELETE`), `-Headers` (the ones the browser asked for) and `-Expose-Headers` (`Mcp-Session-Id, WWW-Authenticate`); other origins get `204` with none of them. Without `http.cors`, no CORS headers are sent. An `origin` function isn't supported here: CORS stays off, and a warning is logged.
- **`http.security.dnsRebindingProtection`**: with `allowedHosts` or `allowedOrigins`, a request for any other host gets `403` `{ "error": "Forbidden", "message": "Invalid Host header" }`.

#### Production secrets

With `NODE_ENV=production`, FrontMCP refuses to make up secrets. For the session secret, which only clients on protocol versions before 2026-07-28 need, the handler answers `500` with what to fix:

```json
{
  "error": "server_misconfigured",
  "code": "SESSION_SECRET_REQUIRED",
  "message": "Set MCP_SESSION_SECRET in the deployment environment (e.g. `wrangler secret put MCP_SESSION_SECRET`). Session IDs are encrypted with it, and production refuses the development machine-id fallback."
}
```

| Code | When | Health checks |
| --- | --- | --- |
| `SESSION_SECRET_REQUIRED` | `MCP_SESSION_SECRET` isn't set, and a request declares a protocol version before 2026-07-28. 2026-07-28 requests work without it, anonymous and static-key callers included. (Changed in 1.8.5: before, every server needed it, and every request got the `500`.) | Still answer `200`. |
| `JWT_SECRET_REQUIRED` | `JWT_SECRET` isn't set, and the auth mode signs tokens, like `local`. | The server can't be built: `createFetchHandler()` rejects with a `JwtSecretRequiredError`, whose `code` is this one. On an edge isolate, every request gets the `500`, health checks included. |
| `JWT_SECRET_INVALID` | `JWT_SECRET` is shorter than 32 bytes. | The same, with a `JwtSecretWeakError`. |

On Cloudflare, set them with `wrangler secret put`. Outside production, FrontMCP falls back to a secret derived from the machine for sessions, and a random one for tokens, which doesn't survive a restart. The Playground can't switch to production, so this was checked in Node.

#### Caveats

- **Health checks are `/healthz`, `/health` and `/readyz`** by default, as on FrontMCP's Node server. `health` renames or turns them off: see [Health checks](#health-checks).
- **`http.port`, `http.routes`, `http.socketPath` and the bind address don't apply.** Your runtime decides where requests come from, and custom routes aren't served: route them in your own code before calling the handler.
- **With `splitByApp: true`, nothing is served at the entry path itself**, as on the Node server: point each client at its app's path.
- **OAuth routes pass on the cookies FrontMCP sets.** In `local` mode, `/oauth/authorize` answers with a `Set-Cookie` that ties the sign-in to the browser that started it (`frontmcp_signin_…`, `HttpOnly`, `SameSite=Lax`, on `/oauth`), and the sign-in only finishes when the browser sends it back. A script can't read `Set-Cookie` from a response in a browser, so this was checked in Node.
- **Keep one handler per process.** Each `createFetchHandler()` builds a new server, so state in providers isn't shared between two handlers.
- On Cloudflare, pass `ctx`: FrontMCP calls its `waitUntil()` so a streamed response to an older client can finish after the handler returns.

---

## Usage

### Serving MCP from a Cloudflare Worker

The Worker passes its `ctx` and `env` along, in the handler's order. The tests send it the requests an MCP client would; `requests.ts` builds them.

```ts worker.ts active
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./config";

type Ctx = { waitUntil(promise: Promise<unknown>): void };

const handler = FrontMcpInstance.createFetchHandler(config);

export default {
  async fetch(request: Request, env: unknown, ctx: Ctx): Promise<Response> {
    return (await handler)(request, ctx, env);
  },
};
```

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

const ctx = { waitUntil() {} };

test("calls a tool at /mcp", async () => {
  const res = await worker.fetch(mcpRequest("https://desk.example.com/mcp", "tools/call", { name: "get_ticket", arguments: { id: "T-1" } }), {}, ctx);
  const body = await res.json();
  expect(body.result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
});

test("answers health checks", async () => {
  const res = await worker.fetch(new Request("https://desk.example.com/healthz"), {}, ctx);
  expect(await res.json()).toEqual({ status: "ok", server: { name: "help-desk", version: "1.0.0" }, transport: "web-fetch" });
});

test("anything else is a 404 that names the endpoint", async () => {
  const res = await worker.fetch(mcpRequest("https://desk.example.com/", "tools/list"), {}, ctx);
  expect(res.status).toBe(404);
  expect(await res.json()).toEqual({ error: "Not Found", entryPaths: ["/mcp"] });
});

test("a tool name that doesn't match its header is refused", async () => {
  const request = mcpRequest("https://desk.example.com/mcp", "tools/call", { name: "get_ticket", arguments: { id: "T-1" } }, { "mcp-name": "close_ticket" });
  const res = await worker.fetch(request, {}, ctx);
  expect(res.status).toBe(400);
  expect((await res.json()).error).toEqual({
    code: -32020,
    message: "Header mismatch: mcp-name header value 'close_ticket' does not match body value 'get_ticket'",
  });
});
```

```ts requests.ts
// What an MCP 2026-07-28 client sends: the method, and for calls the tool's name,
// repeated in headers, and the protocol version in two places.
export function mcpRequest(url: string, method: string, params: Record<string, unknown> = {}, headers: Record<string, string> = {}) {
  return new Request(url, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "mcp-protocol-version": "2026-07-28",
      "mcp-method": method,
      ...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}),
      ...headers,
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method,
      params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}
```

```ts config.ts
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { entryPath: "/mcp" },
};
```

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

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

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

`frontmcp build` has a Cloudflare target that writes a Worker like this one for you. [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere) walks through the same Worker step by step.

### Answering requests that aren't MCP calls

Health checks, stray paths and methods, and requests without the 2026-07-28 headers all get a response, never an exception. Your runtime or router sees each status as it is:

Other methods at the endpoint belong to the session-based protocol versions before 2026-07-28, so they're checked in Node rather than in this browser Playground: a `GET` without `Accept: text/event-stream` gets `406`, a `DELETE` for an unknown session gets `404` with the text `Session not found`, and `OPTIONS` gets `405` unless you configure `http.cors`. The last three tests show where a server with several endpoints serves each app.

```ts routes.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { Billing, BillingApi, HelpDesk } from "./apps";

const info = { name: "help-desk", version: "1.0.0" };
const handler = FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk] });
const at = (path: string, init?: RequestInit) => handler.then((h) => h(new Request(`https://desk.example.com${path}`, init)));

test("health checks at /healthz, /readyz and /health", async () => {
  const healthz = await at("/healthz");
  expect(healthz.headers.get("cache-control")).toBe("no-store");
  expect(await healthz.json()).toEqual({ status: "ok", server: info, transport: "web-fetch" });
  expect(await (await at("/health")).json()).toEqual({ status: "ok", server: info, transport: "web-fetch" });

  const readyz = await at("/readyz");
  expect(readyz.status).toBe(200);
  expect(await readyz.json()).toMatchObject({ status: "ready", catalog: { toolCount: 1 }, transport: "web-fetch" });
});

test("every response carries the security headers", async () => {
  for (const res of [await at("/healthz"), await at("/nothing-here")]) {
    expect(res.headers.get("x-content-type-options")).toBe("nosniff");
    expect(res.headers.get("x-frame-options")).toBe("DENY");
    expect(res.headers.get("x-powered-by")).toBeNull();
  }
});

test("requests without the 2026-07-28 headers, or without JSON", async () => {
  const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } });
  const noHeaders = await at("/", { method: "POST", headers: { "content-type": "application/json" }, body });
  expect(await noHeaders.json()).toMatchObject({ error: { code: -32020, message: "Header mismatch: the mcp-protocol-version header is required" } });
  const headers = { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" };
  const notJson = await at("/", { method: "POST", headers, body: "{oops" });
  expect(await notJson.json()).toMatchObject({ error: { code: -32600, message: "Invalid Request: missing method" } });
});

test("hosts other than the allowed ones are refused", async () => {
  const guarded = await FrontMcpInstance.createFetchHandler({
    info,
    apps: [HelpDesk],
    http: { security: { dnsRebindingProtection: { enabled: true, allowedHosts: ["desk.example.com"] } } },
  });
  const res = await guarded(new Request("https://attacker.example/healthz"));
  expect(res.status).toBe(403);
  expect(await res.json()).toEqual({ error: "Forbidden", message: "Invalid Host header" });
});

test("`health` renames the checks, or turns them off", async () => {
  const renamed = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk], health: { healthzPath: "/live", readyzPath: "/ready" } });
  const get = (path: string) => renamed(new Request(`https://desk.example.com${path}`));
  expect((await get("/live")).status).toBe(200);
  expect((await get("/ready")).status).toBe(200);
  expect((await get("/healthz")).status).toBe(404);

  const off = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk], health: { enabled: false } });
  expect((await off(new Request("https://desk.example.com/healthz"))).status).toBe(404);
});

test("a failing probe makes /readyz a 503", async () => {
  const database = { name: "database", check: async () => ({ status: "unhealthy", error: "connection refused" }) };
  const failing = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk], health: { includeDetails: true, probes: [database] } });
  const res = await failing(new Request("https://desk.example.com/readyz"));
  expect(res.status).toBe(503);
  expect(await res.json()).toMatchObject({ status: "not_ready", probes: { database: { status: "unhealthy", error: "connection refused" } } });
});

test("a body over `http.bodyLimit` is refused with 413", async () => {
  const limited = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk], http: { bodyLimit: "1kb" } });
  const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { padding: "x".repeat(2000) } });
  const res = await limited(new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json" }, body }));
  expect(res.status).toBe(413);
  expect(await res.json()).toMatchObject({ error: { code: -32600, message: "Payload Too Large", data: { limit: 1024 } } });
});

async function toolsAt(handler: (request: Request) => Promise<Response>, path: string) {
  const headers = { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" };
  const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } });
  const response = await handler(new Request(`https://desk.example.com${path}`, { method: "POST", headers, body }));
  if (response.status !== 200) return response.status;
  return (await response.json()).result.tools.map((tool: { name: string }) => tool.name);
}

test("with splitByApp, each app at its own path, and nothing at the endpoint", async () => {
  const split = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk, Billing], splitByApp: true });
  expect(await toolsAt(split, "/help-desk")).toEqual(["get_ticket"]);
  expect(await toolsAt(split, "/billing")).toEqual(["refund_invoice"]);
  expect(await toolsAt(split, "/")).toBe(404);
});

test("a standalone app at its own path; the other apps at the endpoint", async () => {
  const withStandalone = await FrontMcpInstance.createFetchHandler({ info, apps: [BillingApi, HelpDesk] });
  expect(await toolsAt(withStandalone, "/billing-api")).toEqual(["refund_invoice"]);
  expect(await toolsAt(withStandalone, "/")).toEqual(["get_ticket"]);
  expect(await toolsAt(withStandalone, "/help-desk")).toBe(404);

  const onlyStandalone = await FrontMcpInstance.createFetchHandler({ info, apps: [BillingApi] });
  expect(await toolsAt(onlyStandalone, "/")).toEqual([]);
});

test("a 404 lists every endpoint", async () => {
  const split = await FrontMcpInstance.createFetchHandler({ info, apps: [HelpDesk, Billing], splitByApp: true, http: { entryPath: "/mcp" } });
  const res = await split(new Request("https://desk.example.com/mcp", { method: "POST" }));
  expect(res.status).toBe(404);
  expect(await res.json()).toEqual({ error: "Not Found", entryPaths: ["/mcp/help-desk", "/mcp/billing"] });
});
```

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

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "open" };
  }
}

@Tool({ name: "refund_invoice", description: "Refund an invoice", inputSchema: { invoice: z.string() } })
class RefundInvoice extends ToolContext {
  async execute({ invoice }: { invoice: string }) {
    return { invoice, refunded: true };
  }
}

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

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

@App({ id: "billing-api", name: "Billing API", tools: [RefundInvoice], standalone: true })
export class BillingApi {}
```

### Health checks

`/healthz` says the process is up; `/health` answers the same. `/readyz` runs the server's readiness probes and answers `200` with the report, or `503` and `"status": "not_ready"` when one is unhealthy. The handler reads the same [`health` options](https://frontmcp.dev/reference/server/observability#health) as FrontMCP's Node server: `healthzPath` and `readyzPath` rename the checks, `readyz.enabled: false` drops `/readyz`, `enabled: false` drops them all, and `probes` adds checks of your own, listed in the report when `includeDetails` is `true` (it's on outside production by default). The tests above show each. Health checks answer before the MCP endpoint's own checks, so a platform's prober needs no token or MCP headers.

Changed in 1.8.7: before, the handler ignored `health` and `http.bodyLimit`, answered `/readyz` with the same short body as `/healthz`, and answered `/health` with `404`.

### Catching a misconfigured server at startup

`createFetchHandler()` runs the same startup checks as `createDirect()`, so a server that can't start never gets a handler. The error names what to fix:

```ts startup.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance, assertStaticStartupConfig } from "@frontmcp/sdk";
import { Admin, Billing, HelpDesk } from "./apps";

const info = { name: "help-desk", version: "1.0.0" };

test("an invalid configuration rejects createFetchHandler()", async () => {
  await expect(FrontMcpInstance.createFetchHandler({ info: { name: "help-desk" } } as never)).rejects.toThrow("expected string, received undefined");
});

test("so does an `approval` that no plugin enforces", async () => {
  await expect(FrontMcpInstance.createFetchHandler({ info, apps: [Billing()] })).rejects.toThrow(
    `Unenforced metadata: Tool "refund_invoice" declares 'approval' (enforced by ApprovalPlugin from @frontmcp/plugin-approval)`,
  );
});

test("assertStaticStartupConfig() finds it without building the server", () => {
  expect(() => assertStaticStartupConfig({ info, apps: [Billing()] })).toThrow(`Tool "refund_invoice" declares 'approval'`);
  expect(() => assertStaticStartupConfig({ info, apps: [HelpDesk] })).not.toThrow();
});

test("it finds a rule that names an unknown profile", async () => {
  const authorities = { profiles: { admn: { roles: { any: ["admin"] } } } }; // a typo: the tool asks for "admin"
  const message = `Invalid authorities rule: Tool "purge_closed": authorities names an unknown profile "admin"`;
  expect(() => assertStaticStartupConfig({ info, apps: [Admin()], authorities })).toThrow(message);
  await expect(FrontMcpInstance.createFetchHandler({ info, apps: [Admin()], authorities })).rejects.toThrow(message);
});

test("🚩 it doesn't validate the configuration itself", async () => {
  const invalid = { info: { name: "help-desk" }, apps: [HelpDesk] } as never;
  expect(() => assertStaticStartupConfig(invalid)).not.toThrow();
  await expect(FrontMcpInstance.createFetchHandler(invalid)).rejects.toThrow("expected string, received undefined");
});
```

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

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "open" };
  }
}

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

@Tool({ name: "purge_closed", description: "Delete closed tickets, for admins", inputSchema: {}, authorities: "admin" })
class PurgeClosed extends ToolContext {
  async execute() {
    return { purged: 31 };
  }
}

@Tool({ name: "refund_invoice", description: "Refund an invoice", inputSchema: { invoice: z.string() }, approval: true })
class RefundInvoice extends ToolContext {
  async execute({ invoice }: { invoice: string }) {
    return { invoice, refunded: true };
  }
}

// These two stop a server from starting, so they're made in the tests, not served by the Playground.
export const Admin = () => {
  @App({ id: "admin", name: "Admin", tools: [PurgeClosed] })
  class AdminApp {}
  return AdminApp;
};

// 🚩 `approval` needs the Approval plugin.
export const Billing = () => {
  @App({ id: "billing", name: "Billing", tools: [RefundInvoice] })
  class BillingApp {}
  return BillingApp;
};
```

On an edge isolate, `createFetchHandler()` runs only `assertStaticStartupConfig()`, so the `approval` failure and the profile typo stop it there. The invalid configuration doesn't: the handler is created, and each request gets `500` `{"error":"server_misconfigured",…}`, with the code `CONFIG_INVALID`. (The Playground isn't an edge isolate; this was checked in Node with the `EdgeRuntime` global.) Run `createDirect()` in a test before you deploy: it runs every check.

### Serving from Deno, Bun or a framework

The handler has the shape each of them expects. Pass the runtime's connection info as `ctx`, so FrontMCP knows the client's address:

```ts deno.ts
const handler = await FrontMcpInstance.createFetchHandler(config);
Deno.serve((request, info) => handler(request, info));
```

```ts bun.ts
const handler = await FrontMcpInstance.createFetchHandler(config);
Bun.serve({ port: 8080, fetch: (request, server) => handler(request, server) });
```

In a framework, route every method at the MCP path to the handler, and set `http.entryPath` to the same path, because the handler checks the path too:

```ts hono.ts
const handler = await FrontMcpInstance.createFetchHandler({ ...config, http: { entryPath: "/mcp" } });
app.all("/mcp", (c) => handler(c.req.raw));
```

```ts app/mcp/route.ts
// Next.js: one handler for each method a client uses.
const handler = FrontMcpInstance.createFetchHandler({ ...config, http: { entryPath: "/mcp" } });
export const GET = async (request: Request) => (await handler)(request);
export const POST = GET;
export const DELETE = GET;
```

These need their runtimes, so they don't run here. The Playground below passes the same `ctx` objects Deno and Bun do.

### Building the handler from a scope

[`createWebFetchHandler()`](#createwebfetchhandlerscope-options) serves one endpoint of a server built with `FrontMcpInstance.createForGraph(config)`, with options `createFetchHandler()` doesn't take. Here the help desk is served at two paths, and the billing app, which is `standalone`, only by a handler of its own:

```ts scope.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance, createWebFetchHandler } from "@frontmcp/sdk";
import { config } from "./config";
import { mcpRequest } from "./requests";

type Scope = Parameters<typeof createWebFetchHandler>[0];
type Options = Parameters<typeof createWebFetchHandler>[1];

const server = FrontMcpInstance.createForGraph(config);

async function handlerFor(options: Options = {}, app?: string) {
  const instance = await server;
  return createWebFetchHandler((app ? instance.getAppScope(app) : instance.getPrimaryScope()) as Scope, options);
}

const getTicket = (path: string) => mcpRequest(`https://desk.example.com${path}`, "tools/call", { name: "get_ticket", arguments: { id: "T-1" } });

test("`entryPath` serves MCP at every path it lists", async () => {
  const handler = await handlerFor({ entryPath: ["/mcp", "/v1/mcp"] });
  for (const path of ["/mcp", "/v1/mcp"]) {
    const res = await handler(getTicket(path));
    expect((await res.json()).result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
  }
});

test("the handler serves only the scope it was given", async () => {
  const desk = await handlerFor();
  const res = await desk(mcpRequest("https://desk.example.com/mcp/billing", "tools/list"));
  expect(res.status).toBe(404);
  expect(await res.json()).toEqual({ error: "Not Found", entryPaths: ["/mcp"] });

  const billing = await handlerFor({ entryPath: "/mcp/billing" }, "billing");
  const list = await (await billing(mcpRequest("https://desk.example.com/mcp/billing", "tools/list"))).json();
  expect(list.result.tools.map((t: { name: string }) => t.name)).toEqual(["get_invoice"]);
});

test("createFetchHandler() serves both endpoints", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const list = await (await handler(mcpRequest("https://desk.example.com/mcp/billing", "tools/list"))).json();
  expect(list.result.tools.map((t: { name: string }) => t.name)).toEqual(["get_invoice"]);
});

test("`healthPaths` replaces /healthz and /readyz", async () => {
  const handler = await handlerFor({ healthPaths: ["/livez"] });
  const live = await handler(new Request("https://desk.example.com/livez"));
  expect(await live.json()).toEqual({ status: "ok", server: { name: "help-desk", version: "1.0.0" }, transport: "web-fetch" });
  expect((await handler(new Request("https://desk.example.com/readyz"))).status).toBe(404);
});

test("a `sessionRouter` sees each MCP request first, and can pass it on", async () => {
  const sessionRouter = async (_request: Request, env: unknown) => ((env as { SESSIONS?: unknown })?.SESSIONS ? new Response("routed to the session host") : undefined);
  const handler = await handlerFor({ sessionRouter });
  expect(await (await handler(getTicket("/mcp"), undefined, { SESSIONS: {} })).text()).toBe("routed to the session host");
  expect((await (await handler(getTicket("/mcp"))).json()).result.structuredContent.id).toBe("T-1");
});
```

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

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

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

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

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk, Billing],
  http: { entryPath: "/mcp" },
};
```

```ts requests.ts hidden
export function mcpRequest(url: string, method: string, params: Record<string, unknown> = {}, headers: Record<string, string> = {}) {
  return new Request(url, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "mcp-protocol-version": "2026-07-28",
      "mcp-method": method,
      ...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}),
      ...headers,
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method,
      params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}
```

`FrontMcpInstance.createForGraph(config)` builds the server and its scopes without serving them, and the handlers you make serve them. This runs in the Playground, and was also checked in Node, with a CORS `origin` that overrides `http.cors`.

### Reading the client's address

This tool returns `this.context.metadata.clientIp`. The tests call it the way Deno and Bun would, then from behind a proxy, before and after trusting it:

```ts where.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "where_from", description: "The caller's IP address, as FrontMCP sees it. For debugging.", inputSchema: {} })
export class WhereFrom extends ToolContext {
  async execute() {
    return { clientIp: this.context.metadata.clientIp ?? null };
  }
}
```

```ts where.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { WhereFrom } from "./where.tool";
import { mcpRequest } from "./requests";

@App({ id: "desk", name: "Desk", tools: [WhereFrom] })
class Desk {}

const handler = FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] });

async function clientIp(headers: Record<string, string>, ctx?: object) {
  const res = await (await handler)(mcpRequest("https://desk.example.com/", "tools/call", { name: "where_from", arguments: {} }, headers), ctx);
  return (await res.json()).result.structuredContent.clientIp;
}

test("Deno's and Bun's connection info give the address", async () => {
  expect(await clientIp({}, { remoteAddr: { hostname: "203.0.113.7" } })).toBe("203.0.113.7");
  expect(await clientIp({}, { requestIP: () => ({ address: "203.0.113.8" }) })).toBe("203.0.113.8");
});

test("X-Forwarded-For is ignored by default", async () => {
  expect(await clientIp({ "x-forwarded-for": "198.51.100.4" })).toBeNull();
});

test("with FRONTMCP_TRUST_PROXY, the proxy's entry counts", async () => {
  process.env.FRONTMCP_TRUST_PROXY = "1";
  try {
    expect(await clientIp({ "x-forwarded-for": "10.0.0.1, 198.51.100.4" })).toBe("198.51.100.4");
    expect(await clientIp({ "x-real-ip": "198.51.100.5" })).toBe("198.51.100.5");
    process.env.FRONTMCP_TRUSTED_PROXY_DEPTH = "2";
    expect(await clientIp({ "x-forwarded-for": "10.0.0.1, 198.51.100.4" })).toBe("10.0.0.1");
  } finally {
    delete process.env.FRONTMCP_TRUST_PROXY;
    delete process.env.FRONTMCP_TRUSTED_PROXY_DEPTH;
  }
});

test("CF-Connecting-IP counts only on Cloudflare", async () => {
  const request = () => mcpRequest("https://desk.example.com/", "tools/call", { name: "where_from", arguments: {} }, { "cf-connecting-ip": "203.0.113.9" });
  const elsewhere = await (await handler)(request());
  expect((await elsewhere.json()).result.structuredContent.clientIp).toBeNull();

  const onCloudflare = request();
  Object.defineProperty(onCloudflare, "cf", { value: { colo: "FRA" } }); // what Cloudflare adds to each request
  const res = await (await handler)(onCloudflare);
  expect((await res.json()).result.structuredContent.clientIp).toBe("203.0.113.9");
});
```

```ts requests.ts hidden
export function mcpRequest(url: string, method: string, params: Record<string, unknown> = {}, headers: Record<string, string> = {}) {
  return new Request(url, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "mcp-protocol-version": "2026-07-28",
      "mcp-method": method,
      ...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}),
      ...headers,
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method,
      params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}
```

With `X-Forwarded-For: 10.0.0.1, 198.51.100.4`, the proxy added the last entry, the address that connected to it. The first entry is whatever the client wrote, which is why FrontMCP takes the last.

### Reading a Worker's bindings

A Worker's `env` holds its bindings: KV namespaces, D1 databases, R2 buckets, secrets and `[vars]`. The handler's third argument carries it, and a tool, a resource, a prompt or an agent reads it as `this.workerEnv`, as does a job that `execute_job` runs while the client waits. It's typed as `Readonly<Record<string, unknown>> | undefined`, so cast it to the shape you bind. Where nothing passes an `env`, like a request without one, `this.workerEnv` is `undefined`. A server built in a Durable Object or a queue consumer with `create()` or `createDirect()` gets no request from the handler: give it the bindings with its `workerEnv` option, as in [Passing a Worker's bindings](https://frontmcp.dev/reference/sdk/create#passing-a-workers-bindings) (since 1.9.4). (Changed in 1.9: before, prompts and jobs didn't get it.)

```ts tickets.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

type Env = { TICKETS?: { get(key: string): Promise<string | null> } };

@Tool({ name: "get_ticket", description: "Get one support ticket from the Worker's KV namespace", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const tickets = (this.workerEnv as Env | undefined)?.TICKETS;
    if (!tickets) return { id, title: null, source: "no bindings" };
    return { id, title: await tickets.get(id), source: "kv" };
  }
}
```

```ts tickets.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { GetTicket } from "./tickets.tool";
import { mcpRequest } from "./requests";

@App({ id: "desk", name: "Desk", tools: [GetTicket] })
class Desk {}

const handler = FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] });
const call = async (env?: unknown) => {
  const request = mcpRequest("https://desk.example.com/", "tools/call", { name: "get_ticket", arguments: { id: "T-1" } });
  const res = await (await handler)(request, { waitUntil() {} }, env);
  return (await res.json()).result.structuredContent;
};

test("a tool reads the bindings the Worker passed", async () => {
  const TICKETS = { get: async (key: string) => `Cannot log in (${key})` };
  expect(await call({ TICKETS, ENVIRONMENT: "staging" })).toEqual({ id: "T-1", title: "Cannot log in (T-1)", source: "kv" });
});

test("without an env, `this.workerEnv` is undefined", async () => {
  expect(await call()).toEqual({ id: "T-1", title: null, source: "no bindings" });
});
```

```ts requests.ts hidden
export function mcpRequest(url: string, method: string, params: Record<string, unknown> = {}, headers: Record<string, string> = {}) {
  return new Request(url, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "mcp-protocol-version": "2026-07-28",
      "mcp-method": method,
      ...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}),
      ...headers,
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method,
      params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}
```

New in 1.8.7: before, a tool had no way to reach a Worker's bindings. Also, FrontMCP now reads `NODE_ENV` each time it needs it, not once, so a `NODE_ENV = "production"` in wrangler's `[vars]` switches production mode on. (The Playground can't be a Worker, so that was checked in Node, by changing `process.env.NODE_ENV` between calls.)

### Letting web pages connect

A browser only lets a page read a response from another origin if the server allows it with CORS headers. Set `http.cors` to the origins that host your MCP client:

```ts config.ts
export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { cors: { origin: ["https://desk.example.com"], maxAge: 600 } },
};
```

A preflight from `https://desk.example.com` that asks for `content-type, mcp-protocol-version, mcp-method` then gets `204` with:

```http
Access-Control-Allow-Origin: https://desk.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS, DELETE
Access-Control-Allow-Headers: content-type, mcp-protocol-version, mcp-method
Access-Control-Expose-Headers: Mcp-Session-Id, WWW-Authenticate
Access-Control-Max-Age: 600
Vary: Origin
```

The response to the `POST` that follows carries the same `Access-Control-Allow-Origin`. A preflight from any other origin gets `204` with none of these headers, so the browser blocks the page. Browsers don't let code set the `Origin` header, so this can't run in the Playground; it was checked in Node.

CORS only affects browsers, and isn't access control: anything else can still send requests. Use `auth` for that.

### Requiring a token

`auth` works as it does on FrontMCP's Node server, and the handler serves its OAuth routes too, including `remote` mode's provider callback, `/oauth/provider/<id>/callback` (a `404` before 1.8.4). With `static`, a request needs one of the listed tokens:

```ts config.ts active
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: { mode: "static" as const, tokens: ["sk_desk_1"] },
};
```

```ts auth.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./config";
import { mcpRequest } from "./requests";

const handler = FrontMcpInstance.createFetchHandler(config);

test("without a token, 401 and a challenge", async () => {
  const res = await (await handler)(mcpRequest("https://desk.example.com/", "tools/list"));
  expect(res.status).toBe(401);
  expect(res.headers.get("www-authenticate")).toBe('Bearer realm="mcp"');
  expect(await res.json()).toEqual({ error: "Unauthorized" });
});

test("with the token, the call goes through", async () => {
  const request = mcpRequest("https://desk.example.com/", "tools/call", { name: "get_ticket", arguments: { id: "T-1" } }, { authorization: "Bearer sk_desk_1" });
  const res = await (await handler)(request);
  expect((await res.json()).result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
});
```

```ts requests.ts hidden
export function mcpRequest(url: string, method: string, params: Record<string, unknown> = {}, headers: Record<string, string> = {}) {
  return new Request(url, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "mcp-protocol-version": "2026-07-28",
      "mcp-method": method,
      ...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}),
      ...headers,
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method,
      params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}
```

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

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

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

The Playground's own client sends no token, so the tests build the handler from `config.ts` themselves. In a real server, read the tokens from your platform's secrets rather than writing them in code.

### Serving clients on older protocol versions

Clients that predate MCP 2026-07-28 start with `initialize` and expect a session. The handler answers them, without one: `initialize` gets a `200` event stream with the negotiated protocol version and no `mcp-session-id` header (checked in Node; this browser Playground can't open a legacy session), and later requests work without it:

```ts legacy.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

const handler = FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] });

function legacy(id: number, method: string, params: Record<string, unknown>) {
  return new Request("https://desk.example.com/", {
    method: "POST",
    headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
    body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
  });
}

test("later requests work without a session", async () => {
  const res = await (await handler)(legacy(2, "tools/call", { name: "get_ticket", arguments: { id: "T-1" } }));
  expect(await res.text()).toContain('"structuredContent":{"id":"T-1","title":"Cannot log in","status":"open"}');
});
```

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

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

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

Each request is handled on its own. Tools that don't depend on a session work the same for every client; anything a legacy client expects the server to remember between requests is gone.

### Serving calls at the same time

On Node, and in the Playground, which provides a minimal `AsyncContext`, calls sent at once run at the same time, and so do two `this.callTool()` inside one request:

```ts lookup.tools.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "lookup", description: "Look up a ticket (takes 100 ms)", inputSchema: { id: z.string() } })
export class Lookup extends ToolContext {
  async execute({ id }: { id: string }) {
    await new Promise((resolve) => setTimeout(resolve, 100));
    return { id, status: "open" };
  }
}

@Tool({ name: "lookup_both", description: "Look up two tickets at once", inputSchema: {} })
export class LookupBoth extends ToolContext {
  async execute() {
    // 🚩 Fails with AsyncContextOverlapError in a browser without AsyncContext
    const [a, b] = await Promise.all([this.callTool("lookup", { id: "T-1" }), this.callTool("lookup", { id: "T-2" })]);
    return { tickets: [a.structuredContent, b.structuredContent] };
  }
}
```

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

test("two calls sent at once run at the same time", async ({ mcp }) => {
  const started = Date.now();
  await Promise.all([mcp.tools.call("lookup", { id: "T-1" }), mcp.tools.call("lookup", { id: "T-2" })]);
  expect(Date.now() - started).toBeLessThan(200);
});

test("two calls inside one request can overlap", async ({ mcp }) => {
  const result = await mcp.tools.call("lookup_both", {});
  expect(result.json()).toEqual({ tickets: [{ id: "T-1", status: "open" }, { id: "T-2", status: "open" }] });
});
```

FrontMCP's browser build without `AsyncContext` serves [one request at a time](#in-a-browser) instead: there the two calls sent at once take turns, about 200 ms in all, and `lookup_both` fails with `AsyncContextOverlapError` (`Concurrent runs of an async context overlapped in one request, …`). A tool that serves such browsers calls other tools one after the other: `await` the first `this.callTool()` before starting the second.

---

## Troubleshooting

### `{"error":"Not Found","entryPaths":["/"]}`

The request's path isn't the MCP endpoint. `entryPaths` says where the endpoint is: `/` unless `http.entryPath` sets it. A server with several endpoints lists each, like `["/mcp/help-desk", "/mcp/billing"]` with `splitByApp`, which serves nothing at the entry path itself. Either point the client there, or set `http: { entryPath: "/mcp" }` to match the URL your clients use. [The Worker example](#serving-mcp-from-a-cloudflare-worker) serves `/mcp`.

### `Header mismatch: the mcp-protocol-version header is required`

The request has a 2026-07-28 body but not the headers 2026-07-28 requires: `MCP-Protocol-Version` and `Mcp-Method`, plus `Mcp-Name` on calls. Clients built for 2026-07-28 send them; code that builds requests by hand has to, as `requests.ts` does above. A proxy between the client and the handler must pass them through.

### `Header mismatch: mcp-name header value '…' does not match body value '…'`

`Mcp-Name` names a different tool, prompt or resource from the body. Something rewrote one without the other, often a proxy or a retry that reuses headers.

### `{"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED"}`

The server runs with `NODE_ENV=production` and no `MCP_SESSION_SECRET`, and the request came from a client on a protocol version before 2026-07-28. Set one, a long random string such as the output of `openssl rand -hex 32`, and keep it the same on every instance. `JWT_SECRET_REQUIRED` and `JWT_SECRET_INVALID` mean the same for `JWT_SECRET`. See [production secrets](#production-secrets).

### `Not Acceptable: Client must accept text/event-stream`

A `GET` to the MCP endpoint without `Accept: text/event-stream`, often a browser or a health check pointed at the endpoint. Point health checks at `/healthz`.

### `this.context.metadata.clientIp` is `undefined`

The runtime didn't pass who connected, and proxy headers aren't trusted. Pass Deno's `info` or Bun's `server` as `ctx`; on Cloudflare the address comes from `CF-Connecting-IP`. Behind your own proxy, set `FRONTMCP_TRUST_PROXY=1`. See [the client's address](#the-clients-address).

### A web page can't read the responses

The page's origin isn't in `http.cors.origin`, or `http.cors` isn't set, in which case the browser's preflight gets `405` `Method not allowed.`. Add the origin. See [letting web pages connect](#letting-web-pages-connect).

### An older client keeps sending `initialize`

It expects an `Mcp-Session-Id` header, and the handler doesn't create sessions. Most clients carry on without one; a client that insists needs FrontMCP's Node server (`frontmcp dev`, or [`bootstrap()`](https://frontmcp.dev/reference/sdk/frontmcp-instance)), which keeps sessions.
