# @frontmcp/edge

> createEdgeMcp() runs a FrontMCP server on Cloudflare Workers from a plain configuration, bundled by wrangler with no frontmcp build and no decorators: what it serves, sessions in a Durable Object, managed mode, and how it differs from the cloudflare build target.

Source: https://frontmcp.dev/reference/deployment/edge

`@frontmcp/edge` runs a FrontMCP server on Cloudflare Workers from a plain configuration object. The Worker's entry file calls `createEdgeMcp()` with the configuration you'd give `@FrontMcp`, and exports what it returns; `wrangler` bundles that file as it is. There's no `frontmcp build` step and no `@FrontMcp` class, and with [`tool()`](https://frontmcp.dev/reference/sdk/tool#writing-a-tool-as-a-function) and [`app()`](https://frontmcp.dev/reference/sdk/app#building-an-app-without-a-decorator) no decorator at all. Underneath, requests go to the same web-standard handler as with [`frontmcp build --target cloudflare`](https://frontmcp.dev/reference/deployment/cloudflare-workers), and `createEdgeMcp()` adds two things that target doesn't have: sessions kept in a Durable Object, and a managed mode that pulls a [Skilled OpenAPI](https://frontmcp.dev/reference/plugins/skilled-openapi) bundle and refreshes it from a Cron Trigger.

```ts
import { createEdgeMcp } from "@frontmcp/edge";

const mcp = createEdgeMcp(config);   // { fetch, SessionDurableObject, scheduled? }
export default mcp;
```

---

## Reference

### `createEdgeMcp(config)`

```ts src/worker.ts
import { createEdgeMcp } from "@frontmcp/edge";
import { app, tool, z } from "@frontmcp/sdk";

const searchTickets = tool({
  name: "search_tickets",
  description: "Search tickets by title",
  inputSchema: { query: z.string() },
})(async ({ query }) => ({ query, tickets: [{ id: "T-1", title: "Cannot log in" }] }));

export default createEdgeMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [app({ id: "help-desk", name: "Help Desk", tools: [searchTickets] })],
  http: { entryPath: "/mcp" },
});
```

```text title="wrangler.toml"
name = "help-desk"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
```

[See more examples below.](#usage)

- **Install** `@frontmcp/edge` and `@frontmcp/sdk`, both `1.9.3`, and `wrangler`. `@frontmcp/plugin-skilled-openapi`, which [managed mode](#managed-mode) uses, comes with `@frontmcp/edge`.
- **`nodejs_compat` is required.** Without it the bundle fails with `Could not resolve "http"`, `"events"`, `"path"` and more.
- **`apps` is required.** Without it the first request answers `500` with `CONFIG_INVALID`. Since 1.9, `tools` and `resources` at the top level are served too, so `apps: []` with `tools` works.
- `wrangler` bundles the SDK's browser build (its `browser` export, since 1.9), so nothing needs stubbing.

#### Options

`config` is what [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp#options) takes, with three more fields:

| Field | Type | Description |
| --- | --- | --- |
| `info`, `apps`, `tools`, `http`, `auth`, … | | As on `@FrontMcp`. `http.entryPath` is the MCP endpoint's path, `/` by default. The server never listens: `createEdgeMcp()` sets `serve: false`. |
| `sessions` | `{ binding? }` | Keep each session in a Durable Object, bound under `binding`, `FRONTMCP_SESSIONS` by default. [See below.](#sessions-in-a-durable-object) |
| `managed` | `ManagedEdgeOptions` | Pull a Skilled OpenAPI bundle from a bundle server. [See below.](#managed-mode) |
| `skillIndex` | `{ binding?, ttlSeconds? }`, or a function of `env` | Keep the skills' search index in a KV namespace, bound under `binding`, `FRONTMCP_SKILL_INDEX` by default, so a new isolate reads it instead of building it. Not tried for this page. |

#### Returns

| Member | What it is |
| --- | --- |
| `fetch(request, env?, ctx?)` | The Worker's request handler. |
| `SessionDurableObject` | The Durable Object class for `sessions`. Always there; export it only when you use `sessions`. |
| `scheduled(event?, env?, ctx?)` | With `managed` only: the Cron Trigger handler, which pulls the bundle again. |

### What `fetch` does

- **The server is built on the first request**, not when the module loads, since a Worker can't do I/O then. Later requests reuse it.
- **String bindings reach `process.env`.** On the first request, each string in `env`, your `[vars]` and secrets, is copied into `process.env`, without replacing a value already there. So `process.env.DESK_REGION` works without the `nodejs_compat_populate_process_env` flag.
- **Other bindings reach tools as [`this.workerEnv`](https://frontmcp.dev/reference/deployment/cloudflare-workers#bindings)**, the request's `env`: a KV namespace, a D1 database, an R2 bucket.
- **It serves what [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler#what-it-answers) serves**: MCP at `http.entryPath`; `/healthz` with `200` `{ "status": "ok", "server": { "name", "version" }, "transport": "web-fetch" }`; and `404` `{ "error": "Not Found", "entryPaths": ["/mcp"] }` for other paths.
- **`this.runtimeContext.runtime` is `"edge"`**, and `this.runtimeContext.env` is the `NODE_ENV` in `[vars]` when it's set there, and `"development"` when it isn't. In 1.9 that holds under `wrangler dev` too: with `NODE_ENV = "production"` in `[vars]`, the Worker is in production locally.
- **Background tasks are off.** The log says `[tasks] Background tasks are unavailable on this edge/serverless runtime …` and the server starts without them; there's no need to set `tasks: { enabled: false }`.

#### When the server can't be built

| Fault | What happens |
| --- | --- |
| A fault FrontMCP finds without building the server, like `featureFlag` or `approval` on a tool with no plugin to enforce it | `createEdgeMcp()` throws when the module is evaluated, so the Worker doesn't start: `wrangler dev` reports `The Workers runtime failed to start` and `Uncaught Error: UnenforcedMetadataError: Unenforced metadata: Tool "export_tickets" declares 'featureFlag' …`. |
| A configuration the SDK refuses, like one without `apps` | Every request, `/healthz` included, answers `500` `{ "error": "server_misconfigured", "code": "CONFIG_INVALID", "message": "The FrontMCP configuration failed validation, so the server refuses to start. The server log names the invalid fields." }`. The log has them: `[frontmcp/edge] The server failed to start; requests are refused until a retry succeeds. Invalid configuration: apps: Invalid input: expected array, received undefined`. |
| A missing `MCP_SESSION_SECRET` in production | Only requests that need it fail: an `initialize` from a client on a protocol version before 2026-07-28 answers `500` with `SESSION_SECRET_REQUIRED`, while 2026-07-28 requests are served. |

A failed build is tried again by a later request, once a delay has passed: 1 second after the first failure, 2 after the second, and so on. Under `wrangler dev`, five requests over 7 seconds built the server three times. The [cloudflare build target's entry](https://frontmcp.dev/reference/deployment/cloudflare-workers#when-the-server-cant-be-built) backs off the same way.

### Sessions in a Durable Object

Without `sessions`, the Worker keeps no sessions: an `initialize` from a client on a protocol version before 2026-07-28 gets no `Mcp-Session-Id`, and each of its requests is served on its own, as with [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler#sessions). MCP 2026-07-28 needs no session.

With `sessions: {}`, the Worker sends each session's requests to a Durable Object named after the session id, which keeps that session's server and transport between requests. Export the class and bind it:

```ts src/worker.ts
const mcp = createEdgeMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [app({ id: "help-desk", name: "Help Desk", tools: [searchTickets] })],
  http: { entryPath: "/mcp" },
  sessions: {},
});

export default mcp;
export const FrontMcpSession = mcp.SessionDurableObject;
```

```text title="wrangler.toml"
[[durable_objects.bindings]]
name = "FRONTMCP_SESSIONS"
class_name = "FrontMcpSession"

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

Under `wrangler dev`, an `initialize` on 2025-06-18 got an `Mcp-Session-Id`, the session's next requests were served by its Durable Object, with `this.context.sessionId` the session id, and a request with an id no session has answered `400` `Bad Request: Server not initialized`. With `sessions: {}` and no binding in `wrangler.toml`, the Worker served requests without sessions, and `initialize` got no `Mcp-Session-Id`. FrontMCP's documentation says the Durable Object also keeps the session's `GET` stream open, so server notifications reach the client; that wasn't tried for this page.

### Managed mode

`managed` pulls a signed [Skilled OpenAPI bundle](https://frontmcp.dev/reference/plugins/skilled-openapi#sources) from a bundle server, as the plugin's `saas` source does, and serves its skills next to your apps. A Worker has no timers between requests, so it refreshes from a Cron Trigger instead of polling:

```ts src/worker.ts
import { env } from "cloudflare:workers";
import { createEdgeMcp, kvBundleCacheFromEnv } from "@frontmcp/edge";

export default createEdgeMcp({
  info: { name: "billing", version: "1.0.0" },
  apps: [],
  managed: {
    endpoint: "https://bundles.acme.example/v1/bundles/billing",
    authToken: env.BUNDLE_PULL_TOKEN,
    expectedAudience: "billing-prod",
    jwksUrl: "https://bundles.acme.example/.well-known/jwks.json",
    expectedIssuer: "https://bundles.acme.example",
    cache: kvBundleCacheFromEnv("BUNDLE_CACHE"),
  },
});
```

```text title="wrangler.toml"
[[kv_namespaces]]
binding = "BUNDLE_CACHE"
id = "…"

[triggers]
crons = ["*/5 * * * *"]
```

`BUNDLE_PULL_TOKEN` is a secret, read here from the Worker's `env`. The fields are the `saas` source's (`endpoint`, `authToken`, `expectedAudience`, `jwksUrl`, `expectedIssuer`, `enableWebhook`) and the plugin's (`requireSignature`, `trustedKeys`, `dev`, `credentials`, `outbound`), with these differences:

| Field | On the edge |
| --- | --- |
| `cache` | The last bundle pulled, kept in KV: `kvBundleCacheFromEnv("BINDING")` reads the namespace from each request's `env`. A store of your own, `{ read, write }`, works too. |
| `pollIntervalMs` | Ignored: the Cron Trigger calls `scheduled()`, which pulls again. |
| `bundleCacheDir` | Ignored: a Worker has no file system. |

This wasn't tried against a bundle server. With an endpoint that can't be reached, under `wrangler dev`, the Worker above started and listed the plugin's `search_skill`, `load_skill` and `run_workflow` tools, logged `[saas-source] initial pull failed (…); attempting cached bundle fallback` and then `initial bundle sync failed: [saas-source] initial pull failed and no cached bundle is available in the injected bundle cache`, the KV store, and a run of `scheduled()` failed with an error, which is what makes Cloudflare report the Cron run as failed.

### Compared with `frontmcp build --target cloudflare`

| | `@frontmcp/edge` | [`--target cloudflare`](https://frontmcp.dev/reference/deployment/cloudflare-workers) |
| --- | --- | --- |
| The Worker's entry | Your file, which calls `createEdgeMcp(config)` | `dist/cloudflare/index.js`, generated from your `@FrontMcp` class |
| Build | `wrangler` bundles your TypeScript | `frontmcp build`, then `wrangler` |
| Decorators | Not needed: `tool()`, `app()` and a plain configuration | A `@FrontMcp` class |
| `wrangler.toml` | All yours | The build manages `main` and `compatibility_flags`, and writes `name` and `compatibility_date` when missing |
| Checks before deploying | Only `wrangler`'s bundling | The build also refuses `redis` and `sqlite` in the source |
| Sessions for older clients | In a Durable Object, with `sessions` | None: each request is served on its own |
| Managed bundle and Cron | `managed` and `scheduled()` | No |
| `env` | String bindings copied to `process.env` on the first request; `this.workerEnv` | The same |

Both serve MCP, `/healthz` and the same `404`, and answer the same way when the server can't be built.

#### Caveats

- **The upload is large.** For the one-tool Worker above, `wrangler deploy --dry-run` reports `Total Upload: 9176.16 KiB / gzip: 1701.50 KiB`, and `7682.60 KiB / gzip: 1423.56 KiB` with the managed-mode plugin [aliased away](#leaving-out-the-managed-mode-package).
- **Deno and Bun:** the package says it runs there too, and passes the second argument of their `fetch` on, for the client's IP. Not tried for this page.
- `wrangler dev` was run for this page with `wrangler` 4.147 and FrontMCP 1.9.2, and the Worker above again with `wrangler` 4.149 and 1.9.3: `/healthz`, a `tools/call`, the `404`; `wrangler deploy` only with `--dry-run`, with 1.9.3. The Playground can't run `@frontmcp/edge`: it isn't one of the packages its examples can import.
- The package has been published since FrontMCP 1.5.

---

## Usage

### Running a Worker from a plain configuration

With `src/worker.ts` and `wrangler.toml` from [above](#createedgemcpconfig):

```bash
npm install @frontmcp/edge@1.9.3 @frontmcp/sdk@1.9.3
npm install -D wrangler
npx wrangler dev --port 8787
curl http://127.0.0.1:8787/healthz
```

```json
{"status":"ok","server":{"name":"help-desk","version":"1.0.0"},"transport":"web-fetch"}
```

The first request builds the server, and the log shows `Initializing FrontMCP "help-desk v1.0.0"...` and `Scope ready — 1 app, 1 tool`. A 2026-07-28 `tools/call` of `search_tickets` at `http://127.0.0.1:8787/mcp` returns its result, and `POST /` answers `404` with `"entryPaths":["/mcp"]`. `npx wrangler deploy` uploads it.

### Reading variables and bindings

A tool reads `[vars]` and secrets from `process.env`, and objects like a KV namespace from `this.workerEnv`, or `ctx.workerEnv` in a `tool()` function:

```ts src/worker.ts
import { createEdgeMcp } from "@frontmcp/edge";
import { app, tool, z } from "@frontmcp/sdk";

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

const rememberNote = 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() },
})(async ({ id, note }, ctx) => {
  const kv = ctx.workerEnv?.["DESK_KV"] as KvNamespace | undefined;
  if (!kv) return { stored: false, region: process.env.DESK_REGION ?? null };
  await kv.put(`note:${id}`, note);
  return { stored: true, note: await kv.get(`note:${id}`), region: process.env.DESK_REGION ?? null };
});

export default createEdgeMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [app({ id: "help-desk", name: "Help Desk", tools: [rememberNote] })],
  http: { entryPath: "/mcp" },
});
```

```text title="wrangler.toml"
name = "help-desk"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

[vars]
DESK_REGION = "eu-west"

[[kv_namespaces]]
binding = "DESK_KV"
id = "…"
```

Under `wrangler dev`, `remember_note` with `{ "id": "T-1", "note": "Asked for a screenshot" }` returned `{ "stored": true, "note": "Asked for a screenshot", "region": "eu-west" }`.

### Checking production locally

Set `NODE_ENV` in `[vars]`, and `wrangler dev` runs the Worker in production:

```text title="wrangler.toml"
[vars]
NODE_ENV = "production"
```

Without `MCP_SESSION_SECRET`, `/healthz` and 2026-07-28 requests answer as before, and an `initialize` from an older client answers `500` with `SESSION_SECRET_REQUIRED`. Put the secret in `.dev.vars` for `wrangler dev`, and set it with `npx wrangler secret put MCP_SESSION_SECRET` before deploying.

### Leaving out the managed-mode package

`createEdgeMcp()` loads `@frontmcp/plugin-skilled-openapi` only for `managed`, but `wrangler` bundles the import either way. To leave the plugin out, point the import at an empty module with `[alias]`:

```text title="wrangler.toml"
[alias]
"@frontmcp/plugin-skilled-openapi" = "./src/no-managed.ts"
```

```ts src/no-managed.ts
// Stands in for @frontmcp/plugin-skilled-openapi, which @frontmcp/edge loads only for `managed`.
export default {};
```

The Worker from [above](#createedgemcpconfig) then uploads `7682.60 KiB / gzip: 1423.56 KiB` instead of `9176.16 KiB / gzip: 1701.50 KiB`, and serves the same. Don't alias it if you use `managed`.

---

## Troubleshooting

### `Could not resolve "http"`, `"events"`, `"path"`, …

`wrangler.toml` lacks `compatibility_flags = ["nodejs_compat"]`, which FrontMCP needs.

### `The Workers runtime failed to start` with `UnenforcedMetadataError`

`createEdgeMcp()` checks the configuration when the module is evaluated, and threw. The message names the entry and the field, like `Tool "export_tickets" declares 'featureFlag'`. Install the plugin that enforces the field, or remove it. See [when the server can't be built](#when-the-server-cant-be-built).

### `{"error":"server_misconfigured","code":"CONFIG_INVALID",…}`, on every path

The configuration failed the SDK's validation on the first request. The Worker's log names each invalid field, like `Invalid configuration: apps: Invalid input: expected array, received undefined`: check it against [`@FrontMcp`'s options](https://frontmcp.dev/reference/sdk/frontmcp#options). `apps` is required.

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

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

### `Bad Request: Server not initialized`, with `sessions`

The request's `Mcp-Session-Id` has no session: it was never issued, or its Durable Object was evicted. The client should send `initialize` again.
