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. The build always emits both.4
Test locally in workerd, then deploy
5
Verify
https://<your-worker-url>/mcp (Streamable HTTP).frontmcp build --target cloudflare regenerates wrangler.toml’s four top-level keys on every build (alwaysWriteConfig). It does not emit [[kv_namespaces]], [[durable_objects]], [[r2_buckets]], [[d1_databases]], or [triggers], and the generated entry passes only the Request (not env) to the handler — so the decorator-build path can’t wire CF bindings. For bindings + the auto-update Cron, use the managed-edge path below with a hand-maintained wrangler.toml.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.
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(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 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).
MCP_SESSION_SECRET is required on a production isolate (NODE_ENV=production) — the flow’s session:verify stage encrypts session IDs with it. 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.
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. 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.