@frontmcp/skills 1.8.5 → 1.8.7

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 (55) hide show
  1. package/catalog/create-tool/SKILL.md +24 -24
  2. package/catalog/create-tool/examples/22-tool-with-ui-html-template.md +0 -1
  3. package/catalog/create-tool/examples/23-tool-with-ui-filesource-tsx.md +7 -6
  4. package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +4 -6
  5. package/catalog/create-tool/examples/27-tool-with-examples-metadata.md +3 -2
  6. package/catalog/create-tool/references/decorator-options.md +1 -1
  7. package/catalog/create-tool/references/elicitation.md +1 -0
  8. package/catalog/create-tool/references/file-layout.md +2 -0
  9. package/catalog/create-tool/references/ui-widgets.md +96 -40
  10. package/catalog/create-tool/rules/widget-paths-anchor-with-import-meta-url.md +10 -3
  11. package/catalog/frontmcp-config/examples/configure-throttle/distributed-redis-throttle.md +8 -0
  12. package/catalog/frontmcp-config/references/configure-auth.md +1 -0
  13. package/catalog/frontmcp-config/references/configure-deployment-targets.md +19 -1
  14. package/catalog/frontmcp-config/references/configure-http.md +6 -4
  15. package/catalog/frontmcp-config/references/configure-security-headers.md +18 -13
  16. package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +19 -4
  17. package/catalog/frontmcp-config/references/configure-throttle.md +25 -7
  18. package/catalog/frontmcp-deployment/SKILL.md +19 -19
  19. package/catalog/frontmcp-deployment/examples/build-for-browser/react-provider-setup.md +5 -3
  20. package/catalog/frontmcp-deployment/examples/deploy-to-vercel/vercel-mcp-endpoint-test.md +1 -1
  21. package/catalog/frontmcp-deployment/examples/deploy-to-vercel/vercel-with-kv.md +3 -1
  22. package/catalog/frontmcp-deployment/examples/deploy-to-vercel/vercel-with-skills-cache.md +1 -1
  23. package/catalog/frontmcp-deployment/examples/deploy-to-vercel-config/minimal-vercel-config.md +7 -3
  24. package/catalog/frontmcp-deployment/examples/deploy-to-vercel-config/vercel-config-with-security-headers.md +4 -3
  25. package/catalog/frontmcp-deployment/references/build-for-browser.md +8 -0
  26. package/catalog/frontmcp-deployment/references/build-for-mcpb.md +9 -8
  27. package/catalog/frontmcp-deployment/references/build-for-sdk.md +9 -9
  28. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +3 -1
  29. package/catalog/frontmcp-deployment/references/deploy-to-lambda.md +9 -8
  30. package/catalog/frontmcp-deployment/references/deploy-to-vercel-config.md +15 -3
  31. package/catalog/frontmcp-deployment/references/deploy-to-vercel.md +16 -8
  32. package/catalog/frontmcp-deployment/references/protocol-versions.md +9 -0
  33. package/catalog/frontmcp-development/examples/official-plugins/cache-and-feature-flags.md +5 -0
  34. package/catalog/frontmcp-development/examples/official-plugins/remember-plugin-session-memory.md +7 -5
  35. package/catalog/frontmcp-development/references/create-agent.md +8 -7
  36. package/catalog/frontmcp-development/references/create-plugin.md +17 -0
  37. package/catalog/frontmcp-development/references/official-plugins.md +66 -8
  38. package/catalog/frontmcp-guides/references/example-task-manager.md +2 -2
  39. package/catalog/frontmcp-guides/references/example-weather-api.md +2 -2
  40. package/catalog/frontmcp-production-readiness/examples/distributed-ha/ha-kubernetes-3-replicas.md +1 -0
  41. package/catalog/frontmcp-production-readiness/examples/production-node-sdk/package-json-config.md +2 -2
  42. package/catalog/frontmcp-production-readiness/examples/production-vercel/vercel-edge-config.md +1 -0
  43. package/catalog/frontmcp-production-readiness/references/common-checklist.md +4 -0
  44. package/catalog/frontmcp-production-readiness/references/distributed-ha.md +17 -7
  45. package/catalog/frontmcp-production-readiness/references/health-readiness-endpoints.md +3 -1
  46. package/catalog/frontmcp-setup/examples/project-structure-nx/nx-generator-scaffolding.md +1 -1
  47. package/catalog/frontmcp-setup/references/nx-workflow.md +25 -17
  48. package/catalog/frontmcp-setup/references/setup-redis.md +37 -7
  49. package/catalog/frontmcp-setup/references/setup-sqlite.md +9 -6
  50. package/catalog/frontmcp-testing/SKILL.md +24 -23
  51. package/catalog/frontmcp-testing/references/setup-testing.md +28 -3
  52. package/catalog/frontmcp-testing/references/test-auth.md +8 -0
  53. package/catalog/frontmcp-testing/references/test-e2e-handler.md +1 -0
  54. package/catalog/skills-manifest.json +9 -6
  55. package/package.json +1 -1
@@ -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
 
@@ -610,13 +610,14 @@ class DocsAgent extends AgentContext {}
610
610
 
611
611
  ## Troubleshooting
612
612
 
613
- | Problem | Cause | Solution |
614
- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
615
- | Agent not appearing in tool listing | Not registered in `agents` array | Add agent class to `@App` or `@FrontMcp` `agents` array |
616
- | LLM authentication error | API key not set or incorrect env variable | Verify the environment variable name in `apiKey: { env: '...' }` is set |
617
- | Inner tools not being called | Tools not listed in `tools` array of `@Agent` | Add tool classes to the `tools` field in the `@Agent` decorator |
618
- | Agent times out | No timeout or rate limit configured | Add `timeout: { executeMs: 120_000 }` and `rateLimit` to `@Agent` options |
619
- | Peer agent not callable | Peer has `isVisible: false`, or orchestrator lacks `canSeeOtherAgents: true`, or peer is not in `visibleAgents` whitelist | Set `swarm.isVisible: true` on the peer and `swarm.canSeeOtherAgents: true` (and add the peer to `visibleAgents`) on the orchestrator |
613
+ | Problem | Cause | Solution |
614
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
615
+ | Agent not appearing in tool listing | Not registered in `agents` array | Add agent class to `@App` or `@FrontMcp` `agents` array |
616
+ | LLM authentication error | API key not set or incorrect env variable | Verify the environment variable name in `apiKey: { env: '...' }` is set |
617
+ | Inner tools not being called | Tools not listed in `tools` array of `@Agent` | Add tool classes to the `tools` field in the `@Agent` decorator |
618
+ | Agent times out | No timeout or rate limit configured | Add `timeout: { executeMs: 120_000 }` and `rateLimit` to `@Agent` options |
619
+ | Peer agent not callable | Peer has `isVisible: false`, or orchestrator lacks `canSeeOtherAgents: true`, or peer is not in `visibleAgents` whitelist | Set `swarm.isVisible: true` on the peer and `swarm.canSeeOtherAgents: true` (and add the peer to `visibleAgents`) on the orchestrator |
620
+ | Agent call fails with `INVALID_OUTPUT` | The model's reply does not match the agent's `outputSchema` (a value outside an enum, or text that is not JSON) | Tighten the prompt or loosen the schema; the error message names the field, for example `output does not match outputSchema at priority`. The result never carries a stack trace |
620
621
 
621
622
  ## Examples
622
623
 
@@ -68,6 +68,7 @@ For plugins that accept runtime configuration, extend `DynamicPlugin<TOptions, T
68
68
  ```typescript
69
69
  abstract class DynamicPlugin<TOptions extends object, TInput extends object = TOptions> {
70
70
  static dynamicProviders?(options: any): readonly ProviderType[];
71
+ static dynamicTools?(options: any): readonly ToolType[];
71
72
  static init<TThis>(options: InitOptions<TInput>): PluginReturn<TOptions>;
72
73
  get<T>(token: Reference<T>): T;
73
74
  }
@@ -77,6 +78,7 @@ abstract class DynamicPlugin<TOptions extends object, TInput extends object = TO
77
78
  - `TInput` -- the input type users provide to `init()` (may have optional fields)
78
79
  - `init()` creates a provider entry for use in `plugins: [...]` arrays
79
80
  - `dynamicProviders()` returns providers computed from the input options
81
+ - `dynamicTools()` returns tools computed from the input options
80
82
 
81
83
  ## Quick Start: Minimal DynamicPlugin
82
84
 
@@ -328,6 +330,21 @@ export default class MyPlugin extends DynamicPlugin<MyPluginOptions, MyPluginOpt
328
330
 
329
331
  The reverse does not work: an option-derived provider cannot inject a provider that a nested plugin exports.
330
332
 
333
+ ### Options named like plugin metadata, and option-derived tools
334
+
335
+ `init(options)` spreads the options into the plugin's metadata, so an option named like a list-valued metadata key (`tools`, `resources`, `prompts`, `skills`, `adapters`, `plugins`, `exports`) used to be read as that list: `RememberPlugin.init({ tools: { enabled: true } })` crashed at startup. A non-array value under one of those keys is now an option and stays out of the metadata; an array still contributes.
336
+
337
+ To register tools only when an option asks for it, declare `static dynamicTools(options)`, the counterpart of `dynamicProviders`. Its tools are added to those from `@Plugin({ tools })` and from an array `tools` option:
338
+
339
+ ```typescript
340
+ export default class MemoryPlugin extends DynamicPlugin<MemoryOptions, MemoryOptionsInput> {
341
+ static override dynamicTools = (options: MemoryOptionsInput): readonly ToolType[] =>
342
+ options.tools?.enabled ? [RememberTool, RecallTool] : [];
343
+ }
344
+ ```
345
+
346
+ `dynamicTools` runs for `init(options)`; `init({ inject, useFactory })` takes its tools from the `@Plugin` metadata, since the options are unknown until the factory runs.
347
+
331
348
  ### Installing the same plugin in several apps
332
349
 
333
350
  Each app that installs a plugin gets its own copy of the plugin's providers, including CONTEXT-scoped ones. Tools, resources and prompts resolve the nearest definition in their own hierarchy (plugin, then app, then server). So `this.myService` in app A uses A's options even when app B installs `MyPlugin.init()` with different options:
@@ -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,17 @@ 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
+ `tools.prefix` renames them (`prefix: 'memory_'` gives `memory_recall`, ...; each description names the prefixed siblings) and
350
+ `tools.allowedScopes` rejects any other `scope` (including the default `session` when a call omits it) with a public `REMEMBER_SCOPE_NOT_ALLOWED` error that lists the allowed scopes. With `enabled` unset or `false` none is registered.
351
+
352
+ All four take an optional `scope` (default `session`) and describe it to the model the same way:
353
+ `session` is this session, or without one (stateless HTTP, MCP 2026-07-28) the signed-in caller
354
+ across its requests; `user` is the signed-in caller across all of its sessions; `tool` is the tool
355
+ running the call, for the same caller as `session` -- each memory tool has its own, so `recall`
356
+ does not see what `remember_this` stored in `tool` scope; `global` is shared by every caller. An
357
+ anonymous caller cannot use `user`, nor `session` or `tool` without a session. No scope lasts
358
+ "until disconnect" or "forever": entries last until forgotten or until their `ttl` runs out.
359
+
343
360
  ---
344
361
 
345
362
  ## 3. Approval Plugin (`@frontmcp/plugin-approval`)
@@ -424,7 +441,12 @@ authInfo.extra.approvalContext = { type: 'project', identifier: resolvedProjectI
424
441
  `maxTtlMs`, however it was stored.
425
442
  6. Otherwise refused with state `pending` (or `expired`).
426
443
 
427
- A refused call throws `ApprovalRequiredError`; the client receives an error result.
444
+ A refused call throws `ApprovalRequiredError`; the client receives an error result whose text is
445
+ exactly the tool's `approvalMessage` (or the default `Tool "<full name>" requires approval to
446
+ execute. Allow?`, or `Tool "<full name>" execution denied.` for a denial) and whose `_meta.code` is
447
+ `APPROVAL_REQUIRED`. The approval errors extend `PublicMcpError`, so this holds in production too:
448
+ the message is never replaced by `Internal FrontMCP error` and never carries a stack trace (releases
449
+ up to 1.8.5 wrapped refusals as internal server errors).
428
450
 
429
451
  Approvals are looked up by the tool's full name, `<owner id>:<tool name>`, so pass that name to
430
452
  `this.approval` grant and check methods. The owner is the app that declares the tool, or the
@@ -482,6 +504,24 @@ class DangerousActionTool extends ToolContext {
482
504
  }
483
505
  ```
484
506
 
507
+ Each grant records its grantor in `grantedBy`. Without one, it is the signed-in caller whose tool
508
+ made the grant: `{ source: 'user', identifier: '<user id>', method: 'implicit' }` (a tool that grants
509
+ through `this.approval` asked no one, so it is not an `'interactive'` answer; a tool that did ask passes
510
+ `grantedBy: userGrantor(<user id>)`). Without a signed-in user (no principal, or an anonymous `anon:`
511
+ subject) it is `{ source: 'user', method: 'implicit' }` with no identifier. `revokeApproval()` records
512
+ `revokedBy` the same way and `getRevocations(toolId)` reads it back (kept 24 hours). Releases up to 1.8.5
513
+ recorded both as `'policy'`; 1.8.6 recorded `'interactive'` and kept no `revokedBy`. Pass `grantedBy` to
514
+ record anything else; `userGrantor`'s third argument is an options object, not the method:
515
+
516
+ ```typescript
517
+ import { policyGrantor, userGrantor } from '@frontmcp/plugin-approval';
518
+
519
+ await this.approval.grantSessionApproval('my-app:file_write', { grantedBy: policyGrantor('safe-list') });
520
+ await this.approval.grantUserApproval('my-app:file_write', {
521
+ grantedBy: userGrantor('user-123', 'Jane Doe', { method: 'interactive' }),
522
+ });
523
+ ```
524
+
485
525
  ### Per-Tool Approval Metadata
486
526
 
487
527
  ```typescript
@@ -581,7 +621,8 @@ class GlobalCacheServer {}
581
621
 
582
622
  ### Storage Types
583
623
 
584
- - `memory` -- In-process Map with automatic eviction. No external dependencies.
624
+ - `memory` -- In-process Map with automatic eviction. No external dependencies. One store per server: servers built in
625
+ the same process (even from the same app class or `CachePlugin.init()` result) never share entries.
585
626
  - `redis` -- Dedicated Redis connection with native TTL support. Plugin manages the client.
586
627
  - `redis-client` -- Bring your own ioredis client instance.
587
628
  - `global-store` -- Reuses the Redis connection from `@FrontMcp({ redis: {...} })`.
@@ -626,7 +667,7 @@ CachePlugin.init({
626
667
  });
627
668
  ```
628
669
 
629
- A tool is cached if it matches any pattern OR has `cache: true` (or a cache object) in its metadata.
670
+ 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
671
 
631
672
  ### Cache Bypass
632
673
 
@@ -646,6 +687,21 @@ identity. Two calls share an entry only when all three match.
646
687
  Set `keyByIdentity: false` to drop identity from the key, and only for output that is identical for every caller. See
647
688
  "Cache keys include the caller's identity" above.
648
689
 
690
+ ### What a Cache Hit Returns
691
+
692
+ A hit skips `execute()` and answers with the cached output unchanged: its `content` and `structuredContent` are the
693
+ same as the call that filled the cache. The hit is marked on the result's own `_meta` (`result._meta.cache === 'hit'`),
694
+ never inside the data, so a tool without an `outputSchema` does not see `_meta` in its `structuredContent` or text.
695
+ A miss has no `cache` key in `_meta`. A plugin hook that wants to add result metadata the same way sets the
696
+ `tools:call-tool` flow state's `resultMeta`:
697
+
698
+ ```typescript
699
+ @ToolHook.Did('execute')
700
+ tagResult(flowCtx: FlowCtxOf<'tools:call-tool'>) {
701
+ flowCtx.state.set('resultMeta', { ...flowCtx.state.resultMeta, traced: true });
702
+ }
703
+ ```
704
+
649
705
  ---
650
706
 
651
707
  ## 5. Feature Flags Plugin (`@frontmcp/plugin-feature-flags`)
@@ -660,7 +716,9 @@ A disabled flag filters the entry out of `tools/list`, `resources/list`, `prompt
660
716
 
661
717
  That matters because a listing is not an access control. Clients cache listings and hold
662
718
  resource URIs and prompt names from earlier sessions, so anything gated only at list time
663
- stays reachable by name. If the adapter is unavailable the gate uses the ref's
719
+ stays reachable by name. The refusal is a public `FeatureFlagDisabledError` (`FEATURE_FLAG_DISABLED`, 403)
720
+ that names the capability and the flag. `FeatureFlagPlugin.init()` with no (or an unknown) `adapter` throws a
721
+ `FeatureFlagConfigurationError` at startup. If the adapter is unavailable the gate uses the ref's
664
722
  `defaultValue`, and a bare string ref (no default) fails closed.
665
723
 
666
724
  ### Installation
@@ -31,8 +31,8 @@ An authenticated task management MCP server with CRUD tools, a Redis-backed prov
31
31
  },
32
32
  "devDependencies": {
33
33
  "@frontmcp/testing": "^1.0.0",
34
- "jest": "^29.0.0",
35
- "ts-jest": "^29.0.0",
34
+ "jest": "^30.0.0",
35
+ "ts-jest": "^29.4.0",
36
36
  "typescript": "^5.4.0",
37
37
  },
38
38
  }
@@ -30,8 +30,8 @@ A complete beginner MCP server that exposes a weather lookup tool and a static r
30
30
  },
31
31
  "devDependencies": {
32
32
  "@frontmcp/testing": "^1.0.0",
33
- "jest": "^29.0.0",
34
- "ts-jest": "^29.0.0",
33
+ "jest": "^30.0.0",
34
+ "ts-jest": "^29.4.0",
35
35
  "typescript": "^5.4.0",
36
36
  },
37
37
  }
@@ -51,6 +51,7 @@ class MainApp {}
51
51
  provider: 'redis',
52
52
  host: process.env['REDIS_HOST'] || 'redis',
53
53
  port: 6379,
54
+ defaultTtlMs: 30 * 60_000, // slides while the owning pod serves the session; default 1 hour
54
55
  },
55
56
  },
56
57
  },
@@ -58,8 +58,8 @@ Shows the correct package.json configuration for publishing a FrontMCP SDK packa
58
58
 
59
59
  "devDependencies": {
60
60
  "@frontmcp/testing": "^1.0.0",
61
- "jest": "^29.0.0",
62
- "ts-jest": "^29.0.0",
61
+ "jest": "^30.0.0",
62
+ "ts-jest": "^29.4.0",
63
63
  "typescript": "^5.4.0",
64
64
  "zod": "^4.0.0",
65
65
  },
@@ -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 or mid-run: the default fails closed (`GuardStorageUnavailableError`); `throttle.storage.fallback: 'memory'` uses per-instance counters instead
63
67
  - [ ] Large payload limits are set to prevent memory exhaustion
64
68
 
65
69
  ### Dependencies
@@ -122,12 +122,20 @@ When a request arrives for a session owned by a dead pod:
122
122
 
123
123
  Each pod subscribes to `mcp:ha:notify:{nodeId}` via Redis Pub/Sub. Cross-pod MCP notifications (progress updates, resource changes) are published to the target pod's channel for local delivery.
124
124
 
125
+ ## Redis Connection, TTL and Recovery
126
+
127
+ - HA uses one dedicated ioredis client built from the top-level `redis` config (host/port/password/db/tls or `url`). It reconnects on its own, logs errors at a rate-limited interval, and is closed on shutdown. Vercel KV cannot back HA.
128
+ - The orphan scanner reads `<keyPrefix>session:` (default `mcp:transport:session:`), the same prefix the session store writes, and only runs when `transport.persistence.redis` is set.
129
+ - Session TTL is `persistence.defaultTtlMs`, then `persistence.redis.defaultTtlMs`, then 1 hour. The pod serving a session refreshes it at most once per quarter TTL.
130
+ - If Redis is unreachable at startup the server still starts; the session store retries with exponential backoff (1s doubling to 30s) and persistence resumes without a restart.
131
+ - `.frontmcp/machine-id` is only read/written in standalone development (never in `distributed` or `serverless`); in Kubernetes the machine ID is `HOSTNAME`.
132
+
125
133
  ## Load Balancer Affinity
126
134
 
127
135
  FrontMCP sets:
128
136
 
129
137
  - **Cookie**: `__frontmcp_node` on Streamable HTTP initialize
130
- - **Header**: `X-FrontMCP-Machine-Id` on every distributed response
138
+ - **Header**: `X-FrontMCP-Machine-Id` on every distributed response (initialize, message POSTs, DELETE, stateless requests and SSE), applied by the hookable `applyNodeHeaders` flow stage
131
139
 
132
140
  NGINX sticky session example:
133
141
 
@@ -173,12 +181,14 @@ upstream mcp_backend {
173
181
 
174
182
  ## Troubleshooting
175
183
 
176
- | Problem | Cause | Solution |
177
- | ---------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------ |
178
- | Sessions not transferred after pod death | `heartbeatTtlMs` too high | Lower TTL while keeping >= 2x interval (e.g., 20-30s for a 10s interval) |
179
- | `HaConfigurationError` on startup | Missing Redis config | Add `redis` to `@FrontMcp()` decorator |
180
- | Duplicate notifications | Shared Redis subscriber connection | Use dedicated connections per relay |
181
- | Session takeover race failures | High pod count + simultaneous restarts | Increase `takeoverGracePeriodMs` |
184
+ | Problem | Cause | Solution |
185
+ | ---------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
186
+ | Sessions not transferred after pod death | `heartbeatTtlMs` too high | Lower TTL while keeping >= 2x interval (e.g., 20-30s for a 10s interval) |
187
+ | `HaConfigurationError` on startup | Missing Redis config | Add `redis` to `@FrontMcp()` decorator |
188
+ | Duplicate notifications | Shared Redis subscriber connection | Use dedicated connections per relay |
189
+ | Sessions expire too early or too late | TTL not configured | Set `transport.persistence.defaultTtlMs` (or `persistence.redis.defaultTtlMs`); default is 1 hour and slides while the owning pod serves requests |
190
+ | Redis was down when pods started | Startup connect failed | Nothing to do: the session store reconnects with backoff (1s to 30s) and `/readyz` turns 200 |
191
+ | Session takeover race failures | High pod count + simultaneous restarts | Increase `takeoverGracePeriodMs` |
182
192
 
183
193
  ## Examples
184
194
 
@@ -81,7 +81,9 @@ Deep check: probes all registered dependencies, returns catalog hash and registr
81
81
  The health service automatically registers probes for:
82
82
 
83
83
  - **Session store** (Redis/Vercel KV) via `TransportService.pingSessionStore()`
84
- - **Remote MCP apps** via the existing `HealthCheckManager` background checks
84
+ - **Remote MCP apps** via the existing `HealthCheckManager` background checks. Until the first check completes the probe is `degraded` (`state: unknown`), so `/readyz` stays 200; a remote that fails its checks is `unhealthy` and gives 503.
85
+
86
+ The fetch handler (Workers, Vercel Edge, Deno) honours the same `health` settings (`healthzPath`, `readyzPath`, `readyz.enabled`, `enabled: false` gives 404).
85
87
 
86
88
  ## Custom Probes
87
89
 
@@ -52,7 +52,7 @@ apps/billing/
52
52
  index.ts # barrel exports updated automatically
53
53
  project.json
54
54
  tsconfig.json
55
- jest.config.ts
55
+ jest.config.cjs
56
56
  ```
57
57
 
58
58
  ```bash
@@ -41,12 +41,14 @@ This creates a full Nx workspace with `@frontmcp/nx` pre-installed, sample app,
41
41
 
42
42
  ### Option B: Add FrontMCP to an existing Nx workspace
43
43
 
44
- Install the plugin:
44
+ Install the plugin with `nx add`. It runs the plugin's `init` generator, which adds `@frontmcp/sdk`, `frontmcp`, `@frontmcp/testing` and the Jest toolchain to `package.json` (existing versions are kept) and makes the `@frontmcp/nx:build`, `build-exec` and `test` executors cacheable in `nx.json` `targetDefaults`:
45
45
 
46
46
  ```bash
47
- yarn add -D @frontmcp/nx
47
+ nx add @frontmcp/nx
48
48
  ```
49
49
 
50
+ If you install the packages yourself (`yarn add -D @frontmcp/nx @frontmcp/sdk frontmcp @frontmcp/testing`), run `nx g @frontmcp/nx:init` once to get the same setup.
51
+
50
52
  Then initialize the workspace structure:
51
53
 
52
54
  ```bash
@@ -148,7 +150,7 @@ Creates a `SKILL.md`-based skill directory in `apps/my-app/src/skills/my-skill/`
148
150
  nx g @frontmcp/nx:agent my-agent --project=my-app
149
151
  ```
150
152
 
151
- Creates an `@Agent`-decorated class in `apps/my-app/src/agents/`. Agents are autonomous AI components with their own LLM providers and isolated scopes, automatically exposed as `use-agent:<agent_id>` tools.
153
+ Creates an `@Agent`-decorated class in `apps/my-app/src/agents/`. Agents are autonomous AI components with their own LLM providers and isolated scopes, automatically exposed as `use-agent:<agent_id>` tools. The generated `llm` block picks `anthropic` (`ANTHROPIC_API_KEY`) for `claude*` models and `openai` (`OPENAI_API_KEY`) otherwise, and `--tools a,b` imports each tool class from `../tools/<name>.tool` (de-duplicated) instead of using string names.
152
154
 
153
155
  ### Plugin
154
156
 
@@ -156,7 +158,7 @@ Creates an `@Agent`-decorated class in `apps/my-app/src/agents/`. Agents are aut
156
158
  nx g @frontmcp/nx:plugin my-plugin --project=my-app
157
159
  ```
158
160
 
159
- Creates a `@Plugin` class extending `DynamicPlugin` in `apps/my-app/src/plugins/`. Plugins participate in lifecycle events and can contribute additional capabilities.
161
+ Creates a `@Plugin` class extending `DynamicPlugin` in `apps/my-app/src/plugins/`. The plugin takes its options in the constructor and contributes providers through a **static** `dynamicProviders(options)` method; there is no `onRegister` hook to implement.
160
162
 
161
163
  ### Adapter
162
164
 
@@ -164,7 +166,7 @@ Creates a `@Plugin` class extending `DynamicPlugin` in `apps/my-app/src/plugins/
164
166
  nx g @frontmcp/nx:adapter my-adapter --project=my-app
165
167
  ```
166
168
 
167
- Creates an `@Adapter` class extending `DynamicAdapter` in `apps/my-app/src/adapters/`. Adapters convert external definitions (OpenAPI, Lambda, etc.) into generated tools, resources, and prompts.
169
+ Creates an `@Adapter` class extending `DynamicAdapter` in `apps/my-app/src/adapters/`. Adapters convert external definitions (OpenAPI, Lambda, etc.) into generated tools, resources, and prompts. The generated class stores its `{ name } & Options` constructor argument and `fetch()` returns a `FrontMcpAdapterResponse`.
168
170
 
169
171
  ### Provider
170
172
 
@@ -172,7 +174,7 @@ Creates an `@Adapter` class extending `DynamicAdapter` in `apps/my-app/src/adapt
172
174
  nx g @frontmcp/nx:provider my-provider --project=my-app
173
175
  ```
174
176
 
175
- Creates a `@Provider` class in `apps/my-app/src/providers/`. Providers are named singletons resolved via DI (e.g., database pools, API clients, config).
177
+ Creates a `@Provider` class in `apps/my-app/src/providers/`. Providers are named singletons resolved via DI (e.g., database pools, API clients, config). The class is its own token: register `providers: [MyProvider]` and resolve it with `this.get(MyProvider)`; `--scope singleton` maps to `ProviderScope.GLOBAL`, `request`/`context` to `ProviderScope.CONTEXT`.
176
178
 
177
179
  ### Flow
178
180
 
@@ -180,7 +182,7 @@ Creates a `@Provider` class in `apps/my-app/src/providers/`. Providers are named
180
182
  nx g @frontmcp/nx:flow my-flow --project=my-app
181
183
  ```
182
184
 
183
- Creates a `@Flow` class extending `FlowBase` in `apps/my-app/src/flows/`. Flows define execution pipelines with hooks and stages.
185
+ Creates a `@Flow` class extending `FlowBase` in `apps/my-app/src/flows/`. Flows define execution pipelines with hooks and stages. The generated flow declares its schemas, registers itself through `declare global { interface ExtendFlows }` so `runFlow` is typed, and implements each plan step with a `@Stage` method from `FlowHooksOf(name)`.
184
186
 
185
187
  ### Job
186
188
 
@@ -214,7 +216,9 @@ Creates an `@AuthProvider` class in `apps/my-app/src/auth-providers/`. Auth prov
214
216
  nx build my-server
215
217
  ```
216
218
 
217
- Builds the server and all its dependencies in the correct order. Nx caches build outputs so subsequent builds of unchanged projects are instant.
219
+ Builds the server and all its dependencies in the correct order. Nx caches build outputs so subsequent builds of unchanged projects are instant (generated projects set `cache: true`, and `init` covers existing workspaces).
220
+
221
+ The `@frontmcp/nx:build` executor runs `frontmcp build` from the project root using the `frontmcp` CLI installed in the workspace (never `npx`, which would download the newest CLI). Choose the platform with the `target` option (`node`, `vercel`, `lambda`, `cloudflare`); `adapter` is a deprecated alias. Code imported from workspace libraries through `tsconfig.base.json` path aliases is resolved and bundled for every target.
218
222
 
219
223
  ### Test a Single Project
220
224
 
@@ -222,7 +226,9 @@ Builds the server and all its dependencies in the correct order. Nx caches build
222
226
  nx test my-app
223
227
  ```
224
228
 
225
- Runs Jest tests for the specified project. Test files must use `.spec.ts` extension (not `.test.ts`).
229
+ Runs `frontmcp test` from the project root. The generated `jest.config.cjs` uses the swc transform, loads `@frontmcp/testing/setup`, and maps the `tsconfig.base.json` path aliases so imports of workspace libraries resolve. Test files must use `.spec.ts` extension (not `.test.ts`).
230
+
231
+ The `inspector` executor forwards its `port` option as the `CLIENT_PORT` environment variable (the `frontmcp inspector` command has no port flag).
226
232
 
227
233
  ### Build All Projects
228
234
 
@@ -284,8 +290,9 @@ my-project/
284
290
  my-app.app.ts # @App class
285
291
  index.ts # barrel exports
286
292
  project.json
293
+ package.json # minimal manifest so `frontmcp build` runs in the project root
287
294
  tsconfig.json
288
- jest.config.ts
295
+ jest.config.cjs
289
296
  libs/
290
297
  my-lib/
291
298
  src/
@@ -405,13 +412,14 @@ Complete list of all `@frontmcp/nx` generators from `generators.json`:
405
412
 
406
413
  ## Troubleshooting
407
414
 
408
- | Problem | Cause | Solution |
409
- | ---------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
410
- | `Cannot find module '@frontmcp/nx'` | Plugin not installed | Run `yarn add -D @frontmcp/nx` and ensure it appears in `devDependencies` |
411
- | Generator creates files in the wrong directory | Missing or incorrect `--project` flag | Always pass `--project=<app-name>` for primitive generators; verify the app exists in `apps/` |
412
- | `nx affected` runs nothing despite changes | Base branch not configured or no dependency link | Check `nx.json` for `defaultBase` setting; verify the changed file belongs to a project in the graph |
413
- | Build fails with circular dependency error | Library A imports from Library B and vice versa | Use `nx graph` to visualize the cycle; extract shared code into a new library |
414
- | Cache not working (full rebuild every time) | Missing or misconfigured `cacheableOperations` in `nx.json` | Ensure `build`, `test`, and `lint` are listed in `targetDefaults` with `cache: true` |
415
+ | Problem | Cause | Solution |
416
+ | ---------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
417
+ | `Cannot find module '@frontmcp/nx'` | Plugin not installed | Run `yarn add -D @frontmcp/nx` and ensure it appears in `devDependencies` |
418
+ | Generator creates files in the wrong directory | Missing or incorrect `--project` flag | Always pass `--project=<app-name>` for primitive generators; verify the app exists in `apps/` |
419
+ | `nx affected` runs nothing despite changes | Base branch not configured or no dependency link | Check `nx.json` for `defaultBase` setting; verify the changed file belongs to a project in the graph |
420
+ | Build fails with circular dependency error | Library A imports from Library B and vice versa | Use `nx graph` to visualize the cycle; extract shared code into a new library |
421
+ | Cache not working (full rebuild every time) | Executor targets are not marked cacheable | Run `nx g @frontmcp/nx:init`, or set `cache: true` on the target / in `targetDefaults` |
422
+ | `Cannot find module '@scope/lib'` in Jest | Old `jest.config.ts` without the path-alias mapper | Use the generated `jest.config.cjs` (maps `tsconfig.base.json` paths) or add a `moduleNameMapper` |
415
423
 
416
424
  ## Examples
417
425
 
@@ -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. If Redis goes away while the server runs, a rate-limited call is refused with the same error, not `Internal FrontMCP error`. Opt in to per-instance counters explicitly (they also cover a mid-run outage):
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