createFetchHandler()
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.
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:
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);
},
};Parameters
| Parameter | Type | Description |
|---|---|---|
config | FrontMcpConfigInput | The object you'd pass to @FrontMcp. Not the decorated class, which fails with 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(), 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.
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), 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 | 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, at once rather than as a promise:
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 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, andcreateFetchHandler()serves each at its path. This handler serves only the scope you pass, at itsentryPath: 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 its500and503answers. - 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.
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. |
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. |
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 and, on an edge isolate, a server that can't be built.
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. |
OPTIONS, with http.cors set | 204 and the CORS headers, 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. |
GET /metrics, with metrics on | The scrape, as on FrontMCP's Node server. See 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 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, each app at <entryPath>/<app id>, and nothing at the entry path itself. See Answering requests that aren't 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 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 mode. |
traceparent | this.context.traceContext: 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 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, 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() and 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 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 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? }.originistrue(reflect the request's origin),"*", one origin, or a list. A preflight from a listed origin gets204withAccess-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 get204with none of them. Withouthttp.cors, no CORS headers are sent. Anoriginfunction isn't supported here: CORS stays off, and a warning is logged.http.security.dnsRebindingProtection: withallowedHostsorallowedOrigins, a request for any other host gets403{ "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:
{
"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,/healthand/readyzby default, as on FrontMCP's Node server.healthrenames or turns them off: see Health checks. http.port,http.routes,http.socketPathand 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
localmode,/oauth/authorizeanswers with aSet-Cookiethat 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 readSet-Cookiefrom 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 itswaitUntil()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.
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);
},
};Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
frontmcp build has a Cloudflare target that writes a Worker like this one for you. 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.
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"] });
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 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:
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");
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
const handler = await FrontMcpInstance.createFetchHandler(config);
Deno.serve((request, info) => handler(request, info));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:
const handler = await FrontMcpInstance.createFetchHandler({ ...config, http: { entryPath: "/mcp" } });
app.all("/mcp", (c) => handler(c.req.raw));// 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() 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:
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");
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 (since 1.9.4). (Changed in 1.9: before, prompts and jobs didn't get it.)
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" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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:
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:
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"] },
};Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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"}');
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
FrontMCP's browser build without AsyncContext serves one request at a time 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 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.
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.
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.
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()), which keeps sessions.