Cloudflare Workers

frontmcp build --target cloudflare compiles your server into a Worker: an entry module that hands each request to FrontMCP's web-standard handler, createFetchHandler(), and a wrangler.toml that points at it. wrangler bundles the result, runs it locally in the same runtime as Cloudflare with wrangler dev, and uploads it with wrangler deploy. The Worker keeps no sessions and can't reach Redis over TCP or a SQLite file, so it serves MCP 2026-07-28 clients fully and older clients one request at a time.

frontmcp build --target cloudflare   # dist/cloudflare/index.js, and main in wrangler.toml
npx wrangler dev                     # locally, in workerd
npx wrangler deploy

@frontmcp/edge, new in 1.9, runs a server on Workers without frontmcp build: from a plain configuration, bundled by wrangler as it is, with sessions kept in a Durable Object and a managed mode that pulls a Skilled OpenAPI bundle. This page is about the frontmcp build target.


Reference

What the build writes

dist/cloudflare/
  main.js, help-desk.app.js, …   your compiled code, as ES modules
  package.json                   { "type": "module" }
  serverless-setup.js            sets FRONTMCP_SERVERLESS, FRONTMCP_WORKER, FRONTMCP_DEPLOYMENT_MODE and FRONTMCP_HTTP_ENTRY_PATH
  index.js                       the Worker's entry
  tools/queue.widget.tsx, …      your tools' *.widget.tsx and *.widget.jsx files, at the path they have under src/

The build doesn't bundle anything: wrangler does, when it runs or deploys the Worker. It prints Cloudflare Workers adapter is experimental. See docs for limitations. on every build.

Since 1.8.6 the build also copies the widget files of tools with a file widget, and says copied 1 widget source file (*.widget.tsx/jsx) to dist/cloudflare. That doesn't put them in the Worker: wrangler bundles what index.js imports, nothing imports a widget file, and wrangler deploy --dry-run --outdir wrote only index.js, so a file widget has nothing to read once the Worker is deployed.

serverless-setup.js runs before your server's module, so @FrontMcp builds a fetch handler instead of listening (what @FrontMcp does on import):

dist/cloudflare/serverless-setup.js
// Auto-generated — sets env before the @FrontMcp decorator runs.
process.env.FRONTMCP_SERVERLESS = '1';
process.env.FRONTMCP_DEPLOYMENT_MODE = 'serverless';
process.env.FRONTMCP_WORKER = '1';
process.env.FRONTMCP_HTTP_ENTRY_PATH = "/mcp";
globalThis.FRONTMCP_BUILD_TARGET = globalThis.FRONTMCP_BUILD_TARGET || "cloudflare";

The FRONTMCP_HTTP_ENTRY_PATH line is transport.http.path from frontmcp.config, and only appears when it's set. http.entryPath in @FrontMcp still wins over it. So the Worker serves the path frontmcp dev serves, as the Vercel, Lambda and distributed builds do since 1.8.7 (the endpoint path). When deployments[].server sets security headers, the build adds FRONTMCP_HSTS, FRONTMCP_CSP_* and the like to this file (security headers).

index.js exports { fetch(request, env, ctx) }. On the first request of an isolate it copies every string binding in env (your [vars] and secrets) into process.env, without replacing a value that's already there; then it passes the request, ctx and env to the handler, which is built on that first request.

wrangler.toml

frontmcp create --target cloudflare writes one, followed by comments on the secrets to set:

wrangler.toml
name = "help-desk"
main = "dist/cloudflare/index.js"
compatibility_date = "2024-11-11"
compatibility_flags = ["nodejs_compat", "nodejs_compat_populate_process_env"]

[vars]
NODE_ENV = "production"

NODE_ENV = "production" puts wrangler dev in production too (below). 2024-11-11 is the earliest date Vercel KV works with (storage); before 1.9.2, create and the build wrote 2024-09-23.

Every build rewrites only what it manages, and keeps your comments, [vars], bindings and other sections:

KeyWhat the build does
mainAlways sets it to dist/cloudflare/index.js, or the same path under --out-dir: --out-dir build-out gives build-out/cloudflare/index.js (since 1.9.2).
nameWrites it only when the file has none. When it differs from deployments[].wrangler.name in frontmcp.config, it keeps the file's and warns: wrangler.toml declares name = "…" but frontmcp.config resolves to "…". Keeping "…".
compatibility_dateWrites it, 2024-11-11, only when the file has none.
compatibility_flagsAdds nodejs_compat, which FrontMCP needs, and nodejs_compat_populate_process_env, which fills process.env from [vars] and secrets before your module runs; keeps the flags you added.

deployments: [{ target: "cloudflare", wrangler: { name, compatibilityDate, compatibilityFlags } }] in frontmcp.config sets what the build writes.

Production mode and secrets

FrontMCP reads the Worker's own NODE_ENV, from [vars] or a secret, first. Without one, it falls back to what wrangler writes into the bundle: "production" for wrangler deploy, "development" for wrangler dev. So with NODE_ENV = "production" in [vars], as frontmcp create writes it, the Worker is in production mode under wrangler dev too; wrangler dev --var NODE_ENV:development runs it in development. Under wrangler dev 4.147, with NODE_ENV = "production" in [vars], a tool that read this.runtimeContext.env got production, while process.env.NODE_ENV in the tool's own code was development: wrangler replaces that expression in your code as it bundles, so read this.runtimeContext.env instead.

Changed in 1.9: before, NODE_ENV in [vars] changed nothing, so wrangler dev was never in production and a deployed Worker always was.

In production the Worker needs:

SecretWhenWithout it
MCP_SESSION_SECRETFor clients on protocol versions before 2026-07-28Their initialize answers 500 {"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED","message":"Set MCP_SESSION_SECRET in the deployment environment (e.g. \wrangler secret put MCP_SESSION_SECRET`). …"}. Clients on MCP 2026-07-28, anonymous or with a static key, are served without it, and /healthzanswers200`.
JWT_SECRETlocal and remote authEvery request, health checks included, answers 500 with JWT_SECRET_REQUIRED: see production secrets.

Set them with wrangler secret put MCP_SESSION_SECRET, and locally in .dev.vars, which wrangler dev reads. Checking production locally runs wrangler dev in production mode.

Changed in 1.8.5: before, a production Worker without MCP_SESSION_SECRET answered every anonymous or static-key request with 500 SESSION_SECRET_REQUIRED, 2026-07-28 ones included.

When the server can't be built

A Worker builds the server on its first request. When that fails, every request, health checks included, gets an answer that says so, and later requests try again:

CauseAnswer
A configuration fault, like a missing JWT_SECRET or an authorities rule that names an unknown profile500 { "error": "server_misconfigured", "code", "message" }, with the code of the fault, like JWT_SECRET_REQUIRED
Anything else, like a remote app that can't be reached503 { "error": "server_unavailable", "code": "SERVER_START_FAILED", "message": "The server failed to start, so it refuses requests. Its log has the cause; a request after the Retry-After delay tries to start it again." }, with Retry-After in seconds

The retries back off: the build ran again after 1, 2, 4, then 8 seconds while requests kept arriving, and the log has the cause of each failure (FrontMCP failed to start; requests are refused until a retry succeeds …). Once the cause is gone, the next retry builds the server and requests are served. Changed in 1.8.5: before, only recognized configuration faults answered; any other failure made fetch throw, and every request built the server again at once.

What the Worker serves

What createFetchHandler() serves: MCP at one path, /healthz and /health with a fixed 200 { "status": "ok", "server", "transport": "web-fetch" } that runs no checks, /readyz when you turn it on (Health checks), and 404 { "error": "Not Found", "entryPaths": ["/mcp"] } for everything else. Its caveats apply: no custom routes, the primary scope only. createFetchHandler() serves /metrics since 1.9, and a Worker does since 1.9.2: when the @FrontMcp source mentions metrics and @frontmcp/observability is installed, the build imports it into index.js and registers it with the SDK. With metrics: { enabled: true } and @frontmcp/observability and @opentelemetry/sdk-trace-base installed, /metrics under wrangler dev answered 200 with Prometheus text: the counters you create, like frontmcp_tool_add_calls_total 1 after one call of a tool that counts, and an empty body before any. A Worker has no process to measure, so there are no frontmcp_process_* gauges (changed in 1.9.3: in 1.9.2 it listed five, all 0). Before 1.9.2 such a Worker logged No such module "@frontmcp/observability" and answered every request 503 SERVER_START_FAILED.

A client on MCP 2026-07-28 loses nothing. An older client gets no session id, and each of its requests is served on its own (sessions).

Bindings

String bindings reach your code as process.env.NAME, through the flag and through the entry's copy. KV namespaces, D1 databases, R2 buckets and Durable Objects are objects: the entry passes env to FrontMCP, and a tool reads it as this.workerEnv, the env object of the request it's serving:

const kv = this.workerEnv?.["DESK_KV"] as KVNamespace | undefined;

this.workerEnv is undefined when the request has no env: on the Node server, under createDirect(), and in a fetch handler that wasn't given one. Under wrangler dev with a [[kv_namespaces]] binding, a tool wrote a key to the namespace and read it back, and Object.keys(this.workerEnv) listed NODE_ENV, the other [vars] and the binding. Changed in 1.8.7: in 1.8.6 nothing on a tool's this exposed env, so a tool couldn't reach these bindings through FrontMCP.

Storage

A Worker can't open a TCP connection to Redis or a SQLite file, and the build refuses a server whose @FrontMcp source names either, even behind a condition in the field's value:

Error: [--target cloudflare] config incompatible with Cloudflare Workers:
  - ioredis-style `redis` storage is not supported on --target cloudflare (no Node net). Even an env-gated `redis: process.env.X ? {...} : undefined` still ships the Node-only branch in the worker bundle. Use `redis: { provider: 'vercel-kv' }` (HTTP), or move the redis config behind a build-time `define` so the bundler can dead-code-eliminate it.

A redis spread in only when a variable is set, ...(process.env.REDIS_URL ? { redis: { url: process.env.REDIS_URL } } : {}), gets past the build when REDIS_URL isn't set where you build. With REDIS_URL set in the Worker's environment, every request then answered 503 SERVER_START_FAILED, and the log said ioredis is required for Redis storage adapter. Keep redis out of a Worker's entry.

redis: { provider: "vercel-kv" }, which talks to Vercel KV or Upstash over HTTP, is accepted when the source spells the provider out like that, in any entry file. A redis whose provider the build can't tell, like redis: { url: process.env.REDIS_URL }, is refused with the build could not evaluate the entry to see which provider it uses. A Worker with Vercel KV needs compatibility_date 2024-11-11 or later, which the build writes since 1.9.2. With an earlier date in wrangler.toml, the build warns, redis: { provider: 'vercel-kv' } needs compatibility_date 2024-11-11 or later on Workers (its Upstash client sends cache: 'no-store', which earlier dates reject), but the Worker runs with 2024-09-23: every storage call will fail. Raise compatibility_date in wrangler.toml., and wrangler dev logged Failed to create session store - session persistence disabled with Failed to connect to Vercel KV: The 'cache' field on 'RequestInitializerDict' is not implemented.; with 2024-11-11 it logged Session store connection validated successfully. The Worker still keeps no sessions (what the Worker serves): redis is for what else uses it, like a plugin's "global-store". sqlite fails the build with a message of its own, and so does tasks: { enabled: true } without tasks.redis. Background tasks are off on a Worker unless you ask for them.

Changed in 1.9: before, the build accepted redis: { provider: "vercel-kv" } only from an entry that imported no other TypeScript file, and a Worker built with it failed to load its session store (No such module "@frontmcp/auth").

So the Worker keeps everything in the isolate's memory, which Cloudflare may discard between requests. That's enough for MCP 2026-07-28, where each request carries what it needs, and for transparent or static auth. local and remote auth keep pending sign-ins and refresh tokens in memory by default, so a sign-in whose steps reach different isolates fails. For older clients' sessions on a Worker, @frontmcp/edge keeps each one in a Durable Object.

Caveats

  • The upload for a small server is about 7.7 MB, 1.4 MB compressed, as wrangler deploy --dry-run reports it (Total Upload: 7697.86 KiB / gzip: 1410.18 KiB).
  • frontmcp build --target cloudflare doesn't read a frontmcp.deploy.yaml file.
  • wrangler dev was run for this page with wrangler 4.149 and FrontMCP 1.9.3 (the spread redis again with 1.9.4): metrics and a spread redis; and with wrangler 4.147 and 1.9.2: the endpoint and its 404, production mode and the missing secret, this.runtimeContext.env and Vercel KV. wrangler deploy only with --dry-run. Vercel KV was run against a local server that speaks Upstash's REST API. Bindings and the start-failure retries were last run with 1.9.1.

Usage

Deploying a Worker

In a project made with frontmcp create --target cloudflare, which lists wrangler in devDependencies:

npx frontmcp build --target cloudflare
Cloudflare Workers adapter is experimental. See docs for limitations.
[build] entry: src/main.ts
[build] outDir: dist/cloudflare
[build] target: cloudflare (esnext)
[build] tsconfig.json detected — compiling with project settings
[build] Generating cloudflare deployment files...
  Generated serverless setup at dist/cloudflare/serverless-setup.js
  Generated cloudflare entry at dist/cloudflare/index.js
  Updated wrangler.toml (managed keys only)
Build completed.
Output placed in dist/cloudflare
Server will serve MCP at /mcp
npx wrangler dev --port 8787
curl http://127.0.0.1:8787/healthz
{"status":"ok","server":{"name":"help-desk","version":"1.0.0"},"transport":"web-fetch"}

A tools/call to http://127.0.0.1:8787/mcp returns the tool's result, and / answers 404 with "entryPaths":["/mcp"]. Then set the secret and deploy:

npx wrangler secret put MCP_SESSION_SECRET   # paste the output of: openssl rand -hex 32
npx wrangler deploy

The project's deploy script does the build and wrangler deploy in one step, and dev:worker the build and wrangler dev.

Reading secrets, variables and bindings

A tool reads a Worker's [vars] and secrets from process.env, and its other bindings, like a KV namespace, from this.workerEnv. worker.ts below does what the generated entry does, with createFetchHandler() in place of the decorated class, and the tests send it env the way Cloudflare does:

Open
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "desk_api_status", description: "Which help desk API and region this server uses", inputSchema: {} })
export class DeskApiStatus extends ToolContext {
  async execute() {
    return {
      apiUrl: process.env.DESK_API_URL ?? null,
      region: process.env.DESK_REGION ?? null,
      kvInProcessEnv: process.env.DESK_KV !== undefined,
    };
  }
}

type KvNamespace = { get(key: string): Promise<string | null>; put(key: string, value: string): Promise<void> };

@Tool({
  name: "remember_note",
  description: "Keep a note for a ticket in the Worker's KV namespace, and read it back",
  inputSchema: { id: z.string(), note: z.string() },
})
export class RememberNote extends ToolContext {
  async execute({ id, note }: { id: string; note: string }) {
    const kv = this.workerEnv?.["DESK_KV"] as KvNamespace | undefined;
    if (!kv) return { stored: false, note: null };
    await kv.put(`note:${id}`, note);
    return { stored: true, note: await kv.get(`note:${id}`) };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The Playground's own Call tab calls the tools without the Worker, so desk_api_status shows nulls and remember_note stores nothing: nothing copied a binding, and there's no env. On Cloudflare, nodejs_compat_populate_process_env has already filled process.env before the first request, so the entry's copy only adds what the flag didn't.

Checking production locally

With NODE_ENV = "production" in [vars], as frontmcp create writes it, wrangler dev runs the Worker in production, so a missing secret shows up before you deploy:

npx wrangler dev

Without .dev.vars, /healthz answers 200, and so does a 2026-07-28 tools/call, but an initialize from an older client answers 500 with SESSION_SECRET_REQUIRED. With a .dev.vars file holding MCP_SESSION_SECRET=…, wrangler dev lists it as a hidden variable and initialize succeeds too. npx wrangler dev --var NODE_ENV:development runs it in development instead. (Before 1.9 [vars] didn't count, and --define 'process.env.NODE_ENV:"production"' was the way to see production locally.)


Troubleshooting

[--target cloudflare] config incompatible with Cloudflare Workers

The @FrontMcp source names sqlite, or a redis that isn't written as redis: { provider: "vercel-kv" }, or enables tasks without tasks.redis. Remove the key from the Worker's configuration, or use Vercel KV. The build reads the source, so hiding the key behind process.env doesn't help. See storage.

{"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED",…}

The deployed Worker is in production, has no MCP_SESSION_SECRET, and a client on a protocol version before 2026-07-28 sent a request. Run wrangler secret put MCP_SESSION_SECRET.

503 {"error":"server_unavailable","code":"SERVER_START_FAILED",…}, on every path

The server failed to build, for a reason that isn't a configuration fault, like a remote app that couldn't be reached. wrangler tail, or the wrangler dev output, has the cause. Requests after the Retry-After delay try again. See when the server can't be built.

A missing secret works under wrangler dev, then fails once deployed

wrangler dev runs in development unless the Worker's NODE_ENV is production. Put NODE_ENV = "production" in [vars], as frontmcp create does: see checking production locally.

The 'cache' field on 'RequestInitializerDict' is not implemented

The Worker uses Vercel KV, and its compatibility_date is older than 2024-11-11, as in a wrangler.toml written before 1.9.2. The build warns about it. Raise it in wrangler.toml. See storage.

No such module "@frontmcp/observability"

The Worker was built before 1.9.2 with metrics: { enabled: true }, which it couldn't load, so it didn't start. Build it again with 1.9.3, with @frontmcp/observability and @opentelemetry/sdk-trace-base installed. See what the Worker serves.

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

The request's path isn't the endpoint. entryPaths says where it is: http.entryPath, else transport.http.path from frontmcp.config, else /. Point the client there.

wrangler.toml declares name = "…" but frontmcp.config resolves to "…"

The two names differ and the build kept the file's. Set deployments[].wrangler.name in frontmcp.config to the same name, or change the file.

A tool can't read a KV namespace, D1 database or R2 bucket

Only string bindings reach tools through process.env. Read the others from this.workerEnv: see bindings.