@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)

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" },
});
wrangler.toml
name = "help-desk"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

See more examples below.

  • Install @frontmcp/edge and @frontmcp/sdk, both 1.9.3, and wrangler. @frontmcp/plugin-skilled-openapi, which 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 takes, with three more fields:

FieldTypeDescription
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.
managedManagedEdgeOptionsPull a Skilled OpenAPI bundle from a bundle server. See below.
skillIndex{ binding?, ttlSeconds? }, or a function of envKeep 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

MemberWhat it is
fetch(request, env?, ctx?)The Worker's request handler.
SessionDurableObjectThe 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, the request's env: a KV namespace, a D1 database, an R2 bucket.
  • It serves what createFetchHandler() 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

FaultWhat happens
A fault FrontMCP finds without building the server, like featureFlag or approval on a tool with no plugin to enforce itcreateEdgeMcp() 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 appsEvery 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 productionOnly 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:

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

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"),
  },
});
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:

FieldOn the edge
cacheThe 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.
pollIntervalMsIgnored: the Cron Trigger calls scheduled(), which pulls again.
bundleCacheDirIgnored: 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 entryYour file, which calls createEdgeMcp(config)dist/cloudflare/index.js, generated from your @FrontMcp class
Buildwrangler bundles your TypeScriptfrontmcp build, then wrangler
DecoratorsNot needed: tool(), app() and a plain configurationA @FrontMcp class
wrangler.tomlAll yoursThe build manages main and compatibility_flags, and writes name and compatibility_date when missing
Checks before deployingOnly wrangler's bundlingThe build also refuses redis and sqlite in the source
Sessions for older clientsIn a Durable Object, with sessionsNone: each request is served on its own
Managed bundle and Cronmanaged and scheduled()No
envString bindings copied to process.env on the first request; this.workerEnvThe 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.
  • 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:

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:

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" },
});
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:

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

wrangler.toml
[alias]
"@frontmcp/plugin-skilled-openapi" = "./src/no-managed.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 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.