@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.
- package/catalog/create-tool/SKILL.md +1 -1
- 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 +28 -17
- package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +10 -7
- 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 +17 -12
- package/catalog/create-tool/references/function-style-builder.md +1 -1
- 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 +44 -6
- 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 +6 -4
- package/catalog/frontmcp-config/references/configure-auth.md +1 -0
- package/catalog/frontmcp-config/references/configure-skills-http.md +4 -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-plugin.md +41 -7
- 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 +88 -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 +9 -6
- 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
|
|
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 `'
|
|
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; //
|
|
56
|
-
trustedProxyDepth?: number; //
|
|
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
|
|
115
|
+
## `ipFilter` is enforced on every HTTP route
|
|
116
116
|
|
|
117
|
-
`allowList`, `denyList` and `defaultAction` are checked
|
|
118
|
-
before the rate-limit check and before authentication
|
|
119
|
-
|
|
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
|
|
197
|
-
| `trustedProxyDepth` | `number` | `1` | **Not read
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
|
462
|
-
| ------------------------------ |
|
|
463
|
-
| Context extension registration | `contextExtensions: [{ property: 'auditLog', token: AuditLoggerToken }]` in metadata
|
|
464
|
-
| Type augmentation | `declare module '@frontmcp/sdk' { interface ExecutionContextBase { ... } }` in a separate file
|
|
465
|
-
| Provider types | `Token<AuditLogger> = Symbol('AuditLogger')` with typed token
|
|
466
|
-
| Plugin scope | `scope: 'app'` (default) for app-scoped behavior
|
|
467
|
-
| Dynamic options | Extend `DynamicPlugin<TOptions, TInput>` with `static dynamicProviders()`
|
|
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
|
-
|
|
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:
|