@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.
- package/catalog/frontmcp-config/examples/configure-throttle/server-level-rate-limit.md +1 -2
- package/catalog/frontmcp-config/examples/configure-throttle-guard-config/full-guard-config.md +2 -3
- package/catalog/frontmcp-config/references/configure-auth-modes.md +36 -13
- package/catalog/frontmcp-config/references/configure-http.md +10 -2
- package/catalog/frontmcp-config/references/configure-throttle.md +66 -16
- package/catalog/frontmcp-deployment/SKILL.md +10 -6
- package/catalog/frontmcp-deployment/examples/deploy-to-cloudflare/worker-custom-domain.md +4 -7
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +81 -38
- package/catalog/frontmcp-development/references/create-job.md +2 -2
- package/catalog/frontmcp-development/references/official-plugins.md +94 -4
- package/catalog/frontmcp-development/references/openapi-adapter.md +15 -0
- package/catalog/frontmcp-setup/SKILL.md +8 -7
- package/catalog/frontmcp-testing/SKILL.md +12 -8
- package/catalog/frontmcp-testing/references/setup-testing.md +33 -0
- package/catalog/skills-manifest.json +1 -1
- package/package.json +1 -1
package/catalog/frontmcp-config/examples/configure-throttle-guard-config/full-guard-config.md
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
|
133
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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` |
|
|
147
|
-
| `trustedProxyDepth` | `number` | `1` |
|
|
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
|
|
224
|
-
| ----------------------------------------------- |
|
|
225
|
-
| Rate limits not enforced across instances | In-memory storage used with multiple server replicas
|
|
226
|
-
| All requests rejected with 403 | `ipFilter.defaultAction` set to `'deny'` without any `allowList` entries
|
|
227
|
-
| Tools timing out unexpectedly | `defaultTimeout.executeMs` too low for the tool's normal execution time
|
|
228
|
-
| `X-Forwarded-For` header ignored | `ipFilter.trustProxy`
|
|
229
|
-
| Rate limit resets not aligned with expectations | `windowMs` misunderstood as a sliding window when it is a fixed window
|
|
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
|
|
149
|
-
|
|
|
150
|
-
| Cold start timeout on serverless
|
|
151
|
-
| Session lost between requests
|
|
152
|
-
| CORS errors on browser/web clients
|
|
153
|
-
| Build fails with missing module
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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 (
|
|
61
|
-
main.js # Your compiled server module (
|
|
62
|
-
wrangler.toml # Wrangler configuration (
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
182
|
-
|
|
|
183
|
-
| `https://mcp.example.com` (subdomain) | omit (`/`)
|
|
184
|
-
| `https://example.com/mcp` (path)
|
|
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',
|
|
191
|
-
cors: { origin: true },
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
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 }`)
|
|
253
|
-
| Storage binding | `[[kv_namespaces]]` with matching `binding`
|
|
254
|
-
| Compatibility date | Set via `frontmcp.config.deployments[].wrangler.compatibilityDate`
|
|
255
|
-
| Build command | `frontmcp build --target cloudflare`
|
|
256
|
-
| Secrets | `wrangler secret put MY_SECRET`
|
|
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**.
|
|
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
|
|
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
|
|
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
|
-
|
|
766
|
+
Three further limitations worth knowing:
|
|
678
767
|
|
|
679
|
-
- The
|
|
680
|
-
-
|
|
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
|
|
122
|
-
|
|
|
123
|
-
| `frontmcp create` fails
|
|
124
|
-
| Server fails to start
|
|
125
|
-
| Redis connection refused
|
|
126
|
-
| Nx generator not found
|
|
127
|
-
| Skills not loading
|
|
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
|
|
149
|
-
|
|
|
150
|
-
| Jest not finding test files
|
|
151
|
-
| `SyntaxError: Unexpected token 'export'`
|
|
152
|
-
| Coverage below 95%
|
|
153
|
-
| E2E test timeout
|
|
154
|
-
| DI resolution fails in tests
|
|
155
|
-
| Istanbul shows 0% on async methods
|
|
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
|
|
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
|
},
|