@frontmcp/skills 1.7.2 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/catalog/create-tool/examples/08-tool-with-provider-injection.md +2 -2
  2. package/catalog/create-tool/examples/22-tool-with-ui-html-template.md +7 -7
  3. package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +3 -3
  4. package/catalog/create-tool/references/decorator-options.md +1 -0
  5. package/catalog/create-tool/references/elicitation.md +9 -0
  6. package/catalog/create-tool/references/error-handling.md +10 -6
  7. package/catalog/create-tool/references/execution-context.md +7 -3
  8. package/catalog/create-tool/references/input-schema.md +2 -0
  9. package/catalog/create-tool/references/output-schema.md +6 -1
  10. package/catalog/create-tool/references/throttling.md +7 -8
  11. package/catalog/create-tool/references/ui-widgets.md +14 -1
  12. package/catalog/frontmcp-authorities/SKILL.md +1 -0
  13. package/catalog/frontmcp-authorities/references/custom-evaluators.md +13 -2
  14. package/catalog/frontmcp-authorities/references/rbac-abac-rebac.md +2 -0
  15. package/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md +4 -4
  16. package/catalog/frontmcp-config/examples/configure-throttle/server-level-rate-limit.md +1 -2
  17. package/catalog/frontmcp-config/examples/configure-throttle-guard-config/full-guard-config.md +2 -3
  18. package/catalog/frontmcp-config/references/configure-auth-modes.md +36 -13
  19. package/catalog/frontmcp-config/references/configure-auth.md +1 -0
  20. package/catalog/frontmcp-config/references/configure-http.md +10 -2
  21. package/catalog/frontmcp-config/references/configure-skills-http.md +2 -2
  22. package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +5 -5
  23. package/catalog/frontmcp-config/references/configure-throttle.md +97 -20
  24. package/catalog/frontmcp-deployment/SKILL.md +10 -6
  25. package/catalog/frontmcp-deployment/examples/deploy-to-cloudflare/worker-custom-domain.md +4 -7
  26. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +4 -0
  27. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +82 -39
  28. package/catalog/frontmcp-development/examples/create-plugin-hooks/caching-with-around.md +7 -6
  29. package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md +2 -2
  30. package/catalog/frontmcp-development/references/create-job.md +2 -2
  31. package/catalog/frontmcp-development/references/create-plugin-hooks.md +21 -17
  32. package/catalog/frontmcp-development/references/create-prompt.md +18 -16
  33. package/catalog/frontmcp-development/references/create-provider.md +3 -3
  34. package/catalog/frontmcp-development/references/create-resource.md +10 -8
  35. package/catalog/frontmcp-development/references/create-skill.md +7 -0
  36. package/catalog/frontmcp-development/references/decorators-guide.md +9 -8
  37. package/catalog/frontmcp-development/references/official-plugins.md +167 -6
  38. package/catalog/frontmcp-development/references/openapi-adapter.md +16 -0
  39. package/catalog/frontmcp-observability/references/metrics-endpoint.md +1 -1
  40. package/catalog/frontmcp-observability/references/structured-logging.md +35 -0
  41. package/catalog/frontmcp-setup/SKILL.md +8 -7
  42. package/catalog/frontmcp-testing/SKILL.md +12 -8
  43. package/catalog/frontmcp-testing/examples/test-e2e-handler/tool-call-and-error-e2e.md +3 -1
  44. package/catalog/frontmcp-testing/references/setup-testing.md +42 -9
  45. package/catalog/frontmcp-testing/references/test-e2e-handler.md +35 -0
  46. package/catalog/skills-manifest.json +4 -3
  47. package/package.json +1 -1
@@ -45,7 +45,7 @@ Protect your FrontMCP server with rate limiting, concurrency control, execution
45
45
  partitionBy: 'global', // shared across all clients
46
46
  },
47
47
 
48
- // Global concurrency limit
48
+ // Global concurrency limit (a tool called with this.callTool() runs inside its caller's slot)
49
49
  globalConcurrency: {
50
50
  maxConcurrent: 50,
51
51
  partitionBy: 'global',
@@ -68,8 +68,8 @@ Protect your FrontMCP server with rate limiting, concurrency control, execution
68
68
  allowList: ['10.0.0.0/8', '172.16.0.0/12'], // CIDR ranges
69
69
  denyList: ['192.168.1.100'],
70
70
  defaultAction: 'allow', // 'allow' | 'deny'
71
- trustProxy: true, // trust X-Forwarded-For
72
- trustedProxyDepth: 1, // proxy depth to trust
71
+ // NOTE: trustProxy / trustedProxyDepth are NOT read here -- use the
72
+ // FRONTMCP_TRUST_PROXY and FRONTMCP_TRUSTED_PROXY_DEPTH environment variables.
73
73
  },
74
74
  },
75
75
  })
@@ -112,6 +112,82 @@ class ExpensiveQueryTool extends ToolContext {
112
112
  }
113
113
  ```
114
114
 
115
+ ## `ipFilter` is enforced on every HTTP route
116
+
117
+ `allowList`, `denyList` and `defaultAction` are checked by the `checkIpFilter` stage that starts
118
+ every HTTP-facing flow, before the rate-limit check and before authentication:
119
+
120
+ - the MCP endpoint (`<entryPath>`, `/sse`, `/message`) -- rejected with HTTP 403 and JSON-RPC
121
+ error `-32001`;
122
+ - `/oauth/*`, `/.well-known/*`, `llm.txt` / `llm_full.txt`, the skills HTTP API, and custom
123
+ `http.routes` (before `auth: true` verification) -- rejected with HTTP 403 and
124
+ `{ "error": "forbidden", "message": "Client IP rejected by ipFilter" }`.
125
+
126
+ Health and readiness probes (`/healthz`, `/readyz`, `/health`) and `/metrics` (bearer-token
127
+ protected) are exempt. `IpBlockedError` / `IpNotAllowedError` are not thrown by the built-in
128
+ filter.
129
+
130
+ A request whose client IP cannot be established matches neither list and gets
131
+ `defaultAction` -- with `'deny'` it is rejected. An IPv4-mapped peer (`::ffff:203.0.113.7`,
132
+ how a dual-stack Node socket reports an IPv4 client) matches IPv4 rules.
133
+
134
+ An `ipFilter` block works on its own -- you do not need to configure a `global` rate limit
135
+ alongside it for the filter to run.
136
+
137
+ ### Where the client IP comes from
138
+
139
+ | Runtime | Client IP |
140
+ | ------------------------------------------ | ------------------------------------------------------------------ |
141
+ | Node / Express / serverless handler | Socket peer |
142
+ | Behind a declared proxy | `X-Forwarded-For` (`FRONTMCP_TRUST_PROXY=true`, see below) |
143
+ | Cloudflare Workers (incl. Durable Objects) | `CF-Connecting-IP` -- trusted only when running on Workers |
144
+ | Deno | `info.remoteAddr` -- use `Deno.serve(handler)` |
145
+ | Bun | `server.requestIP(request)` -- use `Bun.serve({ fetch: handler })` |
146
+
147
+ ## `partitionBy: 'ip'` needs a declared trusted proxy
148
+
149
+ The client IP comes from the socket peer address. `X-Forwarded-For` and `X-Real-IP` are set
150
+ by whoever sent the request, so they are ignored unless you declare a trusted proxy:
151
+
152
+ ```bash
153
+ FRONTMCP_TRUST_PROXY=true # honour forwarded headers -- ONLY behind a real proxy
154
+ FRONTMCP_TRUSTED_PROXY_DEPTH=1 # how many proxies you run in front of the app
155
+ ```
156
+
157
+ - Behind a load balancer **without** `FRONTMCP_TRUST_PROXY`, every request looks like it came
158
+ from the balancer and all clients share one bucket.
159
+ - **With** it but no proxy actually in front, a caller forges the header and gets a fresh
160
+ bucket per request, so the limit never triggers.
161
+
162
+ The client is read `FRONTMCP_TRUSTED_PROXY_DEPTH` hops back from the end of the chain: callers
163
+ can prepend entries, but only your own proxies append to it. The value is validated as an IP
164
+ address before use. A chain shorter than the configured depth was not built by your proxies,
165
+ so the socket peer is used instead.
166
+
167
+ > **`FRONTMCP_TRUST_PROXY` is only as good as your network boundary.** Trusting forwarded
168
+ > headers means trusting whoever can set them, so two things must hold:
169
+ >
170
+ > - **Every ingress path traverses the configured proxy chain.** If a caller can reach the
171
+ > origin directly -- a public origin IP, a peered VPC, a second ingress that skips the
172
+ > balancer -- they choose the whole `X-Forwarded-For` chain, and counting hops from its end
173
+ > just lands on an address they picked.
174
+ > - **The edge strips and rebuilds the forwarded headers.** The outermost proxy must discard
175
+ > any inbound `X-Forwarded-For` and `X-Real-IP` and write its own, so the only entries in
176
+ > the chain are ones your proxies appended.
177
+ >
178
+ > With depth `1` and no `X-Forwarded-For` at all, `X-Real-IP` is used -- the single-hop nginx
179
+ > convention. It is never consulted alongside a chain, because a caller can send both.
180
+
181
+ On Cloudflare Workers, Deno and Bun the web-fetch adapter supplies the platform's peer
182
+ address (table above), so edge callers get their own buckets too.
183
+
184
+ When no IP can be established the request falls back to the authenticated user
185
+ (`user:<userId>`), and to a single `ip:unresolved` partition when there is no user either. It
186
+ never keys on the session id: `mcp-session-id` is caller-supplied and a request without one is
187
+ given a fresh UUID, so keying on it would mint a new budget per request. The shared bucket is
188
+ contended by design -- bounded contention beats an unbounded budget -- and declaring your proxy
189
+ is what takes callers out of it.
190
+
115
191
  ## Configuration Types
116
192
 
117
193
  ### RateLimitConfig
@@ -138,19 +214,20 @@ class ExpensiveQueryTool extends ToolContext {
138
214
 
139
215
  ### IpFilterConfig
140
216
 
141
- | Field | Type | Default | Description |
142
- | ------------------- | ------------------- | --------- | ----------------------------------- |
143
- | `allowList` | `string[]` | — | Allowed IPs or CIDR ranges |
144
- | `denyList` | `string[]` | — | Blocked IPs or CIDR ranges |
145
- | `defaultAction` | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list |
146
- | `trustProxy` | `boolean` | `false` | Trust X-Forwarded-For header |
147
- | `trustedProxyDepth` | `number` | `1` | How many proxy hops to trust |
217
+ | Field | Type | Default | Description |
218
+ | ------------------- | ------------------- | --------- | ------------------------------------------------------------------ |
219
+ | `allowList` | `string[]` | — | Allowed IPs or CIDR ranges |
220
+ | `denyList` | `string[]` | — | Blocked IPs or CIDR ranges |
221
+ | `defaultAction` | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list, or no client IP is known |
222
+ | `trustProxy` | `boolean` | `false` | **Not read** (startup warning). Use `FRONTMCP_TRUST_PROXY` |
223
+ | `trustedProxyDepth` | `number` | `1` | **Not read** (startup warning). Use `FRONTMCP_TRUSTED_PROXY_DEPTH` |
148
224
 
149
225
  ## Partition Strategies
150
226
 
151
227
  - **`'global'`** — Single shared counter for all clients. Use for global capacity limits.
152
228
  - **`'ip'`** — Separate counter per client IP. Use for per-client rate limiting.
153
- - **`'session'`** — Separate counter per MCP session. Use for per-session fairness.
229
+ - **`'session'`** — Separate counter per MCP session the server verified; a `mcp-session-id` it does not accept is ignored. Use for per-session fairness. A request without a verified session (every MCP 2026-07-28 request, stateless HTTP, or a rejected session id) falls back to the signed-in user; anonymous callers share one `anonymous` counter. A `throttle.global` partitioned by session, user or a function is checked right after authentication; one partitioned by `'ip'` or `'global'` is checked before it.
230
+ - **`'userId'`** — Separate counter per signed-in user. Anonymous callers fall back to the session, as above.
154
231
 
155
232
  ## Distributed Rate Limiting
156
233
 
@@ -196,7 +273,7 @@ done
196
273
 
197
274
  ### Configuration
198
275
 
199
- - [ ] `throttle.enabled` is set to `true` in the `@FrontMcp` decorator
276
+ - [ ] `throttle.enabled` is set to `true` in the `@FrontMcp` decorator for the server-level options (`global`, `globalConcurrency`, the `default*` settings, `ipFilter`). A tool's own `rateLimit`/`concurrency` apply without it; `throttle.enabled: false` turns every guard off
200
277
  - [ ] `global.maxRequests` and `global.windowMs` are set to reasonable production values
201
278
  - [ ] `defaultTimeout.executeMs` is configured to prevent runaway tool executions
202
279
  - [ ] IP filter `defaultAction` matches your security posture (`allow` for open, `deny` for restricted)
@@ -216,17 +293,17 @@ done
216
293
 
217
294
  - [ ] Sending requests beyond the rate limit returns HTTP 429
218
295
  - [ ] Blocked IPs receive HTTP 403
219
- - [ ] Tool executions that exceed `executeMs` are terminated and return a timeout error
296
+ - [ ] Tool executions that exceed `executeMs` return an `EXECUTION_TIMEOUT` error and abort `this.signal` and the tool's pending `this.fetch()` requests; the tool passes `this.signal` to other cancellable work so it stops too
220
297
 
221
298
  ## Troubleshooting
222
299
 
223
- | Problem | Cause | Solution |
224
- | ----------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
225
- | Rate limits not enforced across instances | In-memory storage used with multiple server replicas | Configure `storage: { type: 'redis' }` in the throttle block to share counters |
226
- | All requests rejected with 403 | `ipFilter.defaultAction` set to `'deny'` without any `allowList` entries | Add the allowed IP ranges to `allowList` or change `defaultAction` to `'allow'` |
227
- | Tools timing out unexpectedly | `defaultTimeout.executeMs` too low for the tool's normal execution time | Increase the global default or set a per-tool `timeout.executeMs` override |
228
- | `X-Forwarded-For` header ignored | `ipFilter.trustProxy` not enabled or `trustedProxyDepth` too low | Set `trustProxy: true` and adjust `trustedProxyDepth` to match your proxy chain |
229
- | Rate limit resets not aligned with expectations | `windowMs` misunderstood as a sliding window when it is a fixed window | The window is fixed; all counters reset at the end of each `windowMs` interval |
300
+ | Problem | Cause | Solution |
301
+ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
302
+ | Rate limits not enforced across instances | In-memory storage used with multiple server replicas | Configure `storage: { type: 'redis' }` in the throttle block to share counters |
303
+ | All requests rejected with 403 | `ipFilter.defaultAction` set to `'deny'` without any `allowList` entries, or the runtime reports no client IP (a custom fetch wrapper that drops the second handler argument on Deno/Bun) | Add the allowed IP ranges to `allowList`, pass the platform's second argument through to the handler, or change `defaultAction` to `'allow'` |
304
+ | Tools timing out unexpectedly | `defaultTimeout.executeMs` too low for the tool's normal execution time | Increase the global default or set a per-tool `timeout.executeMs` override |
305
+ | `X-Forwarded-For` header ignored | No trusted proxy declared. `ipFilter.trustProxy` / `trustedProxyDepth` are accepted by the schema but NOT read -- client-IP extraction happens in the SDK context layer, before guard config is reachable | Set the `FRONTMCP_TRUST_PROXY=true` and `FRONTMCP_TRUSTED_PROXY_DEPTH` environment variables instead |
306
+ | Rate limit resets not aligned with expectations | `windowMs` misunderstood as a sliding window when it is a fixed window | The window is fixed; all counters reset at the end of each `windowMs` interval |
230
307
 
231
308
  ## Examples
232
309
 
@@ -77,6 +77,7 @@ Beyond `frontmcp build`, the CLI provides commands for the full deployment lifec
77
77
  | ---------------------------- | ----------------------------------------------------------------------------------- |
78
78
  | `frontmcp build -t <target>` | Build for target: `node`, `vercel`, `lambda`, `cloudflare`, `cli`, `browser`, `sdk` |
79
79
  | `frontmcp build -t cli --js` | Build CLI as JS bundle (instead of native binary via SEA) |
80
+ | `frontmcp build --no-clean` | Keep existing output instead of clearing the target's output directory first |
80
81
  | `frontmcp start <name>` | Start a named MCP server with supervisor (process management) |
81
82
  | `frontmcp stop <name>` | Stop managed server (`-f` for force kill) |
82
83
  | `frontmcp restart <name>` | Restart managed server |
@@ -145,12 +146,15 @@ Beyond `frontmcp build`, the CLI provides commands for the full deployment lifec
145
146
 
146
147
  ## Troubleshooting
147
148
 
148
- | Problem | Cause | Solution |
149
- | ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- |
150
- | Cold start timeout on serverless | Bundle too large or heavy initialization | Lazy-load providers; reduce bundle with tree shaking; increase function timeout |
151
- | Session lost between requests | Using memory storage on stateless serverless | Switch to platform-native storage (Vercel KV, DynamoDB, etc.) |
152
- | CORS errors on browser/web clients | HTTP CORS not configured | Add CORS config via `configure-http` skill |
153
- | Build fails with missing module | Node-only module in browser/edge build | Use conditional imports or `@frontmcp/utils` cross-platform utilities |
149
+ | Problem | Cause | Solution |
150
+ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
151
+ | Cold start timeout on serverless | Bundle too large or heavy initialization | Lazy-load providers; reduce bundle with tree shaking; increase function timeout |
152
+ | Session lost between requests | Using memory storage on stateless serverless | Switch to platform-native storage (Vercel KV, DynamoDB, etc.) |
153
+ | CORS errors on browser/web clients | HTTP CORS not configured | Add CORS config via `configure-http` skill |
154
+ | Build fails with missing module | Node-only module in browser/edge build | Use conditional imports or `@frontmcp/utils` cross-platform utilities |
155
+ | `TS2688: Cannot find type definition file for 'node'` under Yarn | An older CLI shelled out to `npx tsc`, which starts a process that never loads `.pnp.cjs` | Upgrade the CLI — the build now runs the project's own `typescript` with the current Node binary, or delegates to `yarn`/`pnpm`/`bun` when there is no local install |
156
+ | `@frontmcp/sdk tried to access <pkg> (a peer dependency)` | Yarn Plug'n'Play enforces peer dependencies that a hoisted `node_modules` tree tolerates | Add `nodeLinker: node-modules` to `.yarnrc.yml` and reinstall (`frontmcp create` now scaffolds this for Yarn projects) |
157
+ | Deleted source file still present in `dist/` | An older CLI never cleared the output directory, or `--no-clean` was passed | Rebuild without `--no-clean`; the build now clears the target's output directory first |
154
158
 
155
159
  ## Examples
156
160
 
@@ -6,7 +6,7 @@ description: 'Scaffold a FrontMCP project targeting Cloudflare, configure a cust
6
6
  tags: [deployment, json-rpc, cloudflare, worker, custom, domain]
7
7
  features:
8
8
  - 'Using `frontmcp create --target cloudflare` to scaffold a project with `wrangler.toml` and deploy scripts'
9
- - 'Adding a custom domain with `wrangler domains add` for production-ready URLs'
9
+ - 'Adding a custom domain with `wrangler deploy --domain` for production-ready URLs'
10
10
  - 'End-to-end verification of both the health check and MCP JSON-RPC endpoint'
11
11
  ---
12
12
 
@@ -74,12 +74,9 @@ NODE_ENV = "production"
74
74
  ```
75
75
 
76
76
  ```bash
77
- # Build and deploy
77
+ # Build and deploy to a custom domain
78
78
  frontmcp build --target cloudflare
79
- wrangler deploy
80
-
81
- # Add a custom domain
82
- wrangler domains add mcp.example.com
79
+ npx wrangler deploy --domain mcp.example.com
83
80
 
84
81
  # Verify health endpoint (FrontMCP serves /healthz by default)
85
82
  curl https://mcp.example.com/healthz
@@ -93,7 +90,7 @@ curl -X POST https://mcp.example.com/mcp \
93
90
  ## What This Demonstrates
94
91
 
95
92
  - Using `frontmcp create --target cloudflare` to scaffold a project with `wrangler.toml` and deploy scripts
96
- - Adding a custom domain with `wrangler domains add` for production-ready URLs
93
+ - Adding a custom domain with `wrangler deploy --domain` for production-ready URLs
97
94
  - End-to-end verification of both the health check and MCP JSON-RPC endpoint
98
95
 
99
96
  ## Related
@@ -75,6 +75,10 @@ export default createEdgeMcp({
75
75
  is the **Cron Trigger** entrypoint that pulls a fresh bundle and hot-swaps it.
76
76
  Managed mode requires the optional peer `@frontmcp/plugin-skilled-openapi`.
77
77
 
78
+ The pull sends `authToken` as a bearer token and never follows a redirect, so
79
+ `endpoint` must serve the bundle directly; a 3xx (or a status-0
80
+ `opaqueredirect`) fails the pull.
81
+
78
82
  This path is bundled by **wrangler** (not `frontmcp build`), so you maintain
79
83
  `wrangler.toml` yourself — it needs a `[[kv_namespaces]] binding = "BUNDLE_CACHE"`
80
84
  and a `[triggers] crontabs = [...]` (the `managed.pollIntervalMs` option is
@@ -36,7 +36,7 @@ Cloudflare Workers support is **experimental**. The Express-to-Workers adapter h
36
36
  ## Prerequisites
37
37
 
38
38
  - A Cloudflare account (https://dash.cloudflare.com)
39
- - Wrangler CLI installed: `npm install -g wrangler`
39
+ - Wrangler — `frontmcp create --target cloudflare` adds it as a devDependency, so `npm run deploy` / `npm run dev:worker` use the project-local version. Invoke it directly as `npx wrangler` rather than installing it globally, so the pinned version is the one that runs.
40
40
  - A built FrontMCP project
41
41
 
42
42
  ## Step 1: Create a Cloudflare-targeted Project
@@ -45,7 +45,7 @@ Cloudflare Workers support is **experimental**. The Express-to-Workers adapter h
45
45
  npx frontmcp create my-app --target cloudflare
46
46
  ```
47
47
 
48
- This generates the project with a `wrangler.toml` and a deploy script (`npm run deploy` runs `wrangler deploy`).
48
+ This generates the project with a `wrangler.toml`, `wrangler` as a devDependency, and `deploy` / `dev:worker` scripts that build first and then run the project-local `wrangler`.
49
49
 
50
50
  ## Step 2: Build for Cloudflare
51
51
 
@@ -57,24 +57,27 @@ This produces:
57
57
 
58
58
  ```text
59
59
  dist/cloudflare/
60
- index.js # Cloudflare Workers entry (CommonJS) — wraps your @FrontMcp server
61
- main.js # Your compiled server module (CommonJS)
62
- wrangler.toml # Wrangler configuration (overwritten on every build)
60
+ index.js # Cloudflare Workers entry (ES Module / Module Worker) — wraps your @FrontMcp server
61
+ main.js # Your compiled server module (ES Module)
62
+ wrangler.toml # Wrangler configuration (managed keys reconciled on every build)
63
63
  ```
64
64
 
65
- Cloudflare Workers use CommonJS (not ESM). The build command sets `--module commonjs` automatically.
65
+ The adapter emits a **Module Worker** (`export default { fetch }`), so the build
66
+ compiles with `--module esnext`. The legacy CommonJS `module.exports` shape is
67
+ read by Cloudflare as a Service Worker, where `nodejs_compat` cannot externalize
68
+ Node builtins and the deploy fails — do not force CommonJS for this target.
66
69
 
67
- > **Important:** The Cloudflare adapter sets `alwaysWriteConfig: true` and overwrites the entire `wrangler.toml` on every build with the template below. Hand-edited bindings (`[[kv_namespaces]]`, `[vars]`, `[[d1_databases]]`, etc.) WILL be erased the next time you run `frontmcp build --target cloudflare`. Configure `name`, `compatibility_date`, and extra `compatibility_flags` via your `frontmcp.config` file's `deployments[].wrangler` section, and keep bindings in a separate config file referenced from your toolchain (or re-add them after each build).
70
+ > **Important:** The Cloudflare adapter sets `alwaysWriteConfig: true`, but it rewrites only the keys it manages. `main` is always overwritten (it has to track the build output); `name` and `compatibility_date` are written only when the file does not already declare them; `compatibility_flags` is merged. Hand-edited `[vars]`, `[[kv_namespaces]]`, `[[d1_databases]]`, `[triggers]` and comments survive every build. If `wrangler.toml` and `frontmcp.config` disagree on the worker name, the build keeps the file's value and warns instead of renaming your worker.
68
71
 
69
72
  ## Step 3: Configure wrangler.toml
70
73
 
71
- The build always writes this. `compatibility_flags = ["nodejs_compat"]` is **always** emitted — the worker entry is an ES Module that imports `@frontmcp/sdk`'s web-fetch handler, which still transitively pulls in Node builtins (no Express on the Worker), so without the flag the deployed Worker fails to load. The default `compatibility_date` is `2024-09-23` (the date that enables full `nodejs_compat`). `main` is `dist/cloudflare/index.js`.
74
+ The build writes this when the file does not exist yet. `nodejs_compat` is **always** emitted — the worker entry is an ES Module that imports `@frontmcp/sdk`'s web-fetch handler, which still transitively pulls in Node builtins (no Express on the Worker), so without the flag the deployed Worker fails to load. `nodejs_compat_populate_process_env` is emitted too, so `[vars]` and secrets are readable as `process.env.*`; add `nodejs_compat_do_not_populate_process_env` to `wrangler.compatibilityFlags` to opt out. The default `compatibility_date` is `2024-09-23` (the date that enables full `nodejs_compat`). `main` is `dist/cloudflare/index.js`.
72
75
 
73
76
  ```toml
74
77
  name = "frontmcp-worker"
75
78
  main = "dist/cloudflare/index.js"
76
79
  compatibility_date = "2024-09-23"
77
- compatibility_flags = ["nodejs_compat"]
80
+ compatibility_flags = ["nodejs_compat", "nodejs_compat_populate_process_env"]
78
81
  ```
79
82
 
80
83
  `name`, `compatibility_date`, and any extra `compatibilityFlags` come from `frontmcp.config.{ts,js}`'s `deployments` array (`nodejs_compat` is merged in automatically). Example:
@@ -96,7 +99,7 @@ export default {
96
99
  };
97
100
  ```
98
101
 
99
- To add KV storage or other bindings, append them AFTER each build (or use a wrapper script that runs the build then concatenates a `wrangler.bindings.toml` you maintain separately):
102
+ To add KV storage or other bindings, add them to `wrangler.toml` directly — they are preserved across builds:
100
103
 
101
104
  ```toml
102
105
  name = "my-worker"
@@ -115,7 +118,7 @@ NODE_ENV = "production"
115
118
  Create the KV namespace via the dashboard or CLI:
116
119
 
117
120
  ```bash
118
- wrangler kv:namespace create FRONTMCP_KV
121
+ npx wrangler kv:namespace create FRONTMCP_KV
119
122
  ```
120
123
 
121
124
  Copy the returned `id` into your `wrangler.toml`.
@@ -144,22 +147,59 @@ export default MyServer;
144
147
 
145
148
  For session storage, use Upstash Redis (HTTP) via `redis: { provider: 'vercel-kv' }` or wire Cloudflare KV directly inside your tools — the SDK does not include a built-in Cloudflare KV provider, and ioredis-style `redis: { ... }` configs are rejected by the Cloudflare adapter at build time (no Node TCP on Workers).
146
149
 
150
+ ### Secrets, vars and `process.env`
151
+
152
+ 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 (existing values are never overwritten), so ordinary `process.env.MY_API_KEY` reads behave the same on Workers as under `frontmcp dev`. Non-string bindings (KV, D1, R2, Durable Objects) stay on `env`, which the entry forwards to the handler along with `ctx`.
153
+
154
+ A value read at module-eval time — inside the `@FrontMcp({...})` argument itself — is still `undefined`, because the copy happens on the first request. Read configuration inside `execute()` / `read()`, or rely on `nodejs_compat_populate_process_env` (emitted by default), which populates `process.env` before your module evaluates.
155
+
156
+ To keep bindings out of `process.env` entirely, add `nodejs_compat_do_not_populate_process_env` to `wrangler.compatibilityFlags`. Cloudflare's flag only suppresses population at module evaluation, so the build also drops the first-request bridge from the generated entry — otherwise it would put back exactly the values you excluded. Every binding is then read from the `env` argument only.
157
+
158
+ ### Required secrets
159
+
160
+ `NODE_ENV = "production"` in `[vars]` makes this a production deployment, where FrontMCP refuses its development fallbacks:
161
+
162
+ | Secret | Required when | Failure without it |
163
+ | -------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
164
+ | `MCP_SESSION_SECRET` | always in production — `session:verify` encrypts session IDs with it | `500 {"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED"}` |
165
+ | `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"}` |
166
+
167
+ ```bash
168
+ npx wrangler secret put MCP_SESSION_SECRET # openssl rand -hex 32
169
+
170
+ # Only when auth.mode is `local` or `remote`; `public` and `transparent`
171
+ # never mint local JWTs and do not read this.
172
+ npx wrangler secret put JWT_SECRET # openssl rand -hex 32
173
+ ```
174
+
175
+ Because `[vars]` reach `process.env`, `npx wrangler dev` sees the same `NODE_ENV=production` the deployment does, so a missing secret fails locally rather than only after a successful deploy.
176
+
177
+ ### Background tasks
178
+
179
+ 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. `tasks: { enabled: true }` without `tasks.redis` fails the build rather than the deployed worker.
180
+
147
181
  ## Step 5: Deploy
148
182
 
149
183
  ```bash
150
184
  # Preview deployment
151
- wrangler dev
185
+ npx wrangler dev
152
186
 
153
187
  # Production deployment
154
- wrangler deploy
188
+ npx wrangler deploy
155
189
  ```
156
190
 
157
191
  ### Custom Domain
158
192
 
159
- Configure a custom domain in the Cloudflare dashboard under **Workers & Pages > your worker > Settings > Domains & Routes**, or via wrangler:
193
+ Configure a custom domain in the Cloudflare dashboard under **Workers & Pages > your worker > Settings > Domains & Routes**, declare it in `wrangler.toml`, or pass it to the deploy:
160
194
 
161
195
  ```bash
162
- wrangler domains add mcp.example.com
196
+ npx wrangler deploy --domain mcp.example.com
197
+ ```
198
+
199
+ The `wrangler.toml` form is equivalent and survives across deploys:
200
+
201
+ ```toml
202
+ routes = [{ pattern = "mcp.example.com", custom_domain = true }]
163
203
  ```
164
204
 
165
205
  ## Step 6: Verify
@@ -176,19 +216,21 @@ curl -X POST https://frontmcp-worker.your-subdomain.workers.dev/mcp \
176
216
 
177
217
  ## Endpoint path, CORS & SSE — config-driven
178
218
 
219
+ Two settings name this concept. `transport.http.path` in `frontmcp.config.*` configures the **CLI** (`frontmcp dev`, the inspector, the generated `clients[].url`); `@FrontMcp({ http: { entryPath } })` configures the **server**, and that is what the deployed worker reads. The cloudflare build reconciles them — `transport.http.path` becomes the server's default, an explicit decorator `entryPath` still wins, and the build warns on a mismatch and prints the resolved path (`Server will serve MCP at /mcp`).
220
+
179
221
  The worker's transport is driven by the standard `http` + `transport` config (the same fields the Express host reads), so behaviour is identical on both adapters. The worker serves MCP at **exactly one path** — `http.entryPath` (the worker root `/` when unset) — not a guessed `/` + `/mcp` set. Cloudflare never strips the path before it reaches the worker:
180
222
 
181
- | Clients use | `http.entryPath` | `wrangler.toml` route | Worker serves |
182
- | --- | --- | --- | --- |
183
- | `https://mcp.example.com` (subdomain) | omit (`/`) | `routes = [{ pattern = "mcp.example.com", custom_domain = true }]` | `/` |
184
- | `https://example.com/mcp` (path) | `'/mcp'` | `routes = [{ pattern = "example.com/mcp*", zone_name = "example.com" }]` | `/mcp` |
223
+ | Clients use | `http.entryPath` | `wrangler.toml` route | Worker serves |
224
+ | ------------------------------------- | ---------------- | ------------------------------------------------------------------------ | ------------- |
225
+ | `https://mcp.example.com` (subdomain) | omit (`/`) | `routes = [{ pattern = "mcp.example.com", custom_domain = true }]` | `/` |
226
+ | `https://example.com/mcp` (path) | `'/mcp'` | `routes = [{ pattern = "example.com/mcp*", zone_name = "example.com" }]` | `/mcp` |
185
227
 
186
228
  ```ts
187
229
  createEdgeMcp({
188
230
  /* …info, apps… */
189
231
  http: {
190
- entryPath: '/mcp', // the ONE path MCP is served at (omit → root '/')
191
- cors: { origin: true }, // browser MCP clients (e.g. Inspector "Direct" mode); { origin, credentials, maxAge }
232
+ entryPath: '/mcp', // the ONE path MCP is served at (omit → root '/')
233
+ cors: { origin: true }, // browser MCP clients (e.g. Inspector "Direct" mode); { origin, credentials, maxAge }
192
234
  },
193
235
  // transport: 'legacy'/'modern' → SSE streaming on POST; 'stateless-api' → buffered JSON.
194
236
  });
@@ -203,7 +245,7 @@ createEdgeMcp({
203
245
  - **Bundle size**: Workers have a 1 MB compressed / 10 MB uncompressed limit (paid plan: 10 MB / 30 MB). Review dependencies and remove unused packages to reduce bundle size.
204
246
  - **CPU time**: 10 ms CPU time on free plan, 30 seconds on paid. Long-running operations must be optimized or use Durable Objects.
205
247
  - **No native modules**: `better-sqlite3` and other native Node.js modules are not available. Use KV, D1, or Upstash Redis for storage.
206
- - **Streaming**: Streamable HTTP works, including SSE responses (`POST` with `Accept: text/event-stream`) and the server→client SSE `GET` stream. The worker uses the SDK's `WebStandardStreamableHTTPServerTransport` — which **is** the standard Streamable HTTP transport (the Node `StreamableHTTPServerTransport` is a thin `req`/`res` wrapper over it, so there's one engine). **Server→client notifications** on the standalone `GET` stream require **stateful sessions** — set `sessions: {}` and bind the `SessionDurableObject` (Durable Object) per `Mcp-Session-Id`. Without it the worker is stateless and the `GET` stream can't deliver pushed notifications.
248
+ - **Streaming**: Streamable HTTP works, including SSE responses (`POST` with `Accept: text/event-stream`) and the server→client SSE `GET` stream. The worker uses the SDK's `WebStandardStreamableHTTPServerTransport` — which **is** the standard Streamable HTTP transport (the Node `StreamableHTTPServerTransport` is a thin `req`/`res` wrapper over it, so there's one engine). **Server→client notifications** on the standalone `GET` stream require **stateful sessions** — set `sessions: {}` and bind the `SessionDurableObject` (Durable Object) per `Mcp-Session-Id`. Without it the worker is stateless and the `GET` stream can't deliver pushed notifications. A stateless worker also returns no `Mcp-Session-Id` to a session-based (2025-06-18) client; each of its requests is served on its own.
207
249
 
208
250
  ### Stateful sessions (Durable Object)
209
251
 
@@ -220,8 +262,9 @@ class_name = "FrontMcpSession"
220
262
  [[migrations]]
221
263
  tag = "v1"
222
264
  new_classes = ["FrontMcpSession"]
223
- [vars]
224
- MCP_SESSION_SECRET = "..." # required on production isolates; bridged into process.env
265
+ # MCP_SESSION_SECRET is required on production isolates. Set it as a SECRET, not
266
+ # a var — `[vars]` is committed plaintext:
267
+ # npx wrangler secret put MCP_SESSION_SECRET # openssl rand -hex 32
225
268
  ```
226
269
 
227
270
  One DO per session holds a persistent transport so the `GET` notification stream stays open and `tools/call` notifications reach it. It runs the **same `http:request` flow** (auth/session:verify/router/audit/metrics + hooks) as the stateless path — so transparent auth returns `401` + `WWW-Authenticate` on the worker too.
@@ -236,24 +279,24 @@ One DO per session holds a persistent transport so the `GET` notification stream
236
279
 
237
280
  ## Troubleshooting
238
281
 
239
- | Problem | Cause | Solution |
240
- | ----------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------- |
241
- | Worker exceeds size limit | Too many bundled dependencies | Review dependencies and remove unused packages to reduce bundle size |
282
+ | Problem | Cause | Solution |
283
+ | ----------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
284
+ | Worker exceeds size limit | Too many bundled dependencies | Review dependencies and remove unused packages to reduce bundle size |
242
285
  | Module format errors | Worker bundled as a Service Worker | FrontMCP Cloudflare builds emit an **ES Module Worker** (`export default { fetch }`); `nodejs_compat` requires it. Don't force `type`/CommonJS |
243
- | KV binding errors | Namespace not created or binding name mismatch | Run `wrangler kv:namespace create` and copy the `id` into `wrangler.toml` |
244
- | Timeout errors | CPU time exceeds plan limit | Upgrade plan or offload heavy computation to Durable Objects |
245
- | CORS failures on MCP endpoint | Missing CORS headers in Worker response | `@frontmcp/edge`: pass `cors: { origin: true }` to `createEdgeMcp({...})` (transport-level CORS) |
286
+ | KV binding errors | Namespace not created or binding name mismatch | Run `npx wrangler kv:namespace create` and copy the `id` into `wrangler.toml` |
287
+ | Timeout errors | CPU time exceeds plan limit | Upgrade plan or offload heavy computation to Durable Objects |
288
+ | CORS failures on MCP endpoint | Missing CORS headers in Worker response | `@frontmcp/edge`: pass `cors: { origin: true }` to `createEdgeMcp({...})` (transport-level CORS) |
246
289
 
247
290
  ## Common Patterns
248
291
 
249
- | Pattern | Correct | Incorrect | Why |
250
- | ------------------ | -------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
292
+ | Pattern | Correct | Incorrect | Why |
293
+ | ------------------ | ---------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
251
294
  | Module format | ES Module Worker (`main = "dist/cloudflare/index.js"`, `export default { fetch }`) | Service Worker / forced CommonJS | FrontMCP Cloudflare builds emit an ES Module Worker at this exact path; the build overwrites `wrangler.toml`. `nodejs_compat` requires the Module shape |
252
- | Transport key | `transport: { protocol: 'modern' }` (or `{ sse: true, streamable: true }`) | `transport: { type: 'sse' }` | The schema field is `protocol`; valid presets are `'legacy' \| 'modern' \| 'stateless-api' \| 'full'`, or pass a `ProtocolConfig` object |
253
- | Storage binding | `[[kv_namespaces]]` with matching `binding` | Hardcoded KV namespace ID in code | Bindings are injected at runtime by Workers |
254
- | Compatibility date | Set via `frontmcp.config.deployments[].wrangler.compatibilityDate` | Hand-editing `wrangler.toml` | The build overwrites `wrangler.toml`; config-driven values survive |
255
- | Build command | `frontmcp build --target cloudflare` | `frontmcp build` (no target) | Default target is Node.js, not Workers |
256
- | Secrets | `wrangler secret put MY_SECRET` | Storing secrets in `[vars]` | `[vars]` are visible in plaintext in the dashboard |
295
+ | Transport key | `transport: { protocol: 'modern' }` (or `{ sse: true, streamable: true }`) | `transport: { type: 'sse' }` | The schema field is `protocol`; valid presets are `'legacy' \| 'modern' \| 'stateless-api' \| 'full'`, or pass a `ProtocolConfig` object |
296
+ | Storage binding | `[[kv_namespaces]]` with matching `binding` | Hardcoded KV namespace ID in code | Bindings are injected at runtime by Workers |
297
+ | Compatibility date | Set via `frontmcp.config.deployments[].wrangler.compatibilityDate` | Hand-editing `wrangler.toml` | The build overwrites `wrangler.toml`; config-driven values survive |
298
+ | Build command | `frontmcp build --target cloudflare` | `frontmcp build` (no target) | Default target is Node.js, not Workers |
299
+ | Secrets | `wrangler secret put MY_SECRET` | Storing secrets in `[vars]` | `[vars]` are visible in plaintext in the dashboard |
257
300
 
258
301
  ## Verification Checklist
259
302
 
@@ -270,8 +313,8 @@ One DO per session holds a persistent transport so the `GET` notification stream
270
313
 
271
314
  **Deployment**
272
315
 
273
- - [ ] `wrangler dev` serves the MCP endpoint locally
274
- - [ ] `wrangler deploy` succeeds without errors
316
+ - [ ] `npx wrangler dev` serves the MCP endpoint locally
317
+ - [ ] `npx wrangler deploy` succeeds without errors
275
318
  - [ ] Health endpoint responds with 200
276
319
 
277
320
  **Runtime**
@@ -30,21 +30,22 @@ export class CachePlugin {
30
30
 
31
31
  @Around('execute', { priority: 90 })
32
32
  async cacheResults(ctx, next) {
33
- const key = `${ctx.toolName}:${JSON.stringify(ctx.input)}`;
33
+ const { name, arguments: toolArguments } = ctx.state.required.input;
34
+ const key = `${name}:${JSON.stringify(toolArguments)}`;
35
+ const toolContext = ctx.state.required.toolContext;
34
36
  const cached = this.cache.get(key);
35
37
 
36
38
  if (cached && cached.expiry > Date.now()) {
37
- return cached.data;
39
+ toolContext.output = cached.data;
40
+ return; // not calling next() skips the execute stage
38
41
  }
39
42
 
40
- const result = await next();
43
+ await next(); // resolves with no value; the result is on toolContext.output
41
44
 
42
45
  this.cache.set(key, {
43
- data: result,
46
+ data: toolContext.output,
44
47
  expiry: Date.now() + 60_000,
45
48
  });
46
-
47
- return result;
48
49
  }
49
50
  }
50
51
  ```
@@ -24,7 +24,7 @@ Demonstrates a production-ready server configuration combining CodeCall, Remembe
24
24
 
25
25
  ```typescript
26
26
  // src/server.ts
27
- import { ApprovalPlugin } from '@frontmcp/plugin-approval';
27
+ import { ApprovalPlugin, ApprovalScope } from '@frontmcp/plugin-approval';
28
28
  import CachePlugin from '@frontmcp/plugin-cache';
29
29
  import CodeCallPlugin from '@frontmcp/plugin-codecall';
30
30
  import FeatureFlagPlugin from '@frontmcp/plugin-feature-flags';
@@ -100,7 +100,7 @@ import { Tool, ToolContext, z } from '@frontmcp/sdk';
100
100
  },
101
101
  approval: {
102
102
  required: true,
103
- defaultScope: 'session',
103
+ defaultScope: ApprovalScope.SESSION,
104
104
  category: 'write',
105
105
  riskLevel: 'high',
106
106
  approvalMessage: 'Allow data deletion for this session?',
@@ -268,7 +268,7 @@ Control who can interact with jobs using the `permissions` field. **`permissions
268
268
 
269
269
  **Requires 1.7.2 or later.** Before 1.7.2 (GHSA-58v2-gpcc-jmqv) the `permissions` array was validated and stored but never evaluated — every job was reachable by every caller who could reach `execute_job`. On older versions do not rely on this field for access control.
270
270
 
271
- Semantics: no rules for an action means allow (the documented default); once any rule targets an action, **all** rules for that action must pass, and `roles`/`scopes` within a single rule are **any-of**. Enforcement happens in `JobExecutionManager`, so background runs and workflow steps are covered, and `list_jobs` hides entries the caller could not run. A denial is indistinguishable from "not found" so restricted job names cannot be enumerated.
271
+ Semantics: no rules for an action means allow (the documented default); once any rule targets an action, **all** rules for that action must pass, and `roles`/`scopes` within a single rule are **any-of**. A directly executed job is checked in `JobExecutionManager` (the `execute_job` tool, triggers, background runs); a job reached as a workflow step is checked against its own rules in `WorkflowStepExecutor`, so authorizing the workflow does not launder the jobs it references. `list_jobs` hides entries the caller could not run, and a denial is indistinguishable from "not found" so restricted job names cannot be enumerated.
272
272
 
273
273
  ### Permission Rule Shape
274
274
 
@@ -333,7 +333,7 @@ class DataExportJob extends JobContext {
333
333
 
334
334
  ### Combining Permission Strategies
335
335
 
336
- Within a single rule, `roles`, `scopes`, and `custom` are additive -- all specified conditions must be met. Add additional entries to grant other actions (or alternative role/scope sets):
336
+ Within a single rule, `roles`, `scopes`, and `custom` are additive -- all specified conditions must be met, while the list inside `roles` (or `scopes`) is any-of. Add one entry per action. Adding a SECOND entry for the same action makes the check stricter, not looser -- both rules must pass -- so express alternatives as a longer `roles`/`scopes` list within one rule, never as an extra entry:
337
337
 
338
338
  ```typescript
339
339
  permissions: [