@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.
- 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/references/configure-auth.md +1 -0
- 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 +49 -22
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +4 -0
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +1 -1
- 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-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 +77 -6
- package/catalog/frontmcp-development/references/openapi-adapter.md +2 -1
- package/catalog/frontmcp-observability/references/metrics-endpoint.md +1 -1
- package/catalog/frontmcp-observability/references/structured-logging.md +35 -0
- package/catalog/frontmcp-testing/examples/test-e2e-handler/tool-call-and-error-e2e.md +3 -1
- package/catalog/frontmcp-testing/references/setup-testing.md +9 -9
- package/catalog/frontmcp-testing/references/test-e2e-handler.md +35 -0
- package/catalog/skills-manifest.json +3 -2
- 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
|
|
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
|
-
|
|
39
|
+
toolContext.output = cached.data;
|
|
40
|
+
return; // not calling next() skips the execute stage
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
await next(); // resolves with no value; the result is on toolContext.output
|
|
41
44
|
|
|
42
45
|
this.cache.set(key, {
|
|
43
|
-
data:
|
|
46
|
+
data: toolContext.output,
|
|
44
47
|
expiry: Date.now() + 60_000,
|
|
45
48
|
});
|
|
46
|
-
|
|
47
|
-
return result;
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
```
|
package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md
CHANGED
|
@@ -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:
|
|
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
|
|
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.
|
|
@@ -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:
|
|
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
|
|
439
|
-
|
|
440
|
-
|
|
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
|
|
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
|
|
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:
|
|
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()`
|
|
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
|
|