@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.
- package/catalog/create-tool/examples/08-tool-with-provider-injection.md +2 -2
- package/catalog/create-tool/examples/22-tool-with-ui-html-template.md +7 -7
- package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +3 -3
- package/catalog/create-tool/references/decorator-options.md +1 -0
- package/catalog/create-tool/references/elicitation.md +9 -0
- package/catalog/create-tool/references/error-handling.md +10 -6
- package/catalog/create-tool/references/execution-context.md +7 -3
- package/catalog/create-tool/references/input-schema.md +2 -0
- package/catalog/create-tool/references/output-schema.md +6 -1
- package/catalog/create-tool/references/throttling.md +7 -8
- package/catalog/create-tool/references/ui-widgets.md +14 -1
- package/catalog/frontmcp-authorities/SKILL.md +1 -0
- package/catalog/frontmcp-authorities/references/custom-evaluators.md +13 -2
- package/catalog/frontmcp-authorities/references/rbac-abac-rebac.md +2 -0
- package/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md +4 -4
- package/catalog/frontmcp-config/examples/configure-throttle/server-level-rate-limit.md +1 -2
- package/catalog/frontmcp-config/examples/configure-throttle-guard-config/full-guard-config.md +2 -3
- package/catalog/frontmcp-config/references/configure-auth-modes.md +36 -13
- package/catalog/frontmcp-config/references/configure-auth.md +1 -0
- package/catalog/frontmcp-config/references/configure-http.md +10 -2
- package/catalog/frontmcp-config/references/configure-skills-http.md +2 -2
- package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +5 -5
- package/catalog/frontmcp-config/references/configure-throttle.md +97 -20
- package/catalog/frontmcp-deployment/SKILL.md +10 -6
- package/catalog/frontmcp-deployment/examples/deploy-to-cloudflare/worker-custom-domain.md +4 -7
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +4 -0
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +82 -39
- package/catalog/frontmcp-development/examples/create-plugin-hooks/caching-with-around.md +7 -6
- package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md +2 -2
- package/catalog/frontmcp-development/references/create-job.md +2 -2
- package/catalog/frontmcp-development/references/create-plugin-hooks.md +21 -17
- package/catalog/frontmcp-development/references/create-prompt.md +18 -16
- package/catalog/frontmcp-development/references/create-provider.md +3 -3
- package/catalog/frontmcp-development/references/create-resource.md +10 -8
- package/catalog/frontmcp-development/references/create-skill.md +7 -0
- package/catalog/frontmcp-development/references/decorators-guide.md +9 -8
- package/catalog/frontmcp-development/references/official-plugins.md +167 -6
- package/catalog/frontmcp-development/references/openapi-adapter.md +16 -0
- package/catalog/frontmcp-observability/references/metrics-endpoint.md +1 -1
- package/catalog/frontmcp-observability/references/structured-logging.md +35 -0
- package/catalog/frontmcp-setup/SKILL.md +8 -7
- package/catalog/frontmcp-testing/SKILL.md +12 -8
- package/catalog/frontmcp-testing/examples/test-e2e-handler/tool-call-and-error-e2e.md +3 -1
- package/catalog/frontmcp-testing/references/setup-testing.md +42 -9
- package/catalog/frontmcp-testing/references/test-e2e-handler.md +35 -0
- package/catalog/skills-manifest.json +4 -3
- 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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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, //
|
|
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.
|
|
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
|
|
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
|
-
|
|
260
|
+
toolContext.output = cached.data;
|
|
261
|
+
return; // not calling next() skips the execute stage
|
|
259
262
|
}
|
|
260
263
|
|
|
261
|
-
|
|
264
|
+
await next(); // resolves with no value; the result is on toolContext.output
|
|
262
265
|
|
|
263
266
|
this.cache.set(key, {
|
|
264
|
-
data:
|
|
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
|
|
387
|
-
| Around next() | `
|
|
388
|
-
|
|
|
389
|
-
|
|
|
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;
|
|
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
|
-
|
|
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
|
|
406
|
-
| ------------------- |
|
|
407
|
-
| Return type | `execute()` returns `
|
|
408
|
-
| Argument validation | Mark arguments as `required: true` in `arguments` array
|
|
409
|
-
| Multi-turn priming | Use `assistant` role messages to prime expected response patterns
|
|
410
|
-
| Resource embedding | Use `type: 'resource'` content with a resource URI
|
|
411
|
-
| Error handling | Use `this.fail(err)` for validation failures in execute
|
|
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
|
|
433
|
-
|
|
|
434
|
-
| Prompt not appearing in `prompts/list`
|
|
435
|
-
| `MissingPromptArgumentError` on optional argument
|
|
436
|
-
| LLM ignores priming messages
|
|
437
|
-
|
|
|
438
|
-
| `this.get(TOKEN)` throws
|
|
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 `
|
|
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 `
|
|
369
|
+
- [ ] Missing provider throws `ProviderNotAvailableError` with a clear message
|
|
370
370
|
|
|
371
371
|
## Troubleshooting
|
|
372
372
|
|
|
373
373
|
| Problem | Cause | Solution |
|
|
374
374
|
| -------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
375
|
-
| `
|
|
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}`
|
|
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
|
|
576
|
-
|
|
|
577
|
-
| Resource not appearing in `resources/list`
|
|
578
|
-
| URI validation error at startup
|
|
579
|
-
| Template parameters are empty
|
|
580
|
-
| Binary content is garbled
|
|
581
|
-
| `this.get(TOKEN)` throws
|
|
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
|
|
769
|
-
|
|
|
770
|
-
| Tool does not appear in `tools/list` MCP response
|
|
771
|
-
| `this.get(Token)` throws `
|
|
772
|
-
| Resource returns 404 / `ResourceNotFoundError`
|
|
773
|
-
| Hook never fires
|
|
774
|
-
| Plugin context extension is `undefined` at runtime
|
|
775
|
-
| Agent `execute()` returns empty result
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
837
|
+
Three further limitations worth knowing:
|
|
678
838
|
|
|
679
|
-
- The
|
|
680
|
-
-
|
|
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:
|
|
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
|
|