@frontmcp/skills 1.7.2 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/catalog/create-tool/examples/08-tool-with-provider-injection.md +2 -2
- package/catalog/create-tool/examples/22-tool-with-ui-html-template.md +7 -7
- package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +3 -3
- package/catalog/create-tool/references/decorator-options.md +1 -0
- package/catalog/create-tool/references/elicitation.md +9 -0
- package/catalog/create-tool/references/error-handling.md +10 -6
- package/catalog/create-tool/references/execution-context.md +7 -3
- package/catalog/create-tool/references/input-schema.md +2 -0
- package/catalog/create-tool/references/output-schema.md +6 -1
- package/catalog/create-tool/references/throttling.md +7 -8
- package/catalog/create-tool/references/ui-widgets.md +14 -1
- package/catalog/frontmcp-authorities/SKILL.md +1 -0
- package/catalog/frontmcp-authorities/references/custom-evaluators.md +13 -2
- package/catalog/frontmcp-authorities/references/rbac-abac-rebac.md +2 -0
- package/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md +4 -4
- 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-auth.md +1 -0
- package/catalog/frontmcp-config/references/configure-http.md +10 -2
- package/catalog/frontmcp-config/references/configure-skills-http.md +2 -2
- package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +5 -5
- package/catalog/frontmcp-config/references/configure-throttle.md +97 -20
- 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-skills-only.md +4 -0
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +82 -39
- package/catalog/frontmcp-development/examples/create-plugin-hooks/caching-with-around.md +7 -6
- package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md +2 -2
- package/catalog/frontmcp-development/references/create-job.md +2 -2
- package/catalog/frontmcp-development/references/create-plugin-hooks.md +21 -17
- package/catalog/frontmcp-development/references/create-prompt.md +18 -16
- package/catalog/frontmcp-development/references/create-provider.md +3 -3
- package/catalog/frontmcp-development/references/create-resource.md +10 -8
- package/catalog/frontmcp-development/references/create-skill.md +7 -0
- package/catalog/frontmcp-development/references/decorators-guide.md +9 -8
- package/catalog/frontmcp-development/references/official-plugins.md +167 -6
- package/catalog/frontmcp-development/references/openapi-adapter.md +16 -0
- package/catalog/frontmcp-observability/references/metrics-endpoint.md +1 -1
- package/catalog/frontmcp-observability/references/structured-logging.md +35 -0
- package/catalog/frontmcp-setup/SKILL.md +8 -7
- package/catalog/frontmcp-testing/SKILL.md +12 -8
- package/catalog/frontmcp-testing/examples/test-e2e-handler/tool-call-and-error-e2e.md +3 -1
- package/catalog/frontmcp-testing/references/setup-testing.md +42 -9
- package/catalog/frontmcp-testing/references/test-e2e-handler.md +35 -0
- package/catalog/skills-manifest.json +4 -3
- package/package.json +1 -1
|
@@ -45,7 +45,7 @@ Protect your FrontMCP server with rate limiting, concurrency control, execution
|
|
|
45
45
|
partitionBy: 'global', // shared across all clients
|
|
46
46
|
},
|
|
47
47
|
|
|
48
|
-
// Global concurrency limit
|
|
48
|
+
// Global concurrency limit (a tool called with this.callTool() runs inside its caller's slot)
|
|
49
49
|
globalConcurrency: {
|
|
50
50
|
maxConcurrent: 50,
|
|
51
51
|
partitionBy: 'global',
|
|
@@ -68,8 +68,8 @@ Protect your FrontMCP server with rate limiting, concurrency control, execution
|
|
|
68
68
|
allowList: ['10.0.0.0/8', '172.16.0.0/12'], // CIDR ranges
|
|
69
69
|
denyList: ['192.168.1.100'],
|
|
70
70
|
defaultAction: 'allow', // 'allow' | 'deny'
|
|
71
|
-
|
|
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,82 @@ class ExpensiveQueryTool extends ToolContext {
|
|
|
112
112
|
}
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
+
## `ipFilter` is enforced on every HTTP route
|
|
116
|
+
|
|
117
|
+
`allowList`, `denyList` and `defaultAction` are checked by the `checkIpFilter` stage that starts
|
|
118
|
+
every HTTP-facing flow, before the rate-limit check and before authentication:
|
|
119
|
+
|
|
120
|
+
- the MCP endpoint (`<entryPath>`, `/sse`, `/message`) -- rejected with HTTP 403 and JSON-RPC
|
|
121
|
+
error `-32001`;
|
|
122
|
+
- `/oauth/*`, `/.well-known/*`, `llm.txt` / `llm_full.txt`, the skills HTTP API, and custom
|
|
123
|
+
`http.routes` (before `auth: true` verification) -- rejected with HTTP 403 and
|
|
124
|
+
`{ "error": "forbidden", "message": "Client IP rejected by ipFilter" }`.
|
|
125
|
+
|
|
126
|
+
Health and readiness probes (`/healthz`, `/readyz`, `/health`) and `/metrics` (bearer-token
|
|
127
|
+
protected) are exempt. `IpBlockedError` / `IpNotAllowedError` are not thrown by the built-in
|
|
128
|
+
filter.
|
|
129
|
+
|
|
130
|
+
A request whose client IP cannot be established matches neither list and gets
|
|
131
|
+
`defaultAction` -- with `'deny'` it is rejected. An IPv4-mapped peer (`::ffff:203.0.113.7`,
|
|
132
|
+
how a dual-stack Node socket reports an IPv4 client) matches IPv4 rules.
|
|
133
|
+
|
|
134
|
+
An `ipFilter` block works on its own -- you do not need to configure a `global` rate limit
|
|
135
|
+
alongside it for the filter to run.
|
|
136
|
+
|
|
137
|
+
### Where the client IP comes from
|
|
138
|
+
|
|
139
|
+
| Runtime | Client IP |
|
|
140
|
+
| ------------------------------------------ | ------------------------------------------------------------------ |
|
|
141
|
+
| Node / Express / serverless handler | Socket peer |
|
|
142
|
+
| Behind a declared proxy | `X-Forwarded-For` (`FRONTMCP_TRUST_PROXY=true`, see below) |
|
|
143
|
+
| Cloudflare Workers (incl. Durable Objects) | `CF-Connecting-IP` -- trusted only when running on Workers |
|
|
144
|
+
| Deno | `info.remoteAddr` -- use `Deno.serve(handler)` |
|
|
145
|
+
| Bun | `server.requestIP(request)` -- use `Bun.serve({ fetch: handler })` |
|
|
146
|
+
|
|
147
|
+
## `partitionBy: 'ip'` needs a declared trusted proxy
|
|
148
|
+
|
|
149
|
+
The client IP comes from the socket peer address. `X-Forwarded-For` and `X-Real-IP` are set
|
|
150
|
+
by whoever sent the request, so they are ignored unless you declare a trusted proxy:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
FRONTMCP_TRUST_PROXY=true # honour forwarded headers -- ONLY behind a real proxy
|
|
154
|
+
FRONTMCP_TRUSTED_PROXY_DEPTH=1 # how many proxies you run in front of the app
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- Behind a load balancer **without** `FRONTMCP_TRUST_PROXY`, every request looks like it came
|
|
158
|
+
from the balancer and all clients share one bucket.
|
|
159
|
+
- **With** it but no proxy actually in front, a caller forges the header and gets a fresh
|
|
160
|
+
bucket per request, so the limit never triggers.
|
|
161
|
+
|
|
162
|
+
The client is read `FRONTMCP_TRUSTED_PROXY_DEPTH` hops back from the end of the chain: callers
|
|
163
|
+
can prepend entries, but only your own proxies append to it. The value is validated as an IP
|
|
164
|
+
address before use. A chain shorter than the configured depth was not built by your proxies,
|
|
165
|
+
so the socket peer is used instead.
|
|
166
|
+
|
|
167
|
+
> **`FRONTMCP_TRUST_PROXY` is only as good as your network boundary.** Trusting forwarded
|
|
168
|
+
> headers means trusting whoever can set them, so two things must hold:
|
|
169
|
+
>
|
|
170
|
+
> - **Every ingress path traverses the configured proxy chain.** If a caller can reach the
|
|
171
|
+
> origin directly -- a public origin IP, a peered VPC, a second ingress that skips the
|
|
172
|
+
> balancer -- they choose the whole `X-Forwarded-For` chain, and counting hops from its end
|
|
173
|
+
> just lands on an address they picked.
|
|
174
|
+
> - **The edge strips and rebuilds the forwarded headers.** The outermost proxy must discard
|
|
175
|
+
> any inbound `X-Forwarded-For` and `X-Real-IP` and write its own, so the only entries in
|
|
176
|
+
> the chain are ones your proxies appended.
|
|
177
|
+
>
|
|
178
|
+
> With depth `1` and no `X-Forwarded-For` at all, `X-Real-IP` is used -- the single-hop nginx
|
|
179
|
+
> convention. It is never consulted alongside a chain, because a caller can send both.
|
|
180
|
+
|
|
181
|
+
On Cloudflare Workers, Deno and Bun the web-fetch adapter supplies the platform's peer
|
|
182
|
+
address (table above), so edge callers get their own buckets too.
|
|
183
|
+
|
|
184
|
+
When no IP can be established the request falls back to the authenticated user
|
|
185
|
+
(`user:<userId>`), and to a single `ip:unresolved` partition when there is no user either. It
|
|
186
|
+
never keys on the session id: `mcp-session-id` is caller-supplied and a request without one is
|
|
187
|
+
given a fresh UUID, so keying on it would mint a new budget per request. The shared bucket is
|
|
188
|
+
contended by design -- bounded contention beats an unbounded budget -- and declaring your proxy
|
|
189
|
+
is what takes callers out of it.
|
|
190
|
+
|
|
115
191
|
## Configuration Types
|
|
116
192
|
|
|
117
193
|
### RateLimitConfig
|
|
@@ -138,19 +214,20 @@ class ExpensiveQueryTool extends ToolContext {
|
|
|
138
214
|
|
|
139
215
|
### IpFilterConfig
|
|
140
216
|
|
|
141
|
-
| Field | Type | Default | Description
|
|
142
|
-
| ------------------- | ------------------- | --------- |
|
|
143
|
-
| `allowList` | `string[]` | — | Allowed IPs or CIDR ranges
|
|
144
|
-
| `denyList` | `string[]` | — | Blocked IPs or CIDR ranges
|
|
145
|
-
| `defaultAction` | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list |
|
|
146
|
-
| `trustProxy` | `boolean` | `false` |
|
|
147
|
-
| `trustedProxyDepth` | `number` | `1` |
|
|
217
|
+
| Field | Type | Default | Description |
|
|
218
|
+
| ------------------- | ------------------- | --------- | ------------------------------------------------------------------ |
|
|
219
|
+
| `allowList` | `string[]` | — | Allowed IPs or CIDR ranges |
|
|
220
|
+
| `denyList` | `string[]` | — | Blocked IPs or CIDR ranges |
|
|
221
|
+
| `defaultAction` | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list, or no client IP is known |
|
|
222
|
+
| `trustProxy` | `boolean` | `false` | **Not read** (startup warning). Use `FRONTMCP_TRUST_PROXY` |
|
|
223
|
+
| `trustedProxyDepth` | `number` | `1` | **Not read** (startup warning). Use `FRONTMCP_TRUSTED_PROXY_DEPTH` |
|
|
148
224
|
|
|
149
225
|
## Partition Strategies
|
|
150
226
|
|
|
151
227
|
- **`'global'`** — Single shared counter for all clients. Use for global capacity limits.
|
|
152
228
|
- **`'ip'`** — Separate counter per client IP. Use for per-client rate limiting.
|
|
153
|
-
- **`'session'`** — Separate counter per MCP session. Use for per-session fairness.
|
|
229
|
+
- **`'session'`** — Separate counter per MCP session the server verified; a `mcp-session-id` it does not accept is ignored. Use for per-session fairness. A request without a verified session (every MCP 2026-07-28 request, stateless HTTP, or a rejected session id) falls back to the signed-in user; anonymous callers share one `anonymous` counter. A `throttle.global` partitioned by session, user or a function is checked right after authentication; one partitioned by `'ip'` or `'global'` is checked before it.
|
|
230
|
+
- **`'userId'`** — Separate counter per signed-in user. Anonymous callers fall back to the session, as above.
|
|
154
231
|
|
|
155
232
|
## Distributed Rate Limiting
|
|
156
233
|
|
|
@@ -196,7 +273,7 @@ done
|
|
|
196
273
|
|
|
197
274
|
### Configuration
|
|
198
275
|
|
|
199
|
-
- [ ] `throttle.enabled` is set to `true` in the `@FrontMcp` decorator
|
|
276
|
+
- [ ] `throttle.enabled` is set to `true` in the `@FrontMcp` decorator for the server-level options (`global`, `globalConcurrency`, the `default*` settings, `ipFilter`). A tool's own `rateLimit`/`concurrency` apply without it; `throttle.enabled: false` turns every guard off
|
|
200
277
|
- [ ] `global.maxRequests` and `global.windowMs` are set to reasonable production values
|
|
201
278
|
- [ ] `defaultTimeout.executeMs` is configured to prevent runaway tool executions
|
|
202
279
|
- [ ] IP filter `defaultAction` matches your security posture (`allow` for open, `deny` for restricted)
|
|
@@ -216,17 +293,17 @@ done
|
|
|
216
293
|
|
|
217
294
|
- [ ] Sending requests beyond the rate limit returns HTTP 429
|
|
218
295
|
- [ ] Blocked IPs receive HTTP 403
|
|
219
|
-
- [ ] Tool executions that exceed `executeMs`
|
|
296
|
+
- [ ] Tool executions that exceed `executeMs` return an `EXECUTION_TIMEOUT` error and abort `this.signal` and the tool's pending `this.fetch()` requests; the tool passes `this.signal` to other cancellable work so it stops too
|
|
220
297
|
|
|
221
298
|
## Troubleshooting
|
|
222
299
|
|
|
223
|
-
| Problem | Cause
|
|
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 | Add the allowed IP ranges to `allowList
|
|
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
|
|
300
|
+
| Problem | Cause | Solution |
|
|
301
|
+
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
302
|
+
| Rate limits not enforced across instances | In-memory storage used with multiple server replicas | Configure `storage: { type: 'redis' }` in the throttle block to share counters |
|
|
303
|
+
| All requests rejected with 403 | `ipFilter.defaultAction` set to `'deny'` without any `allowList` entries, or the runtime reports no client IP (a custom fetch wrapper that drops the second handler argument on Deno/Bun) | Add the allowed IP ranges to `allowList`, pass the platform's second argument through to the handler, or change `defaultAction` to `'allow'` |
|
|
304
|
+
| Tools timing out unexpectedly | `defaultTimeout.executeMs` too low for the tool's normal execution time | Increase the global default or set a per-tool `timeout.executeMs` override |
|
|
305
|
+
| `X-Forwarded-For` header ignored | No trusted proxy declared. `ipFilter.trustProxy` / `trustedProxyDepth` are accepted by the schema but NOT read -- client-IP extraction happens in the SDK context layer, before guard config is reachable | Set the `FRONTMCP_TRUST_PROXY=true` and `FRONTMCP_TRUSTED_PROXY_DEPTH` environment variables instead |
|
|
306
|
+
| Rate limit resets not aligned with expectations | `windowMs` misunderstood as a sliding window when it is a fixed window | The window is fixed; all counters reset at the end of each `windowMs` interval |
|
|
230
307
|
|
|
231
308
|
## Examples
|
|
232
309
|
|
|
@@ -77,6 +77,7 @@ Beyond `frontmcp build`, the CLI provides commands for the full deployment lifec
|
|
|
77
77
|
| ---------------------------- | ----------------------------------------------------------------------------------- |
|
|
78
78
|
| `frontmcp build -t <target>` | Build for target: `node`, `vercel`, `lambda`, `cloudflare`, `cli`, `browser`, `sdk` |
|
|
79
79
|
| `frontmcp build -t cli --js` | Build CLI as JS bundle (instead of native binary via SEA) |
|
|
80
|
+
| `frontmcp build --no-clean` | Keep existing output instead of clearing the target's output directory first |
|
|
80
81
|
| `frontmcp start <name>` | Start a named MCP server with supervisor (process management) |
|
|
81
82
|
| `frontmcp stop <name>` | Stop managed server (`-f` for force kill) |
|
|
82
83
|
| `frontmcp restart <name>` | Restart managed server |
|
|
@@ -145,12 +146,15 @@ Beyond `frontmcp build`, the CLI provides commands for the full deployment lifec
|
|
|
145
146
|
|
|
146
147
|
## Troubleshooting
|
|
147
148
|
|
|
148
|
-
| Problem
|
|
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
|
|
@@ -75,6 +75,10 @@ export default createEdgeMcp({
|
|
|
75
75
|
is the **Cron Trigger** entrypoint that pulls a fresh bundle and hot-swaps it.
|
|
76
76
|
Managed mode requires the optional peer `@frontmcp/plugin-skilled-openapi`.
|
|
77
77
|
|
|
78
|
+
The pull sends `authToken` as a bearer token and never follows a redirect, so
|
|
79
|
+
`endpoint` must serve the bundle directly; a 3xx (or a status-0
|
|
80
|
+
`opaqueredirect`) fails the pull.
|
|
81
|
+
|
|
78
82
|
This path is bundled by **wrangler** (not `frontmcp build`), so you maintain
|
|
79
83
|
`wrangler.toml` yourself — it needs a `[[kv_namespaces]] binding = "BUNDLE_CACHE"`
|
|
80
84
|
and a `[triggers] crontabs = [...]` (the `managed.pollIntervalMs` option is
|
|
@@ -36,7 +36,7 @@ Cloudflare Workers support is **experimental**. The Express-to-Workers adapter h
|
|
|
36
36
|
## Prerequisites
|
|
37
37
|
|
|
38
38
|
- A Cloudflare account (https://dash.cloudflare.com)
|
|
39
|
-
- Wrangler
|
|
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
|
});
|
|
@@ -203,7 +245,7 @@ createEdgeMcp({
|
|
|
203
245
|
- **Bundle size**: Workers have a 1 MB compressed / 10 MB uncompressed limit (paid plan: 10 MB / 30 MB). Review dependencies and remove unused packages to reduce bundle size.
|
|
204
246
|
- **CPU time**: 10 ms CPU time on free plan, 30 seconds on paid. Long-running operations must be optimized or use Durable Objects.
|
|
205
247
|
- **No native modules**: `better-sqlite3` and other native Node.js modules are not available. Use KV, D1, or Upstash Redis for storage.
|
|
206
|
-
- **Streaming**: Streamable HTTP works, including SSE responses (`POST` with `Accept: text/event-stream`) and the server→client SSE `GET` stream. The worker uses the SDK's `WebStandardStreamableHTTPServerTransport` — which **is** the standard Streamable HTTP transport (the Node `StreamableHTTPServerTransport` is a thin `req`/`res` wrapper over it, so there's one engine). **Server→client notifications** on the standalone `GET` stream require **stateful sessions** — set `sessions: {}` and bind the `SessionDurableObject` (Durable Object) per `Mcp-Session-Id`. Without it the worker is stateless and the `GET` stream can't deliver pushed notifications.
|
|
248
|
+
- **Streaming**: Streamable HTTP works, including SSE responses (`POST` with `Accept: text/event-stream`) and the server→client SSE `GET` stream. The worker uses the SDK's `WebStandardStreamableHTTPServerTransport` — which **is** the standard Streamable HTTP transport (the Node `StreamableHTTPServerTransport` is a thin `req`/`res` wrapper over it, so there's one engine). **Server→client notifications** on the standalone `GET` stream require **stateful sessions** — set `sessions: {}` and bind the `SessionDurableObject` (Durable Object) per `Mcp-Session-Id`. Without it the worker is stateless and the `GET` stream can't deliver pushed notifications. A stateless worker also returns no `Mcp-Session-Id` to a session-based (2025-06-18) client; each of its requests is served on its own.
|
|
207
249
|
|
|
208
250
|
### Stateful sessions (Durable Object)
|
|
209
251
|
|
|
@@ -220,8 +262,9 @@ class_name = "FrontMcpSession"
|
|
|
220
262
|
[[migrations]]
|
|
221
263
|
tag = "v1"
|
|
222
264
|
new_classes = ["FrontMcpSession"]
|
|
223
|
-
|
|
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**
|
|
@@ -30,21 +30,22 @@ export class CachePlugin {
|
|
|
30
30
|
|
|
31
31
|
@Around('execute', { priority: 90 })
|
|
32
32
|
async cacheResults(ctx, next) {
|
|
33
|
-
const
|
|
33
|
+
const { name, arguments: toolArguments } = ctx.state.required.input;
|
|
34
|
+
const key = `${name}:${JSON.stringify(toolArguments)}`;
|
|
35
|
+
const toolContext = ctx.state.required.toolContext;
|
|
34
36
|
const cached = this.cache.get(key);
|
|
35
37
|
|
|
36
38
|
if (cached && cached.expiry > Date.now()) {
|
|
37
|
-
|
|
39
|
+
toolContext.output = cached.data;
|
|
40
|
+
return; // not calling next() skips the execute stage
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
await next(); // resolves with no value; the result is on toolContext.output
|
|
41
44
|
|
|
42
45
|
this.cache.set(key, {
|
|
43
|
-
data:
|
|
46
|
+
data: toolContext.output,
|
|
44
47
|
expiry: Date.now() + 60_000,
|
|
45
48
|
});
|
|
46
|
-
|
|
47
|
-
return result;
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
```
|
package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md
CHANGED
|
@@ -24,7 +24,7 @@ Demonstrates a production-ready server configuration combining CodeCall, Remembe
|
|
|
24
24
|
|
|
25
25
|
```typescript
|
|
26
26
|
// src/server.ts
|
|
27
|
-
import { ApprovalPlugin } from '@frontmcp/plugin-approval';
|
|
27
|
+
import { ApprovalPlugin, ApprovalScope } from '@frontmcp/plugin-approval';
|
|
28
28
|
import CachePlugin from '@frontmcp/plugin-cache';
|
|
29
29
|
import CodeCallPlugin from '@frontmcp/plugin-codecall';
|
|
30
30
|
import FeatureFlagPlugin from '@frontmcp/plugin-feature-flags';
|
|
@@ -100,7 +100,7 @@ import { Tool, ToolContext, z } from '@frontmcp/sdk';
|
|
|
100
100
|
},
|
|
101
101
|
approval: {
|
|
102
102
|
required: true,
|
|
103
|
-
defaultScope:
|
|
103
|
+
defaultScope: ApprovalScope.SESSION,
|
|
104
104
|
category: 'write',
|
|
105
105
|
riskLevel: 'high',
|
|
106
106
|
approvalMessage: 'Allow data deletion for this session?',
|
|
@@ -268,7 +268,7 @@ Control who can interact with jobs using the `permissions` field. **`permissions
|
|
|
268
268
|
|
|
269
269
|
**Requires 1.7.2 or later.** Before 1.7.2 (GHSA-58v2-gpcc-jmqv) the `permissions` array was validated and stored but never evaluated — every job was reachable by every caller who could reach `execute_job`. On older versions do not rely on this field for access control.
|
|
270
270
|
|
|
271
|
-
Semantics: no rules for an action means allow (the documented default); once any rule targets an action, **all** rules for that action must pass, and `roles`/`scopes` within a single rule are **any-of**.
|
|
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: [
|