> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentfront.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Cloudflare Worker

> Deploy a FrontMCP MCP server to Cloudflare Workers — the proven decorator build path, plus the auto-updating managed edge runtime

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:

| Path | API | Status |
| - | - | - |
| **Decorator build** | `@FrontMcp` app + `frontmcp build --target cloudflare` | ✅ **Works today** — boots on real workerd (covered by `apps/e2e/demo-e2e-cloudflare`) |
| **Managed edge** | `@frontmcp/edge` `createEdgeMcp({ managed })` | 🧪 **Experimental** — API + KV cache + Cron wiring are built and unit/integration-tested, but the full managed bundle does **not** yet boot on workerd (see [Current status](#current-status--limitations)) |

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.

<Steps>
  <Step title="Write the server">
    ```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    // src/main.ts
    import 'reflect-metadata';
    import { App, FrontMcp, Tool, ToolContext, z } from '@frontmcp/sdk';

    @Tool({ name: 'echo', description: 'Echo a message', inputSchema: { message: z.string() } })
    class EchoTool extends ToolContext {
    async execute(input: { message: string }) {
    return { content: [{ type: 'text' as const, text: `Echo: ${input.message}` }] };
    }
    }

    @App({ id: 'demo', name: 'demo', tools: [EchoTool] })
    class DemoApp {}

    @FrontMcp({
    info: { name: 'my-worker', version: '1.0.0' },
    apps: [DemoApp],
    // Serve MCP at /mcp (the worker default is root `/`). See "Endpoint path,
    // CORS & SSE" below — all three are driven by this same `http`/`transport` config.
    // You can also declare it once as `transport.http.path` in frontmcp.config;
    // the cloudflare build feeds that in as this field's default.
    http: { entryPath: '/mcp' },
    })
    class MyServer {}

    export default MyServer;
    ```

    <Warning>
      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).
    </Warning>
  </Step>

  <Step title="Add frontmcp.config.js">
    ```js theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    // frontmcp.config.js
    module.exports = {
      name: 'my-worker',
      entry: './src/main.ts',
      deployments: [{ target: 'cloudflare', wrangler: { name: 'my-worker' } }],
    };
    ```

    `wrangler.name` is the deployed Worker name; the build writes it into `wrangler.toml`.
  </Step>

  <Step title="Build">
    ```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    frontmcp build --target cloudflare
    ```

    This emits `dist/cloudflare/index.js` (an ES Module Worker) and **(re)writes the top-level keys** of `wrangler.toml`:

    ```toml theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    name = "my-worker"
    main = "dist/cloudflare/index.js"
    compatibility_date = "2024-09-23"
    compatibility_flags = ["nodejs_compat", "nodejs_compat_populate_process_env"]
    ```

    <Note>
      `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.
    </Note>

    <Note>
      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.
    </Note>
  </Step>

  <Step title="Test locally in workerd, then deploy">
    ```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    npx wrangler dev            # boots the bundle in workerd (same engine as prod)
    npx wrangler login          # one-time auth to your account
    npx wrangler deploy
    ```
  </Step>

  <Step title="Verify">
    ```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
    curl https://my-worker.<subdomain>.workers.dev/healthz
    # → { "status": "ok", "transport": "web-fetch", "server": { "name": "my-worker", ... } }

    curl -s https://my-worker.<subdomain>.workers.dev/mcp \
     -H 'content-type: application/json' \
     -H 'accept: application/json, text/event-stream' \
     -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'

    ```

    Point any MCP client at `https://<your-worker-url>/mcp` (Streamable HTTP).
  </Step>
</Steps>

<Note>
  `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.
</Note>

### 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`:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
const sid = process.env.TWILIO_ACCOUNT_SID; // set via `wrangler secret put`
```

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`:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@Tool({ name: 'cache_get' })
class CacheGet extends ToolContext {
  async execute({ key }: { key: string }) {
    const kv = this.workerEnv?.MY_KV as KVNamespace | undefined; // undefined outside a Worker request
    return kv?.get(key);
  }
}
```

`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.

<Warning>
  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.
</Warning>

### Required secrets

A worker with `NODE_ENV = "production"` in `[vars]` is a production deployment, and FrontMCP refuses its development fallbacks there:

| Secret | Required when | Failure without it |
| - | - | - |
| `MCP_SESSION_SECRET` | in production, for session clients (Durable Object sessions, protocol before 2026-07-28) — `session:verify` encrypts their session IDs with it; 2026-07-28 requests need none | `500 {"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED"}` |
| `JWT_SECRET` | `auth.mode` is `local` or `remote` (these mint tokens) | the server refuses to start; requests answer `500 {"error":"server_misconfigured","code":"JWT_SECRET_REQUIRED"}` |

```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
npx wrangler secret put MCP_SESSION_SECRET   # openssl rand -hex 32

# Only when auth.mode is `local` or `remote`; `public` and `transparent`
# never mint local JWTs and do not read this.
npx wrangler secret put JWT_SECRET           # openssl rand -hex 32
```

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:

| Failure | Answer |
| - | - |
| A configuration fault: a missing or weak secret, a startup check, a config the schema refuses | `500 {"error":"server_misconfigured","code":"…","message":"<remedy>"}` (`code`: `SESSION_SECRET_REQUIRED`, `JWT_SECRET_REQUIRED`, `JWT_SECRET_INVALID`, `UNENFORCED_METADATA`, `AUTH_CONFIGURATION_ERROR`, `CONFIG_INVALID`) |
| Anything else: a remote that refused the connection, a package that failed to load | `503 {"error":"server_unavailable","code":"SERVER_START_FAILED"}` with `Retry-After` |

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.

<Note>
  **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.
</Note>

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:

| You expose | `http.entryPath` | Cloudflare route (`wrangler.toml`) | Worker serves |
| - | - | - | - |
| `https://mcp.example.com` (subdomain) | omit (root `/`) | `routes = [{ pattern = "mcp.example.com", custom_domain = true }]` | `/` |
| `https://example.com/mcp` (path) | `'/mcp'` | `routes = [{ pattern = "example.com/mcp*", zone_name = "example.com" }]` | `/mcp` |

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
export default createEdgeMcp({
  info: { name: 'my-worker', version: '1.0.0' },
  apps: [MyApp],
  tasks: { enabled: false },
  http: {
    entryPath: '/mcp',        // the ONE path MCP is served at (omit → root '/')
    cors: { origin: true },   // reflect Origin so a browser MCP client (Inspector "Direct") can connect
  },
  // transport: 'legacy' (default) / 'modern' → SSE streaming on POST;
  // transport: 'stateless-api' → buffered JSON responses instead.
});
```

* **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](/frontmcp/features/skills-only-deployment) from a SaaS endpoint, caches it in **KV**, and refreshes it on a **Cron Trigger**, so the server's capabilities update without a redeploy.

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
// worker.ts
import { env } from 'cloudflare:workers';
import { createEdgeMcp, kvBundleCacheFromEnv } from '@frontmcp/edge';

export default createEdgeMcp({
  info: { name: 'my-worker', version: '1.0.0' },
  apps: [],
  tasks: { enabled: false },
  managed: {
    endpoint: 'https://cloud.example.com/v1/bundles/acme',
    // The pull JWT the SaaS issued for this server (iss = expectedIssuer, aud includes
    // expectedAudience, with exp), kept in a Worker secret:
    //   npx wrangler secret put FRONTMCP_PULL_TOKEN
    authToken: env.FRONTMCP_PULL_TOKEN,
    expectedAudience: 'acme-mcp',
    jwksUrl: 'https://cloud.example.com/.well-known/jwks.json',
    expectedIssuer: 'https://cloud.example.com',
    // KV-backed last-good cache, resolved from the per-request `env` (CF bindings
    // live on `env`, not module scope — so pass the factory, not a built store).
    cache: kvBundleCacheFromEnv('BUNDLE_CACHE'),
  },
});
```

`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`:

```toml theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
name = "my-worker"
main = "worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

# Last-good bundle cache (createEdgeMcp reads it via kvBundleCacheFromEnv).
[[kv_namespaces]]
binding = "BUNDLE_CACHE"
id = "<your-kv-namespace-id>"   # create with: npx wrangler kv namespace create BUNDLE_CACHE

# Cron Trigger that drives scheduled() → refresh. Set your refresh cadence here
# (the `managed.pollIntervalMs` option is IGNORED on edge — Workers have no
# background timers between requests).
[triggers]
crontabs = ["*/5 * * * *"]
```

### 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.

<Warning>
  Managed mode requires the optional peer `@frontmcp/plugin-skilled-openapi`. And see [Current status](#current-status--limitations) — the full managed bundle does not yet boot on workerd.
</Warning>

***

## Storage on the edge

Cloudflare KV backs the generic FrontMCP `StorageAdapter`, so factory-based stores (sessions, elicitation, cache) can run on Workers:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { createStorage } from '@frontmcp/utils';

const storage = await createStorage({
  type: 'cloudflare-kv',
  cloudflareKv: { namespace: env.MY_KV },  // a KV binding from the Worker env
});
```

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:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
// worker.ts
const mcp = createEdgeMcp({
  info: { name: 'my-worker', version: '1.0.0' },
  apps: [MyApp],
  tasks: { enabled: false },
  http: { entryPath: '/mcp' },
  sessions: {}, // default binding name: FRONTMCP_SESSIONS
});
export default mcp;
export const FrontMcpSession = mcp.SessionDurableObject; // bind in wrangler.toml
```

```toml theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
# wrangler.toml
[[durable_objects.bindings]]
name = "FRONTMCP_SESSIONS"
class_name = "FrontMcpSession"

[[migrations]]
tag = "v1"
new_classes = ["FrontMcpSession"]

[vars]
MCP_SESSION_SECRET = "..." # or `wrangler secret put MCP_SESSION_SECRET`
```

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`.

<Note>
  `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.
</Note>

## 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.

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { createWebFetchHandler } from '@frontmcp/sdk';

// `scope` comes from a booted FrontMcpInstance (e.g. instance.getScopes()[0]).
const handler = createWebFetchHandler(scope, { entryPath: '/mcp' });

// Cloudflare Worker / Deno / Bun all speak Web `Request`/`Response`:
export default { fetch: (request: Request) => handler(request) };

// Deno and Bun: forward the runtime's second argument — it is how the handler learns the client IP.
Deno.serve((request, info) => handler(request, info));
Bun.serve({ fetch: (request, server) => handler(request, server) });
```

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

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
type WebFetchHandler = (request: Request) => Promise<Response>;

function createWebFetchHandler(scope: Scope, options?: CreateWebFetchHandlerOptions): WebFetchHandler;

interface CreateWebFetchHandlerOptions {
  /** MCP endpoint path(s). Defaults to the scope's `http.entryPath`, else `/`. A trailing slash is normalized; an array opts into a multi-path allow-list. */
  entryPath?: string | string[];
  /** Paths answered with a 200 liveness/readiness response. Defaults to `/healthz`, `/readyz`. */
  healthPaths?: string[];
  /** CORS for browser MCP clients. Defaults to the scope's `http.cors`. */
  cors?: WebFetchCorsOptions;
  /** Optional Durable-Object session router (stateful sessions); falls through to stateless when it returns no `Response`. */
  sessionRouter?: WebFetchSessionRouter;
}
```

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.

<Note>
  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.
</Note>

**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`](/frontmcp/deployment/deploy-manifest) manifest is schema-only (no runtime consumer yet).

<CardGroup cols={2}>
  <Card title="Deployment targets" icon="layer-group" href="/frontmcp/features/deployment-targets">
    All build targets (node / cloudflare / vercel / browser).
  </Card>

  <Card title="Skills-Only Deployment" icon="rocket-launch" href="/frontmcp/features/skills-only-deployment">
    The skilled-OpenAPI model managed mode pulls.
  </Card>

  <Card title="Deploy manifest reference" icon="file-code" href="/frontmcp/deployment/deploy-manifest">
    The `frontmcp.deploy.yaml` schema (forward-looking).
  </Card>

  <Card title="Production build" icon="box" href="/frontmcp/deployment/production-build">
    Bundling + production hardening.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.