@frontmcp/skills 1.8.5 → 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 (28) 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-config/examples/configure-throttle/distributed-redis-throttle.md +8 -0
  8. package/catalog/frontmcp-config/references/configure-auth.md +1 -0
  9. package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +19 -4
  10. package/catalog/frontmcp-config/references/configure-throttle.md +25 -7
  11. package/catalog/frontmcp-deployment/examples/deploy-to-vercel/vercel-mcp-endpoint-test.md +1 -1
  12. package/catalog/frontmcp-deployment/examples/deploy-to-vercel/vercel-with-kv.md +1 -1
  13. package/catalog/frontmcp-deployment/examples/deploy-to-vercel/vercel-with-skills-cache.md +1 -1
  14. package/catalog/frontmcp-deployment/examples/deploy-to-vercel-config/minimal-vercel-config.md +7 -3
  15. package/catalog/frontmcp-deployment/examples/deploy-to-vercel-config/vercel-config-with-security-headers.md +4 -3
  16. package/catalog/frontmcp-deployment/references/deploy-to-vercel-config.md +15 -3
  17. package/catalog/frontmcp-deployment/references/deploy-to-vercel.md +1 -1
  18. package/catalog/frontmcp-deployment/references/protocol-versions.md +9 -0
  19. package/catalog/frontmcp-development/examples/official-plugins/cache-and-feature-flags.md +5 -0
  20. package/catalog/frontmcp-development/examples/official-plugins/remember-plugin-session-memory.md +7 -5
  21. package/catalog/frontmcp-development/references/official-plugins.md +58 -7
  22. package/catalog/frontmcp-production-readiness/examples/production-vercel/vercel-edge-config.md +1 -0
  23. package/catalog/frontmcp-production-readiness/references/common-checklist.md +4 -0
  24. package/catalog/frontmcp-setup/references/setup-redis.md +37 -7
  25. package/catalog/frontmcp-testing/references/setup-testing.md +2 -1
  26. package/catalog/frontmcp-testing/references/test-e2e-handler.md +1 -0
  27. package/catalog/skills-manifest.json +6 -3
  28. 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
 
@@ -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
 
@@ -97,6 +97,7 @@ class Server {}
97
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
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
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.
100
101
 
101
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.
102
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": [
@@ -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,6 +122,15 @@ 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
+ **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
+
125
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
@@ -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
 
@@ -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
 
@@ -244,7 +244,9 @@ class GlobalStoreServer {}
244
244
 
245
245
  ### Storage Types
246
246
 
247
- - `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.
248
250
  - `redis` -- Dedicated Redis connection. Plugin manages the client lifecycle.
249
251
  - `redis-client` -- Bring your own ioredis client instance.
250
252
  - `vercel-kv` -- Vercel KV (Redis-compatible). Uses `@vercel/kv` package.
@@ -285,7 +287,8 @@ class MyTool extends ToolContext {
285
287
  - `session` -- Default scope. With a verified session, valid only for that session and cleared
286
288
  when it ends. Without one (stateless transport, MCP 2026-07-28), it belongs to the authenticated
287
289
  principal and lasts across that principal's requests until its TTL, not per request.
288
- - `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.
289
292
  - `tool` -- Scoped to a specific tool plus the same identity as `session` (the verified session,
290
293
  else the authenticated principal). Isolated per tool.
291
294
  - `global` -- Shared across all sessions and users. Use carefully.
@@ -296,7 +299,10 @@ sends. A stateless HTTP transport (shared `__stateless__` id), MCP 2026-07-28 (n
296
299
  unverified `mcp-session-id` carry no session identity: `session` and `tool` scope fall back to the
297
300
  authenticated principal, and an unauthenticated request without a verified session is refused
298
301
  with a `RememberIdentityError` rather than given a namespace shared with other clients. `user`
299
- 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'`.
300
306
 
301
307
  **Set `REMEMBER_SECRET` on every instance that shares a store.** All scopes, `session` and
302
308
  `tool` included, derive their encryption key from that secret plus the scope identity. A
@@ -340,6 +346,14 @@ clear the legacy prefixes manually if you want the storage back.
340
346
  - `forget` -- Remove a stored value by key
341
347
  - `list_memories` -- List all stored keys, optionally filtered by pattern
342
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
+
343
357
  ---
344
358
 
345
359
  ## 3. Approval Plugin (`@frontmcp/plugin-approval`)
@@ -424,7 +438,12 @@ authInfo.extra.approvalContext = { type: 'project', identifier: resolvedProjectI
424
438
  `maxTtlMs`, however it was stored.
425
439
  6. Otherwise refused with state `pending` (or `expired`).
426
440
 
427
- 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).
428
447
 
429
448
  Approvals are looked up by the tool's full name, `<owner id>:<tool name>`, so pass that name to
430
449
  `this.approval` grant and check methods. The owner is the app that declares the tool, or the
@@ -482,6 +501,22 @@ class DangerousActionTool extends ToolContext {
482
501
  }
483
502
  ```
484
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
+
485
520
  ### Per-Tool Approval Metadata
486
521
 
487
522
  ```typescript
@@ -581,7 +616,8 @@ class GlobalCacheServer {}
581
616
 
582
617
  ### Storage Types
583
618
 
584
- - `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.
585
621
  - `redis` -- Dedicated Redis connection with native TTL support. Plugin manages the client.
586
622
  - `redis-client` -- Bring your own ioredis client instance.
587
623
  - `global-store` -- Reuses the Redis connection from `@FrontMcp({ redis: {...} })`.
@@ -626,7 +662,7 @@ CachePlugin.init({
626
662
  });
627
663
  ```
628
664
 
629
- 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.
630
666
 
631
667
  ### Cache Bypass
632
668
 
@@ -646,6 +682,21 @@ identity. Two calls share an entry only when all three match.
646
682
  Set `keyByIdentity: false` to drop identity from the key, and only for output that is identical for every caller. See
647
683
  "Cache keys include the caller's identity" above.
648
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
+
649
700
  ---
650
701
 
651
702
  ## 5. Feature Flags Plugin (`@frontmcp/plugin-feature-flags`)
@@ -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
@@ -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.5",
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",