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
2
Add frontmcp.config.js
wrangler.name is the deployed Worker name; the build writes it into wrangler.toml.3
Build
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
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:
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.
Required secrets
A worker withNODE_ENV = "production" in [vars] is a production deployment, and FrontMCP refuses its development fallbacks there:
[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: anapproval 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 standardhttp + 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.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(falseto disable,{ origin, credentials, maxAge }to configure). A functionoriginisn’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 understateless-api). Server→clientGETstreams are always honored. - A trailing slash is normalized (
/mcp/matches/mcp);/healthz+/readyzalways answer a liveness200regardless ofentryPath.
The decorator-build path (@FrontMcp({ http, transport })+frontmcp build --target cloudflare) and the@frontmcp/edgecreateEdgeMcp({ 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 — emittingnotifications/*/list_changed. - Single-flight: boot and Cron refresh are mutually exclusive (no double-pull on cold start).
- No redirects: the pull sends
authTokenas a bearer token and never follows a redirect, soendpointmust serve the bundle directly; a 3xx (or a status-0opaqueredirect) 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.
Storage on the edge
Cloudflare KV backs the generic FrontMCPStorageAdapter, so factory-based stores (sessions, elicitation, cache) can run on Workers:
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 standaloneGET 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:
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.
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
@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/mcpwith a/healthzliveness probe. createEdgeMcpalso 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 Cronscheduledrefresh wiring are unit/integration-tested.- The worker runs the real
http:requestflow — 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 NodeStreamableHTTPServerTransportis a thinreq/reswrapper over it). Configure transparent auth and the flow’scheckAuthorizationstage returns401+WWW-Authenticateon the worker, just like Node. - Stateful sessions via a Durable Object (
sessions: {}+ the boundSessionDurableObject): the Streamable HTTP standaloneGETnotification 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/callbackand the federated consent screen’s submission: the worker matches path parameters like Express, so remote mode and localprovidersfinish a sign-in on the worker. CloudflareKvStorageAdapter(KV only, with the honest limits above).
createEdgeMcp today needs two things:
- It must run with
serve: false(now the default insidecreateEdgeMcp) 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(pullsnode:tty),raw-body(pullssafer-buffer, which crashes at module-eval on workerd), andcross-spawn(pullsnode:child_process, via the MCP stdio client). Seecloudflare-sandbox/build-edge.mjsfor 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.- Stateless mode has no server push. Without
sessions, the worker is stateless and the standaloneGETnotification stream can’t deliver server→client notifications (each request is a fresh isolate) — enablesessions(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/cloudflaresubpath yet. - No worker observability sink, no deploy CLI / GitHub Action / push webhook, and the declarative
frontmcp.deploy.yamlmanifest 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.