@frontmcp/skills 1.8.0 → 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 (38) 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/references/configure-auth.md +1 -0
  17. package/catalog/frontmcp-config/references/configure-skills-http.md +2 -2
  18. package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +5 -5
  19. package/catalog/frontmcp-config/references/configure-throttle.md +49 -22
  20. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +4 -0
  21. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +1 -1
  22. package/catalog/frontmcp-development/examples/create-plugin-hooks/caching-with-around.md +7 -6
  23. package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md +2 -2
  24. package/catalog/frontmcp-development/references/create-plugin-hooks.md +21 -17
  25. package/catalog/frontmcp-development/references/create-prompt.md +18 -16
  26. package/catalog/frontmcp-development/references/create-provider.md +3 -3
  27. package/catalog/frontmcp-development/references/create-resource.md +10 -8
  28. package/catalog/frontmcp-development/references/create-skill.md +7 -0
  29. package/catalog/frontmcp-development/references/decorators-guide.md +9 -8
  30. package/catalog/frontmcp-development/references/official-plugins.md +77 -6
  31. package/catalog/frontmcp-development/references/openapi-adapter.md +2 -1
  32. package/catalog/frontmcp-observability/references/metrics-endpoint.md +1 -1
  33. package/catalog/frontmcp-observability/references/structured-logging.md +35 -0
  34. package/catalog/frontmcp-testing/examples/test-e2e-handler/tool-call-and-error-e2e.md +3 -1
  35. package/catalog/frontmcp-testing/references/setup-testing.md +9 -9
  36. package/catalog/frontmcp-testing/references/test-e2e-handler.md +35 -0
  37. package/catalog/skills-manifest.json +3 -2
  38. package/package.json +1 -1
@@ -30,21 +30,22 @@ export class CachePlugin {
30
30
 
31
31
  @Around('execute', { priority: 90 })
32
32
  async cacheResults(ctx, next) {
33
- const key = `${ctx.toolName}:${JSON.stringify(ctx.input)}`;
33
+ const { name, arguments: toolArguments } = ctx.state.required.input;
34
+ const key = `${name}:${JSON.stringify(toolArguments)}`;
35
+ const toolContext = ctx.state.required.toolContext;
34
36
  const cached = this.cache.get(key);
35
37
 
36
38
  if (cached && cached.expiry > Date.now()) {
37
- return cached.data;
39
+ toolContext.output = cached.data;
40
+ return; // not calling next() skips the execute stage
38
41
  }
39
42
 
40
- const result = await next();
43
+ await next(); // resolves with no value; the result is on toolContext.output
41
44
 
42
45
  this.cache.set(key, {
43
- data: result,
46
+ data: toolContext.output,
44
47
  expiry: Date.now() + 60_000,
45
48
  });
46
-
47
- return result;
48
49
  }
49
50
  }
50
51
  ```
@@ -24,7 +24,7 @@ Demonstrates a production-ready server configuration combining CodeCall, Remembe
24
24
 
25
25
  ```typescript
26
26
  // src/server.ts
27
- import { ApprovalPlugin } from '@frontmcp/plugin-approval';
27
+ import { ApprovalPlugin, ApprovalScope } from '@frontmcp/plugin-approval';
28
28
  import CachePlugin from '@frontmcp/plugin-cache';
29
29
  import CodeCallPlugin from '@frontmcp/plugin-codecall';
30
30
  import FeatureFlagPlugin from '@frontmcp/plugin-feature-flags';
@@ -100,7 +100,7 @@ import { Tool, ToolContext, z } from '@frontmcp/sdk';
100
100
  },
101
101
  approval: {
102
102
  required: true,
103
- defaultScope: 'session',
103
+ defaultScope: ApprovalScope.SESSION,
104
104
  category: 'write',
105
105
  riskLevel: 'high',
106
106
  approvalMessage: 'Allow data deletion for this session?',
@@ -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.
@@ -271,7 +293,9 @@ user. If the data really is shared, use `scope: 'global'`.
271
293
  `tool` included, derive their encryption key from that secret plus the scope identity. A
272
294
  session id is not a secret -- the client knows it and it travels in the `mcp-session-id`
273
295
  header -- so it cannot be the key material on its own. Instances with different secrets cannot
274
- read each other's entries.
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.
275
299
 
276
300
  **Upgrading past that change moves existing `session`, `tool` and `user` entries.** The key
277
301
  derivation change orphans `session` and `tool` ciphertext, and the namespace now percent-encodes
@@ -353,6 +377,11 @@ class AuditedServer {}
353
377
  class WebhookServer {}
354
378
  ```
355
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
+
356
385
  ### Modes
357
386
 
358
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.
@@ -373,6 +402,34 @@ name the context that lets it skip the gate. Set the context when you authentica
373
402
  authInfo.extra.approvalContext = { type: 'project', identifier: resolvedProjectId };
374
403
  ```
375
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
+
376
433
  ### Using `this.approval` in Tools
377
434
 
378
435
  ```typescript
@@ -405,11 +462,13 @@ class DangerousActionTool extends ToolContext {
405
462
  ### Per-Tool Approval Metadata
406
463
 
407
464
  ```typescript
465
+ import { ApprovalScope } from '@frontmcp/plugin-approval';
466
+
408
467
  @Tool({
409
468
  name: 'file_write',
410
469
  approval: {
411
470
  required: true,
412
- defaultScope: 'session', // 'session' | 'user' | 'time-limited'
471
+ defaultScope: ApprovalScope.SESSION, // SESSION | USER | TIME_LIMITED | TOOL_SPECIFIC | CONTEXT_SPECIFIC
413
472
  category: 'write',
414
473
  riskLevel: 'medium', // 'low' | 'medium' | 'high' | 'critical'
415
474
  approvalMessage: 'Allow file writing for this session?',
@@ -435,9 +494,11 @@ asking -- a profile, a balance, a tenant's records, anything filtered by the cal
435
494
  permissions -- and a key built only from the tool and its arguments serves the first caller's
436
495
  response to everyone else.
437
496
 
438
- The identity is the authenticated subject (`sub` / `userId`), then the client id, then the
439
- session. A call with no identity at all gets a key of its own rather than one shared with
440
- every other identity-less caller.
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.
441
502
 
442
503
  Set `keyByIdentity: false` **only** when every caller would get byte-identical output: public
443
504
  reference data, a currency table, a static document.
@@ -688,7 +749,17 @@ class ExperimentalTool extends ToolContext {
688
749
  }
689
750
  ```
690
751
 
691
- 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.
692
763
 
693
764
  ---
694
765
 
@@ -318,7 +318,8 @@ FrontMCP's secure defaults:
318
318
 
319
319
  The rules above cover _loading the spec_. **Calling** a generated tool is a different fetch,
320
320
  and it never follows redirects either (**GHSA-qh67-4345-cw2q**): the adapter sets
321
- `redirect: 'manual'` and refuses any 3xx with `OPENAPI_REDIRECT_NOT_FOLLOWED`.
321
+ `redirect: 'manual'` and refuses any 3xx -- and the status-0 `opaqueredirect` response browser
322
+ runtimes return instead -- with `OPENAPI_REDIRECT_NOT_FOLLOWED`.
322
323
 
323
324
  Following one would send the request to a destination the _upstream_ chose. Only `baseUrl` is
324
325
  validated, and only for its scheme, so a 3xx is an unvalidated hop -- including to an internal
@@ -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
 
@@ -99,6 +99,41 @@ logging: {
99
99
 
100
100
  Redaction is recursive (handles nested objects) and case-insensitive.
101
101
 
102
+ ## CodeCall Audit Events
103
+
104
+ If `CodeCallPlugin` is installed, it emits a structured audit event at every meaningful point in a
105
+ script execution. The plugin registers the bridge itself, so the events reach your normal log
106
+ output at `info` under the `codecall:audit` prefix with no wiring.
107
+
108
+ Event families, all carrying `executionId` for correlation:
109
+
110
+ - `codecall:execution:start` / `:success` / `:failure` / `:timeout`
111
+ - `codecall:tool:call:start` / `:success` / `:failure`
112
+ - `codecall:security:self-reference` / `:access-denied` / `:ast-blocked`
113
+ - `codecall:search:performed` / `codecall:describe:performed` / `codecall:invoke:performed`
114
+
115
+ No event carries script source, tool arguments, tool results, or query text — a script is reduced
116
+ to `scriptHash` + `scriptLength`, a query to `queryLength`. Do not add those fields when building
117
+ on this; the omission is what makes the events safe to emit at `info`.
118
+
119
+ To route events elsewhere, subscribe rather than parsing logs:
120
+
121
+ ```typescript
122
+ import { AUDIT_EVENT_TYPES, AuditLoggerService, type AuditEvent } from '@frontmcp/plugin-codecall';
123
+
124
+ const audit = scope.providers.get(AuditLoggerService);
125
+ const unsubscribe = audit.subscribe((event: AuditEvent) => myShipper.send(event));
126
+ ```
127
+
128
+ Fan-out is synchronous and on the execution hot path — hand off to a queue, never do network I/O
129
+ inside the listener. Note the file transport writes only the message, so structured fields are
130
+ dropped there; use the structured transport or subscribe directly.
131
+
132
+ **Over stdio:** stdout carries the MCP JSON-RPC frames, so nothing else may be written there.
133
+ `runStdio()` redirects the stdout-bound `console` methods to stderr, and the NDJSON `stdout` sink
134
+ defaults to stderr when `FRONTMCP_STDIO` is set. Never configure a sink with an explicit
135
+ `stream: process.stdout` on a stdio server — an explicit stream overrides the guard.
136
+
102
137
  ## Examples
103
138
 
104
139
  | Example | Level | Description |
@@ -8,6 +8,7 @@ features:
8
8
  - 'Calling tools via `client.tools.call(name, args)` and asserting success with `toBeSuccessful()`'
9
9
  - 'Asserting text content with the `toHaveTextContent()` matcher'
10
10
  - 'Asserting error results with `toBeError()` for invalid input and unknown tools'
11
+ - 'Matching the tool error code: invalid input is an `isError` result with `_meta.code: "INVALID_INPUT"`, so `toBeError(''INVALID_INPUT'')` matches it (a numeric code matches JSON-RPC errors only)'
11
12
  - 'Testing edge cases like zero values'
12
13
  ---
13
14
 
@@ -53,7 +54,7 @@ describe('Tool Call E2E', () => {
53
54
 
54
55
  it('returns an error for invalid input', async () => {
55
56
  const result = await client.tools.call('add_numbers', { a: 'bad' });
56
- expect(result).toBeError();
57
+ expect(result).toBeError('INVALID_INPUT');
57
58
  });
58
59
 
59
60
  it('returns an error for a nonexistent tool', async () => {
@@ -74,6 +75,7 @@ describe('Tool Call E2E', () => {
74
75
  - Calling tools via `client.tools.call(name, args)` and asserting success with `toBeSuccessful()`
75
76
  - Asserting text content with the `toHaveTextContent()` matcher
76
77
  - Asserting error results with `toBeError()` for invalid input and unknown tools
78
+ - Matching the tool error code: invalid input is an `isError` result with `_meta.code: "INVALID_INPUT"`, so `toBeError('INVALID_INPUT')` matches it (a numeric code matches JSON-RPC errors only)
77
79
  - Testing edge cases like zero values
78
80
 
79
81
  ## Related
@@ -421,15 +421,15 @@ A nested `test.describe` inherits an outer skip. The `(name, fn)` form still ski
421
421
  import { expect } from '@frontmcp/testing';
422
422
  ```
423
423
 
424
- | Matcher | Asserts |
425
- | ------------------------- | ----------------------------------------------------- |
426
- | `toContainTool(name)` | Tools list includes a tool with the given name |
427
- | `toContainResource(uri)` | Resources list includes a resource with the given URI |
428
- | `toContainPrompt(name)` | Prompts list includes a prompt with the given name |
429
- | `toBeSuccessful()` | Tool call result is not an error |
430
- | `toBeError()` | Tool call result is an MCP error |
431
- | `toHaveTextContent(text)` | Result contains text content matching the string |
432
- | `toHaveMimeType(mime)` | Resource content has the expected MIME type |
424
+ | Matcher | Asserts |
425
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------ |
426
+ | `toContainTool(name)` | Tools list includes a tool with the given name |
427
+ | `toContainResource(uri)` | Resources list includes a resource with the given URI |
428
+ | `toContainPrompt(name)` | Prompts list includes a prompt with the given name |
429
+ | `toBeSuccessful()` | Tool call result is not an error |
430
+ | `toBeError(code?)` | Result is an error; a string code matches `_meta.code` (`'INVALID_INPUT'`), a number matches a JSON-RPC error code |
431
+ | `toHaveTextContent(text)` | Result contains text content matching the string |
432
+ | `toHaveMimeType(mime)` | Resource content has the expected MIME type |
433
433
 
434
434
  ## Running Tests with Nx
435
435