@frontmcp/skills 1.8.4 → 1.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/catalog/create-tool/examples/23-tool-with-ui-filesource-tsx.md +6 -3
  2. package/catalog/create-tool/examples/27-tool-with-examples-metadata.md +3 -2
  3. package/catalog/create-tool/references/elicitation.md +1 -0
  4. package/catalog/create-tool/references/file-layout.md +2 -0
  5. package/catalog/create-tool/references/ui-widgets.md +31 -2
  6. package/catalog/create-tool/rules/widget-paths-anchor-with-import-meta-url.md +10 -3
  7. package/catalog/frontmcp-channels/references/channel-sources.md +7 -1
  8. package/catalog/frontmcp-config/examples/configure-auth-modes/local-behind-tunnel.md +8 -7
  9. package/catalog/frontmcp-config/examples/configure-throttle/distributed-redis-throttle.md +8 -0
  10. package/catalog/frontmcp-config/references/configure-auth-modes.md +2 -0
  11. package/catalog/frontmcp-config/references/configure-auth.md +4 -1
  12. package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +19 -4
  13. package/catalog/frontmcp-config/references/configure-throttle.md +25 -7
  14. package/catalog/frontmcp-deployment/examples/deploy-to-vercel/vercel-mcp-endpoint-test.md +1 -1
  15. package/catalog/frontmcp-deployment/examples/deploy-to-vercel/vercel-with-kv.md +1 -1
  16. package/catalog/frontmcp-deployment/examples/deploy-to-vercel/vercel-with-skills-cache.md +1 -1
  17. package/catalog/frontmcp-deployment/examples/deploy-to-vercel-config/minimal-vercel-config.md +7 -3
  18. package/catalog/frontmcp-deployment/examples/deploy-to-vercel-config/vercel-config-with-security-headers.md +4 -3
  19. package/catalog/frontmcp-deployment/references/build-for-browser.md +2 -0
  20. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +10 -0
  21. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +13 -5
  22. package/catalog/frontmcp-deployment/references/deploy-to-vercel-config.md +15 -3
  23. package/catalog/frontmcp-deployment/references/deploy-to-vercel.md +1 -1
  24. package/catalog/frontmcp-deployment/references/protocol-versions.md +10 -1
  25. package/catalog/frontmcp-development/examples/official-plugins/cache-and-feature-flags.md +5 -0
  26. package/catalog/frontmcp-development/examples/official-plugins/remember-plugin-session-memory.md +7 -5
  27. package/catalog/frontmcp-development/references/create-agent.md +4 -0
  28. package/catalog/frontmcp-development/references/create-skill-with-tools.md +2 -0
  29. package/catalog/frontmcp-development/references/create-skill.md +3 -1
  30. package/catalog/frontmcp-development/references/official-plugins.md +59 -7
  31. package/catalog/frontmcp-development/references/openapi-adapter.md +7 -7
  32. package/catalog/frontmcp-production-readiness/examples/production-vercel/vercel-edge-config.md +1 -0
  33. package/catalog/frontmcp-production-readiness/references/common-checklist.md +4 -0
  34. package/catalog/frontmcp-setup/references/setup-redis.md +37 -7
  35. package/catalog/frontmcp-testing/references/setup-testing.md +2 -1
  36. package/catalog/frontmcp-testing/references/test-e2e-handler.md +1 -0
  37. package/catalog/skills-manifest.json +7 -4
  38. package/package.json +1 -1
@@ -16,7 +16,7 @@ Tool with a `.tsx` widget in a separate file via the `FileSource` form — the r
16
16
 
17
17
  For any React widget, FileSource is the right pattern. The widget lives in its own `.widget.tsx` file with its own React imports; the tool decorator just points at it.
18
18
 
19
- > **Prerequisite:** `@frontmcp/ui` installed at the same version as `@frontmcp/sdk`. Without it, server-side bundling fails — the framework injects an auto-generated React mount that imports `McpBridgeProvider` from `@frontmcp/ui/react`.
19
+ > **Prerequisites:** `@frontmcp/ui` installed at the same version as `@frontmcp/sdk` — the framework injects an auto-generated React mount that imports `McpBridgeProvider` from `@frontmcp/ui/react`. And `esbuild` installed as a runtime dependency (`npm install esbuild`, not a devDependency) — `@frontmcp/uipack` loads it to bundle the widget when the tool is called. `frontmcp create` projects get `esbuild` through the `frontmcp` package.
20
20
 
21
21
  ## Code
22
22
 
@@ -41,8 +41,10 @@ import { Tool, ToolContext } from '@frontmcp/sdk';
41
41
 
42
42
  import { inputSchema, outputSchema, type SalesChartInput, type SalesChartOutput } from './sales-chart.schema';
43
43
 
44
- // Anchor the widget path to THIS source file — bare relative paths resolve
45
- // against process.cwd() (issue #444), which fails in any non-trivial layout.
44
+ // Anchor the widget path to THIS file — bare relative paths resolve against
45
+ // process.cwd() (issue #444), which fails in any non-trivial layout. Once
46
+ // compiled, this resolves next to the compiled file, so the widget must ship
47
+ // with the build (`frontmcp build` copies *.widget.tsx files — #649).
46
48
  const widgetPath = fileURLToPath(new URL('./sales-chart.widget.tsx', import.meta.url));
47
49
 
48
50
  @Tool({
@@ -107,6 +109,7 @@ export default function SalesChartWidget({ output }: Props) {
107
109
  ## Why these defaults matter
108
110
 
109
111
  - **`import.meta.url` anchoring** — relative paths in `FileSource` resolve against `process.cwd()`, not the tool file (#444). Running the server from a different directory breaks the widget at tool-call time. Anchoring fixes it once.
112
+ - **Ship the widget** — the anchored path points next to the **compiled** file after a build, and tsc doesn't emit `*.widget.tsx`. `frontmcp build` copies widget files into the output (directly next to the bundle for the bundled `node` / `cli` / `lambda` / `vercel` targets, so keep widget file names unique); a plain `tsc` build needs its own copy step (#649).
110
113
  - **`resourceMode` unset** — leave it. The framework picks `'inline'` for Claude (React bundled into the widget — actually renders) and `'cdn'` for everyone else (smaller payload via esm.sh). Setting it explicitly only locks in one behavior across all clients.
111
114
  - **`hydrate: false`** — default. React SSR output is static HTML; the bridge IIFE handles any interactivity. Enabling hydration creates React error #418 in Claude's iframe sandbox where the client-side render diverges from the SSR render.
112
115
  - **`*.widget.tsx` naming** — the scaffolded `tsconfig.json` excludes `**/*.widget.tsx` from the server typecheck (#445). The widget compiles via uipack/esbuild at render time with its own React-aware config.
@@ -14,7 +14,7 @@ features:
14
14
 
15
15
  Tool with the `examples: [...]` field on `@Tool({...})` — concrete input (and optional expected output) examples consumed by the CodeCall `codecall:describe` tool to give agents accurate usage examples.
16
16
 
17
- The `examples` field is purely advisory — the CodeCall `describe` tool uses it as the highest-priority source of usage examples (user-provided examples take precedence over auto-generated ones, up to 5). It is **not** emitted in the `tools/list` MCP response. Use it for any tool that benefits from concrete usage hints when CodeCall is enabled.
17
+ The `examples` field is purely advisory — the CodeCall `describe` tool uses it as the source of usage examples: when a tool declares examples, `codecall:describe` returns only those (up to 5) and adds no auto-generated one. It is **not** emitted in the `tools/list` MCP response. Use it for any tool that benefits from concrete usage hints when CodeCall is enabled.
18
18
 
19
19
  ## Code
20
20
 
@@ -74,7 +74,8 @@ export class ConvertCurrencyTool extends ToolContext {
74
74
 
75
75
  ## Where examples show up
76
76
 
77
- - **`codecall:describe`** — the CodeCall plugin's `describe` tool uses these as its top-priority source of usage examples (user-provided examples win over auto-generated ones, capped at 5). This is the one place `examples` is actually read.
77
+ - **`codecall:describe`** — the CodeCall plugin's `describe` tool returns these as the tool's usage examples (capped at 5), with no auto-generated example next to them. This is the one place `examples` is actually read.
78
+ - **Without `examples`** — `codecall:describe` generates one example from the tool's intent, with arguments built only from the input schema's properties (required ones, the query-like or first filter property for a search, the real pagination properties for a list, the first enum value), or `{}` when none fits. Values stay within the property's `enum`, `const`, bounds and length limits; a `pattern` or `format` is not generated, so an optional property that has one is left out. It never shows an argument the schema does not declare, such as a made-up `query`.
78
79
  - **Not in `tools/list`** — the `tools/list` MCP response does not include `examples`; clients never see them there.
79
80
 
80
81
  ## When to include `output?`
@@ -129,6 +129,7 @@ There are no server-to-client requests in this revision. `this.elicit()` answers
129
129
  - A client without the `elicitation` capability for the mode gets error `-32021`. Capabilities come with each request, and `this.elicit()` checks them itself. A session-based check such as `this.scope.notifications.getClientCapabilities(sessionId)` finds nothing here, so call `this.elicit()` directly.
130
130
  - `sendElicitationResult` is not listed to 2026-07-28 clients.
131
131
  - Anonymous callers keep their `requestState` across rounds (it binds to one shared anonymous principal).
132
+ - **More than one instance? Set `VAULT_SECRET` (or `JWT_SECRET`) to the same value on every instance.** `requestState` is signed with `VAULT_SECRET`, else `JWT_SECRET`, else a random per-process key; under the per-process key a round that lands on another instance (or after a restart) fails verification and the tool asks its first question again. Production servers with `redis`/`transport.persistence` but neither secret log a startup warning, and each rejected round logs `reason: 'bad-signature'` with a `hint` naming `VAULT_SECRET`.
132
133
 
133
134
  ## See also
134
135
 
@@ -89,6 +89,8 @@ If you hoisted the entire `@Tool({...})` config, consumers would drag the `@Tool
89
89
 
90
90
  `.tsx` / `.jsx` widget files use the `*.widget.tsx` naming convention. The scaffolded `tsconfig.json` excludes `**/*.widget.tsx` from the server typecheck (#445 fix) — widgets are bundled separately by `@frontmcp/uipack` (esbuild) at render time, with React loaded externally. If you want IDE typecheck for widget sources, add a sibling `tsconfig.widget.json` with `jsx: 'react-jsx'` and `include: ['src/**/*.widget.tsx']`.
91
91
 
92
+ Keep each widget beside the tool that uses it, with a unique file name. tsc never emits widget files, so `frontmcp build` copies them into the output — for the bundled `node` / `cli` / `lambda` / `vercel` targets directly next to the bundle, where every tool's `__dirname` points (#649). Two widgets with the same file name can't both go there; the build skips them with a warning.
93
+
92
94
  ## See also
93
95
 
94
96
  - [`derived-types.md`](./derived-types.md) — why schemas hoist
@@ -73,7 +73,7 @@ Or use the FileSource form — it sidesteps the issue.
73
73
  | `preferredHeight` | — | `number` (px) or CSS string (`'50vh'`). Initial widget height; auto-resize grows/shrinks from this baseline. |
74
74
  | `minHeight` / `maxHeight` | — | `number` (px) or CSS string. Clamp the widget height; auto-resize never reports outside this range. |
75
75
  | `aspectRatio` | — | CSS `aspect-ratio` (`'16 / 9'` or `1.5`). Hosts that honor it size by ratio instead of measured height. |
76
- | `autoResize` | `true` | Auto-report content height to the host via a debounced `ResizeObserver` on `#root`. Set `false` to opt out (CSS still applies). |
76
+ | `autoResize` | `true` | Report the document height (margins included) to the host after the handshake. Set `false` to opt out (CSS still applies). |
77
77
  | `csp` | — | `{ connectDomains?, resourceDomains? }` — emitted on the resource content's `_meta.ui.csp` (#455). Claude honors CSP only here. |
78
78
  | `contentSecurity` | strict | `{ allowUnsafeLinks?, allowInlineScripts?, bypassSanitization? }` — keep defaults. |
79
79
  | `escapeStringResults` | unset | `true` escapes plain string results of a template function; `html` / `trustedHtml` stay markup. Default in 1.9. |
@@ -140,6 +140,12 @@ ui: {
140
140
 
141
141
  See [`rules/widget-paths-anchor-with-import-meta-url.md`](../rules/widget-paths-anchor-with-import-meta-url.md).
142
142
 
143
+ The widget is read when the tool is called, from the path the **compiled** tool computes — an anchored path points into the build output once the tool is compiled, so the file has to ship there (#649):
144
+
145
+ - `frontmcp build` copies every `*.widget.tsx` / `*.widget.jsx` under the entry's directory into the output. tsc-output targets (`distributed`, `cloudflare`) get them at the same relative path, next to each compiled tool. Bundled targets (`node`, `cli`, `lambda`, `vercel`) get them directly next to the bundle, because every bundled module's `__dirname` is the bundle's directory — keep each widget beside its tool and give it a unique file name (the build skips, and warns about, names used twice).
146
+ - Only widget files are copied, not other local files a widget imports.
147
+ - A plain `tsc` build copies nothing — add a copy step. A missing widget fails the call with an `ENOENT` error naming the path it looked for, and the `src/` file when one matches.
148
+
143
149
  ## `@frontmcp/ui` prerequisite (#443)
144
150
 
145
151
  `.tsx` / `.jsx` FileSource widgets require `@frontmcp/ui` in the consuming project — the bundler injects an auto-generated React mount that imports `McpBridgeProvider` from `@frontmcp/ui/react`:
@@ -151,6 +157,16 @@ npm install @frontmcp/ui
151
157
 
152
158
  Match the version to `@frontmcp/sdk`. Without it, server-side bundling fails with a friendly error pointing at this requirement.
153
159
 
160
+ ## `esbuild` prerequisite (#649)
161
+
162
+ `@frontmcp/uipack` loads `esbuild` on demand to bundle a `.tsx` / `.jsx` widget **when the tool is called**, so it must be installed where the server runs — as a runtime dependency:
163
+
164
+ ```bash
165
+ npm install esbuild # in "dependencies", not "devDependencies"
166
+ ```
167
+
168
+ Projects created with `frontmcp create` already have it through the `frontmcp` package. `@frontmcp/uipack` declares it as an optional peer dependency (`>=0.27.0 <1`). Without it, the call fails with an error naming the widget.
169
+
154
170
  ## Widget bridge — `window.FrontMcpBridge`
155
171
 
156
172
  When the widget needs to read tool data or invoke other tools, the bridge IIFE is injected automatically. Set `widgetAccessible: true` to enable `callTool`:
@@ -176,11 +192,22 @@ ui: {
176
192
  | `getToolInput()` / `getToolOutput()` / `getStructuredContent()` | Read the tool data |
177
193
  | `getWidgetState()` / `setWidgetState(state)` | Persisted per-widget state |
178
194
  | `getHostContext()` / `getTheme()` / `getDisplayMode()` | Host context |
195
+ | `onContextChange(cb)` | Subscribe to host context changes (handshake included) |
179
196
  | `hasCapability(cap)` | Probe adapter capabilities |
180
197
  | `onToolResponseMetadata(cb)` | Subscribe to `ui/html` arrival (inline mode) |
181
198
 
182
199
  The bridge routes to the right host adapter (OpenAI SDK / Claude postMessage / FrontMCP direct) automatically. **Never call `window.openai.*` directly** — it works on OpenAI but breaks everywhere else.
183
200
 
201
+ ### Theme
202
+
203
+ The page follows the host theme. When an MCP Apps host sends `theme: 'light' | 'dark'` — in the `ui/initialize` result or a later `ui/notifications/host-context-changed` — the bridge sets `<meta name="color-scheme" content="…">` and `<html data-theme="…">`:
204
+
205
+ - The frame's canvas and form controls match the host (a dark host no longer gets an opaque white frame).
206
+ - Style dark mode with `[data-theme='dark'] …` selectors.
207
+ - `getTheme()` returns the same value; `onContextChange` listeners fire for the handshake context too.
208
+ - Nothing is written when the host sends no theme (the OS fallback `getTheme()` starts with is never applied).
209
+ - A widget styled for light only keeps it with `:root { color-scheme: light }` — author CSS wins over the meta tag.
210
+
184
211
  ## Host considerations
185
212
 
186
213
  | Host | Notes |
@@ -209,7 +236,9 @@ What FrontMCP does with it:
209
236
 
210
237
  - **Static sizing CSS** — `preferredHeight` (initial `height`), `minHeight`, `maxHeight`, and `aspectRatio` are injected as a `<style>` block on `html` / `body` / `#root`, so the widget opens at the right size before any JS runs.
211
238
  - **`_meta` hints** — the same values ride along on the response/discovery `_meta` as `ui/preferredHeight`, `ui/minHeight`, `ui/maxHeight`, `ui/aspectRatio` (and nested under `_meta.ui` in `tools/list`), so hosts that read sizing from metadata pick it up.
212
- - **Runtime auto-resize** — when `autoResize !== false` and `ResizeObserver` is available, the bridge observes `#root` and reports the measured height to the host (debounced via `requestAnimationFrame`), also firing a `widget:resize` event you can listen for. Call `window.FrontMcpBridge.setSize({ height, width, aspectRatio })` to report manually.
239
+ - **Runtime auto-resize** — when `autoResize !== false` and `ResizeObserver` is available, the bridge observes `<html>`, `<body>` and `#root` and reports the page height to the host (debounced via `requestAnimationFrame`), also firing a `widget:resize` event you can listen for. Call `window.FrontMcpBridge.setSize({ height, width, aspectRatio })` to report manually.
240
+ - **What is measured** — the whole document: `<html>` at `height: fit-content`, plus any content overflowing a fixed-height `<body>`, clamped by a px `max-height` on `<html>`. Body margins and margins collapsed through the body (an `<h2>` or `<ul>` at the edge) are counted, and the height shrinks when content does. `preferredHeight` / `minHeight` / `maxHeight` act as the floor and ceiling.
241
+ - **When it is sent** — reports wait for the bridge to initialize. In an ext-apps host the first report goes out once the `ui/initialize` handshake completes (a request sent earlier would be rejected); a report the host rejects is sent again on the next observation, even for the same height. A manual `setSize` called before the handshake is held and delivered right after it (only the latest size).
213
242
 
214
243
  Per-host behavior:
215
244
 
@@ -40,11 +40,11 @@ const widgetPath = fileURLToPath(new URL('./sales-chart.widget.tsx', import.meta
40
40
 
41
41
  - **`process.cwd()` is whoever launched the process.** `yarn dev` from the repo root, `node dist/main.js` from `/opt/app`, a containerized run from `/`, a serverless cold start from `/var/task`, an Nx executor from `apps/<thing>/` — all different cwds.
42
42
  - **Tool sources move around at build time.** ESM build output is often in `dist/`; `.tool.ts` becomes `.tool.js`. The relative reference's resolution chain is fragile to that.
43
- - **`fileURLToPath(new URL('./x', import.meta.url))` is invariant.** It anchors to the **source file** that contains the URL literal — same answer at dev, build, and runtime.
43
+ - **`fileURLToPath(new URL('./x', import.meta.url))` is independent of cwd.** It anchors to the file that contains the URL literal — the source file under `frontmcp dev`, the **compiled** file once the tool is built. That's why the widget must ship with the build (below).
44
44
 
45
45
  ## CommonJS projects (`__dirname`)
46
46
 
47
- `import.meta.url` is **ESM-only**. In a CommonJS project (`package.json` `"type": "commonjs"`, or `tsconfig` `"module": "commonjs"`) `import.meta` is unavailable and the build fails. Anchor with `__dirname` instead — the CJS equivalent, equally invariant to `process.cwd()`:
47
+ `import.meta.url` is **ESM-only**. In a CommonJS project (`package.json` `"type": "commonjs"`, or `tsconfig` `"module": "commonjs"`) `import.meta` is unavailable and the build fails. Anchor with `__dirname` instead — the CJS equivalent, equally independent of `process.cwd()`:
48
48
 
49
49
  ```typescript
50
50
  import { join } from 'node:path';
@@ -57,7 +57,14 @@ const widgetPath = join(__dirname, 'sales-chart.widget.tsx');
57
57
  })
58
58
  ```
59
59
 
60
- Pick the anchor that matches your module system — both resolve to the tool source's directory regardless of cwd. The rule is only that the path must **never** be a bare relative string.
60
+ Pick the anchor that matches your module system — both resolve to the directory of the file that is running, regardless of cwd. The rule is only that the path must **never** be a bare relative string.
61
+
62
+ ## Ship the widget with the build (#649)
63
+
64
+ The widget is read when the tool is called, from the path the **compiled** tool computes, so after a build it has to exist in the output — tsc never emits `*.widget.tsx`:
65
+
66
+ - `frontmcp build` copies every `*.widget.tsx` / `*.widget.jsx` under the entry's directory. tsc-output targets get them next to each compiled tool (same relative path). Bundled targets (`node`, `cli`, `lambda`, `vercel`) get them directly next to the bundle, because every bundled module's `__dirname` is the bundle's directory — so keep each widget beside the tool that uses it and give it a unique file name.
67
+ - A plain `tsc` build copies nothing — add a copy step, or the call fails with an `ENOENT` error that names the path it looked for.
61
68
 
62
69
  ## Also: name the widget `*.widget.tsx`
63
70
 
@@ -37,6 +37,8 @@ class CIAlertChannel extends ChannelContext {
37
37
  }
38
38
  ```
39
39
 
40
+ The path is a `POST` route on FrontMCP's HTTP server (`bootstrap()` / `createHandler()`), guarded like a custom `http.routes` entry: `throttle.ipFilter` runs first, the path may not be a FrontMCP path (MCP endpoint, `/oauth/*`, `/.well-known/*`, `/health`, `/metrics`), and one channel per path (either mistake fails startup). The route does not authenticate the sender: verify signatures (e.g. `X-Hub-Signature-256`) in `onEvent()`. `createDirect()`, stdio and `createFetchHandler()` serve no webhook route.
41
+
40
42
  ## App Event Source
41
43
 
42
44
  Subscribes to the in-process `ChannelEventBus`. Your application code emits events, and the channel transforms them into notifications.
@@ -69,6 +71,8 @@ scope.channelEventBus.emit('app:error', {
69
71
 
70
72
  Automatically pushes when registered agents finish execution. Optionally filter by agent IDs.
71
73
 
74
+ An event is published once an `invoke_<agent>` call has finished: `status: 'success'` only when the agent's output also passed its `outputSchema` (output that fails it is an `'error'`), and a call waiting for the client's answer to an elicitation publishes nothing until it finishes.
75
+
72
76
  ```typescript
73
77
  @Channel({
74
78
  name: 'agent-done',
@@ -96,9 +100,11 @@ class AgentDoneChannel extends ChannelContext {
96
100
  }
97
101
  ```
98
102
 
103
+ The event is `{ agentId, agentName, status: 'success' | 'error', durationMs, output?, error?, runId?, sessionId }`, published after every `invoke_<agent>` call and delivered only to the session that called the agent.
104
+
99
105
  ## Job Completion Source
100
106
 
101
- Pushes when background jobs or workflows complete. Optionally filter by job names.
107
+ Pushes when background jobs or workflows complete. Optionally filter by job names. The event is `{ jobName, jobId, status: 'success' | 'error', durationMs?, output?, error?, attempt, sessionId }` (for a workflow, `jobName` is its name). It goes only to the session that ran the job; a run with no session is delivered to no one.
102
108
 
103
109
  ```typescript
104
110
  @Channel({
@@ -7,7 +7,7 @@ tags: [config, auth, local, tunnel, proxy, issuer, auth-modes]
7
7
  features:
8
8
  - 'Relying on request-host-derived OAuth discovery, which works behind a tunnel or under an http.entryPath without extra config'
9
9
  - 'Setting `local.issuer` to a full public HTTPS URL so the token `iss` matches what clients reach through the proxy'
10
- - 'Knowing `FRONTMCP_PUBLIC_HOST` overrides only the discovery host (scheme/port still come from the HTTP config or local.issuer)'
10
+ - 'Knowing the issuer order: `local.issuer`, then `FRONTMCP_PUBLIC_URL`, then `FRONTMCP_PUBLIC_HOST` (host only), then the request'
11
11
  ---
12
12
 
13
13
  # Local Mode Behind a Tunnel
@@ -20,12 +20,13 @@ Expose a local-mode server through a tunnel or TLS proxy by aligning the token i
20
20
  // src/server.ts
21
21
  // OAuth discovery (.well-known/*) is derived from the incoming request host at
22
22
  // runtime and advertises /oauth/* at the root, so it works behind a tunnel or
23
- // reverse proxy with no extra config. `local.issuer` only aligns the boot-time
24
- // `iss` claim with the public HTTPS URL clients reach.
23
+ // reverse proxy with no extra config. The issuer is the same in discovery, on
24
+ // authorization responses (RFC 9207 `iss`) and in tokens: `local.issuer`, else
25
+ // FRONTMCP_PUBLIC_URL, else FRONTMCP_PUBLIC_HOST, else the request's origin.
25
26
  //
26
- // `FRONTMCP_PUBLIC_HOST=mcp.example.com` would set only the discovery HOST
27
- // (scheme stays http, port stays the HTTP port) — use `local.issuer` when you
28
- // need a different scheme/port, as below.
27
+ // `FRONTMCP_PUBLIC_HOST=mcp.example.com` would set only the HOST (scheme stays
28
+ // http, port stays the HTTP port) — use `local.issuer` when you need a
29
+ // different scheme/port, as below.
29
30
  import { App, FrontMcp, Tool, ToolContext, z } from '@frontmcp/sdk';
30
31
 
31
32
  @Tool({
@@ -65,7 +66,7 @@ class Server {}
65
66
 
66
67
  - Relying on request-host-derived OAuth discovery, which works behind a tunnel or under an http.entryPath without extra config
67
68
  - Setting `local.issuer` to a full public HTTPS URL so the token `iss` matches what clients reach through the proxy
68
- - Knowing `FRONTMCP_PUBLIC_HOST` overrides only the discovery host (scheme/port still come from the HTTP config or local.issuer)
69
+ - Knowing the issuer order: `local.issuer`, then `FRONTMCP_PUBLIC_URL`, then `FRONTMCP_PUBLIC_HOST` (host only), then the request
69
70
 
70
71
  ## Related
71
72
 
@@ -9,6 +9,7 @@ features:
9
9
  - 'Using `keyPrefix` to namespace guard keys in a shared Redis instance'
10
10
  - "Combining `partitionBy: 'ip'` for global limits with `partitionBy: 'session'` per tool"
11
11
  - 'In-memory counters are per-process and would allow N times the intended rate with N instances'
12
+ - "Startup fails closed with `GuardStorageUnavailableError` when Redis is down, unless `fallback: 'memory'`"
12
13
  ---
13
14
 
14
15
  # Distributed Rate Limiting with Redis
@@ -86,6 +87,13 @@ class Server {}
86
87
  - Using `keyPrefix` to namespace guard keys in a shared Redis instance
87
88
  - Combining `partitionBy: 'ip'` for global limits with `partitionBy: 'session'` per tool
88
89
  - In-memory counters are per-process and would allow N times the intended rate with N instances
90
+ - Startup fails closed with `GuardStorageUnavailableError` when Redis is down, unless `fallback: 'memory'`
91
+
92
+ ## Notes
93
+
94
+ - `storage` is the `@frontmcp/utils` storage shape (`type` + `redis: { config }` or `redis: { url }`), not the top-level `redis` shape. A block without `type` is auto-detected from the environment and otherwise runs in memory.
95
+ - Keys read `payments:guard:process_payment:<partition>:rl:...`: a trailing `:` on `keyPrefix` is dropped. Before 1.8.6 it was doubled (`payments:guard::...`), so counters briefly split between versions during a rolling deploy.
96
+ - To keep serving with per-instance counters while Redis is down, add `fallback: 'memory'` to `storage`.
89
97
 
90
98
  ## Related
91
99
 
@@ -39,6 +39,8 @@ auth: {
39
39
 
40
40
  Tokens are compared in constant time over SHA-256 digests, so neither the value nor its length leaks by timing. A match yields a session whose `sub` is `static:<12 hex chars>` — a non-reversible digest prefix of the matching token, so audit logs can tell configured tokens apart without the secret appearing anywhere. Anything else, including a missing credential, is a `401` with a `WWW-Authenticate: Bearer realm="…"` challenge.
41
41
 
42
+ Public, anonymous-transparent and static callers get their claims from `anonymousCallerClaims(options, anonymousId)` in `@frontmcp/auth` (one shape whether the caller starts a session, resumes one, or sends a sessionless MCP 2026-07-28 request). Options: `issuer` (required, the `iss` claim), `scopes` (default `['anonymous']`, space-joined into `scope`), and `subject`. Without `subject` the result is `{ sub: 'anon:<anonymousId>', name: 'Anonymous' }`; with it (static mode passes `static:<12 hex chars>`) `sub` is that subject and `name` is `'Static token'`. Pass a unique `anonymousId` per caller (FrontMCP uses the session `uuid`, the same one when the session starts and on every resume, so an anonymous caller keeps one `sub` for the whole session; or a fresh UUID for a sessionless request) so anonymous callers never share a `sub`-keyed partition. Up to 1.8.4 a new anonymous session's first request got a different `sub` from the rest of the session.
43
+
42
44
  **Use when:** one shared secret is the right granularity and standing up OAuth 2.1 is not. Rotate by deploying with both the old and new token in `tokens`, then dropping the old one.
43
45
 
44
46
  **Do not use when:** you need per-user identity, revocation, or progressive auth — use `local` or `remote`.
@@ -94,7 +94,10 @@ Local mode runs a built-in OAuth 2.1 authorization server and signs its own JWT
94
94
  class Server {}
95
95
  ```
96
96
 
97
- - `local.issuer` -- the `iss` claim set in generated tokens (defaults to a request-host-derived URL if omitted).
97
+ - `local.issuer` -- the issuer named everywhere: discovery's `issuer` and `authorization_servers`, the RFC 9207 `iss` on every authorization response (errors included), and the tokens' `iss`. Without it: `FRONTMCP_PUBLIC_URL` (plus the entry path) when pinned, else the `FRONTMCP_PUBLIC_HOST` boot-time issuer (`http://<host>:<port>`), else the request's origin -- the same on the Node server and under `createFetchHandler()`.
98
+ - The protected resource metadata's `scopes_supported` is what the mode grants: `allowedScopes` (local/remote), `anonymousScopes` (public), `scopes` (static), `requiredScopes` then `scopes` (transparent), plus `authProviders` scopes outside local/remote mode.
99
+ - An MCP 2026-07-28 request has no session: anonymous and static-key callers get none minted, so `MCP_SESSION_SECRET` is needed only for session clients; `this.context.verifiedSessionId` is `undefined` there.
100
+ - A session client's `mcp-session-id` (or legacy SSE `?sessionId=`) is served only when `session:verify` verified it: in `public`, `static` and anonymous `transparent` mode it must decrypt under `MCP_SESSION_SECRET` with the mode's signature (static: of the token that opened it); in authenticated modes it must also belong to the caller's token. Any other id gets HTTP 404 (`-32000`) and the client re-initializes -- the raw id is never used to look up a transport, since anonymous sessions share an empty token and the id is their only credential. Run every instance with the same `MCP_SESSION_SECRET`; any instance then serves a session another one minted.
98
101
 
99
102
  Token signing uses **HS256, a symmetric secret** read from the `JWT_SECRET` environment variable -- there is **no RSA/EC key pair** and no key store. Generate a stable secret (`JWT_SECRET=$(openssl rand -hex 32)`); if it is unset, FrontMCP falls back to a random per-process secret and all tokens are invalidated on restart.
100
103
 
@@ -11,13 +11,23 @@ description: Complete GuardConfig interface reference for rate limiting, concurr
11
11
  interface GuardConfig {
12
12
  enabled: boolean;
13
13
 
14
- // Storage for distributed rate limiting
14
+ // Storage for distributed rate limiting -- a StorageConfig from @frontmcp/utils,
15
+ // NOT the top-level `redis` shape (a block without `type` is auto-detected
16
+ // from REDIS_URL / REDIS_HOST and otherwise runs in memory)
15
17
  storage?: {
16
- type: 'memory' | 'redis';
17
- redis?: RedisOptionsInput;
18
+ type?: 'memory' | 'redis' | 'vercel-kv' | 'upstash' | 'auto';
19
+ redis?:
20
+ | { config: { host: string; port?: number; password?: string; db?: number; tls?: boolean } }
21
+ | { url: string };
22
+ vercelKv?: { url?: string; token?: string };
23
+ upstash?: { url?: string; token?: string };
24
+ // What to do when the backend is unreachable at startup:
25
+ // 'error' (default in production) -- startup fails with GuardStorageUnavailableError (rate limits fail closed)
26
+ // 'memory' (default otherwise) -- start with per-instance counters
27
+ fallback?: 'error' | 'memory';
18
28
  };
19
29
 
20
- keyPrefix?: string; // default: 'mcp:guard:'
30
+ keyPrefix?: string; // default: 'mcp:guard:' -- a trailing ':' is dropped, keys read 'mcp:guard:<entity>:...'
21
31
 
22
32
  // Server-wide limits
23
33
  global?: RateLimitConfig;
@@ -57,6 +67,11 @@ interface IpFilterConfig {
57
67
  }
58
68
  ```
59
69
 
70
+ ## Storage Failure and Key Format
71
+
72
+ - **Fails closed.** When `storage` cannot be reached at startup, the server does not start: startup rejects with `GuardStorageUnavailableError` (code `GUARD_STORAGE_UNAVAILABLE`), whose message names `throttle.storage`. That is the default in production. Set `storage.fallback: 'memory'` to start with per-instance counters instead. (The top-level `redis` and `transport.persistence` differ: they fall back to memory with an error log.)
73
+ - **Keys.** `<keyPrefix><entity>:<partition>:<kind>:...`, e.g. `mcp:guard:export_tickets:global:rl:1790722980000`. Before 1.8.6 the default prefix wrote `mcp:guard::export_tickets:...`; old and new instances do not read each other's counters, so limits briefly split during a rolling deploy. A custom `keyPrefix` without a trailing `:` keeps its keys.
74
+
60
75
  ## Partition Strategies
61
76
 
62
77
  - **`'global'`**: Single counter shared by all clients. Protects total server capacity.
@@ -244,6 +244,22 @@ throttle: {
244
244
  }
245
245
  ```
246
246
 
247
+ `storage` is a `StorageConfig` from `@frontmcp/utils` -- `type` picks the backend, and its options go under the matching key (`redis: { config }` or `redis: { url }`, `vercelKv: { url, token }`, `upstash: { url, token }`). It is NOT the top-level `redis` shape: `{ provider: 'redis', host, port }` has no `type`, so it is auto-detected from `REDIS_URL` / `REDIS_HOST` and otherwise runs in memory.
248
+
249
+ **Rate limits fail closed.** If the store is unreachable at startup, the server does not start: startup rejects with `GuardStorageUnavailableError` (`throttle.storage (redis) is unavailable: ...`), the default in production. To start with per-instance counters instead, opt in:
250
+
251
+ ```typescript
252
+ storage: {
253
+ type: 'redis',
254
+ redis: { url: process.env['REDIS_URL'] },
255
+ fallback: 'memory',
256
+ },
257
+ ```
258
+
259
+ The top-level `redis` and `transport.persistence` behave differently: they fall back to in-memory storage with an error log.
260
+
261
+ Keys are `<keyPrefix><entity>:<partition>:<kind>:...` (`mcp:guard:export_tickets:global:rl:...`); a trailing `:` on `keyPrefix` is dropped. Before 1.8.6 the default prefix wrote `mcp:guard::...`, so counters briefly split between versions during a rolling deploy.
262
+
247
263
  ## Verification
248
264
 
249
265
  ```bash
@@ -297,13 +313,15 @@ done
297
313
 
298
314
  ## Troubleshooting
299
315
 
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 |
316
+ | Problem | Cause | Solution |
317
+ | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
318
+ | 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 |
319
+ | Startup fails with `GuardStorageUnavailableError` | The `throttle.storage` backend is unreachable; rate limits fail closed | Bring the store up, or set `throttle.storage.fallback: 'memory'` to start with per-instance counters |
320
+ | Redis-backed limits still per-instance | `storage` written in the top-level `redis` shape (`{ provider: 'redis', host }`) with no `type`, so it was auto-detected as memory | Use `{ type: 'redis', redis: { config: { host, port } } }` |
321
+ | 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'` |
322
+ | 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 |
323
+ | `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 |
324
+ | 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 |
307
325
 
308
326
  ## Examples
309
327
 
@@ -55,7 +55,7 @@ vercel --prod
55
55
  // `rewrites` keyed on `api/frontmcp.ts`/`.js` (no such file exists).
56
56
  {
57
57
  "version": 2,
58
- "buildCommand": "yarn build",
58
+ "buildCommand": "yarn frontmcp build --target vercel",
59
59
  "installCommand": "yarn install"
60
60
  }
61
61
  ```
@@ -53,7 +53,7 @@ export default class MyServer {}
53
53
  // buildCommand/installCommand are detected from your lockfile.
54
54
  {
55
55
  "version": 2,
56
- "buildCommand": "yarn build",
56
+ "buildCommand": "yarn frontmcp build --target vercel",
57
57
  "installCommand": "yarn install"
58
58
  }
59
59
  ```
@@ -58,7 +58,7 @@ vercel env add LOG_LEVEL info
58
58
  // Do not add functions/rewrites referencing api/frontmcp.* (no such file).
59
59
  {
60
60
  "version": 2,
61
- "buildCommand": "yarn build",
61
+ "buildCommand": "yarn frontmcp build --target vercel",
62
62
  "installCommand": "yarn install"
63
63
  }
64
64
  ```
@@ -11,6 +11,7 @@ tags:
11
11
  - minimal
12
12
  features:
13
13
  - The exact shape of the auto-generated `vercel.json` — three keys, nothing else
14
+ - That `buildCommand` builds the vercel target (`<exec> frontmcp build --target vercel`), not the `build` script
14
15
  - That routing and function configuration live in `.vercel/output/`, not `vercel.json`
15
16
  - That hand-authoring `api/frontmcp.ts` references in `vercel.json` is unnecessary and breaks deploys
16
17
  ---
@@ -25,7 +26,7 @@ features:
25
26
  // vercel.json — yarn project (yarn.lock present)
26
27
  {
27
28
  "version": 2,
28
- "buildCommand": "yarn build",
29
+ "buildCommand": "yarn frontmcp build --target vercel",
29
30
  "installCommand": "yarn install"
30
31
  }
31
32
  ```
@@ -34,7 +35,7 @@ features:
34
35
  // vercel.json — pnpm project (pnpm-lock.yaml present)
35
36
  {
36
37
  "version": 2,
37
- "buildCommand": "pnpm run build",
38
+ "buildCommand": "pnpm exec frontmcp build --target vercel",
38
39
  "installCommand": "pnpm install"
39
40
  }
40
41
  ```
@@ -43,11 +44,13 @@ features:
43
44
  // vercel.json — npm project (package-lock.json present)
44
45
  {
45
46
  "version": 2,
46
- "buildCommand": "npm run build",
47
+ "buildCommand": "npx frontmcp build --target vercel",
47
48
  "installCommand": "npm install"
48
49
  }
49
50
  ```
50
51
 
52
+ `buildCommand` runs the vercel target through the project's package manager (bun projects get `bunx frontmcp build --target vercel`). It is never `<pm> run build`: the `build` script is `frontmcp build`, which builds the config's deployments and never writes `.vercel/output`.
53
+
51
54
  The actual function and routes live under `.vercel/output/`:
52
55
 
53
56
  ```text
@@ -64,6 +67,7 @@ The actual function and routes live under `.vercel/output/`:
64
67
  ## What This Demonstrates
65
68
 
66
69
  - The exact shape of the auto-generated `vercel.json` — three keys, nothing else
70
+ - That `buildCommand` builds the vercel target (`<exec> frontmcp build --target vercel`), not the `build` script
67
71
  - That routing and function configuration live in `.vercel/output/`, not `vercel.json`
68
72
  - That hand-authoring `api/frontmcp.ts` references in `vercel.json` is unnecessary and breaks deploys
69
73
 
@@ -25,11 +25,12 @@ The Vercel adapter emits a minimal `vercel.json` (version + buildCommand + insta
25
25
 
26
26
  ```json
27
27
  // vercel.json — extends the auto-generated minimum with regions + headers.
28
- // The adapter regenerates buildCommand/installCommand from your lockfile,
29
- // so keep them aligned (or let the build rewrite them).
28
+ // The build never overwrites an existing vercel.json, so keep buildCommand
29
+ // building the vercel target (`<exec> frontmcp build --target vercel`) —
30
+ // `yarn build` would run the config's deployments and deploy nothing.
30
31
  {
31
32
  "version": 2,
32
- "buildCommand": "yarn build",
33
+ "buildCommand": "yarn frontmcp build --target vercel",
33
34
  "installCommand": "yarn install",
34
35
  "regions": ["iad1"],
35
36
  "headers": [
@@ -70,6 +70,8 @@ info or running tool:
70
70
  - A request waiting on its client (elicitation, `roots/list`) steps aside while it waits.
71
71
  - Concurrent tool calls inside one request (`Promise.all`) are refused with
72
72
  `AsyncContextOverlapError` once they overlap. Run them one after another.
73
+ - A workflow runs its ready steps one at a time, whatever its `maxConcurrency`, so each step runs
74
+ once instead of overlapping and being retried.
73
75
  - A tool must not call its own server through a `DirectClient`/`DirectMcpServer` (it waits for its
74
76
  own turn); use `this.scope` flows. A request that waits more than 10s for its turn logs why.
75
77
  - Timers and un-awaited promises must not read request context.
@@ -34,6 +34,16 @@ leaves out actions the caller can never run. When the server configures
34
34
  `authorities`, bundle rules are evaluated with the server's engine (its
35
35
  `claimsMapping`, resolvers and custom evaluators).
36
36
 
37
+ A bundle skill's `SKILL.md` is listed in `skill://index.json` at its name
38
+ (`skill://Invoices/SKILL.md`, as SEP-2640 requires) and is also served at the
39
+ same URI with its id (`skill://invoices/SKILL.md`), the id the meta-tools and
40
+ `skills/list` report, with the same gating. A skill whose id is another
41
+ skill's name is refused, so an id names one skill: a bundle with one is not
42
+ applied and the previous bundle stays active. The previous bundle's skills do
43
+ not count, so a new skill can take the name of a skill the bundle drops or
44
+ renames (`registerSkillContent`'s `supersedes` option, which the bundle sync
45
+ fills in).
46
+
37
47
  For the conceptual picture, see [Skills-Only Deployment](https://docs.agentfront.dev/frontmcp/features/skills-only-deployment).
38
48
  For the production-ready decorator build, see [`deploy-to-cloudflare.md`](./deploy-to-cloudflare.md).
39
49
 
@@ -159,10 +159,10 @@ To keep bindings out of `process.env` entirely, add `nodejs_compat_do_not_popula
159
159
 
160
160
  `NODE_ENV = "production"` in `[vars]` makes this a production deployment, where FrontMCP refuses its development fallbacks:
161
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"}` |
162
+ | Secret | Required when | Failure without it |
163
+ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
164
+ | `MCP_SESSION_SECRET` | in production, for session clients (Durable Object sessions, protocol before 2026-07-28) — `session:verify` encrypts their session IDs with it; 2026-07-28 requests need none | `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
166
 
167
167
  ```bash
168
168
  npx wrangler secret put MCP_SESSION_SECRET # openssl rand -hex 32
@@ -172,6 +172,13 @@ npx wrangler secret put MCP_SESSION_SECRET # openssl rand -hex 32
172
172
  npx wrangler secret put JWT_SECRET # openssl rand -hex 32
173
173
  ```
174
174
 
175
+ **When the server fails to start.** The Worker builds the server on its first request. A failed build is answered, never thrown to the platform, and the error's message is not echoed:
176
+
177
+ - A configuration fault (missing or weak secret, a startup check, a config the schema refuses): `500 {"error":"server_misconfigured","code":"…"}` — codes `SESSION_SECRET_REQUIRED`, `JWT_SECRET_REQUIRED`, `JWT_SECRET_INVALID`, `UNENFORCED_METADATA`, `AUTH_CONFIGURATION_ERROR`, `CONFIG_INVALID`.
178
+ - Anything else (a remote that refused the connection, a package that failed to load): `503 {"error":"server_unavailable","code":"SERVER_START_FAILED"}` with `Retry-After`.
179
+
180
+ The failure is kept until a retry delay passes (1 s, doubling up to 60 s); the first request after it builds again. The cause is logged once per attempt (`wrangler tail`). Same for `createEdgeMcp()`, `createFetchHandler()` on an edge isolate, and each session Durable Object.
181
+
175
182
  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
183
 
177
184
  ### Background tasks
@@ -266,7 +273,8 @@ class_name = "FrontMcpSession"
266
273
  [[migrations]]
267
274
  tag = "v1"
268
275
  new_classes = ["FrontMcpSession"]
269
- # MCP_SESSION_SECRET is required on production isolates. Set it as a SECRET, not
276
+ # MCP_SESSION_SECRET is required on production isolates that serve session clients
277
+ # (Durable Object sessions, protocol before 2026-07-28). Set it as a SECRET, not
270
278
  # a var — `[vars]` is committed plaintext:
271
279
  # npx wrangler secret put MCP_SESSION_SECRET # openssl rand -hex 32
272
280
  ```
@@ -16,12 +16,23 @@ After `frontmcp build --target vercel`, your `vercel.json` looks like:
16
16
  ```json
17
17
  {
18
18
  "version": 2,
19
- "buildCommand": "yarn build",
19
+ "buildCommand": "yarn frontmcp build --target vercel",
20
20
  "installCommand": "yarn install"
21
21
  }
22
22
  ```
23
23
 
24
- The `buildCommand` and `installCommand` are detected from your lockfile (`bun.lockb` -> bun, `pnpm-lock.yaml` -> pnpm, `yarn.lock` -> yarn, `package-lock.json` -> npm).
24
+ The package manager is detected from your lockfile, and `buildCommand` runs the **vercel target** through it:
25
+
26
+ | Lockfile | `buildCommand` | `installCommand` |
27
+ | --------------------------- | ------------------------------------------ | ---------------- |
28
+ | `package-lock.json` or none | `npx frontmcp build --target vercel` | `npm install` |
29
+ | `yarn.lock` | `yarn frontmcp build --target vercel` | `yarn install` |
30
+ | `pnpm-lock.yaml` | `pnpm exec frontmcp build --target vercel` | `pnpm install` |
31
+ | `bun.lock` / `bun.lockb` | `bunx frontmcp build --target vercel` | `bun install` |
32
+
33
+ `frontmcp create --target vercel` scaffolds the same file (plus a `$schema` line). The build never overwrites an existing `vercel.json`.
34
+
35
+ **Never use `<pm> run build` as the `buildCommand`.** The project's `build` script is `frontmcp build`, which builds the deployments in `frontmcp.config` (typically `node`) and never writes `.vercel/output` -- Vercel then has nothing to deploy. Files generated before 1.8.6 used `yarn build` / `npm run build`; replace them with the command above.
25
36
 
26
37
  ## Customizing
27
38
 
@@ -30,7 +41,7 @@ You can hand-edit `vercel.json` AFTER the build to add fields Vercel supports
30
41
  ```json
31
42
  {
32
43
  "version": 2,
33
- "buildCommand": "yarn build",
44
+ "buildCommand": "yarn frontmcp build --target vercel",
34
45
  "installCommand": "yarn install",
35
46
  "regions": ["iad1"]
36
47
  }
@@ -43,6 +54,7 @@ To add response headers, prefer setting them inside your FrontMCP server (where
43
54
  - Do **not** add a `rewrites` block routing to `/api/frontmcp` — there is no `api/` directory in the Vercel build output.
44
55
  - Do **not** add a `functions: { "api/frontmcp.ts": ... }` map — the function lives at `.vercel/output/functions/index.func/handler.cjs`, configured by `.vc-config.json` (runtime, handler) generated by the adapter.
45
56
  - Do **not** set `framework` to a value the FrontMCP build doesn't match. Leaving it unset is safest.
57
+ - Do **not** set `buildCommand` to `<pm> run build` / `yarn build` -- that builds the config's deployments, not the vercel target.
46
58
 
47
59
  ## Function Runtime Config
48
60
 
@@ -58,7 +58,7 @@ This produces a Vercel Build Output API v3 structure:
58
58
  vercel.json # version, buildCommand, installCommand
59
59
  ```
60
60
 
61
- The adapter detects your package manager from the lockfile and writes the matching `buildCommand`/`installCommand` into `vercel.json`. No `api/` directory is involved.
61
+ The adapter detects your package manager from the lockfile and writes the matching `installCommand` and a `buildCommand` that builds the **vercel target** through it: `npx frontmcp build --target vercel` (npm), `yarn frontmcp build --target vercel`, `pnpm exec frontmcp build --target vercel`, or `bunx frontmcp build --target vercel`. An existing `vercel.json` is left as it is — including one from `frontmcp create --target vercel`, which writes the same commands. Never set `buildCommand` to `<pm> run build`: the `build` script is `frontmcp build`, which builds the config's deployments (typically `node`) and never writes `.vercel/output`. No `api/` directory is involved.
62
62
 
63
63
  ## Step 2: Configure the Server for Vercel KV
64
64
 
@@ -122,7 +122,16 @@ before the first `elicit()`/`sample()`/`listRoots()` call.
122
122
  and a 10-minute expiry — a tampered or replayed blob is discarded and the
123
123
  exchange restarts.
124
124
 
125
- The client MUST declare the matching capability, or the server answers `-32021`:
125
+ **Multi-instance: set `VAULT_SECRET`.** The signing key is `VAULT_SECRET`, else
126
+ `JWT_SECRET`, else a random per-process key. With the per-process key, a round
127
+ that lands on another instance (or arrives after a restart) fails verification
128
+ and the tool asks its first question again. Set `VAULT_SECRET` (or `JWT_SECRET`)
129
+ to the same value on every instance. In production, `redis` or
130
+ `transport.persistence` without either secret logs a startup warning; each
131
+ rejection logs `mcp-20260728: rejected requestState` with `reason: 'bad-signature'`
132
+ and a `hint` naming `VAULT_SECRET`.
133
+
134
+ The client MUST declare the matching capability, or the server answers `-32021`. (An unversioned call the server only defaulted to 2026-07-28 comes from a client that never declared it; `elicit()` answers that one with `ElicitationNotSupportedError`, as for a legacy client without a session.)
126
135
 
127
136
  ```json
128
137
  "io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {} } }
@@ -9,6 +9,7 @@ features:
9
9
  - 'Using `toolPatterns` glob patterns to cache groups of tools without per-tool configuration'
10
10
  - 'Per-tool `cache` metadata with custom `ttl` (seconds) and `slideWindow` for TTL refresh on hits'
11
11
  - 'Using `cache: true` for simple default-TTL caching'
12
+ - "A cache hit returns the same `content`/`structuredContent` as the miss, marked by `_meta.cache: 'hit'` on the result"
12
13
  - "Gating a tool with `featureFlag: 'beta-search'` -- the tool is hidden from `list_tools` when the flag is off"
13
14
  - 'Accessing `this.featureFlags.isEnabled()` inside a tool for runtime flag checks'
14
15
  ---
@@ -75,6 +76,9 @@ class GetWeatherTool extends ToolContext {
75
76
  return { city: input.city, temperature: weather.temp, condition: weather.condition };
76
77
  }
77
78
  }
79
+
80
+ // A second identical call is a hit: execute() does not run, `structuredContent` is the same
81
+ // `{ city, temperature, condition }` as the first call, and `result._meta.cache === 'hit'`.
78
82
  ```
79
83
 
80
84
  ```typescript
@@ -106,6 +110,7 @@ class BetaSearchTool extends ToolContext {
106
110
  - Using `toolPatterns` glob patterns to cache groups of tools without per-tool configuration
107
111
  - Per-tool `cache` metadata with custom `ttl` (seconds) and `slideWindow` for TTL refresh on hits
108
112
  - Using `cache: true` for simple default-TTL caching
113
+ - A cache hit returns the same `content`/`structuredContent` as the miss, marked by `_meta.cache: 'hit'` on the result
109
114
  - Gating a tool with `featureFlag: 'beta-search'` -- the tool is hidden from `list_tools` when the flag is off
110
115
  - Accessing `this.featureFlags.isEnabled()` inside a tool for runtime flag checks
111
116
 
@@ -6,8 +6,8 @@ description: 'Demonstrates installing the Remember plugin and using `this.rememb
6
6
  tags: [development, session, plugins, remember, plugin, memory]
7
7
  features:
8
8
  - "Installing `RememberPlugin` with `type: 'memory'` for development"
9
- - 'Enabling `tools: { enabled: true }` to expose LLM-callable memory tools (`remember_this`, `recall`, etc.)'
10
- - 'Using `this.remember.set()` with default `session` scope and explicit `user` scope'
9
+ - 'Enabling `tools: { enabled: true }` to expose LLM-callable memory tools (`remember_this`, `recall`, etc.), whose `scope` defaults to `session`'
10
+ - 'Using `this.remember.set()` with default `session` scope and explicit `user` scope (refused for an anonymous caller)'
11
11
  - 'Using `this.remember.get()` with a `defaultValue` fallback'
12
12
  - 'Using `this.remember.knows()` to check key existence without retrieving the value'
13
13
  ---
@@ -54,6 +54,7 @@ import { Tool, ToolContext, z } from '@frontmcp/sdk';
54
54
  class PreferencesTool extends ToolContext {
55
55
  async execute(input: { theme: string; language: string }) {
56
56
  await this.remember.set('theme', input.theme);
57
+ // User scope needs a signed-in caller: an anonymous one is refused with RememberIdentityError
57
58
  await this.remember.set('language', input.language, { scope: 'user' });
58
59
 
59
60
  return { saved: true, theme: input.theme, language: input.language };
@@ -75,7 +76,8 @@ import { Tool, ToolContext, z } from '@frontmcp/sdk';
75
76
  class GreetingTool extends ToolContext {
76
77
  async execute(input: { name: string }) {
77
78
  const theme = await this.remember.get('theme', { defaultValue: 'light' });
78
- const language = await this.remember.get('language', { defaultValue: 'en' });
79
+ // Read from the scope it was stored in
80
+ const language = await this.remember.get('language', { scope: 'user', defaultValue: 'en' });
79
81
  const hasOnboarded = await this.remember.knows('onboarding_complete');
80
82
 
81
83
  return {
@@ -91,8 +93,8 @@ class GreetingTool extends ToolContext {
91
93
  ## What This Demonstrates
92
94
 
93
95
  - Installing `RememberPlugin` with `type: 'memory'` for development
94
- - Enabling `tools: { enabled: true }` to expose LLM-callable memory tools (`remember_this`, `recall`, etc.)
95
- - Using `this.remember.set()` with default `session` scope and explicit `user` scope
96
+ - Enabling `tools: { enabled: true }` to expose LLM-callable memory tools (`remember_this`, `recall`, etc.), whose `scope` defaults to `session`
97
+ - Using `this.remember.set()` with default `session` scope and explicit `user` scope (refused for an anonymous caller)
96
98
  - Using `this.remember.get()` with a `defaultValue` fallback
97
99
  - Using `this.remember.knows()` to check key existence without retrieving the value
98
100
 
@@ -127,6 +127,10 @@ llm: {
127
127
  },
128
128
  ```
129
129
 
130
+ ## Invocation and Hooks
131
+
132
+ Calling `invoke_<agent>` runs `tools:call-tool` for the agent's tool (the agent's `authorities`, `rateLimit`, `concurrency`, `timeout` and plugin fields apply there), then `agents:call-agent`, which runs the agent. Hooks on agent invocation run in that flow: `AgentCallHook` in a plugin, or `@AgentCallHook.Will(...)` / `.Did(...)` methods on the agent class. `this.context` and `CONTEXT`-scoped providers are available inside the agent, including a custom `execute()`.
133
+
130
134
  ## Custom execute() vs Default Agent Loop
131
135
 
132
136
  By default, calling `execute()` runs the full agent loop: the LLM receives the input plus system instructions, decides which inner tools to call, processes results, and iterates until it produces a final answer.
@@ -170,6 +170,8 @@ class StrictWorkflowSkill extends SkillContext {}
170
170
  | `'warn'` | Logs a warning for missing tools but continues. Use during development when tools may not all be available yet. |
171
171
  | `'ignore'` | Silently ignores missing tools. Use for optional tool references or cross-server skills. |
172
172
 
173
+ When a caller loads the skill (`skills/load`, the `skills:load` flow, `GET /skills/{id}`, `/llm_full.txt`), a referenced tool that `availableWhen.surface` doesn't offer that caller (an agent-only tool, for an MCP client) is reported as missing, without its input schema, just as `tools/list` leaves it out. The same skill loaded by an agent lists it as available.
174
+
173
175
  ## Instruction Sources
174
176
 
175
177
  Skills support three ways to provide instructions.
@@ -145,7 +145,7 @@ export default skill({
145
145
 
146
146
  ### URL Reference
147
147
 
148
- Load instructions from a remote URL. Fetched at build time when the skill is loaded.
148
+ Load instructions from a remote URL.
149
149
 
150
150
  ```typescript
151
151
  @Skill({
@@ -156,6 +156,8 @@ Load instructions from a remote URL. Fetched at build time when the skill is loa
156
156
  class ApiStandardsSkill extends SkillContext {}
157
157
  ```
158
158
 
159
+ > **When file and URL instructions are read:** when the server starts, for every skill — the server indexes each skill for `skills/search` and checks its tools then, so a URL is fetched at every start whether or not a client reads the skill. A read that fails is logged (`Failed to load skill <name>: …`) and tried again the first time the skill is loaded; the content is kept once a read succeeds.
160
+
159
161
  ## SkillContext: loadInstructions() and build()
160
162
 
161
163
  The `SkillContext` class resolves instructions regardless of the source type. When the framework serves a skill, it calls `build()` which internally calls `loadInstructions()`.
@@ -131,7 +131,7 @@ The sandboxed VM runs AgentScript (a restricted JavaScript subset). Presets cont
131
131
  CodeCall contributes 4 tools to your server:
132
132
 
133
133
  - `codecall:search` -- Semantic search over all registered tools using TF-IDF scoring with synonym expansion. Input: `{ queries: string[] }` (array of atomic action phrases, max 10). Decompose complex requests into simple actions (e.g., "delete users and send email" becomes `queries: ["delete user", "send email"]`). Returns ranked tool names, descriptions, and relevance scores.
134
- - `codecall:describe` -- Returns full input/output JSON schemas for one or more tools. Input: `{ toolNames: string[] }` (tool names from search results). Use after search to understand tool interfaces before execution. If `notFound` array is non-empty in the response, re-search with corrected queries.
134
+ - `codecall:describe` -- Returns full input/output JSON schemas for one or more tools. Input: `{ toolNames: string[] }` (tool names from search results). Use after search to understand tool interfaces before execution. If `notFound` array is non-empty in the response, re-search with corrected queries. Results are cached for 60 seconds per server and per caller.
135
135
  - `codecall:execute` -- Runs an AgentScript program in the sandboxed VM. Input: `{ script: string }` (AgentScript code). Use `callTool(name, args)` to invoke tools within scripts. The script can call multiple tools, branch on results, and compose outputs.
136
136
  - `codecall:invoke` -- Direct single-tool invocation (available when `directCalls` is enabled). Bypasses the VM for simple one-shot calls.
137
137
 
@@ -178,6 +178,7 @@ CodeCallPlugin.init({
178
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
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
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.
181
+ - `illegal_access` messages name the script's own lines (`FORBIDDEN_LOOP (line 3): …`), whatever the enclave's transform printed; a line that is none of the script's is left out. `tool_error` results carry no `toolInput` (deprecated in the schema, never set).
181
182
 
182
183
  ### Power Features
183
184
 
@@ -243,7 +244,9 @@ class GlobalStoreServer {}
243
244
 
244
245
  ### Storage Types
245
246
 
246
- - `memory` -- In-process Map. Fastest, no persistence. Good for development.
247
+ - `memory` -- In-process Map. Fastest, no persistence. Good for development. One store per server: servers built in the
248
+ same process (even from the same app class or `RememberPlugin.init()` result) never see each other's memory, `global`
249
+ scope included.
247
250
  - `redis` -- Dedicated Redis connection. Plugin manages the client lifecycle.
248
251
  - `redis-client` -- Bring your own ioredis client instance.
249
252
  - `vercel-kv` -- Vercel KV (Redis-compatible). Uses `@vercel/kv` package.
@@ -284,7 +287,8 @@ class MyTool extends ToolContext {
284
287
  - `session` -- Default scope. With a verified session, valid only for that session and cleared
285
288
  when it ends. Without one (stateless transport, MCP 2026-07-28), it belongs to the authenticated
286
289
  principal and lasts across that principal's requests until its TTL, not per request.
287
- - `user` -- Persists for the user across sessions. Tied to user identity.
290
+ - `user` -- Persists for the signed-in user across sessions. Tied to user identity; refused for an
291
+ anonymous caller, whose `anon:<id>` subject names no user.
288
292
  - `tool` -- Scoped to a specific tool plus the same identity as `session` (the verified session,
289
293
  else the authenticated principal). Isolated per tool.
290
294
  - `global` -- Shared across all sessions and users. Use carefully.
@@ -295,7 +299,10 @@ sends. A stateless HTTP transport (shared `__stateless__` id), MCP 2026-07-28 (n
295
299
  unverified `mcp-session-id` carry no session identity: `session` and `tool` scope fall back to the
296
300
  authenticated principal, and an unauthenticated request without a verified session is refused
297
301
  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'`.
302
+ scope is refused with no authenticated user, and an anonymous subject (`anon:<id>`, which the SDK
303
+ makes up for one session or for each request without one) counts as none. `RememberIdentityError`
304
+ is a public MCP error (code `REMEMBER_IDENTITY_REQUIRED`), so the client reads the refusal as
305
+ written, in production too. If the data really is shared, use `scope: 'global'`.
299
306
 
300
307
  **Set `REMEMBER_SECRET` on every instance that shares a store.** All scopes, `session` and
301
308
  `tool` included, derive their encryption key from that secret plus the scope identity. A
@@ -339,6 +346,14 @@ clear the legacy prefixes manually if you want the storage back.
339
346
  - `forget` -- Remove a stored value by key
340
347
  - `list_memories` -- List all stored keys, optionally filtered by pattern
341
348
 
349
+ All four take an optional `scope` (default `session`) and describe it to the model the same way:
350
+ `session` is this session, or without one (stateless HTTP, MCP 2026-07-28) the signed-in caller
351
+ across its requests; `user` is the signed-in caller across all of its sessions; `tool` is the tool
352
+ running the call, for the same caller as `session` -- each memory tool has its own, so `recall`
353
+ does not see what `remember_this` stored in `tool` scope; `global` is shared by every caller. An
354
+ anonymous caller cannot use `user`, nor `session` or `tool` without a session. No scope lasts
355
+ "until disconnect" or "forever": entries last until forgotten or until their `ttl` runs out.
356
+
342
357
  ---
343
358
 
344
359
  ## 3. Approval Plugin (`@frontmcp/plugin-approval`)
@@ -423,7 +438,12 @@ authInfo.extra.approvalContext = { type: 'project', identifier: resolvedProjectI
423
438
  `maxTtlMs`, however it was stored.
424
439
  6. Otherwise refused with state `pending` (or `expired`).
425
440
 
426
- A refused call throws `ApprovalRequiredError`; the client receives an error result.
441
+ A refused call throws `ApprovalRequiredError`; the client receives an error result whose text is
442
+ exactly the tool's `approvalMessage` (or the default `Tool "<full name>" requires approval to
443
+ execute. Allow?`, or `Tool "<full name>" execution denied.` for a denial) and whose `_meta.code` is
444
+ `APPROVAL_REQUIRED`. The approval errors extend `PublicMcpError`, so this holds in production too:
445
+ the message is never replaced by `Internal FrontMCP error` and never carries a stack trace (releases
446
+ up to 1.8.5 wrapped refusals as internal server errors).
427
447
 
428
448
  Approvals are looked up by the tool's full name, `<owner id>:<tool name>`, so pass that name to
429
449
  `this.approval` grant and check methods. The owner is the app that declares the tool, or the
@@ -481,6 +501,22 @@ class DangerousActionTool extends ToolContext {
481
501
  }
482
502
  ```
483
503
 
504
+ Each grant records its grantor in `grantedBy`. Without one, it is the signed-in caller whose tool
505
+ made the grant: `userGrantor(<user id>)`, i.e. `{ source: 'user', identifier: '<user id>', method:
506
+ 'interactive' }`. Without a signed-in user (no principal, or an anonymous `anon:` subject) it is
507
+ `{ source: 'user' }` with no identifier. `revokeApproval()` records `revokedBy` the same way
508
+ (`userRevoker(<user id>)`). Releases up to 1.8.5 recorded both as `'policy'`. Pass `grantedBy` to
509
+ record anything else; `userGrantor`'s third argument is an options object, not the method:
510
+
511
+ ```typescript
512
+ import { policyGrantor, userGrantor } from '@frontmcp/plugin-approval';
513
+
514
+ await this.approval.grantSessionApproval('my-app:file_write', { grantedBy: policyGrantor('safe-list') });
515
+ await this.approval.grantUserApproval('my-app:file_write', {
516
+ grantedBy: userGrantor('user-123', 'Jane Doe', { method: 'interactive' }),
517
+ });
518
+ ```
519
+
484
520
  ### Per-Tool Approval Metadata
485
521
 
486
522
  ```typescript
@@ -580,7 +616,8 @@ class GlobalCacheServer {}
580
616
 
581
617
  ### Storage Types
582
618
 
583
- - `memory` -- In-process Map with automatic eviction. No external dependencies.
619
+ - `memory` -- In-process Map with automatic eviction. No external dependencies. One store per server: servers built in
620
+ the same process (even from the same app class or `CachePlugin.init()` result) never share entries.
584
621
  - `redis` -- Dedicated Redis connection with native TTL support. Plugin manages the client.
585
622
  - `redis-client` -- Bring your own ioredis client instance.
586
623
  - `global-store` -- Reuses the Redis connection from `@FrontMcp({ redis: {...} })`.
@@ -625,7 +662,7 @@ CachePlugin.init({
625
662
  });
626
663
  ```
627
664
 
628
- A tool is cached if it matches any pattern OR has `cache: true` (or a cache object) in its metadata.
665
+ A tool is cached if it matches any pattern OR has `cache: true` (or a cache object) in its metadata. `cache: { ttl: 0 }` (or a negative TTL) turns caching off for the tool, even when it matches a pattern; up to 1.8.5 it cached the first result with no expiry.
629
666
 
630
667
  ### Cache Bypass
631
668
 
@@ -645,6 +682,21 @@ identity. Two calls share an entry only when all three match.
645
682
  Set `keyByIdentity: false` to drop identity from the key, and only for output that is identical for every caller. See
646
683
  "Cache keys include the caller's identity" above.
647
684
 
685
+ ### What a Cache Hit Returns
686
+
687
+ A hit skips `execute()` and answers with the cached output unchanged: its `content` and `structuredContent` are the
688
+ same as the call that filled the cache. The hit is marked on the result's own `_meta` (`result._meta.cache === 'hit'`),
689
+ never inside the data, so a tool without an `outputSchema` does not see `_meta` in its `structuredContent` or text.
690
+ A miss has no `cache` key in `_meta`. A plugin hook that wants to add result metadata the same way sets the
691
+ `tools:call-tool` flow state's `resultMeta`:
692
+
693
+ ```typescript
694
+ @ToolHook.Did('execute')
695
+ tagResult(flowCtx: FlowCtxOf<'tools:call-tool'>) {
696
+ flowCtx.state.set('resultMeta', { ...flowCtx.state.resultMeta, traced: true });
697
+ }
698
+ ```
699
+
648
700
  ---
649
701
 
650
702
  ## 5. Feature Flags Plugin (`@frontmcp/plugin-feature-flags`)
@@ -128,16 +128,16 @@ OpenapiAdapter.init({
128
128
 
129
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
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 |
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 a list of schemes), 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
137
 
138
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
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).
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`, or `includeSecurityInInput`: `true` for every scheme, a list for the schemes it names, like `securitySchemesInInput`; the schemes a list leaves out still need a credential source), `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).
141
141
 
142
142
  ## Spec Polling
143
143
 
@@ -28,6 +28,7 @@ Checklist for verifying the Vercel Build Output API v3 artifact and edge config
28
28
  - [ ] `.vercel/output/functions/index.func/handler.cjs` exists — this is the actual function bundle
29
29
  - [ ] `.vercel/output/functions/index.func/.vc-config.json` declares `runtime: nodejs24.x` (the default written by the build adapter) and `handler: "handler.cjs"`
30
30
  - [ ] No hand-written `vercel.json` with the obsolete `{ "builds": [...], "routes": [...] }` shape — modern adapter emits `{ "version": 2, "buildCommand": ..., "installCommand": ... }` and routes through Build Output API
31
+ - [ ] `vercel.json` `buildCommand` builds the vercel target — `npx|yarn|pnpm exec|bunx frontmcp build --target vercel` — not `<pm> run build` (the `build` script runs `frontmcp build`, which builds the config's deployments and never writes `.vercel/output`; configs generated before 1.8.6 used `yarn build` / `npm run build`)
31
32
  - [ ] No hand-written `src/lambda.ts` / `api/mcp.ts` with a fictional `createVercelHandler(...)` import — the build adapter generates `index.js` that requires your decorated `@FrontMcp` class
32
33
 
33
34
  ## Runtime config (`@FrontMcp` decorator)
@@ -15,6 +15,7 @@ These checks apply to ALL deployment targets. Run them first, then proceed to yo
15
15
  - [ ] Authentication is enabled (`auth` config in `@FrontMcp` or `@frontmcp/auth`)
16
16
  - [ ] API keys/tokens are loaded from environment variables, never hardcoded
17
17
  - [ ] Session storage uses Redis or platform-native store (not in-memory) for multi-instance
18
+ - [ ] `MCP_SESSION_SECRET` is the same on every instance — a session id minted under another secret is answered 404 (the client re-initializes), in every auth mode including `public`
18
19
  - [ ] Session TTL is configured appropriately (not infinite)
19
20
  - [ ] Tool-level authorization is enforced where needed (ApprovalPlugin or custom)
20
21
  - [ ] OAuth redirect URIs are restricted to known domains
@@ -54,12 +55,15 @@ These checks apply to ALL deployment targets. Run them first, then proceed to yo
54
55
  - [ ] Production secrets are managed via secret manager (AWS SSM, Vault, etc.)
55
56
  - [ ] API keys have minimum required permissions
56
57
  - [ ] Secrets are rotated on a schedule
58
+ - [ ] Multi-instance: `VAULT_SECRET` (or `JWT_SECRET`) is set to the same value on every instance — it signs MCP 2026-07-28 `requestState`, and without it a multi-round tool (`elicit()` / `sample()`) whose next round lands on another instance starts over. Startup logs show no `requestState is signed with a per-process key` warning
57
59
 
58
60
  ### Rate Limiting
59
61
 
60
62
  - [ ] Rate limiting is configured for public-facing endpoints
61
63
  - [ ] Per-client/per-IP limits are set
62
64
  - [ ] Throttle configuration uses `@FrontMcp({ throttle: {...} })`
65
+ - [ ] Multi-instance: `throttle.storage` uses the storage shape `{ type: 'redis', redis: { config: { host, port } } }` (not the top-level `redis` shape, which silently falls back to auto-detection)
66
+ - [ ] Decided what happens when the throttle Redis is down at startup: the default fails closed (`GuardStorageUnavailableError`); `throttle.storage.fallback: 'memory'` starts with per-instance counters instead
63
67
  - [ ] Large payload limits are set to prevent memory exhaustion
64
68
 
65
69
  ### Dependencies
@@ -307,6 +307,33 @@ redis-cli -h localhost -p 6379 keys "mcp:*"
307
307
 
308
308
  You should see session keys like `mcp:session:<session-id>`.
309
309
 
310
+ ## Step 8 -- Multiple Instances and Redis Outages
311
+
312
+ ### Share the secrets, not just Redis
313
+
314
+ Every instance behind the load balancer needs the **same** values:
315
+
316
+ - `MCP_SESSION_SECRET` -- session ids are encrypted with it. An id minted under a different secret is answered with HTTP 404 and the client re-initializes (every auth mode, including `public`, where the id is the caller's only credential). An anonymous session minted by one instance is honored by any instance with the same secret.
317
+ - `VAULT_SECRET` (or `JWT_SECRET`) -- signs MCP 2026-07-28 `requestState`. Without either, each instance uses a random per-process key and a multi-round tool (`elicit()` / `sample()`) whose next round lands elsewhere asks its first question again. In production, `redis` or `transport.persistence` without either secret logs a startup warning; each rejected round logs `mcp-20260728: rejected requestState` with `reason: 'bad-signature'` and a `hint` naming `VAULT_SECRET`.
318
+
319
+ ### What happens when Redis is down at startup
320
+
321
+ - `redis` and `transport.persistence` fall back to in-memory storage and log the failure (`[TransportService] Failed to connect to redis - session persistence disabled`); the server starts.
322
+ - `throttle.storage` fails closed: startup aborts with `GuardStorageUnavailableError` (`throttle.storage (redis) is unavailable: …`), the default in production. Opt in to per-instance counters explicitly:
323
+
324
+ ```typescript
325
+ throttle: {
326
+ enabled: true,
327
+ storage: {
328
+ type: 'redis',
329
+ redis: { config: { host: process.env['REDIS_HOST'] ?? 'localhost', port: 6379 } },
330
+ fallback: 'memory', // per-instance counters while Redis is down
331
+ },
332
+ },
333
+ ```
334
+
335
+ `throttle.storage` takes the `@frontmcp/utils` storage shape (`{ type: 'redis', redis: { config } }` or `{ type: 'redis', redis: { url } }`), not the top-level `redis` shape.
336
+
310
337
  ## Common Patterns
311
338
 
312
339
  | Pattern | Recommended | Less explicit | Why |
@@ -340,13 +367,16 @@ You should see session keys like `mcp:session:<session-id>`.
340
367
 
341
368
  ## Troubleshooting
342
369
 
343
- | Problem | Cause | Solution |
344
- | ------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
345
- | `ECONNREFUSED 127.0.0.1:6379` | Redis is not running or Docker container is stopped | Start the container with `docker compose up -d redis` or check the Redis service status |
346
- | `NOAUTH Authentication required` | Password is set on Redis but not provided in config | Add `password` to the `redis` config or set `REDIS_PASSWORD` environment variable |
347
- | `ERR max number of clients reached` | Too many open connections from the application | Set `maxRetriesPerRequest` or use connection pooling; check for connection leaks |
348
- | Vercel KV `401 Unauthorized` | Missing or invalid KV tokens in the environment | Verify `KV_REST_API_URL` and `KV_REST_API_TOKEN` in the Vercel dashboard and redeploy |
349
- | Sessions lost after container restart | Redis running without append-only persistence | Add `--appendonly yes` to the Redis command in docker-compose or use a managed Redis with persistence enabled |
370
+ | Problem | Cause | Solution |
371
+ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
372
+ | `ECONNREFUSED 127.0.0.1:6379` | Redis is not running or Docker container is stopped | Start the container with `docker compose up -d redis` or check the Redis service status |
373
+ | `NOAUTH Authentication required` | Password is set on Redis but not provided in config | Add `password` to the `redis` config or set `REDIS_PASSWORD` environment variable |
374
+ | `ERR max number of clients reached` | Too many open connections from the application | Set `maxRetriesPerRequest` or use connection pooling; check for connection leaks |
375
+ | Vercel KV `401 Unauthorized` | Missing or invalid KV tokens in the environment | Verify `KV_REST_API_URL` and `KV_REST_API_TOKEN` in the Vercel dashboard and redeploy |
376
+ | Sessions lost after container restart | Redis running without append-only persistence | Add `--appendonly yes` to the Redis command in docker-compose or use a managed Redis with persistence enabled |
377
+ | Startup fails with `GuardStorageUnavailableError` | `throttle.storage` Redis is unreachable; rate limits fail closed | Bring Redis up, or set `throttle.storage.fallback: 'memory'` to start with per-instance counters |
378
+ | Clients get 404 for a session they just used (load-balanced) | Instances run with different `MCP_SESSION_SECRET` values | Set the same `MCP_SESSION_SECRET` on every instance |
379
+ | A 2026-07-28 tool repeats its first question; log shows `rejected requestState` / `bad-signature` | `VAULT_SECRET`/`JWT_SECRET` unset or different per instance | Set `VAULT_SECRET` to the same value on every instance |
350
380
 
351
381
  ## Examples
352
382
 
@@ -275,6 +275,7 @@ test.use({
275
275
  });
276
276
 
277
277
  test('server exposes expected tools', async ({ mcp }) => {
278
+ // Every page: `list()` follows `nextCursor`, so this holds past 40 tools too.
278
279
  const tools = await mcp.tools.list();
279
280
  expect(tools).toContainTool('create_record');
280
281
  expect(tools).toContainTool('delete_record');
@@ -586,7 +587,7 @@ node scripts/fix-unused-imports.mjs feature/my-branch
586
587
  ### E2E Tests
587
588
 
588
589
  - [ ] Fixture-based tests use `test.use({ server, port })` for server lifecycle
589
- - [ ] Tools appear in `tools/list` response via `toContainTool()` matcher
590
+ - [ ] Tools appear in `tools/list` response via `toContainTool()` matcher (`mcp.tools.list()` and the other `list()` calls return every page, so no cursor handling is needed)
590
591
  - [ ] Tool calls return expected results via `toBeSuccessful()` matcher
591
592
  - [ ] Authenticated tests use `TestTokenFactory` and verify rejection without token
592
593
 
@@ -12,6 +12,7 @@ Real API references:
12
12
  - `TestServer.start({ command, port })` returns a `TestServer` instance with `info.baseUrl` and `stop()` — `libs/testing/src/server/test-server.ts:101`. There is no `TestServer.create(ServerClass)`.
13
13
  - Build a client with `await McpTestClient.create({ baseUrl }).withTransport('streamable-http').buildAndConnect()` — `libs/testing/src/client/mcp-test-client.builder.ts`.
14
14
  - The public client API is namespaced: `client.tools.list()`, `client.tools.call(name, args)`, `client.resources.list()`, `client.resources.read(uri)`, `client.prompts.list()`, `client.prompts.get(name, args)`, `client.disconnect()` — `libs/testing/src/client/mcp-test-client.ts:306-402`.
15
+ - `tools.list()`, `resources.list()`, `resources.listTemplates()` and `prompts.list()` return **every page**: they follow `nextCursor` until the server stops returning one (a FrontMCP server pages at 40 by default), and throw if a cursor repeats or paging passes 1000 pages. Assert on the full list; there is no cursor to pass.
15
16
 
16
17
  ```typescript
17
18
  // server.e2e.spec.ts
@@ -573,7 +573,7 @@
573
573
  "features": [
574
574
  "Relying on request-host-derived OAuth discovery, which works behind a tunnel or under an http.entryPath without extra config",
575
575
  "Setting `local.issuer` to a full public HTTPS URL so the token `iss` matches what clients reach through the proxy",
576
- "Knowing `FRONTMCP_PUBLIC_HOST` overrides only the discovery host (scheme/port still come from the HTTP config or local.issuer)"
576
+ "Knowing the issuer order: `local.issuer`, then `FRONTMCP_PUBLIC_URL`, then `FRONTMCP_PUBLIC_HOST` (host only), then the request"
577
577
  ]
578
578
  },
579
579
  {
@@ -943,7 +943,8 @@
943
943
  "Configuring `storage: { type: 'redis' }` so rate limit counters are shared across instances",
944
944
  "Using `keyPrefix` to namespace guard keys in a shared Redis instance",
945
945
  "Combining `partitionBy: 'ip'` for global limits with `partitionBy: 'session'` per tool",
946
- "In-memory counters are per-process and would allow N times the intended rate with N instances"
946
+ "In-memory counters are per-process and would allow N times the intended rate with N instances",
947
+ "Startup fails closed with `GuardStorageUnavailableError` when Redis is down, unless `fallback: 'memory'`"
947
948
  ]
948
949
  },
949
950
  {
@@ -1412,6 +1413,7 @@
1412
1413
  "tags": ["deployment", "vercel", "serverless", "config", "minimal"],
1413
1414
  "features": [
1414
1415
  "The exact shape of the auto-generated `vercel.json` \u2014 three keys, nothing else",
1416
+ "That `buildCommand` builds the vercel target (`<exec> frontmcp build --target vercel`), not the `build` script",
1415
1417
  "That routing and function configuration live in `.vercel/output/`, not `vercel.json`",
1416
1418
  "That hand-authoring `api/frontmcp.ts` references in `vercel.json` is unnecessary and breaks deploys"
1417
1419
  ]
@@ -2071,6 +2073,7 @@
2071
2073
  "Using `toolPatterns` glob patterns to cache groups of tools without per-tool configuration",
2072
2074
  "Per-tool `cache` metadata with custom `ttl` (seconds) and `slideWindow` for TTL refresh on hits",
2073
2075
  "Using `cache: true` for simple default-TTL caching",
2076
+ "A cache hit returns the same `content`/`structuredContent` as the miss, marked by `_meta.cache: 'hit'` on the result",
2074
2077
  "Gating a tool with `featureFlag: 'beta-search'` -- the tool is hidden from `list_tools` when the flag is off",
2075
2078
  "Accessing `this.featureFlags.isEnabled()` inside a tool for runtime flag checks"
2076
2079
  ]
@@ -2099,8 +2102,8 @@
2099
2102
  "tags": ["development", "session", "plugins", "remember", "plugin", "memory"],
2100
2103
  "features": [
2101
2104
  "Installing `RememberPlugin` with `type: 'memory'` for development",
2102
- "Enabling `tools: { enabled: true }` to expose LLM-callable memory tools (`remember_this`, `recall`, etc.)",
2103
- "Using `this.remember.set()` with default `session` scope and explicit `user` scope",
2105
+ "Enabling `tools: { enabled: true }` to expose LLM-callable memory tools (`remember_this`, `recall`, etc.), whose `scope` defaults to `session`",
2106
+ "Using `this.remember.set()` with default `session` scope and explicit `user` scope (refused for an anonymous caller)",
2104
2107
  "Using `this.remember.get()` with a `defaultValue` fallback",
2105
2108
  "Using `this.remember.knows()` to check key existence without retrieving the value"
2106
2109
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontmcp/skills",
3
- "version": "1.8.4",
3
+ "version": "1.8.6",
4
4
  "description": "Curated skills catalog for FrontMCP projects",
5
5
  "author": "AgentFront <info@agentfront.dev>",
6
6
  "homepage": "https://docs.agentfront.dev",