Skip to main content
FrontMCP runs on Cloudflare Workers via the Web-standard fetch handler — clients connect to https://your-worker.workers.dev/mcp over the MCP Streamable HTTP transport. There is no Express and no Node req/res shim on the Worker; the native Request is routed straight into the MCP transport. There are two deployment paths, and they have different maturity: Start with the decorator path. It’s the one that’s verified end-to-end.

Path 1 — Deploy a FrontMCP app (decorator build)

This is the proven path. You write a normal @FrontMcp app and frontmcp build --target cloudflare compiles it into an ES Module Worker.
1

Write the server

Keep tool/resource/prompt code worker-safe: no node:fs, timers, Math.random(), or Date.now() at module top level. Inside execute() / read() is fine — that runs in a request context. --target cloudflare rejects sqlite/redis storage config at build time (no native modules / Node net on Workers).
2

Add frontmcp.config.js

wrangler.name is the deployed Worker name; the build writes it into wrangler.toml.
3

Build

This emits dist/cloudflare/index.js (an ES Module Worker) and (re)writes the top-level keys of wrangler.toml:
nodejs_compat with compatibility_date >= 2024-09-23 is required — the SDK runtime still uses some Node builtins behind that flag; without it the Worker won’t load, so the build always emits it. nodejs_compat_populate_process_env mirrors [vars] and secrets into process.env and is emitted by default; add nodejs_compat_do_not_populate_process_env to wrangler.compatibilityFlags to keep process.env empty. The opt-out always wins, so the two are never emitted together (wrangler deploy rejects a config carrying both).Cloudflare’s flag only governs population at module evaluation, and the generated entry separately copies string bindings into process.env on the first request so process.env.MY_API_KEY behaves the same under frontmcp dev and in production. Declaring the opt-out therefore also drops that bridge from the generated entry — otherwise the build would put back exactly the values you excluded. Read bindings from the env argument instead; non-string bindings (KV, D1, R2, Durable Objects) are only ever reachable that way.
Only main is rewritten outright — it has to track the build output. name and compatibility_date are written only when the file does not already declare them, and compatibility_flags is merged rather than replaced, so a name you set by hand is never silently changed. If wrangler.toml and frontmcp.config disagree on the name, the build keeps the file’s and warns.
4

Test locally in workerd, then deploy

5

Verify

Point any MCP client at https://<your-worker-url>/mcp (Streamable HTTP).
frontmcp build --target cloudflare reconciles wrangler.toml’s managed top-level keys on every build (alwaysWriteConfig), but leaves everything else — [vars], [[kv_namespaces]], [[durable_objects]], [[r2_buckets]], [[d1_databases]], [triggers] and your comments — untouched. It does not emit those sections for you; add them by hand and they will survive subsequent builds.The generated entry forwards env and ctx to the handler, so bindings and ctx.waitUntil are reachable from the decorator-build path. For the managed auto-update Cron (scheduled), use the managed-edge path below.

Secrets, vars and process.env

Worker bindings arrive as an argument to fetch, not as environment variables. The generated entry copies every string binding into process.env on the first request (without overwriting anything already there), so ordinary process.env.MY_API_KEY reads work the same on Workers as they do under frontmcp dev:
Non-string bindings (KV, D1, R2, Durable Objects) are objects and stay on env, which the entry forwards to the handler. Inside a tool, resource, prompt or agent, read them with this.workerEnv:
workerEnv is the Worker env of the request being served (read-only), and undefined on Node, stdio and other non-Worker runtimes. NODE_ENV is read live, so a NODE_ENV = "production" entry in wrangler.toml [vars] takes effect for the runtime context even though it only reaches process.env on the first request.
The copy happens on the first request, so a value read at module-eval time — inside the @FrontMcp({...}) argument itself — is still undefined. Read configuration inside execute() / read(), or rely on nodejs_compat_populate_process_env (the build emits it by default), which populates process.env before your module evaluates.

Required secrets

A worker with NODE_ENV = "production" in [vars] is a production deployment, and FrontMCP refuses its development fallbacks there:
Because [vars] now reach process.env, wrangler dev sees the same NODE_ENV=production your deployment does — so a missing secret fails locally instead of only after a successful deploy.

When the server fails to start

A Worker builds the FrontMCP server on its first request (module evaluation forbids timers and I/O, and secrets arrive with the request). If that build fails, the request is answered, never thrown to the platform, and the error’s message is not echoed: A failed build is kept, and requests are refused with it, until a retry delay passes: 1 s after the first failure, doubling up to 60 s. The first request after that builds again, so a transient failure recovers without a redeploy and a permanent one isn’t rebuilt by every request. The cause is logged once per attempt (wrangler tail). This applies to createEdgeMcp(), to createFetchHandler() (and the @FrontMcp decorator) on an edge isolate, and to each session Durable Object.

Background tasks

Cloudflare storage: redis: { provider: 'vercel-kv' } (the HTTP-based Upstash/Vercel KV client) is accepted by --target cloudflare; only TCP redis and sqlite configs are rejected at build time, since Workers cannot open raw sockets or load native modules. Background tasks need a store that outlives a single request and is shared between isolates, which an edge runtime cannot provide in-process. FrontMCP disables them automatically when no distributed store is configured, and the worker serves normally without them — no tasks: { enabled: false } opt-out is needed. Setting tasks: { enabled: true } without tasks.redis fails the build rather than the deployed worker.

Startup checks

The server is built on its first request, but the startup checks the config’s metadata settles run when the module evaluates: an approval or featureFlag field no plugin that reaches the entry enforces, or authorities without the authorities option. A plugin installed on an app reaches only that app’s entries, unless one of its hooks is appliesTo: 'uncovered-apps' as the built-in approval and feature-flag plugins’ are. A tool declared inside an @Agent is reached only by that agent’s plugins (none with execution.useToolFlow: false), and an agent’s plugins reach nothing else. createEdgeMcp throws there, so the worker fails to deploy; the decorator build logs the error and refuses every request. The remaining checks run when the first request builds the server, and a server they refuse fails every request.

Endpoint path, CORS & SSE — all config-driven

The worker’s transport is driven by the standard http + transport config — the same fields the Express host reads — so one config behaves identically on either adapter. There are no edge-specific knobs.
Two settings, one concept. transport.http.path in frontmcp.config.* configures the CLI — frontmcp dev, the inspector, and the clients[].url entries it generates. @FrontMcp({ http: { entryPath } }) configures the server, and it is what the deployed worker reads. The cloudflare build reconciles them: transport.http.path becomes the server’s default, so declaring it once drives dev, the generated client URL, and the worker. An explicit entryPath on the decorator still wins, and the build warns when the two disagree. The build prints the resolved path (Server will serve MCP at /mcp) so it is visible without curling for a 404.
The worker serves MCP at exactly one path: http.entryPath (the worker root / when unset). It does not guess a / + /mcp set — pick the path that matches how you expose the worker. Cloudflare routes never strip the path before it reaches the worker:
  • CORS comes from http.cors (false to disable, { origin, credentials, maxAge } to configure). A function origin isn’t supported on the worker — use a static origin (true / string / string[]).
  • SSE is derived from the transport protocol: streaming is on when Streamable HTTP is enabled and JSON-buffering is off (true under legacy/modern, false under stateless-api). Server→client GET streams are always honored.
  • A trailing slash is normalized (/mcp/ matches /mcp); /healthz + /readyz always answer a liveness 200 regardless of entryPath.
The decorator-build path (@FrontMcp({ http, transport }) + frontmcp build --target cloudflare) and the @frontmcp/edge createEdgeMcp({ http, transport }) path read the same config, so the endpoint path / CORS / SSE behave identically on both.

Path 2 — Managed auto-updating edge (@frontmcp/edge)

The @frontmcp/edge package runs FrontMCP from a plain config object — no decorator, no frontmcp build step — and adds managed mode: it pulls a signed skilled-OpenAPI bundle from a SaaS endpoint, caches it in KV, and refreshes it on a Cron Trigger, so the server’s capabilities update without a redeploy.
createEdgeMcp returns { fetch, scheduled }. The fetch handler serves MCP; the scheduled handler is the Cron Trigger entrypoint that pulls a fresh bundle and hot-swaps it. Both come from the same module export.

Required wrangler.toml (hand-maintained)

The managed path is bundled by wrangler (not frontmcp build), so you own wrangler.toml:

How the cache + refresh behave

  • Boot: the first request lazily builds the scope and pulls the bundle. If the pull fails, it falls back to the last-good bundle in KV (validated against the bundle schema) so a SaaS outage doesn’t kill the server.
  • Refresh: the Cron Trigger invokes scheduled(), which pulls a fresh bundle, persists it to KV, and hot-swaps the live skill/tool registries — emitting notifications/*/list_changed.
  • Single-flight: boot and Cron refresh are mutually exclusive (no double-pull on cold start).
  • No redirects: the pull sends authToken as a bearer token and never follows a redirect, so endpoint must serve the bundle directly; a 3xx (or a status-0 opaqueredirect) fails the pull.
  • No source attached → loud failure: if the bundle source can’t be constructed, scheduled() throws so the Cron run is reported as failed rather than silently succeeding with a stale bundle.
Managed mode requires the optional peer @frontmcp/plugin-skilled-openapi. And see Current status — the full managed bundle does not yet boot on workerd.

Storage on the edge

Cloudflare KV backs the generic FrontMCP StorageAdapter, so factory-based stores (sessions, elicitation, cache) can run on Workers:
KV is eventually-consistent and is not Redis. The adapter is honest about what KV can’t do — these throw StorageNotSupportedError pointing at the (forthcoming) Durable Object path:
  • Atomic counters (incr/decr/incrBy) — no atomic increment on KV.
  • Conditional writes (ifNotExists/ifExists) — no compare-and-set.
  • TTL introspection (ttl) — KV doesn’t expose remaining TTL.
keys() runs over the prefix-scoped, paginated list() API with client-side glob filtering, and the 60-second minimum expirationTtl is enforced. delete()’s “existed” flag and expire()’s re-put are best-effort under eventual consistency.

Stateful sessions & notifications (Durable Objects)

A stateless worker can’t support the Streamable HTTP standalone GET notification stream: each request is a fresh isolate/transport, so a tools/call’s notifications have no path back to the client’s open GET stream (it closes immediately). The fix is a Durable Object — one instance per Mcp-Session-Id holds a persistent MCP server + session-bound transport, so the GET stream stays open and server→client notifications reach it. It runs the same http:request flow as the stateless path — only the transport persists. Enable it with sessions and export the DO class createEdgeMcp returns:
The worker routes a request to its session DO by Mcp-Session-Id (minting one on initialize); the router falls back to stateless handling if the binding isn’t present. Without sessions, the worker stays stateless (request/response tools work; no server push). A session-based (2025-06-18) client then gets no Mcp-Session-Id from initialize and each of its requests is served on its own. A session belongs to the caller that opened it. Its Mcp-Session-Id only addresses the Durable Object, so the flow’s checkPersistentSessionOwner stage binds the session to the caller of its first request and answers any other caller as MCP answers an unknown session (404, Session not found) on POST, GET and DELETE, before the transport sees the request. The check runs before every protocol handler, MCP 2026-07-28 included. The caller is its verified issuer and subject, which a token refresh keeps, else its token (an anonymous grant). The owner is kept in the Durable Object’s storage, so an instance rebuilt after eviction still refuses everyone else (its owner re-initializes the session). An anonymous caller without a token has neither, so on a public server the unguessable session id is the only credential, as for any anonymous session. The owner can end its session with DELETE.
MCP_SESSION_SECRET is required on a production isolate (NODE_ENV=production) that serves session clients (Durable Object sessions, or clients on a protocol before 2026-07-28) — the flow’s session:verify stage encrypts their session IDs with it. MCP 2026-07-28 requests are sessionless and need none. createEdgeMcp bridges Worker env vars/secrets into process.env so FrontMCP’s config resolution sees them.

Low-level: createWebFetchHandler() (custom runtimes — Deno, Bun, custom workers)

createEdgeMcp (above) is the recommended path. Under it sits the SDK’s runtime-agnostic Web-standard transport, exported for advanced use — wiring FrontMCP into Deno, Bun, or a hand-rolled Worker where you control the export default { fetch } yourself. It turns a Scope into a (request: Request) => Promise<Response> handler.
The client IP (used by throttle.ipFilter and partitionBy: 'ip') comes from Deno’s info.remoteAddr, Bun’s server.requestIP(request), or — only when running on Cloudflare Workers — the CF-Connecting-IP header. A handler called without the second argument on Deno or Bun has no client IP, so ipFilter.defaultAction applies to every request.

Types

Unlike the Express/Node host (@FrontMcp({ http }) + serve), this handler has no server to start — the runtime owns the listener and just hands each Request to it. It runs the same http:request flow (auth, quota, audit, routing) as the Express adapter, so behavior is identical across runtimes; only the transport translation differs. FrontMcp.fetch() and createEdgeMcp are thin wrappers that build a scope and call this.

Current status & limitations

FrontMCP-on-Cloudflare is v1.3 in progress. What works vs what’s still missing: Works today (verified on real Cloudflare):
  • The decorator build path (frontmcp build --target cloudflare) boots on real workerd and serves MCP over /mcp with a /healthz liveness probe.
  • createEdgeMcp also deploys + serves on real Cloudflare — bundled with esbuild + a small set of stubs (see the next bullet). Verified end-to-end (initialize / tools/list / tools/call). The KV last-good cache + the Cron scheduled refresh wiring are unit/integration-tested.
  • The worker runs the real http:request flow — the same hookable pipeline every transport runs: session:verify (auth), router, audit, metrics + plugin/user hooks all execute on the worker. The MCP response is produced by the SDK’s web-standard Streamable HTTP transport (WebStandardStreamableHTTPServerTransport) — which is the standard transport (the Node StreamableHTTPServerTransport is a thin req/res wrapper over it). Configure transparent auth and the flow’s checkAuthorization stage returns 401 + WWW-Authenticate on the worker, just like Node.
  • Stateful sessions via a Durable Object (sessions: {} + the bound SessionDurableObject): the Streamable HTTP standalone GET notification stream stays open across requests and requests for a session route back to the same DO — so server→client notifications work. Verified live.
  • OAuth sign-in endpoints, including a federated provider’s redirect to /oauth/provider/:providerId/callback and the federated consent screen’s submission: the worker matches path parameters like Express, so remote mode and local providers finish a sign-in on the worker.
  • CloudflareKvStorageAdapter (KV only, with the honest limits above).
Deploying createEdgeMcp today needs two things:
  • It must run with serve: false (now the default inside createEdgeMcp) so the scope doesn’t construct the Node Express host.
  • Three Node-only transports that are statically bundled but never used on the edge must be stubbed in your bundler: express (pulls node:tty), raw-body (pulls safer-buffer, which crashes at module-eval on workerd), and cross-spawn (pulls node:child_process, via the MCP stdio client). See cloudflare-sandbox/build-edge.mjs for a working esbuild config.
The miniflare-based local e2e is stricter than production — it rejects node:http2/node:fs that real Cloudflare nodejs_compat actually provides. So the edge package runs on real Cloudflare even though the local managed e2e is skipped. The worker-conditioned SDK build (swapping those Node-only transports for the browser variants the SDK + protocol already ship) is the roadmap item that removes the manual stubs.
Known gaps (roadmap):
  • Stateless mode has no server push. Without sessions, the worker is stateless and the standalone GET notification stream can’t deliver server→client notifications (each request is a fresh isolate) — enable sessions (the Durable Object) for that.
  • DO session eviction. A Durable Object is evicted when idle; an in-flight session then needs to re-initialize. WebSocket-hibernation-style persistence is a follow-up.
  • KV only. No R2 (blobs) or D1 (relational) stores, and no @frontmcp/adapters/cloudflare subpath yet.
  • No worker observability sink, no deploy CLI / GitHub Action / push webhook, and the declarative frontmcp.deploy.yaml manifest is schema-only (no runtime consumer yet).

Deployment targets

All build targets (node / cloudflare / vercel / browser).

Skills-Only Deployment

The skilled-OpenAPI model managed mode pulls.

Deploy manifest reference

The frontmcp.deploy.yaml schema (forward-looking).

Production build

Bundling + production hardening.