@frontmcp/skills 1.7.2 → 1.8.0

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.
@@ -60,8 +60,7 @@ class ApiApp {}
60
60
  ipFilter: {
61
61
  allowList: ['10.0.0.0/8'],
62
62
  defaultAction: 'deny',
63
- trustProxy: true,
64
- trustedProxyDepth: 1,
63
+ // trustProxy is NOT read: use the FRONTMCP_TRUST_PROXY environment variable.
65
64
  },
66
65
  },
67
66
  })
@@ -21,7 +21,7 @@ Complete GuardConfig using every available field for maximum protection.
21
21
 
22
22
  ```typescript
23
23
  // src/server.ts
24
- import { FrontMcp, App } from '@frontmcp/sdk';
24
+ import { App, FrontMcp } from '@frontmcp/sdk';
25
25
 
26
26
  @App({ name: 'secure-app' })
27
27
  class SecureApp {}
@@ -76,8 +76,7 @@ class SecureApp {}
76
76
  allowList: ['10.0.0.0/8', '172.16.0.0/12'],
77
77
  denyList: ['192.168.1.100'],
78
78
  defaultAction: 'deny',
79
- trustProxy: true,
80
- trustedProxyDepth: 2,
79
+ // trustProxy is NOT read: use the FRONTMCP_TRUST_PROXY environment variable.
81
80
  },
82
81
  },
83
82
  })
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: configure-auth-modes
3
- description: Detailed comparison of public, transparent, local, and remote auth modes
3
+ description: Detailed comparison of public, static, transparent, local, and remote auth modes
4
4
  ---
5
5
 
6
6
  # Auth Modes Detailed Comparison
@@ -20,6 +20,29 @@ auth: {
20
20
 
21
21
  **Use when:** Development, internal tools, public APIs.
22
22
 
23
+ A JWT bearer is still verified against this instance's own HS256 secret. A bearer that is **not** a JWT is ignored and the request is served anonymously — public mode has no issuer or JWKS to verify it against, and a credentialed request must never fare worse than an anonymous one. For a first-class shared secret, use static mode below.
24
+
25
+ ## Static Mode
26
+
27
+ A fixed shared secret on every request — the shape non-OAuth MCP hosts expect (ChatGPT's custom-app connector calls it "Access token / API key"). No OAuth, no JWT, no JWKS.
28
+
29
+ ```typescript
30
+ auth: {
31
+ mode: 'static',
32
+ tokens: [process.env.MCP_AUTH_TOKEN!],
33
+ // header: 'authorization', // default
34
+ // scheme: 'Bearer', // default; '' for a bare-token header like x-api-key
35
+ // scopes: ['static'], // default
36
+ // realm: 'mcp', // default, used in the WWW-Authenticate challenge
37
+ }
38
+ ```
39
+
40
+ Tokens are compared in constant time over SHA-256 digests, so neither the value nor its length leaks by timing. A match yields a session whose `sub` is `static:<12 hex chars>` — a non-reversible digest prefix of the matching token, so audit logs can tell configured tokens apart without the secret appearing anywhere. Anything else, including a missing credential, is a `401` with a `WWW-Authenticate: Bearer realm="…"` challenge.
41
+
42
+ **Use when:** one shared secret is the right granularity and standing up OAuth 2.1 is not. Rotate by deploying with both the old and new token in `tokens`, then dropping the old one.
43
+
44
+ **Do not use when:** you need per-user identity, revocation, or progressive auth — use `local` or `remote`.
45
+
23
46
  ## Transparent Mode
24
47
 
25
48
  Server validates tokens from an upstream identity provider. Does not issue or refresh tokens.
@@ -67,7 +90,7 @@ Local mode also accepts `allowDefaultPublic` (default `false` — set `true` to
67
90
 
68
91
  > **Public origin (security):** pin `FRONTMCP_PUBLIC_URL` in production. The issuer / resource / OAuth-discovery URLs and the transparent expected audience derive from it rather than from request headers; `X-Forwarded-Host`/`X-Forwarded-Proto` are ignored unless `FRONTMCP_TRUST_PROXY=1` (a trusted proxy that strips client-supplied forwarded headers).
69
92
 
70
- **Progressive / incremental authorization** (opt-in via `incrementalAuth`): when enabled, the minted token carries an `authorized_apps` claim and a `tools/call` for an app NOT in that claim resolves to a `CallToolResult` with `isError: true` and `_meta.code === 'AUTHORIZATION_REQUIRED'` (fields: `authorization_required: true`, `app`, `tool`, `auth_url`, `required_scopes`, `session_mode`, `supports_incremental`). The client declares the initial grant on `/oauth/authorize?…&apps=crm` (omit `apps` to grant all apps) and expands it later by **following the `auth_url` from the failed call** — that URL carries a framework-signed, single-use `ticket` naming the target app and the prior grant, and the new token's claim is the **union** of the prior apps plus the target (the user identity and already-granted apps are preserved; upstream tokens stay server-side). Do NOT hand-assemble `…&mode=incremental&app=slack`: since 1.7.2 (GHSA-2c4g-9c8x-6m8g) an authorize without a valid ticket is an ordinary login and runs the full credential gate, and `/oauth/callback` ignores an `incremental=true` parameter entirely. Without an `incrementalAuth` block, no claim is minted and there is **no** app-level gating (allow-all preserved). `consent` (tool-level) and `incrementalAuth` (app-level) are independent.
93
+ **Progressive / incremental authorization** (opt-in via `incrementalAuth`): when enabled, the minted token carries an `authorized_apps` claim and a `tools/call` for an app NOT in that claim resolves to a `CallToolResult` with `isError: true` and `_meta.code === 'AUTHORIZATION_REQUIRED'` (fields: `authorization_required: true`, `app`, `tool`, `auth_url`, `required_scopes`, `session_mode`, `supports_incremental`). The client declares the initial grant on `/oauth/authorize?…&apps=crm` (omit `apps` to grant all apps) and expands it later by **following the `auth_url` from the failed call** — that URL carries a framework-signed, single-use `ticket` naming the target app and the prior grant, and the new token's claim is the **union** of the prior apps plus the target (the user identity and already-granted apps are preserved; upstream tokens stay server-side). Do NOT hand-assemble `…&mode=incremental&app=slack`: since 1.7.2 (GHSA-2c4g-9c8x-6m8g) an authorize without a valid ticket is an ordinary login and runs the full credential gate, and `/oauth/callback` ignores an `incremental=true` parameter entirely. Single use is enforced with a conditional write against the configured session storage, so a **multi-instance deployment needs shared storage** (Redis, or any adapter supporting `ifNotExists`) for that guarantee to hold across instances; a backend without conditional writes (Cloudflare KV) degrades to an in-memory guard that holds within one instance only and warns at first use. Without an `incrementalAuth` block, no claim is minted and there is **no** app-level gating (allow-all preserved). `consent` (tool-level) and `incrementalAuth` (app-level) are independent.
71
94
 
72
95
  To collect and verify your own credentials, add a declarative `login` (custom page fields / title / subject strategy) and an `authenticate(input, ctx)` verifier that returns `{ ok: true, sub?, claims? }` (custom claims are embedded in the token; reserved claims are stripped) or `{ ok: false, message }` (re-renders the login page; no code issued). Both are optional and default to the built-in email login. See `configure-auth.md` for a full example.
73
96
 
@@ -130,17 +153,17 @@ upstream-token access in tools, and an optional consent layer.
130
153
 
131
154
  ## Comparison Table
132
155
 
133
- | Feature | Public | Transparent | Local | Remote |
134
- | ------------------------ | ------------- | --------------- | ------------------------------- | --------------------------------- |
135
- | Token issuance | Anonymous JWT | None (upstream) | Self-signed (HS256) | Self-signed (HS256) |
136
- | Signing | HS256 secret | Upstream JWKS | HS256 secret (`JWT_SECRET`) | HS256 secret (`JWT_SECRET`) |
137
- | Session-token refresh | No | No | Yes | Yes |
138
- | Upstream-token refresh | n/a | n/a | On-demand (when wired) | Not yet wired (re-auth on expiry) |
139
- | Identity source | Anonymous | Upstream token | Login form / `authenticate()` | Upstream IdP user |
140
- | PKCE support | No | No | Yes | Yes |
141
- | Token persistence | n/a | n/a | memory / sqlite / redis | memory / sqlite / redis |
142
- | Consent (tool selection) | No | No | Optional (screen + enforcement) | Optional (screen + enforcement) |
143
- | Upstream OAuth providers | No | No | 0..N (declared `providers[]`) | Exactly 1 (mandatory) |
156
+ | Feature | Public | Static | Transparent | Local | Remote |
157
+ | ------------------------ | ------------- | -------------------- | --------------- | ------------------------------- | --------------------------------- |
158
+ | Token issuance | Anonymous JWT | None (opaque secret) | None (upstream) | Self-signed (HS256) | Self-signed (HS256) |
159
+ | Signing | HS256 secret | n/a | Upstream JWKS | HS256 secret (`JWT_SECRET`) | HS256 secret (`JWT_SECRET`) |
160
+ | Session-token refresh | No | No | No | Yes | Yes |
161
+ | Upstream-token refresh | n/a | n/a | n/a | On-demand (when wired) | Not yet wired (re-auth on expiry) |
162
+ | Identity source | Anonymous | Configured token | Upstream token | Login form / `authenticate()` | Upstream IdP user |
163
+ | PKCE support | No | No | No | Yes | Yes |
164
+ | Token persistence | n/a | n/a | n/a | memory / sqlite / redis | memory / sqlite / redis |
165
+ | Consent (tool selection) | No | No | No | Optional (screen + enforcement) | Optional (screen + enforcement) |
166
+ | Upstream OAuth providers | No | No | No | 0..N (declared `providers[]`) | Exactly 1 (mandatory) |
144
167
 
145
168
  > "Remote" still issues its own HS256 session token to the MCP client; it delegates **user authentication** to a single upstream IdP rather than delegating token signing. `GET /oauth/authorize` redirects straight to that IdP (no in-tree login page), and tools read the upstream token via `this.orchestration.getToken(id)`.
146
169
 
@@ -129,8 +129,10 @@ after the rebind the browser genuinely considers the request same-origin. Valida
129
129
  server-side defence.
130
130
 
131
131
  With no `allowedHosts` configured, the list is derived from what the process listens on: `localhost`,
132
- `127.0.0.1` and `[::1]`, each with and without the bound port, plus a specific bound NIC address.
133
- Matching is case-insensitive and treats `host` and `host:80`/`host:443` as equal.
132
+ `127.0.0.1` and `[::1]`, each with and without the bound port. Matching is case-insensitive and
133
+ treats `host` and `host:80`/`host:443` as equal. A loopback listener is reachable only under those
134
+ names, so the derived list is exact and is enforced as-is; a routable bind enforces nothing derived
135
+ until you name the public host, and the bound NIC address then joins the list alongside it.
134
136
 
135
137
  **Deployments behind a proxy need one line of config.** A routable bind (`0.0.0.0`, `::`, a specific
136
138
  NIC) is reached under a hostname the process cannot know, so a derived list is not enforced there —
@@ -155,6 +157,12 @@ ENV FRONTMCP_BIND_ADDRESS=all
155
157
  ENV FRONTMCP_ALLOWED_HOSTS=api.example.com,api.example.com:8443
156
158
  ```
157
159
 
160
+ A server bound to `socketPath` has no TCP host — the socket's filesystem permissions are the
161
+ boundary and clients send an arbitrary placeholder `Host` — so the _derived_ allow-list is skipped
162
+ there and needs no configuration. A rebound browser cannot reach a unix socket at all. An
163
+ **explicit** `allowedHosts` / `allowedOrigins` still applies to a socket server, and because the
164
+ client's `Host` is a placeholder it will reject every request — leave it unset.
165
+
158
166
  To turn it off entirely: `dnsRebindingProtection: { enabled: false }`.
159
167
 
160
168
  A request with **no** `Origin` header is allowed through — non-browser clients never send one, and a
@@ -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,56 @@ class ExpensiveQueryTool extends ToolContext {
112
112
  }
113
113
  ```
114
114
 
115
+ ## `ipFilter` is enforced on every request
116
+
117
+ `allowList`, `denyList` and `defaultAction` are checked at the start of the request pipeline,
118
+ before the rate-limit check and before authentication. A rejected client gets HTTP 403 with
119
+ JSON-RPC error `-32001`.
120
+
121
+ An `ipFilter` block works on its own -- you do not need to configure a `global` rate limit
122
+ alongside it for the filter to run.
123
+
124
+ ## `partitionBy: 'ip'` needs a declared trusted proxy
125
+
126
+ The client IP comes from the socket peer address. `X-Forwarded-For` and `X-Real-IP` are set
127
+ by whoever sent the request, so they are ignored unless you declare a trusted proxy:
128
+
129
+ ```bash
130
+ FRONTMCP_TRUST_PROXY=true # honour forwarded headers -- ONLY behind a real proxy
131
+ FRONTMCP_TRUSTED_PROXY_DEPTH=1 # how many proxies you run in front of the app
132
+ ```
133
+
134
+ - Behind a load balancer **without** `FRONTMCP_TRUST_PROXY`, every request looks like it came
135
+ from the balancer and all clients share one bucket.
136
+ - **With** it but no proxy actually in front, a caller forges the header and gets a fresh
137
+ bucket per request, so the limit never triggers.
138
+
139
+ The client is read `FRONTMCP_TRUSTED_PROXY_DEPTH` hops back from the end of the chain: callers
140
+ can prepend entries, but only your own proxies append to it. The value is validated as an IP
141
+ address before use. A chain shorter than the configured depth was not built by your proxies,
142
+ so the socket peer is used instead.
143
+
144
+ > **`FRONTMCP_TRUST_PROXY` is only as good as your network boundary.** Trusting forwarded
145
+ > headers means trusting whoever can set them, so two things must hold:
146
+ >
147
+ > - **Every ingress path traverses the configured proxy chain.** If a caller can reach the
148
+ > origin directly -- a public origin IP, a peered VPC, a second ingress that skips the
149
+ > balancer -- they choose the whole `X-Forwarded-For` chain, and counting hops from its end
150
+ > just lands on an address they picked.
151
+ > - **The edge strips and rebuilds the forwarded headers.** The outermost proxy must discard
152
+ > any inbound `X-Forwarded-For` and `X-Real-IP` and write its own, so the only entries in
153
+ > the chain are ones your proxies appended.
154
+ >
155
+ > With depth `1` and no `X-Forwarded-For` at all, `X-Real-IP` is used -- the single-hop nginx
156
+ > convention. It is never consulted alongside a chain, because a caller can send both.
157
+
158
+ When no IP can be established the request falls back to the authenticated user
159
+ (`user:<userId>`), and to a single `ip:unresolved` partition when there is no user either. It
160
+ never keys on the session id: `mcp-session-id` is caller-supplied and a request without one is
161
+ given a fresh UUID, so keying on it would mint a new budget per request. The shared bucket is
162
+ contended by design -- bounded contention beats an unbounded budget -- and declaring your proxy
163
+ is what takes callers out of it.
164
+
115
165
  ## Configuration Types
116
166
 
117
167
  ### RateLimitConfig
@@ -138,13 +188,13 @@ class ExpensiveQueryTool extends ToolContext {
138
188
 
139
189
  ### IpFilterConfig
140
190
 
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 |
191
+ | Field | Type | Default | Description |
192
+ | ------------------- | ------------------- | --------- | ------------------------------------------------ |
193
+ | `allowList` | `string[]` | — | Allowed IPs or CIDR ranges |
194
+ | `denyList` | `string[]` | — | Blocked IPs or CIDR ranges |
195
+ | `defaultAction` | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list |
196
+ | `trustProxy` | `boolean` | `false` | **Not read.** Use `FRONTMCP_TRUST_PROXY` |
197
+ | `trustedProxyDepth` | `number` | `1` | **Not read.** Use `FRONTMCP_TRUSTED_PROXY_DEPTH` |
148
198
 
149
199
  ## Partition Strategies
150
200
 
@@ -220,13 +270,13 @@ done
220
270
 
221
271
  ## Troubleshooting
222
272
 
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 |
273
+ | Problem | Cause | Solution |
274
+ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
275
+ | 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 |
276
+ | 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'` |
277
+ | 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 |
278
+ | `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 |
279
+ | 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
280
 
231
281
  ## Examples
232
282
 
@@ -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
@@ -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
  });
@@ -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**
@@ -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: [
@@ -260,6 +260,46 @@ class MyTool extends ToolContext {
260
260
  - `tool` -- Scoped to a specific tool + session combination. Isolated per tool.
261
261
  - `global` -- Shared across all sessions and users. Use carefully.
262
262
 
263
+ **`session`, `tool`, and `user` scopes require a per-client identity.** A stateless HTTP
264
+ transport injects the same session id (`__stateless__`) into every request, so it carries no
265
+ session identity. `session` and `tool` scope fall back to the authenticated principal, and an
266
+ unauthenticated stateless request is refused with a `RememberIdentityError` rather than given
267
+ a namespace shared with every other client. `user` scope is refused with no authenticated
268
+ user. If the data really is shared, use `scope: 'global'`.
269
+
270
+ **Set `REMEMBER_SECRET` on every instance that shares a store.** All scopes, `session` and
271
+ `tool` included, derive their encryption key from that secret plus the scope identity. A
272
+ session id is not a secret -- the client knows it and it travels in the `mcp-session-id`
273
+ header -- so it cannot be the key material on its own. Instances with different secrets cannot
274
+ read each other's entries.
275
+
276
+ **Upgrading past that change moves existing `session`, `tool` and `user` entries.** The key
277
+ derivation change orphans `session` and `tool` ciphertext, and the namespace now percent-encodes
278
+ every variable component, which moves any identity containing an escaped character (a `:` in a
279
+ user id, say). Both failures are silent on their own: decryption returns `null` and a moved key
280
+ simply misses, so the value reads as absent.
281
+
282
+ These three scopes are stored under a `v2:` segment (`remember:v2:session:<identity>:<key>`) and
283
+ the plugin **purges the pre-`v2` entries automatically**, warning with the number removed. The
284
+ version segment is what makes that safe -- a purge pattern of `remember:session:*` cannot match a
285
+ live `remember:v2:session:*` key. `global` is not versioned and not purged: neither its keys nor
286
+ its key derivation changed.
287
+
288
+ **The purge runs 24 hours after the fleet first reached the `v2:` layout -- not after this
289
+ process started -- on an unreferenced timer, never on the request path.** The first instance to
290
+ reach the store stamps `<keyPrefix>__layout__` with `{ version, firstSeenAt }`; every instance
291
+ reads it and sweeps only once it is older than the window, re-arming for the remainder until
292
+ then. The clock lives in the store because a process-local timer restarts on every deploy and
293
+ crash, so it never converges on "the fleet has been on `v2:` for a while". The marker is written
294
+ once and never overwritten, and if it cannot be read or parsed the purge stands down rather than
295
+ deleting on an unknown clock.
296
+
297
+ The window has to outlast the rollout _and_ the period in which a bad deploy is rolled back --
298
+ a rollback after the sweep makes the old fleet permanent again with its memory gone. Tune it
299
+ with `legacyPurgeDelayMs`, or pass `skipLegacyPurge: true` to migrate the data yourself. On
300
+ serverless and edge the invocation usually ends before the timer fires, so nothing is purged;
301
+ clear the legacy prefixes manually if you want the storage back.
302
+
263
303
  ### Tools Exposed (when `tools.enabled: true`)
264
304
 
265
305
  - `remember_this` -- Store a key-value pair in memory
@@ -318,6 +358,21 @@ class WebhookServer {}
318
358
  - `recheck` -- Re-evaluates approval status on every tool call. Approval can be granted programmatically via `this.approval.grantSessionApproval()`. Good for interactive approval flows where the user confirms in-band.
319
359
  - `webhook` -- Sends a PKCE-secured webhook to an external approval service. The external service calls back to confirm or deny. Suitable for compliance workflows requiring out-of-band approval.
320
360
 
361
+ ### Pre-approved contexts come from the session
362
+
363
+ `approval.preApprovedContexts` lists contexts that skip the approval check entirely. The
364
+ context a call runs in is taken **only** from `authInfo.extra.approvalContext`, which your
365
+ authentication layer sets while establishing the session.
366
+
367
+ A `context` field in the gated tool's own arguments is ignored. Do not build a flow that
368
+ expects the caller to declare its context -- the caller of a gated tool must not be able to
369
+ name the context that lets it skip the gate. Set the context when you authenticate:
370
+
371
+ ```typescript
372
+ // In your auth layer, not in tool input
373
+ authInfo.extra.approvalContext = { type: 'project', identifier: resolvedProjectId };
374
+ ```
375
+
321
376
  ### Using `this.approval` in Tools
322
377
 
323
378
  ```typescript
@@ -373,6 +428,25 @@ When `approval.required` is `true`, the plugin automatically intercepts tool exe
373
428
 
374
429
  Automatic tool result caching. Cache responses by tool name patterns or per-tool metadata. Supports sliding window TTL and cache bypass headers.
375
430
 
431
+ ### Cache keys include the caller's identity
432
+
433
+ `keyByIdentity` defaults to `true`. Most cached tools return something that depends on who is
434
+ asking -- a profile, a balance, a tenant's records, anything filtered by the caller's own
435
+ permissions -- and a key built only from the tool and its arguments serves the first caller's
436
+ response to everyone else.
437
+
438
+ The identity is the authenticated subject (`sub` / `userId`), then the client id, then the
439
+ session. A call with no identity at all gets a key of its own rather than one shared with
440
+ every other identity-less caller.
441
+
442
+ Set `keyByIdentity: false` **only** when every caller would get byte-identical output: public
443
+ reference data, a currency table, a static document.
444
+
445
+ ```typescript
446
+ // Public data, identical for everyone -- safe to share one entry
447
+ CachePlugin.init({ type: 'memory', toolPatterns: ['reference:*'], keyByIdentity: false });
448
+ ```
449
+
376
450
  ### Installation
377
451
 
378
452
  ```typescript
@@ -472,7 +546,11 @@ The header name is configurable via `bypassHeader` in the plugin options. Defaul
472
546
 
473
547
  ### Cache Key
474
548
 
475
- The cache key is computed from the tool name and the serialized input arguments. Two calls with identical tool name and arguments return the same cached result.
549
+ The cache key is a SHA-256 digest of the tool name, the serialized input arguments, and -- by default -- the caller's
550
+ identity. Two calls share an entry only when all three match.
551
+
552
+ Set `keyByIdentity: false` to drop identity from the key, and only for output that is identical for every caller. See
553
+ "Cache keys include the caller's identity" above.
476
554
 
477
555
  ---
478
556
 
@@ -480,6 +558,17 @@ The cache key is computed from the tool name and the serialized input arguments.
480
558
 
481
559
  Gate tools, resources, prompts, and skills behind feature flags. Integrates with popular feature flag services or static configuration.
482
560
 
561
+ ### A flag withholds the capability, it does not just hide it
562
+
563
+ A disabled flag filters the entry out of `tools/list`, `resources/list`, `prompts/list` and
564
+ `skills/search`, **and** refuses it on direct access: `tools/call`, `resources/read` and
565
+ `prompts/get` each evaluate the flag before executing.
566
+
567
+ That matters because a listing is not an access control. Clients cache listings and hold
568
+ resource URIs and prompt names from earlier sessions, so anything gated only at list time
569
+ stays reachable by name. If the adapter is unavailable the gate uses the ref's
570
+ `defaultValue`, and a bare string ref (no default) fails closed.
571
+
483
572
  ### Installation
484
573
 
485
574
  ```typescript
@@ -674,10 +763,11 @@ The dashboard's MCP scope **inherits the server's authentication**. Its introspe
674
763
  - On an authenticated server (`local`, `remote`, `transparent`, `orchestrated`), the dashboard requires the same credential as everything else.
675
764
  - On a **public** server the dashboard is public too, because the server is. `auth.token` gates the dashboard _page_, not the MCP scope or the SSE stream. If the inventory is sensitive, authenticate the server — do not rely on the dashboard token alone.
676
765
 
677
- Two further limitations worth knowing:
766
+ Three further limitations worth knowing:
678
767
 
679
- - The token is accepted as `Authorization: Bearer <token>` or `?token=`. Prefer the header: a URL token lands in browser history, `Referer` headers and access logs. There is no cookie/session option yet.
680
- - Dashboard options are **process-wide**. Two `@FrontMcp` servers built in one process that configure the dashboard differently share the last configuration registered, including its token; the plugin logs a warning when that happens. Run one dashboard per process.
768
+ - **The bundled page cannot authenticate itself against a non-public server.** The browser client opens `EventSource(sseUrl)` and POSTs with no `Authorization` header, and the SDK reads the credential from that header only. So on a server with `local`/`remote`/`transparent`/`orchestrated` auth the page loads (its own token gates that) but the in-page graph, tool list and SSE stream get `401`. Run the dashboard on a public/development server, or put it behind a proxy that injects a credential — scoped to the dashboard's own routes (`<basePath>/sse` and `<basePath>/message`) and holding no grant beyond the dashboard scope, since injecting a server credential across the MCP endpoint would let any page on that origin issue arbitrary authenticated JSON-RPC. Failing closed here is deliberate — the alternative is the `mode: 'public'` scope that GHSA-rgxj-434m-vxh3 was about.
769
+ - The token is accepted as `Authorization: Bearer <token>` (scheme matched case-insensitively) or `?token=`. Prefer the header: a URL token lands in browser history, `Referer` headers and access logs. There is no cookie/session option yet.
770
+ - Dashboard options are **process-wide**. Two `@FrontMcp` servers built in one process that configure the dashboard with CONFLICTING auth now throw at registration rather than silently sharing the last token; a differing `basePath` or `cdn` logs a warning. Run one dashboard per process, or call `resetDashboardOptions()` between serial constructions.
681
771
 
682
772
  ---
683
773
 
@@ -314,6 +314,21 @@ FrontMCP's secure defaults:
314
314
  IPv6 ULA/link-local), and hostnames are **DNS-resolved** and re-checked.
315
315
  - **`file://` is blocked** (prevents local file reads).
316
316
 
317
+ ### Tool-execution redirects (a separate phase)
318
+
319
+ The rules above cover _loading the spec_. **Calling** a generated tool is a different fetch,
320
+ and it never follows redirects either (**GHSA-qh67-4345-cw2q**): the adapter sets
321
+ `redirect: 'manual'` and refuses any 3xx with `OPENAPI_REDIRECT_NOT_FOLLOWED`.
322
+
323
+ Following one would send the request to a destination the _upstream_ chose. Only `baseUrl` is
324
+ validated, and only for its scheme, so a 3xx is an unvalidated hop -- including to an internal
325
+ or cloud-metadata address. `fetch` also strips only `Authorization` and `Cookie` across
326
+ origins and **forwards custom headers**, which is exactly how this adapter injects API keys
327
+ (`security.headers`, `additionalHeaders`), so following would disclose the backend credential
328
+ to the redirect target.
329
+
330
+ If an operation legitimately redirects, point `baseUrl` at the final host instead.
331
+
317
332
  Configure via `loadOptions.refResolution` (applies to the spec URL **and** `$ref`s):
318
333
 
319
334
  ```typescript
@@ -118,13 +118,14 @@ Entry point for project setup and scaffolding. This skill helps you find the rig
118
118
 
119
119
  ## Troubleshooting
120
120
 
121
- | Problem | Cause | Solution |
122
- | ------------------------ | -------------------------------- | --------------------------------------------------------------------- |
123
- | `frontmcp create` fails | Missing Node.js 24+ or npm/yarn | Install Node.js 24+ and ensure npm/yarn is available |
124
- | Server fails to start | `main.ts` missing default export | Add `export default MyServerClass` to `main.ts` |
125
- | Redis connection refused | Redis not running or wrong URL | Start Redis (`docker compose up redis`) or fix `REDIS_URL` env var |
126
- | Nx generator not found | `@frontmcp/nx` not installed | Run `npm install -D @frontmcp/nx` |
127
- | Skills not loading | Skills placed in wrong directory | Catalog skills go in top-level `skills/`, app skills in `src/skills/` |
121
+ | Problem | Cause | Solution |
122
+ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
+ | `frontmcp create` fails | Missing Node.js 24+ or npm/yarn | Install Node.js 24+ and ensure npm/yarn is available |
124
+ | Server fails to start | `main.ts` missing default export | Add `export default MyServerClass` to `main.ts` |
125
+ | Redis connection refused | Redis not running or wrong URL | Start Redis (`docker compose up redis`) or fix `REDIS_URL` env var |
126
+ | Nx generator not found | `@frontmcp/nx` not installed | Run `npm install -D @frontmcp/nx` |
127
+ | Skills not loading | Skills placed in wrong directory | Catalog skills go in top-level `skills/`, app skills in `src/skills/` |
128
+ | Build or start fails under Yarn 4 with peer-dependency or `TS2688` errors | Yarn defaults to Plug'n'Play, which enforces peer dependencies strictly | `frontmcp create --pm yarn` scaffolds `.yarnrc.yml` with `nodeLinker: node-modules`; add that file and reinstall for projects scaffolded by an older CLI |
128
129
 
129
130
  ## Examples
130
131
 
@@ -99,6 +99,7 @@ This is a router skill. Follow this order to pick a testing approach, then move
99
99
  | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
100
100
  | File naming | Always `.spec.ts` (not `.test.ts`); E2E uses `.e2e.spec.ts` |
101
101
  | File organization | Split E2E tests by app/feature: `e2e/calc.e2e.spec.ts`, `e2e/ecommerce.e2e.spec.ts`. Never put all tests in a single `server.e2e.spec.ts` |
102
+ | Environment | `frontmcp test` loads `.env` / `.env.local` into the Jest child, same precedence as `dev` (real environment wins over the files, config `env.shared` + `env.test` underneath). `--no-env` skips it |
102
103
  | Test runner | Standalone projects: use `frontmcp test` (auto-generates Jest/SWC config; discovers `src/**/*.spec.ts(x)`, `__tests__/**/*.spec.ts(x)`, and `e2e/**/*.e2e.spec.ts(x)`; transforms both `.ts` and `.tsx` with the automatic JSX runtime; transpiles ESM-only deps such as `jose` under npm, yarn AND pnpm's `node_modules/.pnpm/` store — add your own via `test.esmPackages` in `frontmcp.config.ts`; delegates to a user-provided `jest.config.{ts,js,mjs,cjs,json}` if present, which drops the injected ESM transforms). Nx monorepos: use `nx test <lib>` (resolves the project's `jest.config.ts`). Never invoke `jest --config ...` directly |
103
104
  | Coverage threshold | 95%+ across statements, branches, functions, lines |
104
105
  | Test descriptions | Plain English, no prefixes like "PT-001"; describe behavior not implementation |
@@ -145,14 +146,17 @@ This is a router skill. Follow this order to pick a testing approach, then move
145
146
 
146
147
  ## Troubleshooting
147
148
 
148
- | Problem | Cause | Solution |
149
- | ---------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
150
- | Jest not finding test files | Wrong file extension (`.test.ts` instead of `.spec.ts`) | Rename to `.spec.ts`; check `testMatch` in jest.config |
151
- | `SyntaxError: Unexpected token 'export'` | An ESM-only dependency is being ignored instead of transpiled | Add it to `test.esmPackages` in `frontmcp.config.ts`. With a hand-written `jest.config.ts`, use the pnpm-safe `transformIgnorePatterns` in [`setup-testing`](./references/setup-testing.md#jest-configuration) AND make sure `transform` matches `.js` (`^.+\.[tj]sx?$` + `allowJs`) — un-ignoring a file does nothing if no transform matches it |
152
- | Coverage below 95% | Untested error paths or conditional branches | Run `frontmcp test --coverage` and inspect uncovered lines in the report |
153
- | E2E test timeout | Server startup too slow or port conflict | Increase Jest timeout; use random port allocation |
154
- | DI resolution fails in tests | Provider not registered in test scope | Register mock providers before creating the test context |
155
- | Istanbul shows 0% on async methods | TypeScript source-map mismatch with Istanbul | Known issue with some TS compilation settings; verify coverage with actual test output |
149
+ | Problem | Cause | Solution |
150
+ | --------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
151
+ | Jest not finding test files | Wrong file extension (`.test.ts` instead of `.spec.ts`) | Rename to `.spec.ts`; check `testMatch` in jest.config |
152
+ | `SyntaxError: Unexpected token 'export'` | An ESM-only dependency is being ignored instead of transpiled | Add it to `test.esmPackages` in `frontmcp.config.ts`. With a hand-written `jest.config.ts`, use the pnpm-safe `transformIgnorePatterns` in [`setup-testing`](./references/setup-testing.md#jest-configuration) AND make sure `transform` matches `.js` (`^.+\.[tj]sx?$` + `allowJs`) — un-ignoring a file does nothing if no transform matches it |
153
+ | Coverage below 95% | Untested error paths or conditional branches | Run `frontmcp test --coverage` and inspect uncovered lines in the report |
154
+ | E2E test timeout | Server startup too slow or port conflict | Increase Jest timeout; use random port allocation |
155
+ | DI resolution fails in tests | Provider not registered in test scope | Register mock providers before creating the test context |
156
+ | Istanbul shows 0% on async methods | TypeScript source-map mismatch with Istanbul | Known issue with some TS compilation settings; verify coverage with actual test output |
157
+ | Specs see no `.env` values | An older CLI did not load `.env` for `frontmcp test`, only for `dev` | Upgrade the CLI — `frontmcp test` now loads `.env` / `.env.local` with the same precedence as `dev` (real environment wins, so CI secrets still override). Pass `--no-env` for a hermetic run |
158
+ | `Invalid first argument, true` at collection time | `test.skip(condition, reason)` reached Jest's `skip(name, fn)` | Upgrade the CLI — the Playwright signature is supported: `test.skip(!hasCredentials, 'credentials not set')` skips every test registered after it in the enclosing block |
159
+ | Every spec fails with `HTTP 404` after setting `http.entryPath` | The test client always connected to the server root | Upgrade the CLI — the client now follows the `entryPaths` a 404 reports, and `test.use({ entryPath: '/mcp' })` sets it explicitly |
156
160
 
157
161
  ## Examples
158
162
 
@@ -380,6 +380,39 @@ test('unauthenticated call is rejected', async ({ mcp }) => {
380
380
  });
381
381
  ```
382
382
 
383
+ ### Servers that serve MCP somewhere other than `/`
384
+
385
+ A server configured with `http: { entryPath: '/mcp' }` serves MCP at `/mcp`, and the test client follows it:
386
+
387
+ ```typescript
388
+ test.use({
389
+ server: './src/main.ts',
390
+ entryPath: '/mcp',
391
+ });
392
+ ```
393
+
394
+ You usually do not need to set this: when the client's first request 404s and the server reports where it does serve MCP (`{"error":"Not Found","entryPaths":["/mcp"]}`), the client reconnects there on its own, and a failure names the reported paths instead of a bare `HTTP 404`. `entryPath` applies to the MCP endpoint only — OAuth and discovery endpoints stay at the server root.
395
+
396
+ `baseUrl` may also be supplied alongside `server` to override the booted server's own URL (for a proxy, or a different host than it binds).
397
+
398
+ ### Gating a block on credentials
399
+
400
+ `test.skip(condition, reason)` is the Playwright signature and skips every test registered after it in the enclosing block:
401
+
402
+ ```typescript
403
+ const hasCredentials = Boolean(process.env.TWILIO_ACCOUNT_SID);
404
+
405
+ test.describe('against the live API', () => {
406
+ test.skip(!hasCredentials, 'credentials not set');
407
+
408
+ test('looks up a carrier', async ({ mcp }) => {
409
+ // ...
410
+ });
411
+ });
412
+ ```
413
+
414
+ A nested `test.describe` inherits an outer skip. The `(name, fn)` form still skips a single named test. `frontmcp test` loads `.env` / `.env.local`, so a credential in `.env` reaches the condition above.
415
+
383
416
  ## Custom MCP Matchers
384
417
 
385
418
  `@frontmcp/testing` provides Jest matchers tailored for MCP responses. Import `expect` from `@frontmcp/testing` instead of from Jest:
@@ -1276,7 +1276,7 @@
1276
1276
  "tags": ["deployment", "json-rpc", "cloudflare", "worker", "custom", "domain"],
1277
1277
  "features": [
1278
1278
  "Using `frontmcp create --target cloudflare` to scaffold a project with `wrangler.toml` and deploy scripts",
1279
- "Adding a custom domain with `wrangler domains add` for production-ready URLs",
1279
+ "Adding a custom domain with `wrangler deploy --domain` for production-ready URLs",
1280
1280
  "End-to-end verification of both the health check and MCP JSON-RPC endpoint"
1281
1281
  ]
1282
1282
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontmcp/skills",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
4
4
  "description": "Curated skills catalog for FrontMCP projects",
5
5
  "author": "AgentFront <info@agentfront.dev>",
6
6
  "homepage": "https://docs.agentfront.dev",