@frontmcp/edge

中文文档
1.12.2发布于 2026/11/15Apache-2.0TypeScriptESM + CJS构建来源已验证近 30 天下载 5080

Run a FrontMCP MCP server on Cloudflare Workers / V8 isolates from a plain config — no decorators, no build step

原始使用文档
文档目录11 节
  1. Install
  2. Usage
  3. Sessions in a Durable Object
  4. Managed mode
  5. Pushed bundles
  6. API
  7. createEdgeMcp(config)
  8. Docs
  9. Related packages
  10. Contributing
  11. License

@frontmcp/edge

Run 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, with no frontmcp build step and no @FrontMcp class. Compared with frontmcp build --target cloudflare, it adds sessions kept in a Durable Object and a managed mode that pulls a Skilled OpenAPI bundle, refreshes it from a Cron Trigger, and can take bundles pushed to it.

npm License

Install

npm install @frontmcp/edge @frontmcp/sdk
npm install -D wrangler

@frontmcp/sdk is a peer dependency. @frontmcp/plugin-skilled-openapi, which managed mode uses, is a dependency, so it's installed with this package. Install with Node.js 24 or later.

Usage

// 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"]
npx wrangler dev      # local
npx wrangler deploy   # ship

nodejs_compat is required, and so is apps (it may be empty when you list tools at the top level). tool() and app() define tools and apps without decorators; @Tool and @App classes work too.

What the handler does:

  • It builds the server on the first request, not when the module loads, since a Worker can't do I/O then, and reuses it afterwards. A failed build answers 500 (a configuration fault) or 503 with Retry-After, and is tried again after a delay that doubles from 1 second up to 60.
  • It copies string bindings to process.env on the first request (your [vars] and secrets), without replacing values already there. Other bindings, such as a KV namespace, reach tools as this.workerEnv.
  • It serves MCP at http.entryPath (/ by default) and /healthz, and /readyz with health: { readyz: { enabled: true } }.
  • Background tasks are off on the edge; the server logs that and starts without them.
  • On Deno and Bun, which pass fetch a second argument of their own, the handler reads the client IP from it.

Sessions in a Durable Object

Without sessions, the Worker keeps no sessions: each request from a client on a protocol version before 2026-07-28 is served on its own (2026-07-28 needs no session). With sessions: {}, each session's requests go 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
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: [] }));

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: {}, // the Durable Object binding defaults to FRONTMCP_SESSIONS; set { binding } to change it
});

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

If the binding isn't in wrangler.toml, the Worker serves requests without sessions.

Managed mode

managed pulls a signed Skilled OpenAPI bundle (OpenAPI specs compiled into MCP skills and tools) from a bundle server, and serves it next to your apps, so the server's capabilities change without a redeploy. A Worker has no timers between requests, so the bundle is refreshed by a Cron Trigger, which calls the returned scheduled() handler:

// 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.example.com/v1/bundles/billing',
    authToken: env.BUNDLE_PULL_TOKEN,
    expectedAudience: 'billing-prod',
    jwksUrl: 'https://bundles.example.com/.well-known/jwks.json',
    expectedIssuer: 'https://bundles.example.com',
    trustedKeys: [{ keyId: 'billing-2026-11', alg: 'EdDSA', publicKeyPem: env.BUNDLE_SIGNING_KEY }],
    cache: kvBundleCacheFromEnv('BUNDLE_CACHE'),
  },
});
# wrangler.toml
[[kv_namespaces]]
binding = "BUNDLE_CACHE"
id = "<your KV namespace id>"

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

BUNDLE_PULL_TOKEN is a secret (npx wrangler secret put BUNDLE_PULL_TOKEN). The configuration is evaluated when the module loads, so read the secret from the env that cloudflare:workers exports, not from a request.

BUNDLE_SIGNING_KEY is the PEM of the public key the bundle server signs bundles with, and keyId is the key id its bundles name. It isn't secret: a [vars] entry in wrangler.toml will do. The keys at jwksUrl verify only the pull token, not the bundle, so without trustedKeys every bundle is refused with unknown signing keyId and the Worker serves no skills. Up to 1.11.2 this example left trustedKeys out.

FieldDescription
endpointHTTPS URL that serves the signed bundle
authTokenThe token the bundle server issued for this server
expectedAudience, jwksUrl, expectedIssuerHow the bundle server's tokens are verified
trustedKeysThe public keys bundles are signed with: { keyId, alg: 'RS256' | 'EdDSA', publicKeyPem }
cacheKeeps the last bundle applied: kvBundleCacheFromEnv('BINDING'), or a store of your own
enableWebhookAlso take bundles pushed to POST /__skilled_openapi/push, as below
requirePushBodyDigestRefuse a push token without a body_sha256 claim, as below. Default false
pushReplayWhere pushed tokens' jtis are kept: { binding } (default FRONTMCP_PUSH_REPLAY), as below
allowRollbackApply a pulled or pushed bundle older than the one being served, as below. Default false
requireSignature, dev, credentials, outboundPassed to the Skilled OpenAPI plugin. dev skips signature checks: never use it in production
confirmActions that ask the user before their request, as the plugin's confirm; see below
pollIntervalMs, bundleCacheDirIgnored on a Worker, which has no timers or file system

A Cron run ends once the bundle it pulled is applied and kept in cache, also when it is the first event an isolate gets: it then starts the bundle sync, whose first pull is the run's pull. A bundle that fails its signature or its other checks is not applied and not kept, so a cold start that can't reach the bundle server serves the last bundle the Worker applied.

Bundles only move forward: a pulled or pushed bundle older than the one the isolate serves is not applied and not kept, so a validly signed old bundle can't roll the Worker back. Two semver versions compare by semver; the same version (re-signed), or versions that aren't both semver, compare by generatedAt. To roll back, publish the earlier content under a newer version, or set allowRollback: true. An isolate that starts has no bundle served yet, so the cached bundle stands in for it: the isolate serves the cached bundle when its pull fails, when the pulled bundle is refused, or when it is older than the cached one (without allowRollback), so a stale CDN or a rolled-back origin can't take a new isolate back. Serving the cached bundle never writes it back to cache. Up to 1.11.0 a new isolate served whatever it pulled, served nothing when that bundle was refused, and rewrote the cache entry on an offline start.

A Cron run fails (scheduled() throws, so Cloudflare logs the run as an exception and counts it as a failed invocation) when its sync leaves an older bundle serving:

  • The bundle it pulled was not applied: its signature or its other checks failed, or it is older than the one served. The error names it: [frontmcp/edge] Cron refresh: bundle [email protected] was not applied: digest mismatch (...). When the run started the isolate, which then serves the cached bundle, it says was not applied, so the cached bundle serves: older than the cached bundle [email protected] (...).
  • The pull failed: [frontmcp/edge] Cron refresh: the bundle pull failed: pull responded 503. When the run started the isolate, which then serves the cached bundle, it says the bundle pull failed, so the cached bundle serves. A pull that failed on its token names the check: the bundle pull failed (token check: expired): ..., as lastSync.tokenFailure does in /readyz.

Up to 1.11.0, such a run reported success. A run whose pull applies a bundle, or pulls the one served, succeeds.

/readyz says the same, once health: { readyz: { enabled: true } } turns it on (it is off on a Worker by default). While the last sync was refused or failed, its skilled-openapi check is degraded, with details: { state: 'stale', bundleId, bundleVersion, lastSync }: the bundle served, and the refusal or failure with its reason and time. The Worker stays ready, since it serves the last good bundle. Each isolate answers for its own bundle and syncs. The details appear outside production, or with health.includeDetails: true.

Each operation call checks the API's host first: on a Worker, node:dns resolves it over DNS over HTTPS, so the host must resolve publicly.

confirm names the actions that ask the user before their request, by HTTP method, tag or operationId, as the Skilled OpenAPI plugin's confirm option does: confirm: { methods: ['DELETE'] }. The plugin checks it, so an invalid value refuses to start (500 CONFIG_INVALID, and the log names the field). The question is an MCP elicitation, which the server asks only with elicitation: { enabled: true }. On a Cloudflare Worker that setting refuses to start (Elicitation requires distributed storage when running on Edge runtime), so there an action confirm names is refused, the user's confirmation is needed for voidInvoice, and this server can't ask for it: elicitation is disabled; nothing was sent, or with whenUnsupported: 'allow' runs without asking. load_skill marks it with asksForConfirmation: true, and the audit log records what the confirmation came to. Up to 1.11.0 managed mode had no confirm, so every action ran without asking.

Pushed bundles

With enableWebhook: true, the bundle server can push a new bundle instead of waiting for the next Cron run:

POST /__skilled_openapi/push
Authorization: Bearer <JWT>
Content-Type: application/json

{ "schemaVersion": 1, "bundleId": "billing", "version": "4.2.0", ..., "integrity": { ... } }
  • The token is checked as authToken is (signed by a key at jwksUrl, iss equal to expectedIssuer, aud including expectedAudience, not expired). It must also carry a jti of 8 or more characters that no push used, and an iat within five minutes, so the pull token, issued long before, can't push.
  • The token's body_sha256 claim binds it to the body: the base64url SHA-256, without padding, of the exact bytes of the request body (not the bundle's integrity.digest). The Worker hashes the bytes it received; any other body answers 401, and the log says push refused (body_mismatch). The refused token's jti stays unused, so the push it was issued for still applies. Without the claim, a token captured within its five minutes could carry any other bundle the bundle server signed for this Worker. A token without it is accepted, with one warning per isolate, so a bundle server that doesn't set it yet keeps working; requirePushBodyDigest: true refuses it. pushBodyDigest(body) and PUSH_BODY_DIGEST_CLAIM from @frontmcp/adapters/skills compute the claim for a sender.
  • The body is the bundle as JSON, checked as a pulled bundle is: its schema, then its signature against trustedKeys (unless requireSignature: false).
  • It is applied, unless it is older than the bundle being served, after the first pull and after any bundle already queued, so a push and a Cron run never apply at once, and then kept in cache.
StatusBody
200{ "applied": true, "bundleId", "bundleVersion" }
400{ "error": "invalid_bundle" }: the body is not a bundle
401{ "error": "invalid_token" }, with WWW-Authenticate: Bearer error="invalid_token"
409{ "error": "push_replayed" }: a push already used this token
409{ "error": "bundle_outdated" }: the bundle is older than the one being served, which stays
422{ "error": "bundle_not_applied" }: the signature or the bundle's checks failed; the served bundle stays
503{ "error": "temporarily_unavailable" }: the JWKS could not be read, or the jti could not be recorded

A refused token answers the same 401 whatever check refused it. Only the log names it: [saas-source] push refused (wrong_issuer): ..., or bundle push not checked (jwks_unavailable): ... for a 503.

The endpoint is a hookable flow, skilled-openapi:bundle-push, behind the same IP filter and rate limit as the server's other HTTP flows.

The jtis are kept in a Durable Object that every isolate and replica of the Worker reaches, so a push replayed to another isolate is refused too. (KV can't do this: it is eventually consistent, so two isolates could both read a jti as unused.) Export the class and bind it:

// src/worker.ts
const mcp = createEdgeMcp({
  // ...
  managed: { /* ... */ enableWebhook: true }, // the binding defaults to FRONTMCP_PUSH_REPLAY; set pushReplay: { binding } to change it
});

export default mcp;
export const FrontMcpPushReplay = mcp.PushReplayDurableObject;
# wrangler.toml
[[durable_objects.bindings]]
name = "FRONTMCP_PUSH_REPLAY"
class_name = "FrontMcpPushReplay"

[[migrations]]
tag = "v1" # or the next tag, when wrangler.toml already has migrations
new_sqlite_classes = ["FrontMcpPushReplay"]
  • One object per audience keeps the jtis, named after expectedAudience. Each jti is kept until its token's iat is five minutes old, and dropped after that.
  • Without the binding, every push answers 503 temporarily_unavailable, and the log names the missing binding. Pulls and Cron runs are not affected. Up to 1.11.0, the jtis were kept in each isolate's memory, which isolates don't share.
  • On Deno or Bun, set pushReplay to a function of env that returns a store, { claim(key, ttlMs) }: it resolves true when it recorded key, false when key is already recorded, and throws when it can't tell. A push is refused when it throws.

API

createEdgeMcp(config)

config is what @FrontMcp takes (info, apps, tools, http, auth, ...), plus:

FieldTypeDescription
sessions{ binding? }Keep each session in a Durable Object, bound under binding (default FRONTMCP_SESSIONS)
managedManagedEdgeOptionsPull a Skilled OpenAPI bundle, as above
skillIndex{ binding?, ttlSeconds? }, or a function of envKeep the skills' search index in a KV namespace (default binding FRONTMCP_SKILL_INDEX), so a cold start reads it instead of rebuilding it

The server never listens: createEdgeMcp() sets serve: false. Configuration faults that can be found without building the server, such as approval or featureFlag metadata no plugin enforces, throw when the module is evaluated, so the Worker fails to deploy.

It returns:

MemberDescription
fetch(request, env?, ctx?)The Worker's request handler
SessionDurableObjectThe Durable Object class for sessions. Export it only when you use it
PushReplayDurableObjectThe Durable Object class for managed.enableWebhook, as above
scheduled(event?, env?, ctx?)With managed only: the Cron Trigger handler, which pulls the bundle and throws when it isn't applied

Other exports: kvBundleCacheFromEnv, createKvBundleCache, kvSkillIndexCacheFromEnv, createKvSkillIndexCache, buildManagedOpenApiPluginOptions, and the EdgeMcp, EdgeMcpConfig, ManagedEdgeOptions, EdgePushReplayStore and EdgePushReplayStoreFactory types.

Docs

Contributing

Source: libs/edge. Issues and pull requests are welcome; see CONTRIBUTING.md.

License

Apache-2.0. See LICENSE.

版本与发布记录

@frontmcp/edge 共发布 68 个版本,首次发布于 2026/06/21;过去 12 个月发布了 68 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.12.2latest2026/11/151 天前
1.12.12026/11/132 天前
1.12.02026/11/114 天前
1.12.0-rc.1rc2026/11/114 天前
1.11.32026/11/106 天前
1.11.22026/11/096 天前

关于 @frontmcp/edge 的常见问题

@frontmcp/edge 是什么?

Run a FrontMCP MCP server on Cloudflare Workers / V8 isolates from a plain config — no decorators, no build step

如何安装 @frontmcp/edge?

在项目目录运行 npm install @frontmcp/edge,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@frontmcp/sdk(1.12.2)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@frontmcp/edge 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@frontmcp/edge 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@frontmcp/edge 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@frontmcp/edge 可以商用吗?

@frontmcp/edge 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@frontmcp/edge 的下载量有多少?

根据 npm 官方下载统计,@frontmcp/edge 在 2026/10/16 – 2026/11/14 这 30 天内被下载 5,080 次,日均约 169 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@frontmcp/edge 还在维护吗?

最新版本 1.12.2 发布于 2026/11/15(1 天前);至今共发布 68 个版本,其中过去 12 个月发布了 68 个;首次发布于 2026/06/21。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。