@frontmcp/skills 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/catalog/create-tool/SKILL.md +1 -1
  2. package/catalog/create-tool/examples/08-tool-with-provider-injection.md +2 -2
  3. package/catalog/create-tool/examples/22-tool-with-ui-html-template.md +28 -17
  4. package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +10 -7
  5. package/catalog/create-tool/references/decorator-options.md +1 -0
  6. package/catalog/create-tool/references/elicitation.md +9 -0
  7. package/catalog/create-tool/references/error-handling.md +10 -6
  8. package/catalog/create-tool/references/execution-context.md +17 -12
  9. package/catalog/create-tool/references/function-style-builder.md +1 -1
  10. package/catalog/create-tool/references/input-schema.md +2 -0
  11. package/catalog/create-tool/references/output-schema.md +6 -1
  12. package/catalog/create-tool/references/throttling.md +7 -8
  13. package/catalog/create-tool/references/ui-widgets.md +44 -6
  14. package/catalog/frontmcp-authorities/SKILL.md +1 -0
  15. package/catalog/frontmcp-authorities/references/custom-evaluators.md +13 -2
  16. package/catalog/frontmcp-authorities/references/rbac-abac-rebac.md +2 -0
  17. package/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md +6 -4
  18. package/catalog/frontmcp-config/references/configure-auth.md +1 -0
  19. package/catalog/frontmcp-config/references/configure-skills-http.md +4 -2
  20. package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +5 -5
  21. package/catalog/frontmcp-config/references/configure-throttle.md +49 -22
  22. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +4 -0
  23. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +1 -1
  24. package/catalog/frontmcp-development/examples/create-plugin-hooks/caching-with-around.md +7 -6
  25. package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md +2 -2
  26. package/catalog/frontmcp-development/references/create-plugin-hooks.md +21 -17
  27. package/catalog/frontmcp-development/references/create-plugin.md +41 -7
  28. package/catalog/frontmcp-development/references/create-prompt.md +18 -16
  29. package/catalog/frontmcp-development/references/create-provider.md +3 -3
  30. package/catalog/frontmcp-development/references/create-resource.md +10 -8
  31. package/catalog/frontmcp-development/references/create-skill.md +7 -0
  32. package/catalog/frontmcp-development/references/decorators-guide.md +9 -8
  33. package/catalog/frontmcp-development/references/official-plugins.md +88 -6
  34. package/catalog/frontmcp-development/references/openapi-adapter.md +2 -1
  35. package/catalog/frontmcp-observability/references/metrics-endpoint.md +1 -1
  36. package/catalog/frontmcp-observability/references/structured-logging.md +35 -0
  37. package/catalog/frontmcp-testing/examples/test-e2e-handler/tool-call-and-error-e2e.md +3 -1
  38. package/catalog/frontmcp-testing/references/setup-testing.md +9 -9
  39. package/catalog/frontmcp-testing/references/test-e2e-handler.md +35 -0
  40. package/catalog/skills-manifest.json +9 -6
  41. package/package.json +1 -1
@@ -64,10 +64,12 @@ The new top-level `instructions?: string` field on `@FrontMcp` is forwarded verb
64
64
  | `prepend` | Catalog summary first, then channel hints, then server `instructions`. |
65
65
  | `replace` | Surface ONLY the server `instructions`; the catalog AND channel hints are dropped. When `instructions` is empty/undefined this falls back to `'append'` so a misconfig doesn't drop everything. |
66
66
 
67
- The catalog summary is built by `composeInitializeInstructions(...)` and `buildSkillsCatalogSummary(...)` (exported from `@frontmcp/sdk`). It is bounded at **16 KB** with a truncation footer; the footer points clients at `skill://index.json` and `skill://<skillPath>/SKILL.md` for full content (SEP-2640 — singular scheme).
67
+ The catalog summary is built by `composeInitializeInstructions(...)` and `buildSkillsCatalogSummary(...)` (exported from `@frontmcp/sdk`). It is bounded at **16 KB** with a truncation footer. Its header and footer point clients at `skill://index.json` (SEP-2640 — singular scheme), which lists each skill's `skill://<skillPath>/SKILL.md` URI. With `mcpResources: false` no `skill://` resource is served, so they point at the `skills/load` and `skills/search` methods instead, and `sep2640InInstructions` is ignored.
68
68
 
69
69
  > **Dynamic skills:** because the composer recomputes the summary on every `initialize` request, skills registered after server boot **are** picked up automatically.
70
70
 
71
+ > **Per caller:** the summary (and the SEP-2640 `skill://` hints under `sep2640InInstructions`) is composed for the client that initializes, like `skills/list`: it only names skills whose `authorities` that caller satisfies and that the hookable `skills:filter` flow keeps, so a flag-disabled skill's name and description are left out. With nothing gating a skill the instructions are unchanged. Transports use `composeCallerInstructions(scope, { ctx })`, exported from `@frontmcp/sdk` for custom transports.
72
+
71
73
  ## Skills HTTP Authentication
72
74
 
73
75
  ```typescript
@@ -137,7 +139,7 @@ See [`skill-audit-log`](../../frontmcp-extensibility/references/skill-audit-log.
137
139
  | ----------------------------------------- | ----------------------------------------------------------------------------------------------------- |
138
140
  | Local dev, no skills | `skillsConfig` unset |
139
141
  | Public server, hand-curated server prompt | `instructions: '...'`, `injectInstructions: 'off'` |
140
- | Server with many dynamic skills | `injectInstructions: 'append'` (default) or `'replace'` if you want skills to drive the entire prompt |
142
+ | Server with many dynamic skills | `injectInstructions: 'append'` (default) or `'prepend'` if skill guidance must lead the prompt |
141
143
  | Multi-pod production | `cache: { enabled: true, redis: {...} }`, `audit: { signer: Rs256, store: StorageAdapterAuditStore }` |
142
144
  | Compliance / forensic requirements | RS256 signer + persistent store + scheduled `verifyChain(...)` in CI |
143
145
 
@@ -51,9 +51,9 @@ interface TimeoutConfig {
51
51
  interface IpFilterConfig {
52
52
  allowList?: string[]; // IP addresses or CIDR ranges
53
53
  denyList?: string[];
54
- defaultAction?: 'allow' | 'deny'; // default: 'allow'
55
- trustProxy?: boolean; // default: false
56
- trustedProxyDepth?: number; // default: 1
54
+ defaultAction?: 'allow' | 'deny'; // default: 'allow'; also applies when no client IP is known
55
+ trustProxy?: boolean; // NOT read (startup warning) -- set FRONTMCP_TRUST_PROXY
56
+ trustedProxyDepth?: number; // NOT read (startup warning) -- set FRONTMCP_TRUSTED_PROXY_DEPTH
57
57
  }
58
58
  ```
59
59
 
@@ -61,11 +61,11 @@ interface IpFilterConfig {
61
61
 
62
62
  - **`'global'`**: Single counter shared by all clients. Protects total server capacity.
63
63
  - **`'ip'`**: Separate counter per client IP. Fair per-client limiting.
64
- - **`'session'`**: Separate counter per MCP session. Fair per-session limiting.
64
+ - **`'session'`**: Separate counter per MCP session the server verified; a `mcp-session-id` it does not accept is ignored. Fair per-session limiting. A request without a verified session (every MCP 2026-07-28 request, or a rejected session id) falls back to the signed-in user; anonymous callers share one `anonymous` counter.
65
65
 
66
66
  ## Priority Order
67
67
 
68
- 1. IP filter (allow/deny) — checked first
68
+ 1. IP filter (allow/deny) — checked first, on every HTTP route except health probes and `/metrics`
69
69
  2. Global rate limit — checked second
70
70
  3. Global concurrency — checked third
71
71
  4. Per-tool rate limit — checked per tool
@@ -45,7 +45,7 @@ Protect your FrontMCP server with rate limiting, concurrency control, execution
45
45
  partitionBy: 'global', // shared across all clients
46
46
  },
47
47
 
48
- // Global concurrency limit
48
+ // Global concurrency limit (a tool called with this.callTool() runs inside its caller's slot)
49
49
  globalConcurrency: {
50
50
  maxConcurrent: 50,
51
51
  partitionBy: 'global',
@@ -112,15 +112,38 @@ class ExpensiveQueryTool extends ToolContext {
112
112
  }
113
113
  ```
114
114
 
115
- ## `ipFilter` is enforced on every request
115
+ ## `ipFilter` is enforced on every HTTP route
116
116
 
117
- `allowList`, `denyList` and `defaultAction` are checked at the start of the request pipeline,
118
- before the rate-limit check and before authentication. A rejected client gets HTTP 403 with
119
- JSON-RPC error `-32001`.
117
+ `allowList`, `denyList` and `defaultAction` are checked by the `checkIpFilter` stage that starts
118
+ every HTTP-facing flow, before the rate-limit check and before authentication:
119
+
120
+ - the MCP endpoint (`<entryPath>`, `/sse`, `/message`) -- rejected with HTTP 403 and JSON-RPC
121
+ error `-32001`;
122
+ - `/oauth/*`, `/.well-known/*`, `llm.txt` / `llm_full.txt`, the skills HTTP API, and custom
123
+ `http.routes` (before `auth: true` verification) -- rejected with HTTP 403 and
124
+ `{ "error": "forbidden", "message": "Client IP rejected by ipFilter" }`.
125
+
126
+ Health and readiness probes (`/healthz`, `/readyz`, `/health`) and `/metrics` (bearer-token
127
+ protected) are exempt. `IpBlockedError` / `IpNotAllowedError` are not thrown by the built-in
128
+ filter.
129
+
130
+ A request whose client IP cannot be established matches neither list and gets
131
+ `defaultAction` -- with `'deny'` it is rejected. An IPv4-mapped peer (`::ffff:203.0.113.7`,
132
+ how a dual-stack Node socket reports an IPv4 client) matches IPv4 rules.
120
133
 
121
134
  An `ipFilter` block works on its own -- you do not need to configure a `global` rate limit
122
135
  alongside it for the filter to run.
123
136
 
137
+ ### Where the client IP comes from
138
+
139
+ | Runtime | Client IP |
140
+ | ------------------------------------------ | ------------------------------------------------------------------ |
141
+ | Node / Express / serverless handler | Socket peer |
142
+ | Behind a declared proxy | `X-Forwarded-For` (`FRONTMCP_TRUST_PROXY=true`, see below) |
143
+ | Cloudflare Workers (incl. Durable Objects) | `CF-Connecting-IP` -- trusted only when running on Workers |
144
+ | Deno | `info.remoteAddr` -- use `Deno.serve(handler)` |
145
+ | Bun | `server.requestIP(request)` -- use `Bun.serve({ fetch: handler })` |
146
+
124
147
  ## `partitionBy: 'ip'` needs a declared trusted proxy
125
148
 
126
149
  The client IP comes from the socket peer address. `X-Forwarded-For` and `X-Real-IP` are set
@@ -155,6 +178,9 @@ so the socket peer is used instead.
155
178
  > With depth `1` and no `X-Forwarded-For` at all, `X-Real-IP` is used -- the single-hop nginx
156
179
  > convention. It is never consulted alongside a chain, because a caller can send both.
157
180
 
181
+ On Cloudflare Workers, Deno and Bun the web-fetch adapter supplies the platform's peer
182
+ address (table above), so edge callers get their own buckets too.
183
+
158
184
  When no IP can be established the request falls back to the authenticated user
159
185
  (`user:<userId>`), and to a single `ip:unresolved` partition when there is no user either. It
160
186
  never keys on the session id: `mcp-session-id` is caller-supplied and a request without one is
@@ -188,19 +214,20 @@ is what takes callers out of it.
188
214
 
189
215
  ### IpFilterConfig
190
216
 
191
- | Field | Type | Default | Description |
192
- | ------------------- | ------------------- | --------- | ------------------------------------------------ |
193
- | `allowList` | `string[]` | — | Allowed IPs or CIDR ranges |
194
- | `denyList` | `string[]` | — | Blocked IPs or CIDR ranges |
195
- | `defaultAction` | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list |
196
- | `trustProxy` | `boolean` | `false` | **Not read.** Use `FRONTMCP_TRUST_PROXY` |
197
- | `trustedProxyDepth` | `number` | `1` | **Not read.** Use `FRONTMCP_TRUSTED_PROXY_DEPTH` |
217
+ | Field | Type | Default | Description |
218
+ | ------------------- | ------------------- | --------- | ------------------------------------------------------------------ |
219
+ | `allowList` | `string[]` | — | Allowed IPs or CIDR ranges |
220
+ | `denyList` | `string[]` | — | Blocked IPs or CIDR ranges |
221
+ | `defaultAction` | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list, or no client IP is known |
222
+ | `trustProxy` | `boolean` | `false` | **Not read** (startup warning). Use `FRONTMCP_TRUST_PROXY` |
223
+ | `trustedProxyDepth` | `number` | `1` | **Not read** (startup warning). Use `FRONTMCP_TRUSTED_PROXY_DEPTH` |
198
224
 
199
225
  ## Partition Strategies
200
226
 
201
227
  - **`'global'`** — Single shared counter for all clients. Use for global capacity limits.
202
228
  - **`'ip'`** — Separate counter per client IP. Use for per-client rate limiting.
203
- - **`'session'`** — Separate counter per MCP session. Use for per-session fairness.
229
+ - **`'session'`** — Separate counter per MCP session the server verified; a `mcp-session-id` it does not accept is ignored. Use for per-session fairness. A request without a verified session (every MCP 2026-07-28 request, stateless HTTP, or a rejected session id) falls back to the signed-in user; anonymous callers share one `anonymous` counter. A `throttle.global` partitioned by session, user or a function is checked right after authentication; one partitioned by `'ip'` or `'global'` is checked before it.
230
+ - **`'userId'`** — Separate counter per signed-in user. Anonymous callers fall back to the session, as above.
204
231
 
205
232
  ## Distributed Rate Limiting
206
233
 
@@ -246,7 +273,7 @@ done
246
273
 
247
274
  ### Configuration
248
275
 
249
- - [ ] `throttle.enabled` is set to `true` in the `@FrontMcp` decorator
276
+ - [ ] `throttle.enabled` is set to `true` in the `@FrontMcp` decorator for the server-level options (`global`, `globalConcurrency`, the `default*` settings, `ipFilter`). A tool's own `rateLimit`/`concurrency` apply without it; `throttle.enabled: false` turns every guard off
250
277
  - [ ] `global.maxRequests` and `global.windowMs` are set to reasonable production values
251
278
  - [ ] `defaultTimeout.executeMs` is configured to prevent runaway tool executions
252
279
  - [ ] IP filter `defaultAction` matches your security posture (`allow` for open, `deny` for restricted)
@@ -266,17 +293,17 @@ done
266
293
 
267
294
  - [ ] Sending requests beyond the rate limit returns HTTP 429
268
295
  - [ ] Blocked IPs receive HTTP 403
269
- - [ ] Tool executions that exceed `executeMs` are terminated and return a timeout error
296
+ - [ ] Tool executions that exceed `executeMs` return an `EXECUTION_TIMEOUT` error and abort `this.signal` and the tool's pending `this.fetch()` requests; the tool passes `this.signal` to other cancellable work so it stops too
270
297
 
271
298
  ## Troubleshooting
272
299
 
273
- | Problem | Cause | Solution |
274
- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
275
- | Rate limits not enforced across instances | In-memory storage used with multiple server replicas | Configure `storage: { type: 'redis' }` in the throttle block to share counters |
276
- | All requests rejected with 403 | `ipFilter.defaultAction` set to `'deny'` without any `allowList` entries | Add the allowed IP ranges to `allowList` or change `defaultAction` to `'allow'` |
277
- | Tools timing out unexpectedly | `defaultTimeout.executeMs` too low for the tool's normal execution time | Increase the global default or set a per-tool `timeout.executeMs` override |
278
- | `X-Forwarded-For` header ignored | No trusted proxy declared. `ipFilter.trustProxy` / `trustedProxyDepth` are accepted by the schema but NOT read -- client-IP extraction happens in the SDK context layer, before guard config is reachable | Set the `FRONTMCP_TRUST_PROXY=true` and `FRONTMCP_TRUSTED_PROXY_DEPTH` environment variables instead |
279
- | Rate limit resets not aligned with expectations | `windowMs` misunderstood as a sliding window when it is a fixed window | The window is fixed; all counters reset at the end of each `windowMs` interval |
300
+ | Problem | Cause | Solution |
301
+ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
302
+ | Rate limits not enforced across instances | In-memory storage used with multiple server replicas | Configure `storage: { type: 'redis' }` in the throttle block to share counters |
303
+ | All requests rejected with 403 | `ipFilter.defaultAction` set to `'deny'` without any `allowList` entries, or the runtime reports no client IP (a custom fetch wrapper that drops the second handler argument on Deno/Bun) | Add the allowed IP ranges to `allowList`, pass the platform's second argument through to the handler, or change `defaultAction` to `'allow'` |
304
+ | Tools timing out unexpectedly | `defaultTimeout.executeMs` too low for the tool's normal execution time | Increase the global default or set a per-tool `timeout.executeMs` override |
305
+ | `X-Forwarded-For` header ignored | No trusted proxy declared. `ipFilter.trustProxy` / `trustedProxyDepth` are accepted by the schema but NOT read -- client-IP extraction happens in the SDK context layer, before guard config is reachable | Set the `FRONTMCP_TRUST_PROXY=true` and `FRONTMCP_TRUSTED_PROXY_DEPTH` environment variables instead |
306
+ | Rate limit resets not aligned with expectations | `windowMs` misunderstood as a sliding window when it is a fixed window | The window is fixed; all counters reset at the end of each `windowMs` interval |
280
307
 
281
308
  ## Examples
282
309
 
@@ -75,6 +75,10 @@ export default createEdgeMcp({
75
75
  is the **Cron Trigger** entrypoint that pulls a fresh bundle and hot-swaps it.
76
76
  Managed mode requires the optional peer `@frontmcp/plugin-skilled-openapi`.
77
77
 
78
+ The pull sends `authToken` as a bearer token and never follows a redirect, so
79
+ `endpoint` must serve the bundle directly; a 3xx (or a status-0
80
+ `opaqueredirect`) fails the pull.
81
+
78
82
  This path is bundled by **wrangler** (not `frontmcp build`), so you maintain
79
83
  `wrangler.toml` yourself — it needs a `[[kv_namespaces]] binding = "BUNDLE_CACHE"`
80
84
  and a `[triggers] crontabs = [...]` (the `managed.pollIntervalMs` option is
@@ -245,7 +245,7 @@ createEdgeMcp({
245
245
  - **Bundle size**: Workers have a 1 MB compressed / 10 MB uncompressed limit (paid plan: 10 MB / 30 MB). Review dependencies and remove unused packages to reduce bundle size.
246
246
  - **CPU time**: 10 ms CPU time on free plan, 30 seconds on paid. Long-running operations must be optimized or use Durable Objects.
247
247
  - **No native modules**: `better-sqlite3` and other native Node.js modules are not available. Use KV, D1, or Upstash Redis for storage.
248
- - **Streaming**: Streamable HTTP works, including SSE responses (`POST` with `Accept: text/event-stream`) and the server→client SSE `GET` stream. The worker uses the SDK's `WebStandardStreamableHTTPServerTransport` — which **is** the standard Streamable HTTP transport (the Node `StreamableHTTPServerTransport` is a thin `req`/`res` wrapper over it, so there's one engine). **Server→client notifications** on the standalone `GET` stream require **stateful sessions** — set `sessions: {}` and bind the `SessionDurableObject` (Durable Object) per `Mcp-Session-Id`. Without it the worker is stateless and the `GET` stream can't deliver pushed notifications.
248
+ - **Streaming**: Streamable HTTP works, including SSE responses (`POST` with `Accept: text/event-stream`) and the server→client SSE `GET` stream. The worker uses the SDK's `WebStandardStreamableHTTPServerTransport` — which **is** the standard Streamable HTTP transport (the Node `StreamableHTTPServerTransport` is a thin `req`/`res` wrapper over it, so there's one engine). **Server→client notifications** on the standalone `GET` stream require **stateful sessions** — set `sessions: {}` and bind the `SessionDurableObject` (Durable Object) per `Mcp-Session-Id`. Without it the worker is stateless and the `GET` stream can't deliver pushed notifications. A stateless worker also returns no `Mcp-Session-Id` to a session-based (2025-06-18) client; each of its requests is served on its own.
249
249
 
250
250
  ### Stateful sessions (Durable Object)
251
251
 
@@ -30,21 +30,22 @@ export class CachePlugin {
30
30
 
31
31
  @Around('execute', { priority: 90 })
32
32
  async cacheResults(ctx, next) {
33
- const key = `${ctx.toolName}:${JSON.stringify(ctx.input)}`;
33
+ const { name, arguments: toolArguments } = ctx.state.required.input;
34
+ const key = `${name}:${JSON.stringify(toolArguments)}`;
35
+ const toolContext = ctx.state.required.toolContext;
34
36
  const cached = this.cache.get(key);
35
37
 
36
38
  if (cached && cached.expiry > Date.now()) {
37
- return cached.data;
39
+ toolContext.output = cached.data;
40
+ return; // not calling next() skips the execute stage
38
41
  }
39
42
 
40
- const result = await next();
43
+ await next(); // resolves with no value; the result is on toolContext.output
41
44
 
42
45
  this.cache.set(key, {
43
- data: result,
46
+ data: toolContext.output,
44
47
  expiry: Date.now() + 60_000,
45
48
  });
46
-
47
- return result;
48
49
  }
49
50
  }
50
51
  ```
@@ -24,7 +24,7 @@ Demonstrates a production-ready server configuration combining CodeCall, Remembe
24
24
 
25
25
  ```typescript
26
26
  // src/server.ts
27
- import { ApprovalPlugin } from '@frontmcp/plugin-approval';
27
+ import { ApprovalPlugin, ApprovalScope } from '@frontmcp/plugin-approval';
28
28
  import CachePlugin from '@frontmcp/plugin-cache';
29
29
  import CodeCallPlugin from '@frontmcp/plugin-codecall';
30
30
  import FeatureFlagPlugin from '@frontmcp/plugin-feature-flags';
@@ -100,7 +100,7 @@ import { Tool, ToolContext, z } from '@frontmcp/sdk';
100
100
  },
101
101
  approval: {
102
102
  required: true,
103
- defaultScope: 'session',
103
+ defaultScope: ApprovalScope.SESSION,
104
104
  category: 'write',
105
105
  riskLevel: 'high',
106
106
  approvalMessage: 'Allow data deletion for this session?',
@@ -70,16 +70,16 @@ These are the flow names with pre-built hook decorator exports in `@frontmcp/sdk
70
70
 
71
71
  This is the load-bearing invariant behind every hook above: in FrontMCP **every
72
72
  request runs through a flow**, and because flows are made of `@Stage` steps that
73
- `FlowHooksOf` exposes for interception, hooks work *everywhere* automatically.
73
+ `FlowHooksOf` exposes for interception, hooks work _everywhere_ automatically.
74
74
  The hookability is only guaranteed because nothing handles a request outside a
75
75
  flow.
76
76
 
77
77
  Therefore:
78
78
 
79
79
  - **Never bypass the flow pipeline to make something work.** Add or extend a flow
80
- + its stages; do not hand-roll request logic (auth, transport, routing) in a
81
- transport/adapter that skips the flow. A bypass silently deletes every hook on
82
- that path.
80
+ - its stages; do not hand-roll request logic (auth, transport, routing) in a
81
+ transport/adapter that skips the flow. A bypass silently deletes every hook on
82
+ that path.
83
83
  - **Adapters only translate.** A transport adapter (Express, the Web-fetch/worker
84
84
  handler, stdio) converts its native request/response to the flow's normalized
85
85
  `ServerRequest` + `httpRespond` output and then runs the **same** flows. Two
@@ -188,12 +188,12 @@ Both `@Will` and `@Did` (and `@Around`) accept an optional options object:
188
188
 
189
189
  ```typescript
190
190
  @Will('execute', {
191
- priority: 10, // Higher runs first (default: 0)
191
+ priority: 10, // Lower runs first (default: 0)
192
192
  filter: (ctx) => ctx.toolName !== 'health_check', // Predicate to skip
193
193
  })
194
194
  ```
195
195
 
196
- - **priority** (`number`) - Execution order when multiple hooks target the same stage. Higher values run first. Default: `0`.
196
+ - **priority** (`number`) - Execution order when multiple hooks target the same stage. Lower values run first, for `@Will`, `@Did` and `@Around` alike. Default: `0`.
197
197
  - **filter** (`(ctx) => boolean`) - A predicate that receives the flow context. Return `false` to skip this hook for the current invocation.
198
198
 
199
199
  ## Examples
@@ -251,21 +251,22 @@ export class CachePlugin {
251
251
 
252
252
  @Around('execute', { priority: 90 })
253
253
  async cacheResults(ctx, next) {
254
- const key = `${ctx.toolName}:${JSON.stringify(ctx.input)}`;
254
+ const { name, arguments: toolArguments } = ctx.state.required.input;
255
+ const key = `${name}:${JSON.stringify(toolArguments)}`;
256
+ const toolContext = ctx.state.required.toolContext;
255
257
  const cached = this.cache.get(key);
256
258
 
257
259
  if (cached && cached.expiry > Date.now()) {
258
- return cached.data;
260
+ toolContext.output = cached.data;
261
+ return; // not calling next() skips the execute stage
259
262
  }
260
263
 
261
- const result = await next();
264
+ await next(); // resolves with no value; the result is on toolContext.output
262
265
 
263
266
  this.cache.set(key, {
264
- data: result,
267
+ data: toolContext.output,
265
268
  expiry: Date.now() + 60_000,
266
269
  });
267
-
268
- return result;
269
270
  }
270
271
  }
271
272
  ```
@@ -307,6 +308,8 @@ export class MyApp {}
307
308
 
308
309
  Plugins are initialized in array order. Hook priority determines execution order within the same stage.
309
310
 
311
+ Hooks declared on an app's providers, on its plugins (including plugins nested inside them), and on those plugins' providers run only for that app's tools, resources and prompts (`tools:call-tool`, `resources:read-resource`, `prompts:get-prompt`, `completion:complete`), including the ones its adapters and plugins provide, such as the tools an OpenAPI adapter generates. Plugins registered on the server (`@FrontMcp({ plugins })`) apply to every app. Resources and prompts the server serves outside every app, such as the SEP-2640 `skill://` resources, run every app's hooks.
312
+
310
313
  ## Using Hooks Inside a @Tool Class
311
314
 
312
315
  You can add hook methods directly on a `@Tool` class to intercept its own execution flow. The hooks apply only when **this tool** is called:
@@ -383,10 +386,11 @@ Any stage can have `@Will`, `@Did`, `@Stage`, or `@Around` hooks.
383
386
  | Pattern | Correct | Incorrect | Why |
384
387
  | --------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
385
388
  | Hook decorator source | `const { Will, Did } = ToolHook;` or `FlowHooksOf('tools:call-tool')` | Importing `Will` directly from `@frontmcp/sdk` | Decorators must be bound to a specific flow via `FlowHooksOf` or pre-built exports |
386
- | Hook priority | `@Will('execute', { priority: 100 })` for early hooks | Relying on array order without priority | Multiple hooks on the same stage need explicit priority; higher runs first |
387
- | Around next() | `const result = await next(); return result;` | Forgetting to call `next()` in `@Around` | Omitting `next()` silently skips the wrapped stage and all downstream hooks |
388
- | Filter predicate | `filter: (ctx) => ctx.toolName !== 'health_check'` | Checking tool name inside the hook body and returning early | Filters skip the hook cleanly; returning early may leave state inconsistent |
389
- | Tool-level hooks | `@Will('execute')` on a `@Tool` class (scoped to that tool) | `@Will('execute')` on a `@Plugin` class expecting tool-scoped behavior | Plugin hooks fire for all tools; tool-level hooks fire only for that tool |
389
+ | Hook priority | `@Will('execute', { priority: -100 })` for early hooks | Relying on array order without priority | Multiple hooks on the same stage need explicit priority; lower runs first |
390
+ | Around next() | `await next();` | Forgetting to call `next()` in `@Around` | Omitting `next()` skips the wrapped stage; its `@Did` hooks still run |
391
+ | Around error recovery | `catch { ctx.state.required.toolContext.output = fallback; }` | Catching a rejected `next()` without setting the output | Returning normally handles the failure: the stage counts as successful |
392
+ | Filter predicate | `filter: (ctx) => ctx.state.required.input.name !== 'health_check'` | Checking tool name inside the hook body and returning early | A filtered-out hook is skipped cleanly; for `@Around`, the stage still runs |
393
+ | Tool-level hooks | `@Will('execute')` on a `@Tool` class (scoped to that tool) | `@Will('execute')` on a `@Plugin` class expecting tool-scoped behavior | Plugin hooks fire for every tool of the app; tool-level hooks only for that tool |
390
394
 
391
395
  ## Verification Checklist
392
396
 
@@ -411,7 +415,7 @@ Any stage can have `@Will`, `@Did`, `@Stage`, or `@Around` hooks.
411
415
  | Hook never fires | Plugin not registered in `plugins` array | Add plugin class to `@App` or `@FrontMcp` `plugins` array |
412
416
  | Hook fires for wrong flow | Used wrong flow name in `FlowHooksOf` | Verify flow name matches (e.g., `'tools:call-tool'` not `'tool:call'`) |
413
417
  | `@Around` skips the stage entirely | `next()` not called inside the around handler | Always `await next()` to execute the wrapped stage |
414
- | Multiple hooks execute in wrong order | Priorities not set or conflicting | Set explicit `priority` values; higher numbers execute first |
418
+ | Multiple hooks execute in wrong order | Priorities not set or conflicting | Set explicit `priority` values; lower numbers execute first |
415
419
  | `@Stage` replacement causes downstream errors | Return value shape does not match stage contract | Ensure the return matches what the next stage expects (e.g., MCP response format) |
416
420
 
417
421
  ## Examples
@@ -307,6 +307,39 @@ Register with `init()`:
307
307
  class MyServer {}
308
308
  ```
309
309
 
310
+ ### Option-derived providers are registered before nested plugins
311
+
312
+ `dynamicProviders(options)` and `init({ providers })` are registered **before** the plugin's nested `plugins` are built, for both `init(options)` and `init({ inject, useFactory })` (the factory runs first). A nested plugin can inject them:
313
+
314
+ ```typescript
315
+ @Plugin({
316
+ name: 'my-plugin',
317
+ plugins: [
318
+ AuditPlugin.init({
319
+ inject: () => [MyServiceToken],
320
+ useFactory: (service: MyService) => ({ channel: service.auditChannel }),
321
+ }),
322
+ ],
323
+ })
324
+ export default class MyPlugin extends DynamicPlugin<MyPluginOptions, MyPluginOptionsInput> {
325
+ /* dynamicProviders() returns MyServiceToken as above */
326
+ }
327
+ ```
328
+
329
+ The reverse does not work: an option-derived provider cannot inject a provider that a nested plugin exports.
330
+
331
+ ### Installing the same plugin in several apps
332
+
333
+ Each app that installs a plugin gets its own copy of the plugin's providers, including CONTEXT-scoped ones. Tools, resources and prompts resolve the nearest definition in their own hierarchy (plugin, then app, then server). So `this.myService` in app A uses A's options even when app B installs `MyPlugin.init()` with different options:
334
+
335
+ ```typescript
336
+ @App({ id: 'billing', plugins: [MyPlugin.init({ endpoint: 'https://billing.example.com' })], tools: [RefundTool] })
337
+ class BillingApp {}
338
+
339
+ @App({ id: 'ops', plugins: [MyPlugin.init({ endpoint: 'https://ops.example.com' })], tools: [DeployTool] })
340
+ class OpsApp {}
341
+ ```
342
+
310
343
  ## Step 5: Extend Metadata and Execution Context
311
344
 
312
345
  FrontMCP provides two extension mechanisms for plugins: **metadata augmentation** (add fields to decorators) and **context extensions** (add properties to `this` in tools/resources/prompts).
@@ -458,13 +491,14 @@ plugins/
458
491
 
459
492
  ## Common Patterns
460
493
 
461
- | Pattern | Correct | Incorrect | Why |
462
- | ------------------------------ | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
463
- | Context extension registration | `contextExtensions: [{ property: 'auditLog', token: AuditLoggerToken }]` in metadata | `Object.defineProperty(ExecutionContextBase.prototype, ...)` manually | SDK handles runtime installation; manual modification causes ordering issues |
464
- | Type augmentation | `declare module '@frontmcp/sdk' { interface ExecutionContextBase { ... } }` in a separate file | Skipping the augmentation and casting `this` in tools | Without augmentation, TypeScript cannot type-check `this.auditLog` |
465
- | Provider types | `Token<AuditLogger> = Symbol('AuditLogger')` with typed token | `provide: Symbol('AuditLogger')` without type annotation | Typed tokens enable compile-time DI resolution checking |
466
- | Plugin scope | `scope: 'app'` (default) for app-scoped behavior | `scope: 'server'` when hooks should only apply to one app | Server scope fires hooks for all apps in a gateway; default to app |
467
- | Dynamic options | Extend `DynamicPlugin<TOptions, TInput>` with `static dynamicProviders()` | Constructing providers in the constructor body | `dynamicProviders` runs before instantiation, enabling proper DI wiring |
494
+ | Pattern | Correct | Incorrect | Why |
495
+ | ------------------------------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
496
+ | Context extension registration | `contextExtensions: [{ property: 'auditLog', token: AuditLoggerToken }]` in metadata | `Object.defineProperty(ExecutionContextBase.prototype, ...)` manually | SDK handles runtime installation; manual modification causes ordering issues |
497
+ | Type augmentation | `declare module '@frontmcp/sdk' { interface ExecutionContextBase { ... } }` in a separate file | Skipping the augmentation and casting `this` in tools | Without augmentation, TypeScript cannot type-check `this.auditLog` |
498
+ | Provider types | `Token<AuditLogger> = Symbol('AuditLogger')` with typed token | `provide: Symbol('AuditLogger')` without type annotation | Typed tokens enable compile-time DI resolution checking |
499
+ | Plugin scope | `scope: 'app'` (default) for app-scoped behavior | `scope: 'server'` when hooks should only apply to one app | Server scope fires hooks for all apps in a gateway; default to app |
500
+ | Dynamic options | Extend `DynamicPlugin<TOptions, TInput>` with `static dynamicProviders()` | Constructing providers in the constructor body | `dynamicProviders` runs before instantiation, enabling proper DI wiring |
501
+ | Nested plugin needs options | Nested `Plugin.init({ inject: () => [HostToken], useFactory })` injecting a `dynamicProviders` token | Resolving the host's option-derived provider by reading global state | Option-derived providers are registered before nested plugins are built |
468
502
 
469
503
  ## Verification Checklist
470
504
 
@@ -86,11 +86,11 @@ interface PromptArgument {
86
86
  }
87
87
  ```
88
88
 
89
- Required arguments are validated before `execute()` runs. Missing required arguments throw `MissingPromptArgumentError`.
89
+ Required arguments are validated before `execute()` runs. Missing required arguments throw `MissingPromptArgumentError`, which the client receives as a JSON-RPC `-32602` error (so does an unknown prompt name).
90
90
 
91
91
  ### GetPromptResult Structure
92
92
 
93
- The `execute()` method must return a `GetPromptResult`:
93
+ `execute()` returns a `GetPromptResult`, or a string, a message array or an object that the SDK converts into one. The full form is:
94
94
 
95
95
  ```typescript
96
96
  interface GetPromptResult {
@@ -109,6 +109,8 @@ Messages use two roles:
109
109
  - `user` -- represents the human side of the conversation
110
110
  - `assistant` -- primes the conversation with expected response patterns
111
111
 
112
+ The result is checked against this shape. A message with another `role`, such as `'system'`, or content that is not a valid content block fails the call with `INVALID_OUTPUT`.
113
+
112
114
  ### Available Context Methods and Properties
113
115
 
114
116
  `PromptContext` extends `ExecutionContextBase`, providing:
@@ -402,13 +404,13 @@ This creates the prompt file, spec file, and updates barrel exports.
402
404
 
403
405
  ## Common Patterns
404
406
 
405
- | Pattern | Correct | Incorrect | Why |
406
- | ------------------- | ----------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------- |
407
- | Return type | `execute()` returns `Promise<GetPromptResult>` | Returning a plain string or array of strings | MCP protocol requires `{ messages: [...] }` structure |
408
- | Argument validation | Mark arguments as `required: true` in `arguments` array | Manually checking `args.field` inside `execute()` | Framework validates required arguments before `execute()` runs |
409
- | Multi-turn priming | Use `assistant` role messages to prime expected response patterns | Putting all instructions in a single `user` message | Alternating roles guides the LLM toward structured output |
410
- | Resource embedding | Use `type: 'resource'` content with a resource URI | Inlining resource data as raw text in the prompt | Resource references let clients resolve content dynamically |
411
- | Error handling | Use `this.fail(err)` for validation failures in execute | `throw new Error(...)` directly | `this.fail` triggers the error flow with proper MCP error propagation |
407
+ | Pattern | Correct | Incorrect | Why |
408
+ | ------------------- | ------------------------------------------------------------------------ | --------------------------------------------------- | -------------------------------------------------------------------------- |
409
+ | Return type | `execute()` returns a `GetPromptResult`, string, message array or object | Returning nothing | Strings, message arrays and objects are converted to `{ messages: [...] }` |
410
+ | Argument validation | Mark arguments as `required: true` in `arguments` array | Manually checking `args.field` inside `execute()` | Framework validates required arguments before `execute()` runs |
411
+ | Multi-turn priming | Use `assistant` role messages to prime expected response patterns | Putting all instructions in a single `user` message | Alternating roles guides the LLM toward structured output |
412
+ | Resource embedding | Use `type: 'resource'` content with a resource URI | Inlining resource data as raw text in the prompt | Resource references let clients resolve content dynamically |
413
+ | Error handling | Use `this.fail(err)` for validation failures in execute | `throw new Error(...)` directly | `this.fail` triggers the error flow with proper MCP error propagation |
412
414
 
413
415
  ## Verification Checklist
414
416
 
@@ -429,13 +431,13 @@ This creates the prompt file, spec file, and updates barrel exports.
429
431
 
430
432
  ## Troubleshooting
431
433
 
432
- | Problem | Cause | Solution |
433
- | ------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------- |
434
- | Prompt not appearing in `prompts/list` | Not registered in `prompts` array | Add prompt class to `@App` or `@FrontMcp` `prompts` array |
435
- | `MissingPromptArgumentError` on optional argument | Argument marked `required: true` incorrectly | Set `required: false` for optional arguments in the `arguments` array |
436
- | LLM ignores priming messages | Only using `user` role messages | Add `assistant` role messages to prime the conversation pattern |
437
- | Type error on `execute()` return | Returning plain string instead of `GetPromptResult` | Wrap return in `{ messages: [{ role: 'user', content: { type: 'text', text: '...' } }] }` |
438
- | `this.get(TOKEN)` throws DependencyNotFoundError | Provider not registered in scope | Register provider in `providers` array of `@App` or `@FrontMcp` |
434
+ | Problem | Cause | Solution |
435
+ | -------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
436
+ | Prompt not appearing in `prompts/list` | Not registered in `prompts` array | Add prompt class to `@App` or `@FrontMcp` `prompts` array |
437
+ | `MissingPromptArgumentError` on optional argument | Argument marked `required: true` incorrectly | Set `required: false` for optional arguments in the `arguments` array |
438
+ | LLM ignores priming messages | Only using `user` role messages | Add `assistant` role messages to prime the conversation pattern |
439
+ | Call fails with `INVALID_OUTPUT` | A message uses a role other than `user` or `assistant`, or malformed content | Use `role: 'user'` or `'assistant'` and a valid content block such as `{ type: 'text', text: '...' }` |
440
+ | `this.get(TOKEN)` throws ProviderNotAvailableError | Provider not registered in scope | Register provider in `providers` array of `@App` or `@FrontMcp` |
439
441
 
440
442
  ## Examples
441
443
 
@@ -342,7 +342,7 @@ frontmcp dev
342
342
  | Pattern | Correct | Incorrect | Why |
343
343
  | --------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
344
344
  | Token definition | `const DB: Token<DbService> = Symbol('DbService')` (typed Symbol) | `const DB = 'database'` (string literal) | Typed `Token<T>` enables compile-time type checking on `this.get()` |
345
- | DI resolution | `this.get(TOKEN)` with error handling | `this.tryGet(TOKEN)!` with non-null assertion | `get` throws a clear `DependencyNotFoundError`; non-null assertions hide failures |
345
+ | DI resolution | `this.get(TOKEN)` with error handling | `this.tryGet(TOKEN)!` with non-null assertion | `get` throws a clear `ProviderNotAvailableError`; non-null assertions hide failures |
346
346
  | Lifecycle | `AsyncProvider({ useFactory })` for async setup; constructor for sync | Using `onInit()` / `onDestroy()` lifecycle hooks | `@Provider` has no lifecycle hooks; `AsyncProvider` factories are awaited before resolution |
347
347
  | Registration scope | Register at `@App` level for app-scoped, `@FrontMcp` for server-scoped | Registering same provider in multiple apps | Server-scoped providers are shared; duplicating causes multiple instances |
348
348
  | Config provider | `readonly` properties from `process.env` | Mutable properties that change at runtime | Providers are singletons; mutable state can cause race conditions |
@@ -366,13 +366,13 @@ frontmcp dev
366
366
  - [ ] `this.get(TOKEN)` resolves the provider in tools, resources, and agents
367
367
  - [ ] Provider is a singleton (same instance across all contexts)
368
368
  - [ ] Resource-owning providers expose an explicit `close()` / `stop()` method that the host calls before `server.dispose()`
369
- - [ ] Missing provider throws `DependencyNotFoundError` with a clear message
369
+ - [ ] Missing provider throws `ProviderNotAvailableError` with a clear message
370
370
 
371
371
  ## Troubleshooting
372
372
 
373
373
  | Problem | Cause | Solution |
374
374
  | -------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
375
- | `DependencyNotFoundError` at runtime | Provider not registered in scope | Add provider (class or `AsyncProvider` factory) to `providers` array in `@App` or `@FrontMcp` |
375
+ | `ProviderNotAvailableError` at runtime | Provider not registered in scope | Add provider (class or `AsyncProvider` factory) to `providers` array in `@App` or `@FrontMcp` |
376
376
  | Provider constructor throws at startup | Missing environment variable or unreachable service | Validate env in the constructor; restart with the missing config supplied |
377
377
  | `AsyncProvider` factory rejects | Async setup error (DB unreachable, schema fetch failed) | The factory error aborts boot — fix the dependency or wrap with retry inside `useFactory` |
378
378
  | Multiple instances of same provider | Registered in multiple apps instead of server level | Move to `@FrontMcp` `providers` for shared, server-scoped access |
@@ -146,11 +146,13 @@ The `@ResourceTemplate` decorator accepts:
146
146
 
147
147
  - `name` (required) -- unique resource template name
148
148
  - `title` (optional) -- human-readable display title for UIs (if omitted, `name` is used)
149
- - `uriTemplate` (required) -- URI pattern with `{paramName}` placeholders (RFC 6570 style)
149
+ - `uriTemplate` (required) -- URI pattern with RFC 6570 placeholders: `{paramName}` matches one path segment; `{+paramName}` (reserved expansion) can span several, e.g. `files://{+path}` matches `files://docs/a/b.txt` with `path = "docs/a/b.txt"`. Other RFC 6570 operators are not supported
150
150
  - `description` (optional) -- human-readable description
151
151
  - `mimeType` (optional) -- MIME type of the resource content
152
152
  - `icons` (optional) -- array of Icon objects for UI representation (per MCP spec)
153
153
 
154
+ When a template returns a plain value (an object, a string, or an array of items), each content item's `uri` is based on the URI that was read: `users://42/profile`, or `users://42/profile#0`, `#1` for array items without their own `uri`.
155
+
154
156
  ### Class-Based Pattern
155
157
 
156
158
  Use `@ResourceTemplate` with `uriTemplate` instead of `uri`. Type the `ResourceContext` generic parameter to get typed `params`.
@@ -572,13 +574,13 @@ When a client requests completions for the `userId` parameter with a partial str
572
574
 
573
575
  ## Troubleshooting
574
576
 
575
- | Problem | Cause | Solution |
576
- | ------------------------------------------------ | ------------------------------------------------ | ---------------------------------------------------------------------------------- |
577
- | Resource not appearing in `resources/list` | Not registered in `resources` array | Add resource class to `@App` or `@FrontMcp` `resources` array |
578
- | URI validation error at startup | Missing or invalid URI scheme | Ensure URI has a scheme like `config://`, `https://`, or `custom://` |
579
- | Template parameters are empty | Using `@Resource` instead of `@ResourceTemplate` | Switch to `@ResourceTemplate` with `uriTemplate` containing `{param}` placeholders |
580
- | Binary content is garbled | Returning raw buffer in `text` field | Use `blob: buffer.toString('base64')` instead of `text` for binary data |
581
- | `this.get(TOKEN)` throws DependencyNotFoundError | Provider not registered in scope | Register provider in `providers` array of `@App` or `@FrontMcp` |
577
+ | Problem | Cause | Solution |
578
+ | -------------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------- |
579
+ | Resource not appearing in `resources/list` | Not registered in `resources` array | Add resource class to `@App` or `@FrontMcp` `resources` array |
580
+ | URI validation error at startup | Missing or invalid URI scheme | Ensure URI has a scheme like `config://`, `https://`, or `custom://` |
581
+ | Template parameters are empty | Using `@Resource` instead of `@ResourceTemplate` | Switch to `@ResourceTemplate` with `uriTemplate` containing `{param}` placeholders |
582
+ | Binary content is garbled | Returning raw buffer in `text` field | Use `blob: buffer.toString('base64')` instead of `text` for binary data |
583
+ | `this.get(TOKEN)` throws ProviderNotAvailableError | Provider not registered in scope | Register provider in `providers` array of `@App` or `@FrontMcp` |
582
584
 
583
585
  ## Examples
584
586
 
@@ -430,6 +430,13 @@ catalog-installed skill, so the CLAUDE.md auto-generated block, the
430
430
  uniformly. See `frontmcp-skills-usage` for the full flag list and
431
431
  selector matrix.
432
432
 
433
+ Each skill's body comes from whichever source its `@Skill` declares: an
434
+ `instructions: { file }` file is copied as written (so frontmatter such as
435
+ `allowed-tools` survives), while inline and `{ url }` instructions become the
436
+ body. A skill whose instructions cannot be resolved, such as a `file` that no
437
+ longer exists, is skipped with a warning naming the skill and the reason,
438
+ instead of being installed as an empty `SKILL.md`.
439
+
433
440
  If you also want to ship slash commands and a `.claude-plugin/plugin.json`
434
441
  manifest, install the project as a Claude Code plugin instead of just the
435
442
  skills: