@frontmcp/skills 1.8.2 → 1.8.4
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/references/availability.md +2 -0
- package/catalog/frontmcp-auth-ui/references/custom-auth-ui.md +2 -2
- package/catalog/frontmcp-authorities/SKILL.md +33 -10
- package/catalog/frontmcp-authorities/references/authority-profiles.md +13 -7
- package/catalog/frontmcp-authorities/references/rbac-abac-rebac.md +2 -0
- package/catalog/frontmcp-config/references/configure-auth-modes.md +4 -2
- package/catalog/frontmcp-config/references/configure-auth.md +11 -8
- package/catalog/frontmcp-config/references/configure-skills-http.md +13 -0
- package/catalog/frontmcp-deployment/references/build-for-browser.md +19 -0
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +22 -1
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +8 -0
- package/catalog/frontmcp-development/examples/openapi-adapter/authenticated-adapter-with-polling.md +7 -3
- package/catalog/frontmcp-development/references/create-plugin-hooks.md +2 -0
- package/catalog/frontmcp-development/references/create-provider.md +7 -0
- package/catalog/frontmcp-development/references/official-plugins.md +51 -26
- package/catalog/frontmcp-development/references/openapi-adapter.md +25 -8
- package/catalog/frontmcp-production-readiness/references/common-checklist.md +3 -2
- package/catalog/frontmcp-production-readiness/references/production-browser.md +1 -0
- package/package.json +1 -1
|
@@ -100,6 +100,8 @@ These are fine for ergonomic branching. For tools that **shouldn't exist at all*
|
|
|
100
100
|
|
|
101
101
|
This is the safest way to expose internal-only tools that you want an agent / job to call but don't want a user to invoke from a chat UI.
|
|
102
102
|
|
|
103
|
+
An MCP client (and the in-process client of a CLI build, surface `'cli'`) never sees such a tool: it is absent from `tools/list`, and `tools/call` answers `Tool "rotate_secrets" not found`, exactly as for a tool that doesn't exist. Resources, resource templates, prompts, agents and skills (including the skills HTTP endpoints, which count as `'mcp'`) follow the same rule, and CodeCall applies its caller's surface to the tools it reaches. An agent's model calls its tools on `'agent'` (and is only offered those its `surface` allows), a job's or workflow step's `this.callTool()` on `'job'`, and a `@Channel` handling a webhook on `'http-trigger'`. A tool, resource or prompt calling `this.callTool()` is in-process dispatch: that call carries no surface and is not restricted. Code reads its call's surface with `getCallSurface()`. The process-wide axes (`os`, `runtime`, ...) answer `EntryUnavailableError` instead.
|
|
104
|
+
|
|
103
105
|
## See also
|
|
104
106
|
|
|
105
107
|
- [`21-tool-with-availability-constraints`](../examples/21-tool-with-availability-constraints.md)
|
|
@@ -108,7 +108,7 @@ npm install @frontmcp/ui react react-dom
|
|
|
108
108
|
|
|
109
109
|
### React hooks + wrapper + client mount
|
|
110
110
|
|
|
111
|
-
- **`useAuthFlow()`** — the flow-state fields above plus a `submitFinish` handler. `<form onSubmit={submitFinish}>` `preventDefault`s, serializes the form, attaches `pending_auth_id` + `csrf` + the slot marker,
|
|
111
|
+
- **`useAuthFlow()`** — the flow-state fields above plus a `submitFinish` handler. `<form onSubmit={submitFinish}>` `preventDefault`s, serializes the form, attaches `pending_auth_id` + `csrf` + the slot marker, and submits it to the callback as a real form navigation, so the browser follows the OAuth redirect.
|
|
112
112
|
- **`useExtraField(name)`** — `{ onSubmit, result, pending }` for an `auth.extras` form. On success it merges the returned `addedItems` back into context.
|
|
113
113
|
- **`useAddedItems(name)`** — the server-side accumulator for a named extra, reactively.
|
|
114
114
|
- **`<AuthPageWrapper>`** — outer chrome that reads the injected state once, provides it via context, and (by default) renders the enclosing `<form>` with the `pending_auth_id` + `csrf` hidden fields so a no-JS submit still works. Pass `renderForm={false}` to supply your own forms.
|
|
@@ -131,7 +131,7 @@ There is **no `/oauth/ui/:slot.js` route** — the component is transpiled serve
|
|
|
131
131
|
## Security — the framework owns it
|
|
132
132
|
|
|
133
133
|
- **CSRF**: the server mints a per-pending-authorization token, stores it (echoed into `csrfToken`), and verifies it on the finish submit and every `auth.extras` POST with a constant-time compare. Your component never generates or checks it.
|
|
134
|
-
- **CSP + anti-clickjacking**: the auth-UI HTML ships with a strict CSP — `default-src 'self'; script-src 'self' 'unsafe-inline' https://esm.sh; connect-src 'self' https://esm.sh; style-src 'self' 'unsafe-inline' https://esm.sh; img-src 'self' data: https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'` — plus `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-
|
|
134
|
+
- **CSP + anti-clickjacking**: the auth-UI HTML ships with a strict CSP — `default-src 'self'; script-src 'self' 'unsafe-inline' https://esm.sh; connect-src 'self' https://esm.sh; style-src 'self' 'unsafe-inline' https://esm.sh; img-src 'self' data: https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' <redirect origin>` (the validated `redirect_uri`'s origin, plus the upstream authorization endpoints on the provider-selection page) — plus `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin` (a POST of the form carries its `Origin`, which the callback checks), `Cache-Control: no-store`. The finish submit is a POST (`submitMethod: 'POST'`). It allows `https://esm.sh` (deps) + `'unsafe-inline'` (the JSON-escaped state script) but **NOT `'unsafe-eval'`** — the transform is server-side, never in the browser.
|
|
135
135
|
- **No PII**: the injected state carries OAuth client identifiers + control fields only.
|
|
136
136
|
- **Fail-safe**: a component that can't be transpiled (missing / invalid `.tsx`) is logged (error cached so a broken file isn't retried each request) and **falls back to the built-in page** — a broken custom page can't take the server down.
|
|
137
137
|
|
|
@@ -208,8 +208,9 @@ export default class SensitiveActionTool extends ToolContext { ... }
|
|
|
208
208
|
|
|
209
209
|
For dynamic, async authorization that does not warrant a reusable custom evaluator, use the
|
|
210
210
|
`guards` field. Each guard receives the same `AuthoritiesEvaluationContext` and returns
|
|
211
|
-
`true` on grant, or `false`/a denial string on deny.
|
|
212
|
-
|
|
211
|
+
`true` on grant, or `false`/a denial string on deny. Only `true` grants: anything else
|
|
212
|
+
(`undefined` from a guard that forgot to `return`, `null`, `0`, an object) denies. Guards run
|
|
213
|
+
in sequence and combine with other policy fields via `operator` (default AND).
|
|
213
214
|
|
|
214
215
|
```typescript
|
|
215
216
|
import type { AuthorityGuardFn } from '@frontmcp/auth';
|
|
@@ -293,8 +294,9 @@ authorities: {
|
|
|
293
294
|
|
|
294
295
|
- **Deny on load/read** — loading a gated skill the caller can't access throws
|
|
295
296
|
`AuthorityDeniedError` (MCP code `-32003`), the same as a denied `tools/call`.
|
|
296
|
-
Covers `skills/load` (MCP)
|
|
297
|
-
reads (SEP-2640),
|
|
297
|
+
Covers `skills/load` (MCP) and `skill://<path>/SKILL.md` and `skill://<path>/<file>`
|
|
298
|
+
reads (SEP-2640). Over HTTP, `GET /skills/{id}` answers 404 for such a skill, as
|
|
299
|
+
for an unknown one.
|
|
298
300
|
- **Filter on discovery** — gated skills the caller can't access are removed from
|
|
299
301
|
`skills/search` / `skills/list` (MCP), the `skill://index.json` discovery index and
|
|
300
302
|
skill-path autocomplete (SEP-2640), and `GET /skills` (HTTP).
|
|
@@ -312,14 +314,35 @@ Two limitations to design around:
|
|
|
312
314
|
filtering and will hide the skill from discovery. Use role/permission/claims
|
|
313
315
|
authorities for discoverable skills; input-dependent policies still enforce at
|
|
314
316
|
load time. (Same limitation applies to tools/resources/prompts.)
|
|
315
|
-
- **HTTP skills discovery
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
317
|
+
- **HTTP skills discovery follows `skillsConfig.auth`.** With `'inherit'` (the default),
|
|
318
|
+
`GET /skills`, `/llm.txt` and `/llm_full.txt` run the server's own auth, and gated
|
|
319
|
+
skills are evaluated against the caller it verified. `'api-key'`, `'bearer'` and
|
|
320
|
+
`'public'` surface no claims, so there gated skills are left out of every listing and
|
|
321
|
+
`GET /skills/{id}` answers 404 for them. Ungated skills are unaffected.
|
|
319
322
|
|
|
320
323
|
Boot-time fail-fast covers skills too: a `@Skill` with `authorities` but no configured
|
|
321
324
|
authorities engine fails server startup with `AuthConfigurationError`, exactly like a
|
|
322
|
-
tool
|
|
325
|
+
tool, resource, resource template, prompt, agent, or a tool declared inside an agent
|
|
326
|
+
(hidden entries included).
|
|
327
|
+
|
|
328
|
+
### Rules that check nothing are refused
|
|
329
|
+
|
|
330
|
+
Startup fails with `AuthConfigurationError: Invalid authorities rule: …` when an entry's or a
|
|
331
|
+
profile's rule checks nothing or isn't what it looks like, and `AuthoritiesEngine.evaluate()`
|
|
332
|
+
denies such a rule if it runs anyway (even under `not`):
|
|
333
|
+
|
|
334
|
+
- `{}`, `{ roles: {} }`, `{ roles: { all: [] } }`, `allOf: []`, `guards: []`
|
|
335
|
+
- a misspelled field (`role:`), a profile name no profile has (`authorities: 'admn'`), a profile name inside
|
|
336
|
+
`allOf`/`anyOf`, an `operator` other than `'AND'`/`'OR'`
|
|
337
|
+
- an ABAC condition with no `value`, or one the operator can't use: `exists` needs `true`/`false`;
|
|
338
|
+
`in`/`notIn` a non-empty list; `gt`/`gte`/`lt`/`lte` a number; `startsWith`/`endsWith`/`matches`
|
|
339
|
+
a string (a `{ fromInput }` / `{ fromClaims }` reference works for all but `exists`)
|
|
340
|
+
|
|
341
|
+
To leave an entry open, remove its `authorities`. There is no opt-out.
|
|
342
|
+
|
|
343
|
+
`@Agent({ authorities })` gates the agent's `invoke_<id>` tool like any tool (hidden from
|
|
344
|
+
`tools/list`, refused on `tools/call`), and tools declared inside an agent are checked against
|
|
345
|
+
the caller the agent runs for, also with `execution.useToolFlow: false`.
|
|
323
346
|
|
|
324
347
|
## Scenario Routing Table
|
|
325
348
|
|
|
@@ -379,7 +402,7 @@ tool/resource/prompt/agent.
|
|
|
379
402
|
|
|
380
403
|
| Problem | Cause | Solution |
|
|
381
404
|
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
382
|
-
| `
|
|
405
|
+
| Startup: `Invalid authorities rule: … names an unknown profile "admin"` | Profile used in decorator but not in `authorities.profiles` config | Fix the name, or add the profile to the `profiles` field in `@FrontMcp({ authorities })` |
|
|
383
406
|
| All users denied despite correct roles | `claimsMapping.roles` path does not match the actual JWT claim path | Decode a real JWT and verify the dot-path resolves to the roles array |
|
|
384
407
|
| `authorities` field not recognized on decorator | `@frontmcp/auth` not imported (metadata augmentation not active) | Add `import '@frontmcp/auth'` (or `import type ... from '@frontmcp/auth'`) anywhere in your project to activate the metadata augmentation. There is no `@frontmcp/auth/authorities` subpath. |
|
|
385
408
|
| ABAC condition always fails | `{ fromInput: 'tenantId' }` but tool input field is named `tenant_id` | The `fromInput` key must exactly match the tool's input schema field name |
|
|
@@ -62,7 +62,7 @@ export class MyServer {}
|
|
|
62
62
|
|
|
63
63
|
### Single Profile (String)
|
|
64
64
|
|
|
65
|
-
The simplest form. The named profile's policy is evaluated.
|
|
65
|
+
The simplest form. The named profile's policy is evaluated. A name no registered profile has stops the server at startup with `Invalid authorities rule: … names an unknown profile "name"`, alone or in a list of profiles.
|
|
66
66
|
|
|
67
67
|
```typescript
|
|
68
68
|
@Tool({ name: 'delete_user', authorities: 'admin' })
|
|
@@ -253,7 +253,7 @@ The authorities system does not only enforce on execution. The built-in `filterB
|
|
|
253
253
|
- `tools/list` only returns tools the current user is authorized to call
|
|
254
254
|
- `resources/list` only returns resources the current user can read
|
|
255
255
|
- `prompts/list` only returns prompts the current user can get
|
|
256
|
-
- Skills are filtered on every discovery surface: `skills/search` / `skills/list`, the SEP-2640 `skill://index.json` index + skill-path autocomplete, and `GET /skills`. Loading a gated skill the caller can't access
|
|
256
|
+
- Skills are filtered on every discovery surface: `skills/search` / `skills/list`, the SEP-2640 `skill://index.json` index + skill-path autocomplete, and `GET /skills`. Loading a gated skill the caller can't access via `skills/load` or a `skill://…` read is denied with `AuthorityDeniedError` (`-32003`); over HTTP, `GET /skills/{id}` answers 404, as for an unknown skill.
|
|
257
257
|
|
|
258
258
|
This filtering happens automatically. No additional configuration is needed. Entries without an `authorities` field are always visible.
|
|
259
259
|
|
|
@@ -265,11 +265,17 @@ time, where input is available). For entries (especially **skills**) that must r
|
|
|
265
265
|
discoverable, gate them with role/permission/claims-based authorities such as
|
|
266
266
|
`authorities: 'admin'` or `{ roles: { any: ['admin'] } }`.
|
|
267
267
|
|
|
268
|
-
**HTTP skills discovery
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
skills without `authorities` are served over
|
|
268
|
+
**HTTP skills discovery follows `skillsConfig.auth`.** With `'inherit'` (the default),
|
|
269
|
+
`GET /skills`, `/llm.txt` and `/llm_full.txt` run the server's own auth, and gated skills
|
|
270
|
+
are evaluated against the caller it verified. `'api-key'`, `'bearer'` and `'public'`
|
|
271
|
+
surface no claims, so there authority-gated skills are left out of every listing and
|
|
272
|
+
`GET /skills/{id}` answers 404 for them; skills without `authorities` are served over
|
|
273
|
+
HTTP unchanged.
|
|
274
|
+
|
|
275
|
+
**A profile must check something.** A profile whose rule checks nothing (`{}`,
|
|
276
|
+
`{ roles: { all: [] } }`, a misspelled field, a profile name inside `anyOf`, an ABAC
|
|
277
|
+
condition without a usable `value`) fails startup with `Invalid authorities rule: profile
|
|
278
|
+
"<name>": …`, and the engine denies it.
|
|
273
279
|
|
|
274
280
|
## Profile Design Guidelines
|
|
275
281
|
|
|
@@ -163,6 +163,8 @@ interface AbacCondition {
|
|
|
163
163
|
|
|
164
164
|
An anonymous caller (public mode, `allowAnonymous`, or an `anon:` session) has no `user.sub`, even when `claimsMapping.userId` points at another claim, and a subject that is not a string counts as anonymous too, so `{ path: 'user.sub', op: 'exists', value: true }` admits signed-in callers only. A caller whose identity carries no `sub` at all gets the string `claimsMapping.userId` resolves to as its `user.sub`, when there is one, and so passes `exists`. When `claimsMapping.userId` resolves to something other than a string, a signed-in caller keeps its own `sub`. An expected value that resolves to nothing (for example a missing `fromInput` field) never satisfies `eq` or `match`, even when the actual value is missing too. A denied `tools/call` returns an error result with `_meta.code: 'AUTHORITY_DENIED'`.
|
|
165
165
|
|
|
166
|
+
Every condition needs a `value` the operator can use: `exists` takes `true` or `false`, `in`/`notIn` a non-empty list, `gt`/`gte`/`lt`/`lte` a number, and `startsWith`/`endsWith`/`matches` a string; a `{ fromInput }` / `{ fromClaims }` reference works for all but `exists`. A condition without one (for example `{ path: 'user.sub', op: 'exists' }`, which would admit every caller without a `sub`) fails startup with `Invalid authorities rule`, and the engine denies it.
|
|
167
|
+
|
|
166
168
|
### Dynamic Value References
|
|
167
169
|
|
|
168
170
|
Instead of hardcoding values, reference runtime data from tool input or JWT claims.
|
|
@@ -86,9 +86,9 @@ Signing is **HS256 with a symmetric `JWT_SECRET`** (no key pair). Set a stable `
|
|
|
86
86
|
|
|
87
87
|
Local mode also accepts `allowDefaultPublic` (default `false` — set `true` to admit tokenless requests as anonymous instead of returning 401), `anonymousScopes` (default `['anonymous']` — scopes for those anonymous sessions), and `expectedAudience` (reject tokens minted for a different `aud`).
|
|
88
88
|
|
|
89
|
-
> **Client registration (security):**
|
|
89
|
+
> **Client registration (security):** `requireRegisteredClients` defaults to `true` (local/remote): every client must be registered (DCR / `dcr.clients`) or a CIMD client-id URL, so `redirect_uri` is exact-matched (OAuth 2.1) — this prevents auth-code interception via an attacker-chosen redirect. Set it to `false` only for local development. The server grants only the scopes in `allowedScopes` (default: the OpenID scopes). Confidential clients (`token_endpoint_auth_method: client_secret_basic`/`client_secret_post`) are authenticated with a constant-time `client_secret` check on both the code-exchange and refresh grants (Basic header or body param).
|
|
90
90
|
|
|
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).
|
|
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). The Web fetch handler (`createFetchHandler`, Workers) takes the address from the request URL, never from a `Host` header that disagrees with it.
|
|
92
92
|
|
|
93
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.
|
|
94
94
|
|
|
@@ -141,6 +141,8 @@ Endpoints are derived from `provider` using standard OIDC paths
|
|
|
141
141
|
non-standard IdPs, override them with
|
|
142
142
|
`providerConfig.{authEndpoint,tokenEndpoint,userInfoEndpoint,jwksUri}`.
|
|
143
143
|
|
|
144
|
+
> **MCP client registration (upgrade note):** `requireRegisteredClients` defaults to `true` in remote mode too (it was `false` in 1.8.2 and earlier). Remote mode has no `dcr` block (no pre-registered clients) and FrontMCP's own `/oauth/register` is off in production, so a production remote server with the defaults admits only MCP clients that use a CIMD client-id URL; an unregistered plain `client_id` gets a 400 `Unknown client_id` page. Migrate clients to CIMD, or set `requireRegisteredClients: false` for local development only (an unregistered client's `redirect_uri` can't be checked). FrontMCP grants only the scopes in `allowedScopes` (default: the OpenID scopes), unrelated to `scopes` (what it asks the IdP for).
|
|
145
|
+
|
|
144
146
|
**Deferred (not yet wired):** upstream **Dynamic Client Registration**
|
|
145
147
|
(`providerConfig.dcrEnabled` / `registrationEndpoint`) — a pre-registered
|
|
146
148
|
`clientId` is required; and upstream **token auto-refresh** — once the upstream
|
|
@@ -140,7 +140,9 @@ Declare upstream providers to make multi-provider orchestration a turnkey local
|
|
|
140
140
|
class Server {}
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
-
`UpstreamProviderOptions` fields: `id` (required; used in `getToken(id)`), `authorizationEndpoint`/`authorizeUrl` (required), `tokenEndpoint`/`tokenUrl` (required), `clientId` (required), `clientSecret?`, `scopes?`, `name?`, `userInfoEndpoint?`, `jwksUri?`. The per-provider callback URL is auto-computed as `${issuer}/oauth/provider/${id}/callback` — register that URL with each provider.
|
|
143
|
+
`UpstreamProviderOptions` fields: `id` (required; used in `getToken(id)`), `authorizationEndpoint`/`authorizeUrl` (required), `tokenEndpoint`/`tokenUrl` (required), `clientId` (required), `clientSecret?`, `scopes?`, `name?`, `userInfoEndpoint?`, `jwksUri?`, `issuer?`, `additionalIssuers?`. The per-provider callback URL is auto-computed as `${issuer}/oauth/provider/${id}/callback` — register that URL with each provider.
|
|
144
|
+
|
|
145
|
+
The provider's identity comes from its `id_token` only when it verifies against `jwksUri` with `iss` = the provider's `issuer` (or `additionalIssuers`), `aud` containing `clientId`, and a valid `exp`. A provider with no `issuer` never has its `id_token` used (one IdP key set can sign many tenants' tokens); FrontMCP asks `userInfoEndpoint` instead, and with neither the sign-in fails. With `issuer` set, a callback whose RFC 9207 `iss` names another server gets a 400. Set `issuer` for OIDC providers.
|
|
144
146
|
|
|
145
147
|
Tools read downstream tokens through the `this.orchestration` context extension (available in `local`/`remote` mode):
|
|
146
148
|
|
|
@@ -474,13 +476,14 @@ If the vault is not configured, accessing `this.authProviders` throws (`AuthProv
|
|
|
474
476
|
|
|
475
477
|
## Troubleshooting
|
|
476
478
|
|
|
477
|
-
| Problem
|
|
478
|
-
|
|
|
479
|
-
| `JWKS fetch failed` error on startup
|
|
480
|
-
| Tokens rejected with `invalid audience`
|
|
481
|
-
| Sessions lost after server restart
|
|
482
|
-
| Local-mode tokens invalid after restart
|
|
483
|
-
| OAuth redirect fails in local dev
|
|
479
|
+
| Problem | Cause | Solution |
|
|
480
|
+
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
481
|
+
| `JWKS fetch failed` error on startup | The `provider` URL is unreachable or does not serve `/.well-known/jwks.json` | Verify the provider URL is correct and accessible from the server; check network/firewall rules |
|
|
482
|
+
| Tokens rejected with `invalid audience` | The `expectedAudience` value does not match the `aud` claim in the token | Align the `expectedAudience` config with the audience value your identity provider sets in tokens |
|
|
483
|
+
| Sessions lost after server restart | Using the default in-memory session store in production | Switch to Redis or Vercel KV session store via `configure-session` reference |
|
|
484
|
+
| Local-mode tokens invalid after restart | `JWT_SECRET` unset (random per-process secret) and/or `tokenStorage: 'memory'` | Set a stable `JWT_SECRET`; use `tokenStorage: { sqlite: { path } }` or `{ redis }` to persist codes/refresh tokens |
|
|
485
|
+
| OAuth redirect fails in local dev | `remote` mode requires HTTPS and reachable callback URLs | Set `NODE_ENV=development` to relax HTTPS requirements, or use a local OAuth mock server |
|
|
486
|
+
| Sign-in refused: "started in another browser" | The sign-in binding cookie is missing: `frontmcp_signin_<id>`, or `__Host-frontmcp_signin_<id>` over https (only that name counts there, so a sibling subdomain cannot plant it) | Start and finish the sign-in in one browser with cookies on, at the issuer's host; behind a proxy, pin `FRONTMCP_PUBLIC_URL` and forward `x-forwarded-proto` on every request |
|
|
484
487
|
|
|
485
488
|
## Examples
|
|
486
489
|
|
|
@@ -98,6 +98,19 @@ explicitly to opt out of authentication; use `'api-key'` or `'bearer'` to
|
|
|
98
98
|
override the inherited policy with a Skills-specific one. **In production,
|
|
99
99
|
set `auth` explicitly so the policy is visible at the call site.**
|
|
100
100
|
|
|
101
|
+
With `'inherit'`, `/skills`, `/llm.txt` and `/llm_full.txt` need the same credential
|
|
102
|
+
as the MCP endpoint (401/403 otherwise; only a public-mode server lets everyone in),
|
|
103
|
+
and skills with `authorities` are listed only for a caller whose verified claims
|
|
104
|
+
satisfy them. The other modes surface no claims, so gated skills are never served
|
|
105
|
+
over HTTP there, and `GET /skills/<gated id>` answers 404.
|
|
106
|
+
|
|
107
|
+
Custom routes that guard skills content should call `authorizeSkillHttpRequest(scope,
|
|
108
|
+
skillsConfig, request)` from `@frontmcp/sdk`, which covers every mode.
|
|
109
|
+
`createSkillHttpAuthValidator()` is only for an explicit `'api-key'` or `'bearer'`
|
|
110
|
+
(it returns `null` only for `'public'`); its validator only sees headers, so it
|
|
111
|
+
refuses every request under `'inherit'` or an unset `auth`, and code that treated
|
|
112
|
+
`null` as "no auth needed" must switch to `authorizeSkillHttpRequest`.
|
|
113
|
+
|
|
101
114
|
## Skills HTTP Caching
|
|
102
115
|
|
|
103
116
|
```typescript
|
|
@@ -57,6 +57,23 @@ Not all FrontMCP features are available in browser environments:
|
|
|
57
57
|
| Crypto (`@frontmcp/utils`) | Yes | Uses WebCrypto API |
|
|
58
58
|
| Direct client (`connect()`) | Yes | In-memory connection |
|
|
59
59
|
|
|
60
|
+
### Request context in the browser
|
|
61
|
+
|
|
62
|
+
A browser has no `AsyncLocalStorage`. Unless the runtime provides TC39 `AsyncContext`
|
|
63
|
+
(`getAsyncContextMode()` from `@frontmcp/utils` is then `'native'`), the browser build runs
|
|
64
|
+
requests one at a time (`'serialized'`) so a request never reads another request's session, auth
|
|
65
|
+
info or running tool:
|
|
66
|
+
|
|
67
|
+
- `DirectMcpServer` calls, `connect()` clients and `createFetchHandler` requests take turns. A
|
|
68
|
+
request keeps its turn until it returns and everything it started has unwound; background jobs,
|
|
69
|
+
workflows and tasks take their own turn afterwards.
|
|
70
|
+
- A request waiting on its client (elicitation, `roots/list`) steps aside while it waits.
|
|
71
|
+
- Concurrent tool calls inside one request (`Promise.all`) are refused with
|
|
72
|
+
`AsyncContextOverlapError` once they overlap. Run them one after another.
|
|
73
|
+
- A tool must not call its own server through a `DirectClient`/`DirectMcpServer` (it waits for its
|
|
74
|
+
own turn); use `this.scope` flows. A request that waits more than 10s for its turn logs why.
|
|
75
|
+
- Timers and un-awaited promises must not read request context.
|
|
76
|
+
|
|
60
77
|
## Usage with @frontmcp/react
|
|
61
78
|
|
|
62
79
|
The browser build is commonly paired with `@frontmcp/react` for React applications. `FrontMcpProvider` takes a pre-created `DirectMcpServer` (via the SDK's `create()` factory) — not a `serverUrl`. Hooks for listing/invoking are `useListTools` / `useCallTool`:
|
|
@@ -159,6 +176,8 @@ ls dist/browser/
|
|
|
159
176
|
| CORS errors on tool calls | MCP server missing CORS headers | Configure CORS middleware on the MCP server |
|
|
160
177
|
| Bundle too large | All server-side code included | Use `--target browser` and a dedicated client entry file |
|
|
161
178
|
| `@frontmcp/utils` fs throws | File system ops called in browser | Remove fs calls; use API endpoints or in-memory alternatives |
|
|
179
|
+
| `AsyncContextOverlapError` | Concurrent tool calls inside one request | Await the calls one after another (no `AsyncContext` in browser) |
|
|
180
|
+
| A call never returns | A tool calls its own server via a client | Call other tools through `this.scope` flows |
|
|
162
181
|
|
|
163
182
|
## Examples
|
|
164
183
|
|
|
@@ -27,6 +27,13 @@ short AgentScript program in the Worker isolate, where each `callTool(actionId,
|
|
|
27
27
|
input)` invokes a loaded skill's operation. The bundle is pulled from a SaaS
|
|
28
28
|
endpoint, cached in KV, and refreshed on a Cron Trigger.
|
|
29
29
|
|
|
30
|
+
The meta-tools only show what the caller may use: a skill whose
|
|
31
|
+
`requiredAuthorities` the caller doesn't satisfy is left out of `search_skill`
|
|
32
|
+
(and its catalog) and is `SKILL_NOT_FOUND` for `load_skill`, and `load_skill`
|
|
33
|
+
leaves out actions the caller can never run. When the server configures
|
|
34
|
+
`authorities`, bundle rules are evaluated with the server's engine (its
|
|
35
|
+
`claimsMapping`, resolvers and custom evaluators).
|
|
36
|
+
|
|
30
37
|
For the conceptual picture, see [Skills-Only Deployment](https://docs.agentfront.dev/frontmcp/features/skills-only-deployment).
|
|
31
38
|
For the production-ready decorator build, see [`deploy-to-cloudflare.md`](./deploy-to-cloudflare.md).
|
|
32
39
|
|
|
@@ -53,6 +60,8 @@ For the production-ready decorator build, see [`deploy-to-cloudflare.md`](./depl
|
|
|
53
60
|
|
|
54
61
|
```ts
|
|
55
62
|
// worker.ts — the real API is createEdgeMcp (not createWorker)
|
|
63
|
+
import { env } from 'cloudflare:workers';
|
|
64
|
+
|
|
56
65
|
import { createEdgeMcp, kvBundleCacheFromEnv } from '@frontmcp/edge';
|
|
57
66
|
|
|
58
67
|
export default createEdgeMcp({
|
|
@@ -61,7 +70,10 @@ export default createEdgeMcp({
|
|
|
61
70
|
tasks: { enabled: false },
|
|
62
71
|
managed: {
|
|
63
72
|
endpoint: 'https://cloud.example.com/v1/bundles/acme',
|
|
64
|
-
|
|
73
|
+
// The pull JWT the SaaS issued for this server (iss = expectedIssuer, aud includes
|
|
74
|
+
// expectedAudience, with exp), kept in a Worker secret:
|
|
75
|
+
// npx wrangler secret put FRONTMCP_PULL_TOKEN
|
|
76
|
+
authToken: env.FRONTMCP_PULL_TOKEN,
|
|
65
77
|
expectedAudience: 'acme-mcp',
|
|
66
78
|
jwksUrl: 'https://cloud.example.com/.well-known/jwks.json',
|
|
67
79
|
expectedIssuer: 'https://cloud.example.com',
|
|
@@ -79,6 +91,15 @@ The pull sends `authToken` as a bearer token and never follows a redirect, so
|
|
|
79
91
|
`endpoint` must serve the bundle directly; a 3xx (or a status-0
|
|
80
92
|
`opaqueredirect`) fails the pull.
|
|
81
93
|
|
|
94
|
+
Before every pull, `authToken` itself is verified: it must be a JWT signed by a
|
|
95
|
+
key served at `jwksUrl` (fetched without the token, redirects refused), with
|
|
96
|
+
`iss` equal to `expectedIssuer`, an `aud` that includes `expectedAudience` (and
|
|
97
|
+
a `resource` claim that does too, when it has one), and not expired. A token
|
|
98
|
+
that fails is refused with `[saas-source] pull token rejected: …`: nothing is
|
|
99
|
+
pulled, and the KV cache is **not** used in its place. An unreachable JWKS, or
|
|
100
|
+
one with no usable signing key (no RSA, EC or OKP public key for signatures),
|
|
101
|
+
counts as an ordinary pull failure, so the cache fallback still applies.
|
|
102
|
+
|
|
82
103
|
This path is bundled by **wrangler** (not `frontmcp build`), so you maintain
|
|
83
104
|
`wrangler.toml` yourself — it needs a `[[kv_namespaces]] binding = "BUNDLE_CACHE"`
|
|
84
105
|
and a `[triggers] crontabs = [...]` (the `managed.pollIntervalMs` option is
|
|
@@ -178,6 +178,10 @@ Because `[vars]` reach `process.env`, `npx wrangler dev` sees the same `NODE_ENV
|
|
|
178
178
|
|
|
179
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
180
|
|
|
181
|
+
### Startup checks
|
|
182
|
+
|
|
183
|
+
The server is built on its first request, but the checks the config's metadata settles run when the module evaluates, so `createEdgeMcp` throws and the worker fails to deploy: an `approval` or `featureFlag` field no plugin that reaches the entry enforces (`UnenforcedMetadataError`), or `authorities` without the `authorities` option (`AuthConfigurationError`). A plugin installed on an app reaches only that app's entries, unless one of its hooks is `appliesTo: 'uncovered-apps'` as the built-in approval and feature-flag plugins' are. A tool declared inside an `@Agent` is reached only by that agent's plugins (none with `execution.useToolFlow: false`), and an agent's plugins reach nothing else. The remaining checks run when the first request builds the server.
|
|
184
|
+
|
|
181
185
|
## Step 5: Deploy
|
|
182
186
|
|
|
183
187
|
```bash
|
|
@@ -269,6 +273,10 @@ new_classes = ["FrontMcpSession"]
|
|
|
269
273
|
|
|
270
274
|
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.
|
|
271
275
|
|
|
276
|
+
A session belongs to the caller that opened it: the `Mcp-Session-Id` only addresses the DO, so the flow's `checkPersistentSessionOwner` stage binds the session to the caller of its first request (verified issuer + subject, else its token) and answers anyone else with `404 Session not found` on `POST`, `GET` and `DELETE`, before every protocol handler (2026-07-28 included). The owner is kept in the DO's `state.storage`, so a DO rebuilt after eviction still refuses strangers. On a public server (anonymous callers, no token) the unguessable session id is the only credential. The owner ends its session with `DELETE`.
|
|
277
|
+
|
|
278
|
+
OAuth sign-in works on the worker too, including `/oauth/provider/:providerId/callback` (a federated provider's redirect and the federated consent submission): path parameters are matched like Express.
|
|
279
|
+
|
|
272
280
|
## Storage Options
|
|
273
281
|
|
|
274
282
|
| Storage | Use Case | Notes |
|
package/catalog/frontmcp-development/examples/openapi-adapter/authenticated-adapter-with-polling.md
CHANGED
|
@@ -20,8 +20,11 @@ Demonstrates configuring authentication (API key and bearer token) and automatic
|
|
|
20
20
|
|
|
21
21
|
```typescript
|
|
22
22
|
// src/server.ts
|
|
23
|
-
import { FrontMcp, App } from '@frontmcp/sdk';
|
|
24
23
|
import { OpenapiAdapter } from '@frontmcp/adapters';
|
|
24
|
+
import { App, FrontMcp } from '@frontmcp/sdk';
|
|
25
|
+
|
|
26
|
+
// Your code: returns a token issued for the upstream API (secrets manager, token exchange, ...)
|
|
27
|
+
declare function getEvolvingApiToken(ctx: unknown): Promise<string>;
|
|
25
28
|
|
|
26
29
|
@App({
|
|
27
30
|
name: 'integrations',
|
|
@@ -51,8 +54,9 @@ import { OpenapiAdapter } from '@frontmcp/adapters';
|
|
|
51
54
|
name: 'evolving-api',
|
|
52
55
|
url: 'https://api.example.com/openapi.json',
|
|
53
56
|
baseUrl: 'https://api.example.com',
|
|
54
|
-
securityResolver: (tool, ctx) => {
|
|
55
|
-
|
|
57
|
+
securityResolver: async (tool, ctx) => {
|
|
58
|
+
// A credential issued for this API (never the caller's own ctx.authInfo.token)
|
|
59
|
+
return { jwt: await getEvolvingApiToken(ctx) };
|
|
56
60
|
},
|
|
57
61
|
polling: {
|
|
58
62
|
intervalMs: 300000, // Re-fetch spec every 5 minutes
|
|
@@ -190,11 +190,13 @@ Both `@Will` and `@Did` (and `@Around`) accept an optional options object:
|
|
|
190
190
|
@Will('execute', {
|
|
191
191
|
priority: 10, // Lower runs first (default: 0)
|
|
192
192
|
filter: (ctx) => ctx.toolName !== 'health_check', // Predicate to skip
|
|
193
|
+
appliesTo: 'own-app', // Reach of an app plugin's hook (default: 'own-app')
|
|
193
194
|
})
|
|
194
195
|
```
|
|
195
196
|
|
|
196
197
|
- **priority** (`number`) - Execution order when multiple hooks target the same stage. Lower values run first, for `@Will`, `@Did` and `@Around` alike. Default: `0`.
|
|
197
198
|
- **filter** (`(ctx) => boolean`) - A predicate that receives the flow context. Return `false` to skip this hook for the current invocation.
|
|
199
|
+
- **appliesTo** (`'own-app' | 'uncovered-apps'`) - A hook of a plugin installed on an app runs, in `tools/call`, `resources/read`, `prompts/get` and `completion/complete`, only for that app's entries (`'own-app'`). With `'uncovered-apps'` it also runs for the entries of any app that has no instance of the same hook (same class and method) of its own or from a server-level plugin. Use it for gates an entry's metadata asks for (approval, feature flags), so the entry is not left ungated when the plugin sits on another app. Server-level plugins' hooks, and list-flow hooks, already run for every app.
|
|
198
200
|
|
|
199
201
|
## Examples
|
|
200
202
|
|
|
@@ -104,6 +104,13 @@ export const databaseProvider = AsyncProvider({
|
|
|
104
104
|
});
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
`scope` is `ProviderScope.GLOBAL` (the default: one instance per process/worker, right for
|
|
108
|
+
pools and clients) or `ProviderScope.CONTEXT` (one instance per request). A client that opens a
|
|
109
|
+
session (MCP before 2026-07-28) keeps one CONTEXT instance for the session the server verified;
|
|
110
|
+
any other caller, such as one with a token under 2026-07-28, gets new instances on every request,
|
|
111
|
+
never another caller's. State that must outlive a request belongs in a GLOBAL provider or in
|
|
112
|
+
storage keyed by the caller's identity.
|
|
113
|
+
|
|
107
114
|
## Step 3: Register in @App or @FrontMcp
|
|
108
115
|
|
|
109
116
|
```typescript
|
|
@@ -160,8 +160,9 @@ One policy decides every CodeCall surface: `codecall:search`, `codecall:describe
|
|
|
160
160
|
```typescript
|
|
161
161
|
CodeCallPlugin.init({
|
|
162
162
|
mode: 'codecall_only',
|
|
163
|
-
// `tool` is { name, appId, source, description, tags
|
|
164
|
-
|
|
163
|
+
// `tool` is { name, fullName, appId, source, description, tags, annotations, metadata }, read-only;
|
|
164
|
+
// `name` is the tool's own name, never `<appId>:<name>`
|
|
165
|
+
includeTools: (tool) => !tool.name.startsWith('admin:') && !tool.annotations?.destructiveHint,
|
|
165
166
|
directCalls: {
|
|
166
167
|
enabled: true,
|
|
167
168
|
allowedTools: ['users:list', 'crm:users:get'], // bare name, or `<appId>:<name>` to pin one app
|
|
@@ -174,6 +175,9 @@ CodeCallPlugin.init({
|
|
|
174
175
|
- `codecall:searchSkills` and `codecall:searchKnowledge` run the SDK's `skills:filter` flow, so a skill a plugin withholds there (a flag-disabled skill, for one) is absent from both.
|
|
175
176
|
- `directCalls.allowedTools` and `directCalls.filter` only narrow the base policy; listing a withheld tool does not make it callable. Unlisted tools are refused.
|
|
176
177
|
- Hiding a tool from search is not the control; the refusal at execution is. Do not rely on `visibleInListTools` or search ranking to protect a tool.
|
|
178
|
+
- `includeTools` and `directCalls.filter` receive the same object, with the tool's `annotations` and declared `metadata` (`tool.metadata?.annotations` is the same object as `tool.annotations`). It is a deep read-only copy, so a filter cannot change what the next decision reads.
|
|
179
|
+
- Namespace bindings (`mail.send({...})` for a tool named `mail.send`) are AgentScript wrappers over `callTool()` inside the sandbox: they count toward `vm.maxSteps` and pass the rate limit and suspicious-sequence checks exactly like `callTool('mail.send', {...})`. A binding with no argument sends `{}`.
|
|
180
|
+
- `codecall:execute` results never include a `stack`, in any environment. In `runtime_error`, `syntax_error` and `tool_error` messages, stack frames are dropped and absolute paths (POSIX, Windows, UNC, `file:` URLs, quoted paths) become `[path]`; other URLs are kept.
|
|
177
181
|
|
|
178
182
|
### Power Features
|
|
179
183
|
|
|
@@ -277,17 +281,21 @@ class MyTool extends ToolContext {
|
|
|
277
281
|
|
|
278
282
|
### Memory Scopes
|
|
279
283
|
|
|
280
|
-
- `session` --
|
|
284
|
+
- `session` -- Default scope. With a verified session, valid only for that session and cleared
|
|
285
|
+
when it ends. Without one (stateless transport, MCP 2026-07-28), it belongs to the authenticated
|
|
286
|
+
principal and lasts across that principal's requests until its TTL, not per request.
|
|
281
287
|
- `user` -- Persists for the user across sessions. Tied to user identity.
|
|
282
|
-
- `tool` -- Scoped to a specific tool
|
|
288
|
+
- `tool` -- Scoped to a specific tool plus the same identity as `session` (the verified session,
|
|
289
|
+
else the authenticated principal). Isolated per tool.
|
|
283
290
|
- `global` -- Shared across all sessions and users. Use carefully.
|
|
284
291
|
|
|
285
|
-
**`session`, `tool`, and `user` scopes require a per-client identity.**
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
292
|
+
**`session`, `tool`, and `user` scopes require a per-client identity.** `session` and `tool`
|
|
293
|
+
memory belong to the session the server verified, never to an `mcp-session-id` the client merely
|
|
294
|
+
sends. A stateless HTTP transport (shared `__stateless__` id), MCP 2026-07-28 (no sessions) and an
|
|
295
|
+
unverified `mcp-session-id` carry no session identity: `session` and `tool` scope fall back to the
|
|
296
|
+
authenticated principal, and an unauthenticated request without a verified session is refused
|
|
297
|
+
with a `RememberIdentityError` rather than given a namespace shared with other clients. `user`
|
|
298
|
+
scope is refused with no authenticated user. If the data really is shared, use `scope: 'global'`.
|
|
291
299
|
|
|
292
300
|
**Set `REMEMBER_SECRET` on every instance that shares a store.** All scopes, `session` and
|
|
293
301
|
`tool` included, derive their encryption key from that secret plus the scope identity. A
|
|
@@ -405,11 +413,14 @@ authInfo.extra.approvalContext = { type: 'project', identifier: resolvedProjectI
|
|
|
405
413
|
### How the check decides
|
|
406
414
|
|
|
407
415
|
1. `skipApproval: true`, or approval not required: the tool runs.
|
|
408
|
-
2. A recorded **denial** for the caller (session or
|
|
409
|
-
denial outranks pre-approved contexts and any
|
|
416
|
+
2. A recorded **denial** for the caller (session, user, time-limited or context scope): refused
|
|
417
|
+
with state `denied`. A denial outranks pre-approved contexts and any approval.
|
|
410
418
|
3. The session context is one of `preApprovedContexts`: the tool runs.
|
|
411
419
|
4. `alwaysPrompt: true`: refused with state `pending`.
|
|
412
|
-
5.
|
|
420
|
+
5. An approval for the caller that the tool's policy accepts: the tool runs. The caller's session,
|
|
421
|
+
user, time-limited and context approvals all count (a context approval only when the session
|
|
422
|
+
carries that context); its scope must be in `allowedScopes`, and it must be younger than
|
|
423
|
+
`maxTtlMs`, however it was stored.
|
|
413
424
|
6. Otherwise refused with state `pending` (or `expired`).
|
|
414
425
|
|
|
415
426
|
A refused call throws `ApprovalRequiredError`; the client receives an error result.
|
|
@@ -427,9 +438,12 @@ cannot hold a session approval. Releases up to 1.8.1 keyed a request without `mc
|
|
|
427
438
|
its per-request id, so the grant was never found again
|
|
428
439
|
([#597](https://github.com/agentfront/frontmcp/issues/597)).
|
|
429
440
|
|
|
430
|
-
Installed on an app, `ApprovalPlugin` gates
|
|
431
|
-
|
|
432
|
-
|
|
441
|
+
Installed on an app, `ApprovalPlugin` gates that app's tools (including those its adapters and
|
|
442
|
+
plugins provide) against its own store, so two apps can each install it with separate stores. It
|
|
443
|
+
also gates, against its store, the `approval` tools of apps with no approval plugin of their own,
|
|
444
|
+
so such a tool never runs ungated because the plugin sits on another app (releases up to 1.8.2 ran
|
|
445
|
+
them for anyone). Installed on the server, it gates every tool; a tool several plugins gate must
|
|
446
|
+
pass each store's check, and a denial in any of them refuses the call.
|
|
433
447
|
`this.approval` resolves the `ApprovalService` of the nearest `ApprovalPlugin` -- the one the
|
|
434
448
|
tool's own app installed, otherwise the server's -- so with two apps each installing it, a grant
|
|
435
449
|
or check in one app's tool uses that app's store. Releases up to 1.8.1 resolved the store of the
|
|
@@ -456,8 +470,11 @@ class DangerousActionTool extends ToolContext {
|
|
|
456
470
|
// await this.approval.getSessionApprovals() -- List session approvals
|
|
457
471
|
// await this.approval.getUserApprovals() -- List user approvals
|
|
458
472
|
// await this.approval.grantUserApproval('tool-id') -- Persist across sessions
|
|
459
|
-
// await this.approval.grantTimeLimitedApproval('tool-id', 60000) -- Auto-expire
|
|
460
|
-
// await this.approval.revokeApproval('tool-id') -- Revoke
|
|
473
|
+
// await this.approval.grantTimeLimitedApproval('tool-id', 60000) -- Auto-expire (ttlMs > 0)
|
|
474
|
+
// await this.approval.revokeApproval('tool-id') -- Revoke the caller's session, user,
|
|
475
|
+
// time-limited and context approvals;
|
|
476
|
+
// returns whether any was revoked
|
|
477
|
+
// (recorded denials are kept)
|
|
461
478
|
|
|
462
479
|
return { content: [{ type: 'text', text: 'Action completed' }] };
|
|
463
480
|
}
|
|
@@ -477,6 +494,8 @@ import { ApprovalScope } from '@frontmcp/plugin-approval';
|
|
|
477
494
|
category: 'write',
|
|
478
495
|
riskLevel: 'medium', // 'low' | 'medium' | 'high' | 'critical'
|
|
479
496
|
approvalMessage: 'Allow file writing for this session?',
|
|
497
|
+
allowedScopes: [ApprovalScope.SESSION, ApprovalScope.TIME_LIMITED],
|
|
498
|
+
maxTtlMs: 60 * 60 * 1000,
|
|
480
499
|
},
|
|
481
500
|
})
|
|
482
501
|
class FileWriteTool extends ToolContext {
|
|
@@ -484,6 +503,12 @@ class FileWriteTool extends ToolContext {
|
|
|
484
503
|
}
|
|
485
504
|
```
|
|
486
505
|
|
|
506
|
+
- `allowedScopes` is enforced: granting another scope through `this.approval` throws
|
|
507
|
+
`ApprovalScopeNotAllowedError`, and a stored approval of another scope does not open the gate.
|
|
508
|
+
- `maxTtlMs` is enforced: a longer `grantTimeLimitedApproval()` throws `ApprovalOperationError`,
|
|
509
|
+
grants without a TTL get `maxTtlMs`, and no approval counts beyond `grantedAt + maxTtlMs`.
|
|
510
|
+
- A `ttlMs` of 0, a negative number, `NaN` or `Infinity` throws, and a time-limited grant needs one.
|
|
511
|
+
|
|
487
512
|
When `approval.required` is `true`, the plugin automatically intercepts tool execution and checks approval status before allowing the tool to run.
|
|
488
513
|
|
|
489
514
|
---
|
|
@@ -770,7 +795,7 @@ The `toolName` completion of `ui://widget/{toolName}.html` runs the caller's `to
|
|
|
770
795
|
|
|
771
796
|
The skill catalog in the `initialize` instructions (and the SEP-2640 `skill://` hints under `skillsConfig.sep2640InInstructions`) is filtered for the initializing client too, so a flag-disabled skill's name and description never appear there ([#603](https://github.com/agentfront/frontmcp/issues/603)). A skill's `skill://<path>/SKILL.md` entry in `resources/list` is gated by the skill that path serves now -- replace a skill at the same path and the entry takes the new skill's flag ([#606](https://github.com/agentfront/frontmcp/issues/606)).
|
|
772
797
|
|
|
773
|
-
Installed on an `@App`, the gates cover every capability that app provides, including tools, resources and prompts contributed by its adapters (e.g. an OpenAPI adapter) and plugins. Its tool, resource, prompt and completion gates
|
|
798
|
+
Installed on an `@App`, the gates cover every capability that app provides, including tools, resources and prompts contributed by its adapters (e.g. an OpenAPI adapter) and plugins. Its tool, resource, prompt and completion gates also cover the flagged capabilities of apps with no feature-flag plugin of their own, but not those of an app that installs its own -- install it in `@FrontMcp({ plugins })` to gate every app with one adapter. Listings follow the same copy of the plugin as the gates (a plugin on each of two apps decides each app's entries, listed and served, with that app's flags); up to 1.8.3 every copy filtered every app's listings. Releases up to 1.8.2 hid another app's flagged-off tool from `tools/list` but still ran it when called by name. Resources and prompts served outside every app (the SEP-2640 `skill://` resources) are gated by every installed copy. Skills are gated through the `skills:filter` flow, which every skill surface runs -- as the calling user on every transport, stdio and in-memory included; custom plugins can hook `Did('filterSkills')` on it the same way, and reuse `filterServableSkills(scope, skills)` from `@frontmcp/sdk` to serve skills from a surface of their own.
|
|
774
799
|
|
|
775
800
|
---
|
|
776
801
|
|
|
@@ -819,7 +844,7 @@ interface DashboardPluginOptionsInput {
|
|
|
819
844
|
basePath?: string; // Default: '/dashboard'
|
|
820
845
|
auth?: {
|
|
821
846
|
enabled?: boolean; // Default: false
|
|
822
|
-
token?: string; //
|
|
847
|
+
token?: string; // Bearer / x-frontmcp-dashboard-token; ?token=xxx for the page only
|
|
823
848
|
};
|
|
824
849
|
cdn?: {
|
|
825
850
|
entrypoint?: string; // Custom UI bundle URL
|
|
@@ -831,9 +856,9 @@ interface DashboardPluginOptionsInput {
|
|
|
831
856
|
}
|
|
832
857
|
```
|
|
833
858
|
|
|
834
|
-
- `enabled` -- When omitted, the dashboard is automatically enabled in development (`NODE_ENV !== 'production'`) and disabled in production.
|
|
835
|
-
- `basePath` -- URL path where the dashboard is served. Default: `'/dashboard'`.
|
|
836
|
-
- `auth.enabled` / `auth.token` -- Gate the dashboard
|
|
859
|
+
- `enabled` -- When omitted, the dashboard is automatically enabled in development (`NODE_ENV !== 'production'`) and disabled in production. Disabled means disabled everywhere: the page and the dashboard's MCP endpoint answer 404, and its tools refuse with `DASHBOARD_DISABLED` on every transport (`createDirect` and stdio included). To keep the dashboard in production, set `enabled: true` with `auth`.
|
|
860
|
+
- `basePath` -- URL path where the dashboard page is served. Default: `'/dashboard'`. The page's MCP client talks to the dashboard app's own route (`/dashboard`, after the server's `entryPath`), which `basePath` does not move.
|
|
861
|
+
- `auth.enabled` / `auth.token` -- Gate the dashboard on a shared secret: the page, its MCP endpoint and its tools. The page takes `Authorization: Bearer <token>` (preferred) or `?token=<value>`, and sets an HttpOnly `SameSite=Strict` cookie (an HMAC of the token, never the token) that its own MCP client uses. MCP clients send `Authorization: Bearer <token>`, or `x-frontmcp-dashboard-token: <token>` when the server's own auth uses `Authorization`; `?token=` is refused there. Without the token the MCP endpoint answers 401 (`WWW-Authenticate: Bearer realm="frontmcp-dashboard"`). `enabled: true` without a `token` is a **startup error** — the server refuses to boot rather than serve an "authenticated" dashboard with nothing to check. The token is compared in constant time and is never embedded in the served page.
|
|
837
862
|
- `cdn` -- Override default CDN URLs for the dashboard UI bundle and its dependencies. Useful for air-gapped environments.
|
|
838
863
|
|
|
839
864
|
### Security
|
|
@@ -843,12 +868,12 @@ interface DashboardPluginOptionsInput {
|
|
|
843
868
|
The dashboard's MCP scope **inherits the server's authentication**. Its introspection tools (`dashboard:graph`, `dashboard:list-tools`, `dashboard:list-resources`) reach the root scope and enumerate every app, tool, resource and prompt on the server — including names, descriptions and (on request) schemas. Two consequences:
|
|
844
869
|
|
|
845
870
|
- On an authenticated server (`local`, `remote`, `transparent`, `orchestrated`), the dashboard requires the same credential as everything else.
|
|
846
|
-
- On a **public** server
|
|
871
|
+
- On a **public** server, `auth.token` is what keeps the inventory private: it gates the page, the dashboard's MCP endpoint (SSE stream and POSTs included) and the tools. Releases up to 1.8.2 gated only the page, answered MCP with the dashboard disabled, and pointed the page at `<basePath>/sse`.
|
|
847
872
|
|
|
848
873
|
Three further limitations worth knowing:
|
|
849
874
|
|
|
850
|
-
- **The bundled page cannot authenticate itself against a non-public server.**
|
|
851
|
-
- The token is accepted as `Authorization: Bearer <token>` (scheme matched case-insensitively)
|
|
875
|
+
- **The bundled page cannot authenticate itself against a non-public server.** Its cookie carries the dashboard token only; the browser client opens `EventSource(sseUrl)` and POSTs with no `Authorization` header, and the server's own authentication reads its credential from that header. So on a server with `local`/`remote`/`transparent`/`orchestrated` auth the page loads 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 MCP routes (`/dashboard/sse` and `/dashboard/message`, after the server's `entryPath`) 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.
|
|
876
|
+
- The token is accepted as `Authorization: Bearer <token>` (scheme matched case-insensitively), as `x-frontmcp-dashboard-token` on the MCP endpoint, through the page's cookie, and as `?token=` for the page only. Prefer a header: a URL token lands in browser history, `Referer` headers and access logs.
|
|
852
877
|
- 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.
|
|
853
878
|
|
|
854
879
|
---
|
|
@@ -89,8 +89,9 @@ OpenapiAdapter.init({
|
|
|
89
89
|
name: 'my-api',
|
|
90
90
|
url: 'https://api.example.com/openapi.json',
|
|
91
91
|
baseUrl: 'https://api.example.com',
|
|
92
|
-
securityResolver: (tool, ctx) => {
|
|
93
|
-
|
|
92
|
+
securityResolver: async (tool, ctx) => {
|
|
93
|
+
// A credential issued for the API, never the caller's own ctx.authInfo.token
|
|
94
|
+
return { jwt: await getApiToken(ctx) };
|
|
94
95
|
},
|
|
95
96
|
});
|
|
96
97
|
|
|
@@ -110,17 +111,33 @@ OpenapiAdapter.init({
|
|
|
110
111
|
url: 'https://api.example.com/openapi.json',
|
|
111
112
|
baseUrl: 'https://api.example.com',
|
|
112
113
|
headersMapper: (ctx, headers) => {
|
|
113
|
-
|
|
114
|
+
const tenantId = ctx.authInfo.user?.tenantId;
|
|
115
|
+
if (tenantId) headers.set('x-tenant-id', tenantId);
|
|
114
116
|
return headers;
|
|
115
117
|
},
|
|
116
118
|
});
|
|
119
|
+
|
|
120
|
+
// Opt-in token passthrough (High Risk) — only when the API accepts tokens issued for this MCP server
|
|
121
|
+
OpenapiAdapter.init({
|
|
122
|
+
name: 'same-issuer-api',
|
|
123
|
+
url: 'https://api.example.com/openapi.json',
|
|
124
|
+
baseUrl: 'https://api.example.com',
|
|
125
|
+
passthroughCallerToken: true,
|
|
126
|
+
});
|
|
117
127
|
```
|
|
118
128
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
|
122
|
-
|
|
|
123
|
-
|
|
|
129
|
+
With no `authProviderMapper`, `securityResolver` or `staticAuth`, the adapter sends **no** credentials: operations that require auth fail with `Authentication required for tool '…'` and a `SECURITY WARNING` is logged at startup. The caller's MCP token (`ctx.authInfo.token`) is never forwarded implicitly — not by default, and not when an `authProviderMapper` function returns `undefined` — because passing it to another API is token passthrough, which the MCP specification forbids. `passthroughCallerToken: true` is the explicit opt-in, used only after every other credential source came up empty.
|
|
130
|
+
|
|
131
|
+
| Risk Level | Strategy | Description |
|
|
132
|
+
| ---------- | ----------------------------------------------------------- | ---------------------------------------------------- |
|
|
133
|
+
| LOW | `authProviderMapper` or `securityResolver` | Auth from user context, not exposed to clients |
|
|
134
|
+
| MEDIUM | `staticAuth`, `additionalHeaders`, or none | Static credentials, or no credentials at all |
|
|
135
|
+
| HIGH | `includeSecurityInInput: true`, or `securitySchemesInInput` | Auth fields exposed to MCP clients (not recommended) |
|
|
136
|
+
| HIGH | `passthroughCallerToken: true` | The MCP client's own token is sent to the API |
|
|
137
|
+
|
|
138
|
+
`passthroughCallerToken: true` scores HIGH alongside an `authProviderMapper` too (the token is sent when no mapper function returns a credential); only a `securityResolver` or a non-empty `staticAuth` leaves it unused.
|
|
139
|
+
|
|
140
|
+
Resolution order: `securityResolver` → `authProviderMapper` → `staticAuth` (fills every credential no mapper function returned; a mapped value wins) → `passthroughCallerToken`. A security scheme with no `authProviderMapper` entry is refused at startup unless `staticAuth` covers it, `additionalHeaders` carries its credential, `headersMapper` may set it (a header or cookie scheme, checked on each request), or `passthroughCallerToken` does for an HTTP bearer scheme (it sends the caller's token for it and logs a `SECURITY WARNING`; the caller's token never fills an API key, basic, OAuth2 or OpenID Connect scheme). An operation that requires auth is sent only with a credential for one of its own schemes, from a credential option, the tool input (`securitySchemesInInput`, `includeSecurityInInput`), `additionalHeaders` or `headersMapper`; otherwise it fails with `Authentication required for tool '…'`. A tool-input credential is used for a scheme only when no other source supplies one (a server credential always wins).
|
|
124
141
|
|
|
125
142
|
## Spec Polling
|
|
126
143
|
|
|
@@ -18,8 +18,9 @@ These checks apply to ALL deployment targets. Run them first, then proceed to yo
|
|
|
18
18
|
- [ ] Session TTL is configured appropriately (not infinite)
|
|
19
19
|
- [ ] Tool-level authorization is enforced where needed (ApprovalPlugin or custom)
|
|
20
20
|
- [ ] OAuth redirect URIs are restricted to known domains
|
|
21
|
-
- [ ] `FRONTMCP_PUBLIC_URL` is pinned to the canonical origin — issuer / resource / OAuth-discovery URLs and the transparent-mode expected audience derive from it, not from request headers. `X-Forwarded-Host`/`X-Forwarded-Proto` are ignored by default; only set `FRONTMCP_TRUST_PROXY=1` behind a proxy that strips client-supplied forwarded headers
|
|
22
|
-
- [ ] `auth.requireRegisteredClients
|
|
21
|
+
- [ ] `FRONTMCP_PUBLIC_URL` is pinned to the canonical origin — issuer / resource / OAuth-discovery URLs and the transparent-mode expected audience derive from it, not from request headers. `X-Forwarded-Host`/`X-Forwarded-Proto` are ignored by default; only set `FRONTMCP_TRUST_PROXY=1` behind a proxy that strips client-supplied forwarded headers. The Web fetch handler (`createFetchHandler`, Workers) takes the address from the request URL, never from a `Host` header that disagrees with it
|
|
22
|
+
- [ ] `auth.requireRegisteredClients` left at its default `true` (local/remote) so unknown clients can't present an attacker-chosen `redirect_uri` (auth-code interception). Clients register via DCR / `dcr.clients` / CIMD; a production remote-mode server has neither DCR nor `dcr.clients`, so its MCP clients must use CIMD
|
|
23
|
+
- [ ] `auth.allowedScopes` lists the scopes the server may grant (local/remote); anything else a client asks for is dropped
|
|
23
24
|
- [ ] Transparent mode sets `auth.expectedAudience` (bind tokens to this resource) and validates issuer (`providerConfig.verifyIssuer`, default on)
|
|
24
25
|
|
|
25
26
|
### CORS Configuration
|
|
@@ -23,6 +23,7 @@ Target-specific checklist for publishing FrontMCP as a browser-compatible SDK.
|
|
|
23
23
|
- [ ] All file operations removed or polyfilled
|
|
24
24
|
- [ ] Fetch API used instead of Node http/https modules
|
|
25
25
|
- [ ] Works in major browsers (Chrome, Firefox, Safari, Edge)
|
|
26
|
+
- [ ] Without `AsyncContext` requests run one at a time: tools don't start concurrent tool calls inside one request, don't call their own server through a client, and don't read request context from timers
|
|
26
27
|
|
|
27
28
|
## Security
|
|
28
29
|
|