@frontmcp/skills 1.7.2 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/catalog/create-tool/examples/08-tool-with-provider-injection.md +2 -2
  2. package/catalog/create-tool/examples/22-tool-with-ui-html-template.md +7 -7
  3. package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +3 -3
  4. package/catalog/create-tool/references/decorator-options.md +1 -0
  5. package/catalog/create-tool/references/elicitation.md +9 -0
  6. package/catalog/create-tool/references/error-handling.md +10 -6
  7. package/catalog/create-tool/references/execution-context.md +7 -3
  8. package/catalog/create-tool/references/input-schema.md +2 -0
  9. package/catalog/create-tool/references/output-schema.md +6 -1
  10. package/catalog/create-tool/references/throttling.md +7 -8
  11. package/catalog/create-tool/references/ui-widgets.md +14 -1
  12. package/catalog/frontmcp-authorities/SKILL.md +1 -0
  13. package/catalog/frontmcp-authorities/references/custom-evaluators.md +13 -2
  14. package/catalog/frontmcp-authorities/references/rbac-abac-rebac.md +2 -0
  15. package/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md +4 -4
  16. package/catalog/frontmcp-config/examples/configure-throttle/server-level-rate-limit.md +1 -2
  17. package/catalog/frontmcp-config/examples/configure-throttle-guard-config/full-guard-config.md +2 -3
  18. package/catalog/frontmcp-config/references/configure-auth-modes.md +36 -13
  19. package/catalog/frontmcp-config/references/configure-auth.md +1 -0
  20. package/catalog/frontmcp-config/references/configure-http.md +10 -2
  21. package/catalog/frontmcp-config/references/configure-skills-http.md +2 -2
  22. package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +5 -5
  23. package/catalog/frontmcp-config/references/configure-throttle.md +97 -20
  24. package/catalog/frontmcp-deployment/SKILL.md +10 -6
  25. package/catalog/frontmcp-deployment/examples/deploy-to-cloudflare/worker-custom-domain.md +4 -7
  26. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +4 -0
  27. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +82 -39
  28. package/catalog/frontmcp-development/examples/create-plugin-hooks/caching-with-around.md +7 -6
  29. package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md +2 -2
  30. package/catalog/frontmcp-development/references/create-job.md +2 -2
  31. package/catalog/frontmcp-development/references/create-plugin-hooks.md +21 -17
  32. package/catalog/frontmcp-development/references/create-prompt.md +18 -16
  33. package/catalog/frontmcp-development/references/create-provider.md +3 -3
  34. package/catalog/frontmcp-development/references/create-resource.md +10 -8
  35. package/catalog/frontmcp-development/references/create-skill.md +7 -0
  36. package/catalog/frontmcp-development/references/decorators-guide.md +9 -8
  37. package/catalog/frontmcp-development/references/official-plugins.md +167 -6
  38. package/catalog/frontmcp-development/references/openapi-adapter.md +16 -0
  39. package/catalog/frontmcp-observability/references/metrics-endpoint.md +1 -1
  40. package/catalog/frontmcp-observability/references/structured-logging.md +35 -0
  41. package/catalog/frontmcp-setup/SKILL.md +8 -7
  42. package/catalog/frontmcp-testing/SKILL.md +12 -8
  43. package/catalog/frontmcp-testing/examples/test-e2e-handler/tool-call-and-error-e2e.md +3 -1
  44. package/catalog/frontmcp-testing/references/setup-testing.md +42 -9
  45. package/catalog/frontmcp-testing/references/test-e2e-handler.md +35 -0
  46. package/catalog/skills-manifest.json +4 -3
  47. package/package.json +1 -1
@@ -70,16 +70,16 @@ These are the flow names with pre-built hook decorator exports in `@frontmcp/sdk
70
70
 
71
71
  This is the load-bearing invariant behind every hook above: in FrontMCP **every
72
72
  request runs through a flow**, and because flows are made of `@Stage` steps that
73
- `FlowHooksOf` exposes for interception, hooks work *everywhere* automatically.
73
+ `FlowHooksOf` exposes for interception, hooks work _everywhere_ automatically.
74
74
  The hookability is only guaranteed because nothing handles a request outside a
75
75
  flow.
76
76
 
77
77
  Therefore:
78
78
 
79
79
  - **Never bypass the flow pipeline to make something work.** Add or extend a flow
80
- + its stages; do not hand-roll request logic (auth, transport, routing) in a
81
- transport/adapter that skips the flow. A bypass silently deletes every hook on
82
- that path.
80
+ - its stages; do not hand-roll request logic (auth, transport, routing) in a
81
+ transport/adapter that skips the flow. A bypass silently deletes every hook on
82
+ that path.
83
83
  - **Adapters only translate.** A transport adapter (Express, the Web-fetch/worker
84
84
  handler, stdio) converts its native request/response to the flow's normalized
85
85
  `ServerRequest` + `httpRespond` output and then runs the **same** flows. Two
@@ -188,12 +188,12 @@ Both `@Will` and `@Did` (and `@Around`) accept an optional options object:
188
188
 
189
189
  ```typescript
190
190
  @Will('execute', {
191
- priority: 10, // Higher runs first (default: 0)
191
+ priority: 10, // Lower runs first (default: 0)
192
192
  filter: (ctx) => ctx.toolName !== 'health_check', // Predicate to skip
193
193
  })
194
194
  ```
195
195
 
196
- - **priority** (`number`) - Execution order when multiple hooks target the same stage. Higher values run first. Default: `0`.
196
+ - **priority** (`number`) - Execution order when multiple hooks target the same stage. Lower values run first, for `@Will`, `@Did` and `@Around` alike. Default: `0`.
197
197
  - **filter** (`(ctx) => boolean`) - A predicate that receives the flow context. Return `false` to skip this hook for the current invocation.
198
198
 
199
199
  ## Examples
@@ -251,21 +251,22 @@ export class CachePlugin {
251
251
 
252
252
  @Around('execute', { priority: 90 })
253
253
  async cacheResults(ctx, next) {
254
- const key = `${ctx.toolName}:${JSON.stringify(ctx.input)}`;
254
+ const { name, arguments: toolArguments } = ctx.state.required.input;
255
+ const key = `${name}:${JSON.stringify(toolArguments)}`;
256
+ const toolContext = ctx.state.required.toolContext;
255
257
  const cached = this.cache.get(key);
256
258
 
257
259
  if (cached && cached.expiry > Date.now()) {
258
- return cached.data;
260
+ toolContext.output = cached.data;
261
+ return; // not calling next() skips the execute stage
259
262
  }
260
263
 
261
- const result = await next();
264
+ await next(); // resolves with no value; the result is on toolContext.output
262
265
 
263
266
  this.cache.set(key, {
264
- data: result,
267
+ data: toolContext.output,
265
268
  expiry: Date.now() + 60_000,
266
269
  });
267
-
268
- return result;
269
270
  }
270
271
  }
271
272
  ```
@@ -307,6 +308,8 @@ export class MyApp {}
307
308
 
308
309
  Plugins are initialized in array order. Hook priority determines execution order within the same stage.
309
310
 
311
+ Hooks declared on an app's providers, on its plugins (including plugins nested inside them), and on those plugins' providers run only for that app's tools, resources and prompts (`tools:call-tool`, `resources:read-resource`, `prompts:get-prompt`, `completion:complete`), including the ones its adapters and plugins provide, such as the tools an OpenAPI adapter generates. Plugins registered on the server (`@FrontMcp({ plugins })`) apply to every app. Resources and prompts the server serves outside every app, such as the SEP-2640 `skill://` resources, run every app's hooks.
312
+
310
313
  ## Using Hooks Inside a @Tool Class
311
314
 
312
315
  You can add hook methods directly on a `@Tool` class to intercept its own execution flow. The hooks apply only when **this tool** is called:
@@ -383,10 +386,11 @@ Any stage can have `@Will`, `@Did`, `@Stage`, or `@Around` hooks.
383
386
  | Pattern | Correct | Incorrect | Why |
384
387
  | --------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
385
388
  | Hook decorator source | `const { Will, Did } = ToolHook;` or `FlowHooksOf('tools:call-tool')` | Importing `Will` directly from `@frontmcp/sdk` | Decorators must be bound to a specific flow via `FlowHooksOf` or pre-built exports |
386
- | Hook priority | `@Will('execute', { priority: 100 })` for early hooks | Relying on array order without priority | Multiple hooks on the same stage need explicit priority; higher runs first |
387
- | Around next() | `const result = await next(); return result;` | Forgetting to call `next()` in `@Around` | Omitting `next()` silently skips the wrapped stage and all downstream hooks |
388
- | Filter predicate | `filter: (ctx) => ctx.toolName !== 'health_check'` | Checking tool name inside the hook body and returning early | Filters skip the hook cleanly; returning early may leave state inconsistent |
389
- | Tool-level hooks | `@Will('execute')` on a `@Tool` class (scoped to that tool) | `@Will('execute')` on a `@Plugin` class expecting tool-scoped behavior | Plugin hooks fire for all tools; tool-level hooks fire only for that tool |
389
+ | Hook priority | `@Will('execute', { priority: -100 })` for early hooks | Relying on array order without priority | Multiple hooks on the same stage need explicit priority; lower runs first |
390
+ | Around next() | `await next();` | Forgetting to call `next()` in `@Around` | Omitting `next()` skips the wrapped stage; its `@Did` hooks still run |
391
+ | Around error recovery | `catch { ctx.state.required.toolContext.output = fallback; }` | Catching a rejected `next()` without setting the output | Returning normally handles the failure: the stage counts as successful |
392
+ | Filter predicate | `filter: (ctx) => ctx.state.required.input.name !== 'health_check'` | Checking tool name inside the hook body and returning early | A filtered-out hook is skipped cleanly; for `@Around`, the stage still runs |
393
+ | Tool-level hooks | `@Will('execute')` on a `@Tool` class (scoped to that tool) | `@Will('execute')` on a `@Plugin` class expecting tool-scoped behavior | Plugin hooks fire for every tool of the app; tool-level hooks only for that tool |
390
394
 
391
395
  ## Verification Checklist
392
396
 
@@ -411,7 +415,7 @@ Any stage can have `@Will`, `@Did`, `@Stage`, or `@Around` hooks.
411
415
  | Hook never fires | Plugin not registered in `plugins` array | Add plugin class to `@App` or `@FrontMcp` `plugins` array |
412
416
  | Hook fires for wrong flow | Used wrong flow name in `FlowHooksOf` | Verify flow name matches (e.g., `'tools:call-tool'` not `'tool:call'`) |
413
417
  | `@Around` skips the stage entirely | `next()` not called inside the around handler | Always `await next()` to execute the wrapped stage |
414
- | Multiple hooks execute in wrong order | Priorities not set or conflicting | Set explicit `priority` values; higher numbers execute first |
418
+ | Multiple hooks execute in wrong order | Priorities not set or conflicting | Set explicit `priority` values; lower numbers execute first |
415
419
  | `@Stage` replacement causes downstream errors | Return value shape does not match stage contract | Ensure the return matches what the next stage expects (e.g., MCP response format) |
416
420
 
417
421
  ## Examples
@@ -86,11 +86,11 @@ interface PromptArgument {
86
86
  }
87
87
  ```
88
88
 
89
- Required arguments are validated before `execute()` runs. Missing required arguments throw `MissingPromptArgumentError`.
89
+ Required arguments are validated before `execute()` runs. Missing required arguments throw `MissingPromptArgumentError`, which the client receives as a JSON-RPC `-32602` error (so does an unknown prompt name).
90
90
 
91
91
  ### GetPromptResult Structure
92
92
 
93
- The `execute()` method must return a `GetPromptResult`:
93
+ `execute()` returns a `GetPromptResult`, or a string, a message array or an object that the SDK converts into one. The full form is:
94
94
 
95
95
  ```typescript
96
96
  interface GetPromptResult {
@@ -109,6 +109,8 @@ Messages use two roles:
109
109
  - `user` -- represents the human side of the conversation
110
110
  - `assistant` -- primes the conversation with expected response patterns
111
111
 
112
+ The result is checked against this shape. A message with another `role`, such as `'system'`, or content that is not a valid content block fails the call with `INVALID_OUTPUT`.
113
+
112
114
  ### Available Context Methods and Properties
113
115
 
114
116
  `PromptContext` extends `ExecutionContextBase`, providing:
@@ -402,13 +404,13 @@ This creates the prompt file, spec file, and updates barrel exports.
402
404
 
403
405
  ## Common Patterns
404
406
 
405
- | Pattern | Correct | Incorrect | Why |
406
- | ------------------- | ----------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------- |
407
- | Return type | `execute()` returns `Promise<GetPromptResult>` | Returning a plain string or array of strings | MCP protocol requires `{ messages: [...] }` structure |
408
- | Argument validation | Mark arguments as `required: true` in `arguments` array | Manually checking `args.field` inside `execute()` | Framework validates required arguments before `execute()` runs |
409
- | Multi-turn priming | Use `assistant` role messages to prime expected response patterns | Putting all instructions in a single `user` message | Alternating roles guides the LLM toward structured output |
410
- | Resource embedding | Use `type: 'resource'` content with a resource URI | Inlining resource data as raw text in the prompt | Resource references let clients resolve content dynamically |
411
- | Error handling | Use `this.fail(err)` for validation failures in execute | `throw new Error(...)` directly | `this.fail` triggers the error flow with proper MCP error propagation |
407
+ | Pattern | Correct | Incorrect | Why |
408
+ | ------------------- | ------------------------------------------------------------------------ | --------------------------------------------------- | -------------------------------------------------------------------------- |
409
+ | Return type | `execute()` returns a `GetPromptResult`, string, message array or object | Returning nothing | Strings, message arrays and objects are converted to `{ messages: [...] }` |
410
+ | Argument validation | Mark arguments as `required: true` in `arguments` array | Manually checking `args.field` inside `execute()` | Framework validates required arguments before `execute()` runs |
411
+ | Multi-turn priming | Use `assistant` role messages to prime expected response patterns | Putting all instructions in a single `user` message | Alternating roles guides the LLM toward structured output |
412
+ | Resource embedding | Use `type: 'resource'` content with a resource URI | Inlining resource data as raw text in the prompt | Resource references let clients resolve content dynamically |
413
+ | Error handling | Use `this.fail(err)` for validation failures in execute | `throw new Error(...)` directly | `this.fail` triggers the error flow with proper MCP error propagation |
412
414
 
413
415
  ## Verification Checklist
414
416
 
@@ -429,13 +431,13 @@ This creates the prompt file, spec file, and updates barrel exports.
429
431
 
430
432
  ## Troubleshooting
431
433
 
432
- | Problem | Cause | Solution |
433
- | ------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------- |
434
- | Prompt not appearing in `prompts/list` | Not registered in `prompts` array | Add prompt class to `@App` or `@FrontMcp` `prompts` array |
435
- | `MissingPromptArgumentError` on optional argument | Argument marked `required: true` incorrectly | Set `required: false` for optional arguments in the `arguments` array |
436
- | LLM ignores priming messages | Only using `user` role messages | Add `assistant` role messages to prime the conversation pattern |
437
- | Type error on `execute()` return | Returning plain string instead of `GetPromptResult` | Wrap return in `{ messages: [{ role: 'user', content: { type: 'text', text: '...' } }] }` |
438
- | `this.get(TOKEN)` throws DependencyNotFoundError | Provider not registered in scope | Register provider in `providers` array of `@App` or `@FrontMcp` |
434
+ | Problem | Cause | Solution |
435
+ | -------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
436
+ | Prompt not appearing in `prompts/list` | Not registered in `prompts` array | Add prompt class to `@App` or `@FrontMcp` `prompts` array |
437
+ | `MissingPromptArgumentError` on optional argument | Argument marked `required: true` incorrectly | Set `required: false` for optional arguments in the `arguments` array |
438
+ | LLM ignores priming messages | Only using `user` role messages | Add `assistant` role messages to prime the conversation pattern |
439
+ | Call fails with `INVALID_OUTPUT` | A message uses a role other than `user` or `assistant`, or malformed content | Use `role: 'user'` or `'assistant'` and a valid content block such as `{ type: 'text', text: '...' }` |
440
+ | `this.get(TOKEN)` throws ProviderNotAvailableError | Provider not registered in scope | Register provider in `providers` array of `@App` or `@FrontMcp` |
439
441
 
440
442
  ## Examples
441
443
 
@@ -342,7 +342,7 @@ frontmcp dev
342
342
  | Pattern | Correct | Incorrect | Why |
343
343
  | --------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
344
344
  | Token definition | `const DB: Token<DbService> = Symbol('DbService')` (typed Symbol) | `const DB = 'database'` (string literal) | Typed `Token<T>` enables compile-time type checking on `this.get()` |
345
- | DI resolution | `this.get(TOKEN)` with error handling | `this.tryGet(TOKEN)!` with non-null assertion | `get` throws a clear `DependencyNotFoundError`; non-null assertions hide failures |
345
+ | DI resolution | `this.get(TOKEN)` with error handling | `this.tryGet(TOKEN)!` with non-null assertion | `get` throws a clear `ProviderNotAvailableError`; non-null assertions hide failures |
346
346
  | Lifecycle | `AsyncProvider({ useFactory })` for async setup; constructor for sync | Using `onInit()` / `onDestroy()` lifecycle hooks | `@Provider` has no lifecycle hooks; `AsyncProvider` factories are awaited before resolution |
347
347
  | Registration scope | Register at `@App` level for app-scoped, `@FrontMcp` for server-scoped | Registering same provider in multiple apps | Server-scoped providers are shared; duplicating causes multiple instances |
348
348
  | Config provider | `readonly` properties from `process.env` | Mutable properties that change at runtime | Providers are singletons; mutable state can cause race conditions |
@@ -366,13 +366,13 @@ frontmcp dev
366
366
  - [ ] `this.get(TOKEN)` resolves the provider in tools, resources, and agents
367
367
  - [ ] Provider is a singleton (same instance across all contexts)
368
368
  - [ ] Resource-owning providers expose an explicit `close()` / `stop()` method that the host calls before `server.dispose()`
369
- - [ ] Missing provider throws `DependencyNotFoundError` with a clear message
369
+ - [ ] Missing provider throws `ProviderNotAvailableError` with a clear message
370
370
 
371
371
  ## Troubleshooting
372
372
 
373
373
  | Problem | Cause | Solution |
374
374
  | -------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
375
- | `DependencyNotFoundError` at runtime | Provider not registered in scope | Add provider (class or `AsyncProvider` factory) to `providers` array in `@App` or `@FrontMcp` |
375
+ | `ProviderNotAvailableError` at runtime | Provider not registered in scope | Add provider (class or `AsyncProvider` factory) to `providers` array in `@App` or `@FrontMcp` |
376
376
  | Provider constructor throws at startup | Missing environment variable or unreachable service | Validate env in the constructor; restart with the missing config supplied |
377
377
  | `AsyncProvider` factory rejects | Async setup error (DB unreachable, schema fetch failed) | The factory error aborts boot — fix the dependency or wrap with retry inside `useFactory` |
378
378
  | Multiple instances of same provider | Registered in multiple apps instead of server level | Move to `@FrontMcp` `providers` for shared, server-scoped access |
@@ -146,11 +146,13 @@ The `@ResourceTemplate` decorator accepts:
146
146
 
147
147
  - `name` (required) -- unique resource template name
148
148
  - `title` (optional) -- human-readable display title for UIs (if omitted, `name` is used)
149
- - `uriTemplate` (required) -- URI pattern with `{paramName}` placeholders (RFC 6570 style)
149
+ - `uriTemplate` (required) -- URI pattern with RFC 6570 placeholders: `{paramName}` matches one path segment; `{+paramName}` (reserved expansion) can span several, e.g. `files://{+path}` matches `files://docs/a/b.txt` with `path = "docs/a/b.txt"`. Other RFC 6570 operators are not supported
150
150
  - `description` (optional) -- human-readable description
151
151
  - `mimeType` (optional) -- MIME type of the resource content
152
152
  - `icons` (optional) -- array of Icon objects for UI representation (per MCP spec)
153
153
 
154
+ When a template returns a plain value (an object, a string, or an array of items), each content item's `uri` is based on the URI that was read: `users://42/profile`, or `users://42/profile#0`, `#1` for array items without their own `uri`.
155
+
154
156
  ### Class-Based Pattern
155
157
 
156
158
  Use `@ResourceTemplate` with `uriTemplate` instead of `uri`. Type the `ResourceContext` generic parameter to get typed `params`.
@@ -572,13 +574,13 @@ When a client requests completions for the `userId` parameter with a partial str
572
574
 
573
575
  ## Troubleshooting
574
576
 
575
- | Problem | Cause | Solution |
576
- | ------------------------------------------------ | ------------------------------------------------ | ---------------------------------------------------------------------------------- |
577
- | Resource not appearing in `resources/list` | Not registered in `resources` array | Add resource class to `@App` or `@FrontMcp` `resources` array |
578
- | URI validation error at startup | Missing or invalid URI scheme | Ensure URI has a scheme like `config://`, `https://`, or `custom://` |
579
- | Template parameters are empty | Using `@Resource` instead of `@ResourceTemplate` | Switch to `@ResourceTemplate` with `uriTemplate` containing `{param}` placeholders |
580
- | Binary content is garbled | Returning raw buffer in `text` field | Use `blob: buffer.toString('base64')` instead of `text` for binary data |
581
- | `this.get(TOKEN)` throws DependencyNotFoundError | Provider not registered in scope | Register provider in `providers` array of `@App` or `@FrontMcp` |
577
+ | Problem | Cause | Solution |
578
+ | -------------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------- |
579
+ | Resource not appearing in `resources/list` | Not registered in `resources` array | Add resource class to `@App` or `@FrontMcp` `resources` array |
580
+ | URI validation error at startup | Missing or invalid URI scheme | Ensure URI has a scheme like `config://`, `https://`, or `custom://` |
581
+ | Template parameters are empty | Using `@Resource` instead of `@ResourceTemplate` | Switch to `@ResourceTemplate` with `uriTemplate` containing `{param}` placeholders |
582
+ | Binary content is garbled | Returning raw buffer in `text` field | Use `blob: buffer.toString('base64')` instead of `text` for binary data |
583
+ | `this.get(TOKEN)` throws ProviderNotAvailableError | Provider not registered in scope | Register provider in `providers` array of `@App` or `@FrontMcp` |
582
584
 
583
585
  ## Examples
584
586
 
@@ -430,6 +430,13 @@ catalog-installed skill, so the CLAUDE.md auto-generated block, the
430
430
  uniformly. See `frontmcp-skills-usage` for the full flag list and
431
431
  selector matrix.
432
432
 
433
+ Each skill's body comes from whichever source its `@Skill` declares: an
434
+ `instructions: { file }` file is copied as written (so frontmatter such as
435
+ `allowed-tools` survives), while inline and `{ url }` instructions become the
436
+ body. A skill whose instructions cannot be resolved, such as a `file` that no
437
+ longer exists, is skipped with a warning naming the skill and the reason,
438
+ instead of being installed as an empty `SKILL.md`.
439
+
433
440
  If you also want to ship slash commands and a `.claude-plugin/plugin.json`
434
441
  manifest, install the project as a Claude Code plugin instead of just the
435
442
  skills:
@@ -85,6 +85,7 @@ FrontMCP uses a hierarchical decorator system. The nesting order is:
85
85
  | `jobs?` | Background jobs/workflows system (`{ enabled, store? }`) |
86
86
  | `throttle?` | Server-level guard config (see note below) |
87
87
  | `pagination?` | List operation pagination (`tools/list` endpoint) |
88
+ | `fetch?` | What `this.fetch()` adds upstream: `forwardCallerTokenTo` / `forwardCustomHeadersTo` origin allow-lists (default: none), `autoInjectTracingHeaders`, `requestTimeout` |
88
89
  | `ui?` | UI rendering config (CDN overrides for widget imports) |
89
90
  | `extApps?` | Widget-to-host MCP Apps communication (host capabilities, session validation) |
90
91
  | `loader?` | Default npm/ESM package loader for `App.esm()` / `App.remote()` apps |
@@ -765,14 +766,14 @@ class AuditHooks {
765
766
 
766
767
  ## Troubleshooting
767
768
 
768
- | Problem | Cause | Solution |
769
- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
770
- | Tool does not appear in `tools/list` MCP response | Tool class is not registered in any `@App({ tools: [...] })` or `@FrontMcp({ tools: [...] })` | Add the tool class to the `tools` array of the appropriate `@App` or `@FrontMcp` decorator |
771
- | `this.get(Token)` throws `DependencyNotFoundError` | The provider for that token is not registered or is registered in a different app scope | Add a `@Provider` for the token in the same `@App` or in `@FrontMcp({ providers: [...] })` for global access |
772
- | Resource returns 404 / `ResourceNotFoundError` | The `uri` in `@Resource` does not match the requested URI, or `uriTemplate` parameters are misaligned | Verify the URI string exactly matches what the client requests; for templates, confirm `{param}` names match |
773
- | Hook never fires | The `flow` string in `@Will`/`@Did`/`@Around`/`@Stage` does not match any registered flow | Check the flow string against valid flows (e.g., `tools:call-tool`, `resources:read-resource`, `resources:list-resources`) |
774
- | Plugin context extension is `undefined` at runtime | The plugin's `installContextExtension` function was not called, or module augmentation is missing | Ensure the plugin is registered and its context extension function runs at startup; verify the `declare module` augmentation exists |
775
- | Agent `execute()` returns empty result | LLM configuration is missing or invalid (wrong model name, missing API key) | Verify `llm.model` and `llm.provider` in `@Agent`, and ensure the provider API key is set in environment variables |
769
+ | Problem | Cause | Solution |
770
+ | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
771
+ | Tool does not appear in `tools/list` MCP response | Tool class is not registered in any `@App({ tools: [...] })` or `@FrontMcp({ tools: [...] })` | Add the tool class to the `tools` array of the appropriate `@App` or `@FrontMcp` decorator |
772
+ | `this.get(Token)` throws `ProviderNotAvailableError` | The provider for that token is not registered or is registered in a different app scope | Add a `@Provider` for the token in the same `@App` or in `@FrontMcp({ providers: [...] })` for global access |
773
+ | Resource returns 404 / `ResourceNotFoundError` | The `uri` in `@Resource` does not match the requested URI, or `uriTemplate` parameters are misaligned | Verify the URI string exactly matches what the client requests; for templates, confirm `{param}` names match |
774
+ | Hook never fires | The `flow` string in `@Will`/`@Did`/`@Around`/`@Stage` does not match any registered flow | Check the flow string against valid flows (e.g., `tools:call-tool`, `resources:read-resource`, `resources:list-resources`) |
775
+ | Plugin context extension is `undefined` at runtime | The plugin's `installContextExtension` function was not called, or module augmentation is missing | Ensure the plugin is registered and its context extension function runs at startup; verify the `declare module` augmentation exists |
776
+ | Agent `execute()` returns empty result | LLM configuration is missing or invalid (wrong model name, missing API key) | Verify `llm.model` and `llm.provider` in `@Agent`, and ensure the provider API key is set in environment variables |
776
777
 
777
778
  ---
778
779
 
@@ -153,6 +153,28 @@ class MyTool extends ToolContext {
153
153
  }
154
154
  ```
155
155
 
156
+ ### Tool Access Policy
157
+
158
+ One policy decides every CodeCall surface: `codecall:search`, `codecall:describe`, `callTool`/`getTool` and the namespace bindings in `codecall:execute`, and `codecall:invoke`. A withheld tool is not indexed, is reported in describe's `notFound`, and is refused at execution.
159
+
160
+ ```typescript
161
+ CodeCallPlugin.init({
162
+ mode: 'codecall_only',
163
+ // `tool` is { name, appId, source, description, tags }; `name` is the tool's own name, never `<appId>:<name>`
164
+ includeTools: (tool) => !tool.name.startsWith('admin:'),
165
+ directCalls: {
166
+ enabled: true,
167
+ allowedTools: ['users:list', 'crm:users:get'], // bare name, or `<appId>:<name>` to pin one app
168
+ },
169
+ });
170
+ ```
171
+
172
+ - Always withheld: `enabledInCodeCall: false` tools, hidden tools (`visibility: 'hidden'` / `hideFromDiscovery`), `visibility: 'internal'` tools, `codecall:*`, and any tool whose name, qualified name or requested spelling starts with `system:`, `internal:` or `__`.
173
+ - `tool.appId` names the owning app for the tools its adapters and plugins provide too, so `includeTools: (tool) => tool.appId !== 'admin'` withholds every tool of app `admin`.
174
+ - `codecall:searchSkills` and `codecall:searchKnowledge` run the SDK's `skills:filter` flow, so a skill a plugin withholds there (a flag-disabled skill, for one) is absent from both.
175
+ - `directCalls.allowedTools` and `directCalls.filter` only narrow the base policy; listing a withheld tool does not make it callable. Unlisted tools are refused.
176
+ - Hiding a tool from search is not the control; the refusal at execution is. Do not rely on `visibleInListTools` or search ranking to protect a tool.
177
+
156
178
  ### Power Features
157
179
 
158
180
  - **TF-IDF Search** -- Term frequency-inverse document frequency scoring indexes tool names, descriptions, and tags. No external embedding service required.
@@ -260,6 +282,48 @@ class MyTool extends ToolContext {
260
282
  - `tool` -- Scoped to a specific tool + session combination. Isolated per tool.
261
283
  - `global` -- Shared across all sessions and users. Use carefully.
262
284
 
285
+ **`session`, `tool`, and `user` scopes require a per-client identity.** A stateless HTTP
286
+ transport injects the same session id (`__stateless__`) into every request, so it carries no
287
+ session identity. `session` and `tool` scope fall back to the authenticated principal, and an
288
+ unauthenticated stateless request is refused with a `RememberIdentityError` rather than given
289
+ a namespace shared with every other client. `user` scope is refused with no authenticated
290
+ user. If the data really is shared, use `scope: 'global'`.
291
+
292
+ **Set `REMEMBER_SECRET` on every instance that shares a store.** All scopes, `session` and
293
+ `tool` included, derive their encryption key from that secret plus the scope identity. A
294
+ session id is not a secret -- the client knows it and it travels in the `mcp-session-id`
295
+ header -- so it cannot be the key material on its own. Instances with different secrets cannot
296
+ read each other's entries. With none of `REMEMBER_SECRET`, `MCP_MEMORY_SECRET` or
297
+ `MCP_SESSION_SECRET` set in production, the plugin falls back to a random in-memory secret and
298
+ logs a warning once; its encrypted memory is then lost on restart.
299
+
300
+ **Upgrading past that change moves existing `session`, `tool` and `user` entries.** The key
301
+ derivation change orphans `session` and `tool` ciphertext, and the namespace now percent-encodes
302
+ every variable component, which moves any identity containing an escaped character (a `:` in a
303
+ user id, say). Both failures are silent on their own: decryption returns `null` and a moved key
304
+ simply misses, so the value reads as absent.
305
+
306
+ These three scopes are stored under a `v2:` segment (`remember:v2:session:<identity>:<key>`) and
307
+ the plugin **purges the pre-`v2` entries automatically**, warning with the number removed. The
308
+ version segment is what makes that safe -- a purge pattern of `remember:session:*` cannot match a
309
+ live `remember:v2:session:*` key. `global` is not versioned and not purged: neither its keys nor
310
+ its key derivation changed.
311
+
312
+ **The purge runs 24 hours after the fleet first reached the `v2:` layout -- not after this
313
+ process started -- on an unreferenced timer, never on the request path.** The first instance to
314
+ reach the store stamps `<keyPrefix>__layout__` with `{ version, firstSeenAt }`; every instance
315
+ reads it and sweeps only once it is older than the window, re-arming for the remainder until
316
+ then. The clock lives in the store because a process-local timer restarts on every deploy and
317
+ crash, so it never converges on "the fleet has been on `v2:` for a while". The marker is written
318
+ once and never overwritten, and if it cannot be read or parsed the purge stands down rather than
319
+ deleting on an unknown clock.
320
+
321
+ The window has to outlast the rollout _and_ the period in which a bad deploy is rolled back --
322
+ a rollback after the sweep makes the old fleet permanent again with its memory gone. Tune it
323
+ with `legacyPurgeDelayMs`, or pass `skipLegacyPurge: true` to migrate the data yourself. On
324
+ serverless and edge the invocation usually ends before the timer fires, so nothing is purged;
325
+ clear the legacy prefixes manually if you want the storage back.
326
+
263
327
  ### Tools Exposed (when `tools.enabled: true`)
264
328
 
265
329
  - `remember_this` -- Store a key-value pair in memory
@@ -313,11 +377,59 @@ class AuditedServer {}
313
377
  class WebhookServer {}
314
378
  ```
315
379
 
380
+ **`ApprovalPlugin.init()` registers the approval check itself.** Do not add `ApprovalCheckPlugin`
381
+ to `plugins`; listing it as well is harmless and the check still runs once per call. Require
382
+ 1.8.1 or later: in 1.8.0 and earlier `ApprovalPlugin.init()` registered no check at all, so
383
+ tools marked `approval` ran unapproved.
384
+
316
385
  ### Modes
317
386
 
318
387
  - `recheck` -- Re-evaluates approval status on every tool call. Approval can be granted programmatically via `this.approval.grantSessionApproval()`. Good for interactive approval flows where the user confirms in-band.
319
388
  - `webhook` -- Sends a PKCE-secured webhook to an external approval service. The external service calls back to confirm or deny. Suitable for compliance workflows requiring out-of-band approval.
320
389
 
390
+ ### Pre-approved contexts come from the session
391
+
392
+ `approval.preApprovedContexts` lists contexts that skip the approval check entirely. The
393
+ context a call runs in is taken **only** from `authInfo.extra.approvalContext`, which your
394
+ authentication layer sets while establishing the session.
395
+
396
+ A `context` field in the gated tool's own arguments is ignored. Do not build a flow that
397
+ expects the caller to declare its context -- the caller of a gated tool must not be able to
398
+ name the context that lets it skip the gate. Set the context when you authenticate:
399
+
400
+ ```typescript
401
+ // In your auth layer, not in tool input
402
+ authInfo.extra.approvalContext = { type: 'project', identifier: resolvedProjectId };
403
+ ```
404
+
405
+ ### How the check decides
406
+
407
+ 1. `skipApproval: true`, or approval not required: the tool runs.
408
+ 2. A recorded **denial** for the caller (session or user scope): refused with state `denied`. A
409
+ denial outranks pre-approved contexts and any session approval.
410
+ 3. The session context is one of `preApprovedContexts`: the tool runs.
411
+ 4. `alwaysPrompt: true`: refused with state `pending`.
412
+ 5. A valid approval for the caller: the tool runs.
413
+ 6. Otherwise refused with state `pending` (or `expired`).
414
+
415
+ A refused call throws `ApprovalRequiredError`; the client receives an error result.
416
+
417
+ Approvals are looked up by the tool's full name, `<owner id>:<tool name>`, so pass that name to
418
+ `this.approval` grant and check methods. The owner is the app that declares the tool, or the
419
+ adapter or plugin that provides it (`my-app:file_write` for a tool declared on app `my-app`,
420
+ `github-api:create_issue` for one its `github-api` adapter provides). Session approvals belong to the
421
+ caller's session; on the stateless HTTP transport, where every request shares one session id,
422
+ they are keyed by the authenticated principal (`authInfo.extra.userId`, then `authInfo.extra.sub`,
423
+ then `authInfo.clientId`). A stateless call with no
424
+ principal cannot hold a session approval.
425
+
426
+ Installed on an app, `ApprovalPlugin` gates only that app's tools (including those its adapters
427
+ and plugins provide) against its own store, so two apps can each install it with separate stores.
428
+ Installed on the server, it gates every tool; a tool both gate must pass each store's check. With
429
+ two apps each installing it, `this.approval` currently resolves the store of the app registered
430
+ last ([#600](https://github.com/agentfront/frontmcp/issues/600)) -- grant through each app's
431
+ store directly, or install `ApprovalPlugin` once on the server.
432
+
321
433
  ### Using `this.approval` in Tools
322
434
 
323
435
  ```typescript
@@ -350,11 +462,13 @@ class DangerousActionTool extends ToolContext {
350
462
  ### Per-Tool Approval Metadata
351
463
 
352
464
  ```typescript
465
+ import { ApprovalScope } from '@frontmcp/plugin-approval';
466
+
353
467
  @Tool({
354
468
  name: 'file_write',
355
469
  approval: {
356
470
  required: true,
357
- defaultScope: 'session', // 'session' | 'user' | 'time-limited'
471
+ defaultScope: ApprovalScope.SESSION, // SESSION | USER | TIME_LIMITED | TOOL_SPECIFIC | CONTEXT_SPECIFIC
358
472
  category: 'write',
359
473
  riskLevel: 'medium', // 'low' | 'medium' | 'high' | 'critical'
360
474
  approvalMessage: 'Allow file writing for this session?',
@@ -373,6 +487,27 @@ When `approval.required` is `true`, the plugin automatically intercepts tool exe
373
487
 
374
488
  Automatic tool result caching. Cache responses by tool name patterns or per-tool metadata. Supports sliding window TTL and cache bypass headers.
375
489
 
490
+ ### Cache keys include the caller's identity
491
+
492
+ `keyByIdentity` defaults to `true`. Most cached tools return something that depends on who is
493
+ asking -- a profile, a balance, a tenant's records, anything filtered by the caller's own
494
+ permissions -- and a key built only from the tool and its arguments serves the first caller's
495
+ response to everyone else.
496
+
497
+ The identity is the subject your auth layer puts in `authInfo.extra` (`sub` / `userId`), then
498
+ the client id, then the session. For a request the SDK verified, the client id is the token's
499
+ `sub`, or `anon:<id>` for an anonymous session. The session id the stateless HTTP transport
500
+ gives every request is not an identity. A call with no identity at all gets a key of its own
501
+ rather than one shared with every other identity-less caller.
502
+
503
+ Set `keyByIdentity: false` **only** when every caller would get byte-identical output: public
504
+ reference data, a currency table, a static document.
505
+
506
+ ```typescript
507
+ // Public data, identical for everyone -- safe to share one entry
508
+ CachePlugin.init({ type: 'memory', toolPatterns: ['reference:*'], keyByIdentity: false });
509
+ ```
510
+
376
511
  ### Installation
377
512
 
378
513
  ```typescript
@@ -472,7 +607,11 @@ The header name is configurable via `bypassHeader` in the plugin options. Defaul
472
607
 
473
608
  ### Cache Key
474
609
 
475
- The cache key is computed from the tool name and the serialized input arguments. Two calls with identical tool name and arguments return the same cached result.
610
+ The cache key is a SHA-256 digest of the tool name, the serialized input arguments, and -- by default -- the caller's
611
+ identity. Two calls share an entry only when all three match.
612
+
613
+ Set `keyByIdentity: false` to drop identity from the key, and only for output that is identical for every caller. See
614
+ "Cache keys include the caller's identity" above.
476
615
 
477
616
  ---
478
617
 
@@ -480,6 +619,17 @@ The cache key is computed from the tool name and the serialized input arguments.
480
619
 
481
620
  Gate tools, resources, prompts, and skills behind feature flags. Integrates with popular feature flag services or static configuration.
482
621
 
622
+ ### A flag withholds the capability, it does not just hide it
623
+
624
+ A disabled flag filters the entry out of `tools/list`, `resources/list`, `prompts/list` and
625
+ `skills/search`, **and** refuses it on direct access: `tools/call`, `resources/read` and
626
+ `prompts/get` each evaluate the flag before executing.
627
+
628
+ That matters because a listing is not an access control. Clients cache listings and hold
629
+ resource URIs and prompt names from earlier sessions, so anything gated only at list time
630
+ stays reachable by name. If the adapter is unavailable the gate uses the ref's
631
+ `defaultValue`, and a bare string ref (no default) fails closed.
632
+
483
633
  ### Installation
484
634
 
485
635
  ```typescript
@@ -599,7 +749,17 @@ class ExperimentalTool extends ToolContext {
599
749
  }
600
750
  ```
601
751
 
602
- The plugin hooks into listing and execution flows for tools, resources, prompts, and skills. When a flag evaluates to `false`, the corresponding entry is filtered from list results and direct invocation returns an error.
752
+ The plugin hooks into listing and execution flows for tools, resources, resource templates, prompts, and skills. When a flag evaluates to `false`, the corresponding entry is filtered from list results and direct access is refused:
753
+
754
+ | Capability | Hidden from | Refused on |
755
+ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
756
+ | Tool | `tools/list` | `tools/call` |
757
+ | Resource | `resources/list` | `resources/read`, `completion/complete` |
758
+ | Resource template | `resources/templates/list` | `resources/read` of any URI it matches, `completion/complete` |
759
+ | Prompt | `prompts/list` | `prompts/get`, `completion/complete` |
760
+ | Skill | `skills/search`, `skills/list`, `skill://index.json`, its `skill://<path>/SKILL.md` entry in `resources/list`, `GET /skills`, `/llm.txt`, `/llm_full.txt`, `codecall:searchSkills`, `codecall:searchKnowledge` | `skills/load`, `skill://<path>/SKILL.md` and its files, `GET /skills/{id}` (same answer as a nonexistent skill) |
761
+
762
+ Installed on an `@App`, the gates cover every capability that app provides, including tools, resources and prompts contributed by its adapters (e.g. an OpenAPI adapter) and plugins. Its tool, resource, prompt and completion gates do not run for other apps' capabilities -- install it in `@FrontMcp({ plugins })` to gate every app. Resources and prompts served outside every app (the SEP-2640 `skill://` resources) are gated by every installed copy. Skills are gated through the `skills:filter` flow, which every skill surface runs -- as the calling user on every transport, stdio and in-memory included; custom plugins can hook `Did('filterSkills')` on it the same way, and reuse `filterServableSkills(scope, skills)` from `@frontmcp/sdk` to serve skills from a surface of their own.
603
763
 
604
764
  ---
605
765
 
@@ -674,10 +834,11 @@ The dashboard's MCP scope **inherits the server's authentication**. Its introspe
674
834
  - On an authenticated server (`local`, `remote`, `transparent`, `orchestrated`), the dashboard requires the same credential as everything else.
675
835
  - On a **public** server the dashboard is public too, because the server is. `auth.token` gates the dashboard _page_, not the MCP scope or the SSE stream. If the inventory is sensitive, authenticate the server — do not rely on the dashboard token alone.
676
836
 
677
- Two further limitations worth knowing:
837
+ Three further limitations worth knowing:
678
838
 
679
- - The token is accepted as `Authorization: Bearer <token>` or `?token=`. Prefer the header: a URL token lands in browser history, `Referer` headers and access logs. There is no cookie/session option yet.
680
- - Dashboard options are **process-wide**. Two `@FrontMcp` servers built in one process that configure the dashboard differently share the last configuration registered, including its token; the plugin logs a warning when that happens. Run one dashboard per process.
839
+ - **The bundled page cannot authenticate itself against a non-public server.** The browser client opens `EventSource(sseUrl)` and POSTs with no `Authorization` header, and the SDK reads the credential from that header only. So on a server with `local`/`remote`/`transparent`/`orchestrated` auth the page loads (its own token gates that) but the in-page graph, tool list and SSE stream get `401`. Run the dashboard on a public/development server, or put it behind a proxy that injects a credential — scoped to the dashboard's own routes (`<basePath>/sse` and `<basePath>/message`) and holding no grant beyond the dashboard scope, since injecting a server credential across the MCP endpoint would let any page on that origin issue arbitrary authenticated JSON-RPC. Failing closed here is deliberate — the alternative is the `mode: 'public'` scope that GHSA-rgxj-434m-vxh3 was about.
840
+ - The token is accepted as `Authorization: Bearer <token>` (scheme matched case-insensitively) or `?token=`. Prefer the header: a URL token lands in browser history, `Referer` headers and access logs. There is no cookie/session option yet.
841
+ - Dashboard options are **process-wide**. Two `@FrontMcp` servers built in one process that configure the dashboard with CONFLICTING auth now throw at registration rather than silently sharing the last token; a differing `basePath` or `cdn` logs a warning. Run one dashboard per process, or call `resetDashboardOptions()` between serial constructions.
681
842
 
682
843
  ---
683
844
 
@@ -314,6 +314,22 @@ FrontMCP's secure defaults:
314
314
  IPv6 ULA/link-local), and hostnames are **DNS-resolved** and re-checked.
315
315
  - **`file://` is blocked** (prevents local file reads).
316
316
 
317
+ ### Tool-execution redirects (a separate phase)
318
+
319
+ The rules above cover _loading the spec_. **Calling** a generated tool is a different fetch,
320
+ and it never follows redirects either (**GHSA-qh67-4345-cw2q**): the adapter sets
321
+ `redirect: 'manual'` and refuses any 3xx -- and the status-0 `opaqueredirect` response browser
322
+ runtimes return instead -- with `OPENAPI_REDIRECT_NOT_FOLLOWED`.
323
+
324
+ Following one would send the request to a destination the _upstream_ chose. Only `baseUrl` is
325
+ validated, and only for its scheme, so a 3xx is an unvalidated hop -- including to an internal
326
+ or cloud-metadata address. `fetch` also strips only `Authorization` and `Cookie` across
327
+ origins and **forwards custom headers**, which is exactly how this adapter injects API keys
328
+ (`security.headers`, `additionalHeaders`), so following would disclose the backend credential
329
+ to the redirect target.
330
+
331
+ If an operation legitimately redirects, point `baseUrl` at the final host instead.
332
+
317
333
  Configure via `loadOptions.refResolution` (applies to the spec URL **and** `$ref`s):
318
334
 
319
335
  ```typescript
@@ -27,7 +27,7 @@ import { FrontMcp } from '@frontmcp/sdk';
27
27
  class Server {}
28
28
  ```
29
29
 
30
- Then scrape: `curl http://localhost:3001/metrics` — Content-Type is the canonical Prometheus `text/plain; version=0.0.4; charset=utf-8`.
30
+ Then scrape: `curl http://localhost:3000/metrics` (the default port is `PORT`, else 3000) — Content-Type is the canonical Prometheus `text/plain; version=0.0.4; charset=utf-8`.
31
31
 
32
32
  ## Configuration
33
33