@frontmcp/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() and app() no decorator at all. Underneath, requests go to the same web-standard handler as with frontmcp build --target cloudflare, 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 bundle and refreshes it from a Cron Trigger.
import { createEdgeMcp } from "@frontmcp/edge";
const mcp = createEdgeMcp(config); // { fetch, SessionDurableObject, scheduled? }
export default mcp;
Reference
createEdgeMcp(config)
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" },
});name = "help-desk"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]- Install
@frontmcp/edgeand@frontmcp/sdk, both1.9.3, andwrangler.@frontmcp/plugin-skilled-openapi, which managed mode uses, comes with@frontmcp/edge. nodejs_compatis required. Without it the bundle fails withCould not resolve "http","events","path"and more.appsis required. Without it the first request answers500withCONFIG_INVALID. Since 1.9,toolsandresourcesat the top level are served too, soapps: []withtoolsworks.wranglerbundles the SDK's browser build (itsbrowserexport, since 1.9), so nothing needs stubbing.
Options
config is what @FrontMcp 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. |
managed | ManagedEdgeOptions | Pull a Skilled OpenAPI bundle from a bundle server. See below. |
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 inenv, your[vars]and secrets, is copied intoprocess.env, without replacing a value already there. Soprocess.env.DESK_REGIONworks without thenodejs_compat_populate_process_envflag. - Other bindings reach tools as
this.workerEnv, the request'senv: a KV namespace, a D1 database, an R2 bucket. - It serves what
createFetchHandler()serves: MCP athttp.entryPath;/healthzwith200{ "status": "ok", "server": { "name", "version" }, "transport": "web-fetch" }; and404{ "error": "Not Found", "entryPaths": ["/mcp"] }for other paths. this.runtimeContext.runtimeis"edge", andthis.runtimeContext.envis theNODE_ENVin[vars]when it's set there, and"development"when it isn't. In 1.9 that holds underwrangler devtoo: withNODE_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 settasks: { 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 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(). 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:
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;[[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 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:
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"),
},
});[[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 | |
|---|---|---|
| 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-runreportsTotal Upload: 9176.16 KiB / gzip: 1701.50 KiB, and7682.60 KiB / gzip: 1423.56 KiBwith the managed-mode plugin aliased away. - Deno and Bun: the package says it runs there too, and passes the second argument of their
fetchon, for the client's IP. Not tried for this page. wrangler devwas run for this page withwrangler4.147 and FrontMCP 1.9.2, and the Worker above again withwrangler4.149 and 1.9.3:/healthz, atools/call, the404;wrangler deployonly 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:
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{"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:
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" },
});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:
[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]:
[alias]
"@frontmcp/plugin-skilled-openapi" = "./src/no-managed.ts"// Stands in for @frontmcp/plugin-skilled-openapi, which @frontmcp/edge loads only for `managed`.
export default {};The Worker from above 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.
{"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. 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.