@frontmcp/skills 1.8.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/catalog/create-tool/examples/08-tool-with-provider-injection.md +2 -2
- package/catalog/create-tool/examples/22-tool-with-ui-html-template.md +7 -7
- package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +3 -3
- package/catalog/create-tool/references/decorator-options.md +1 -0
- package/catalog/create-tool/references/elicitation.md +9 -0
- package/catalog/create-tool/references/error-handling.md +10 -6
- package/catalog/create-tool/references/execution-context.md +7 -3
- package/catalog/create-tool/references/input-schema.md +2 -0
- package/catalog/create-tool/references/output-schema.md +6 -1
- package/catalog/create-tool/references/throttling.md +7 -8
- package/catalog/create-tool/references/ui-widgets.md +14 -1
- package/catalog/frontmcp-authorities/SKILL.md +1 -0
- package/catalog/frontmcp-authorities/references/custom-evaluators.md +13 -2
- package/catalog/frontmcp-authorities/references/rbac-abac-rebac.md +2 -0
- package/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md +4 -4
- package/catalog/frontmcp-config/references/configure-auth.md +1 -0
- package/catalog/frontmcp-config/references/configure-skills-http.md +2 -2
- package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +5 -5
- package/catalog/frontmcp-config/references/configure-throttle.md +49 -22
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +4 -0
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +1 -1
- package/catalog/frontmcp-development/examples/create-plugin-hooks/caching-with-around.md +7 -6
- package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md +2 -2
- package/catalog/frontmcp-development/references/create-plugin-hooks.md +21 -17
- package/catalog/frontmcp-development/references/create-prompt.md +18 -16
- package/catalog/frontmcp-development/references/create-provider.md +3 -3
- package/catalog/frontmcp-development/references/create-resource.md +10 -8
- package/catalog/frontmcp-development/references/create-skill.md +7 -0
- package/catalog/frontmcp-development/references/decorators-guide.md +9 -8
- package/catalog/frontmcp-development/references/official-plugins.md +77 -6
- package/catalog/frontmcp-development/references/openapi-adapter.md +2 -1
- package/catalog/frontmcp-observability/references/metrics-endpoint.md +1 -1
- package/catalog/frontmcp-observability/references/structured-logging.md +35 -0
- package/catalog/frontmcp-testing/examples/test-e2e-handler/tool-call-and-error-e2e.md +3 -1
- package/catalog/frontmcp-testing/references/setup-testing.md +9 -9
- package/catalog/frontmcp-testing/references/test-e2e-handler.md +35 -0
- package/catalog/skills-manifest.json +3 -2
- package/package.json +1 -1
|
@@ -70,7 +70,7 @@ import { inputSchema, outputSchema, type GetUserInput, type GetUserOutput } from
|
|
|
70
70
|
})
|
|
71
71
|
export class GetUserTool extends ToolContext {
|
|
72
72
|
async execute(input: GetUserInput): Promise<GetUserOutput> {
|
|
73
|
-
const users = this.get(USER_SERVICE); // throws
|
|
73
|
+
const users = this.get(USER_SERVICE); // throws ProviderNotAvailableError if not registered
|
|
74
74
|
const user = await users.findById(input.id);
|
|
75
75
|
if (!user) {
|
|
76
76
|
this.fail(new ResourceNotFoundError(`user:${input.id}`)); // never returns
|
|
@@ -106,5 +106,5 @@ export class MainApp {}
|
|
|
106
106
|
|
|
107
107
|
## `this.get` vs `this.tryGet`
|
|
108
108
|
|
|
109
|
-
- `this.get(TOKEN)` — throws `
|
|
109
|
+
- `this.get(TOKEN)` — throws `ProviderNotAvailableError` if not registered. Use when the tool genuinely requires the dep.
|
|
110
110
|
- `this.tryGet(TOKEN)` — returns `undefined` if not registered. Use when the tool degrades gracefully (e.g. optional cache).
|
|
@@ -84,10 +84,10 @@ Move to a `.tsx` FileSource widget the moment you reach for React, useState, eve
|
|
|
84
84
|
|
|
85
85
|
## What `ctx.helpers` includes
|
|
86
86
|
|
|
87
|
-
| Helper | Purpose
|
|
88
|
-
| ------------------------------ |
|
|
89
|
-
| `escapeHtml(str)` | Escape HTML entities; returns `''` for null/undefined
|
|
90
|
-
| `formatDate(date, format?)` | Locale-formatted date
|
|
91
|
-
| `formatCurrency(amount, ccy?)` | ISO-4217 currency formatting
|
|
92
|
-
| `uniqueId(prefix?)` | Deterministic unique ID for DOM elements
|
|
93
|
-
| `jsonEmbed(data)` | Safely embed JSON in a `<script>` tag (escapes
|
|
87
|
+
| Helper | Purpose |
|
|
88
|
+
| ------------------------------ | ------------------------------------------------------------- |
|
|
89
|
+
| `escapeHtml(str)` | Escape HTML entities; returns `''` for null/undefined |
|
|
90
|
+
| `formatDate(date, format?)` | Locale-formatted date |
|
|
91
|
+
| `formatCurrency(amount, ccy?)` | ISO-4217 currency formatting |
|
|
92
|
+
| `uniqueId(prefix?)` | Deterministic unique ID for DOM elements |
|
|
93
|
+
| `jsonEmbed(data)` | Safely embed JSON in a `<script>` tag (escapes `<`, `>`, `&`) |
|
|
@@ -6,7 +6,7 @@ tags: [ui, csp, widgetAccessible, FrontMcpBridge, interactive-widget]
|
|
|
6
6
|
features:
|
|
7
7
|
- "Restricting the widget's outbound `fetch` via `ui.csp.connectDomains` (emitted on the resource per #455)"
|
|
8
8
|
- 'Opting the widget into cross-tool calls with `widgetAccessible: true` and using `window.FrontMcpBridge.callTool(name, args)` instead of host-specific APIs'
|
|
9
|
-
- "Embedding initial data into the widget's inline `<script>` safely via `ctx.helpers.jsonEmbed(...)` (escapes
|
|
9
|
+
- "Embedding initial data into the widget's inline `<script>` safely via `ctx.helpers.jsonEmbed(...)` (escapes `<`, `>` and `&`)"
|
|
10
10
|
- 'Surfacing in-flight status via `invocationStatus.invoking` / `invoked` so the host UI shows feedback'
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -116,7 +116,7 @@ export class ShowQuoteTool extends ToolContext {
|
|
|
116
116
|
|
|
117
117
|
- Restricting the widget's outbound `fetch` via `ui.csp.connectDomains` (emitted on the resource per #455)
|
|
118
118
|
- Opting the widget into cross-tool calls with `widgetAccessible: true` and using `window.FrontMcpBridge.callTool(name, args)` instead of host-specific APIs
|
|
119
|
-
- Embedding initial data into the widget's inline `<script>` safely via `ctx.helpers.jsonEmbed(...)` (escapes
|
|
119
|
+
- Embedding initial data into the widget's inline `<script>` safely via `ctx.helpers.jsonEmbed(...)` (escapes `<`, `>` and `&`)
|
|
120
120
|
- Surfacing in-flight status via `invocationStatus.invoking` / `invoked` so the host UI shows feedback
|
|
121
121
|
|
|
122
122
|
## Why these choices
|
|
@@ -124,4 +124,4 @@ export class ShowQuoteTool extends ToolContext {
|
|
|
124
124
|
- **`widgetAccessible: true`** — required for `window.FrontMcpBridge.callTool`. Without it, the bridge is read-only (the widget can read `getToolInput` / `getToolOutput` but can't invoke tools).
|
|
125
125
|
- **`csp.connectDomains`** — limits what the widget can `fetch` to. Without a CSP, the host's default applies (which may block everything in Claude). With `connectDomains: ['https://api.market.example']`, only that origin is reachable.
|
|
126
126
|
- **`window.FrontMcpBridge.callTool` not `window.openai.callTool`** — the bridge handles host detection. `window.openai.*` works on OpenAI Apps SDK but breaks everywhere else.
|
|
127
|
-
- **`jsonEmbed` not `JSON.stringify`** — `JSON.stringify` doesn't escape `</script>` and can break out of the inline script tag. `jsonEmbed`
|
|
127
|
+
- **`jsonEmbed` not `JSON.stringify`** — `JSON.stringify` doesn't escape `</script>` or `<!--` and can break out of the inline script tag. `jsonEmbed` writes `<`, `>` and `&` as `\u003c`, `\u003e`, `\u0026`.
|
|
@@ -10,6 +10,7 @@ Full surface of the `@Tool` decorator. Mandatory fields are bolded.
|
|
|
10
10
|
| Field | Type | Default | When to set |
|
|
11
11
|
| ----------------- | ------------------------------------------------------------------------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
12
12
|
| **`name`** | `string` (snake_case) | — | Always. MCP protocol convention. `get_weather`, not `getWeather`. |
|
|
13
|
+
| `title` | `string` | — | When the tool needs a display name in `tools/list`. Without it, `annotations.title` is the only title. |
|
|
13
14
|
| `description` | `string` | — | Almost always. Shows in `tools/list` and helps clients choose the right tool. |
|
|
14
15
|
| **`inputSchema`** | Zod raw shape | — | Always. `{ field: z.string() }` — never wrapped in `z.object`. See [`input-schema.md`](./input-schema.md). |
|
|
15
16
|
| `outputSchema` | Zod raw shape / Zod schema / primitive literal / media literal / array | — | **Always** — prevents data leaks and enables CodeCall chaining. See [`output-schema.md`](./output-schema.md). |
|
|
@@ -121,6 +121,15 @@ if (result.status === 'decline') {
|
|
|
121
121
|
}
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
+
## Under MCP 2026-07-28
|
|
125
|
+
|
|
126
|
+
There are no server-to-client requests in this revision. `this.elicit()` answers the first call with `resultType: 'input_required'` and a `requestState`; the client retries with `inputResponses`, and `execute()` runs again **from the top** with each asked `elicit()` returning its answer. Keep side effects after the last `elicit()`, or make them idempotent.
|
|
127
|
+
|
|
128
|
+
- An accepted answer is validated against the schema; a mismatch (or an unknown `action`) fails the call with `INVALID_INPUT`.
|
|
129
|
+
- A client without the `elicitation` capability for the mode gets error `-32021`. Capabilities come with each request, and `this.elicit()` checks them itself. A session-based check such as `this.scope.notifications.getClientCapabilities(sessionId)` finds nothing here, so call `this.elicit()` directly.
|
|
130
|
+
- `sendElicitationResult` is not listed to 2026-07-28 clients.
|
|
131
|
+
- Anonymous callers keep their `requestState` across rounds (it binds to one shared anonymous principal).
|
|
132
|
+
|
|
124
133
|
## See also
|
|
125
134
|
|
|
126
135
|
- [`19-tool-with-elicitation`](../examples/19-tool-with-elicitation.md)
|
|
@@ -45,7 +45,9 @@ async execute(input: { id: string }) {
|
|
|
45
45
|
|
|
46
46
|
## Infrastructure errors → propagate
|
|
47
47
|
|
|
48
|
-
For errors the framework should handle uniformly (network failure, DB unavailable, timeout), just let them throw. The framework wraps them in an `InternalMcpError` with the message redacted before reaching the client, and logs the original
|
|
48
|
+
For errors the framework should handle uniformly (network failure, DB unavailable, timeout), just let them throw. The framework wraps them in an `InternalMcpError` with the message redacted before reaching the client, and logs it once, with the same `errorId` the client sees. The logged message includes the original error's message and stack in every environment; the wrapper's own stack is logged in development only. A `PublicMcpError` (or subclass) thrown from `execute()` is not wrapped: tools, resources and prompts pass it on with its own message and code.
|
|
49
|
+
|
|
50
|
+
Code that catches errors around entries, such as a hook, can apply the same rules with two predicates from `@frontmcp/sdk`. `isClientFacingError(error)` is true for a public error or an authorities refusal, which pass through unwrapped. `isMrtrSignal(error)` is true for an `InputRequiredSignal` or a `MissingClientCapabilityError`, which the 2026-07-28 dispatcher answers itself, so rethrow them.
|
|
49
51
|
|
|
50
52
|
## MCP error classes
|
|
51
53
|
|
|
@@ -103,12 +105,14 @@ The `data` payload lets you surface structured info to the client (rate-limit re
|
|
|
103
105
|
|
|
104
106
|
## `PublicMcpError` vs raw `Error`
|
|
105
107
|
|
|
106
|
-
| Throw |
|
|
107
|
-
| -------------------------------------- |
|
|
108
|
-
| `new PublicMcpError('Quota exceeded')` | `{ code: -
|
|
109
|
-
| `new Error('Quota exceeded')` | `{ code: -32603, message: 'Internal error
|
|
108
|
+
| Throw | `resources/read` / `prompts/get` error |
|
|
109
|
+
| -------------------------------------- | ------------------------------------------------------------------------- |
|
|
110
|
+
| `new PublicMcpError('Quota exceeded')` | `{ code: -32602, message: 'Quota exceeded' }` |
|
|
111
|
+
| `new Error('Quota exceeded')` | `{ code: -32603, message: 'Internal FrontMCP error. … error ID: err_…' }` |
|
|
112
|
+
|
|
113
|
+
`tools/call` answers with an `isError: true` result instead, carrying the same message plus `_meta.code` and `_meta.errorId`. The error's `data` always carries the `errorId`. A public error's JSON-RPC code comes from its `toJsonRpcError()`, else its `statusCode`: 401 → -32001, 403 → -32003, other 4xx → -32602.
|
|
110
114
|
|
|
111
|
-
|
|
115
|
+
In production, raw `Error`s have their messages **redacted** before reaching the client — the framework treats them as potentially-sensitive infrastructure errors. For anything the client should read, use `PublicMcpError` or a subclass.
|
|
112
116
|
|
|
113
117
|
## Non-null assertions are forbidden
|
|
114
118
|
|
|
@@ -12,7 +12,7 @@ description: What ToolContext provides at runtime — this.get, this.fetch, this
|
|
|
12
12
|
| Method | Purpose |
|
|
13
13
|
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
14
14
|
| `execute(input: In): Promise<Out>` | The method you implement |
|
|
15
|
-
| `this.get(token)` | Resolve a DI dependency. Throws `
|
|
15
|
+
| `this.get(token)` | Resolve a DI dependency. Throws `ProviderNotAvailableError` if not registered. |
|
|
16
16
|
| `this.tryGet(token)` | Resolve a DI dependency. Returns `undefined` if not registered. |
|
|
17
17
|
| `this.fail(err)` | Abort execution, trigger the error flow. **Never returns.** Use for business-logic errors. |
|
|
18
18
|
| `this.respond(value)` | Early-return with a value. Validates against `outputSchema`. **Never returns** (throws `FlowControl.respond`). |
|
|
@@ -57,7 +57,7 @@ interface UserService { findById(id: string): Promise<User | null>; }
|
|
|
57
57
|
const USER_SERVICE: Token<UserService> = Symbol('UserService');
|
|
58
58
|
|
|
59
59
|
async execute(input: { userId: string }) {
|
|
60
|
-
// Throws
|
|
60
|
+
// Throws ProviderNotAvailableError if USER_SERVICE isn't registered in scope
|
|
61
61
|
const users = this.get(USER_SERVICE);
|
|
62
62
|
|
|
63
63
|
// Returns undefined if not registered — for optional deps
|
|
@@ -79,6 +79,8 @@ Use `this.get` (throws) when the tool genuinely requires the dependency. Use `th
|
|
|
79
79
|
|
|
80
80
|
`this.fetch` is a thin wrapper around the standard `fetch` that propagates the request's `traceContext` so downstream services can stitch the call into the same trace.
|
|
81
81
|
|
|
82
|
+
It does **not** send the caller's MCP access token or the request's `x-frontmcp-*` headers anywhere unless the target origin is allow-listed in `@FrontMcp({ fetch: { forwardCallerTokenTo, forwardCustomHeadersTo } })` (both default to `[]`). The MCP spec forbids passing the client's token to upstream APIs; call third-party services with `credentials: { provider }` instead. The old `autoInjectAuthHeaders` context option, which sent the token everywhere, is gone. The allow-list only controls what the SDK sends. Listing an origin does not make passing the caller's token through compliant. Only list endpoints that belong to this same MCP server and validate the token as issued for it. For every upstream API, use `credentials: { provider }` or a token issued for that API, for example through token exchange. A request that does carry forwarded caller headers is sent with `redirect: 'manual'`, so a 3xx comes back to the tool instead of following to another origin; with an explicit `redirect: 'error'`, the redirect rejects the `fetch` instead.
|
|
83
|
+
|
|
82
84
|
```typescript
|
|
83
85
|
async execute(input: { url: string }) {
|
|
84
86
|
const response = await this.fetch(input.url);
|
|
@@ -100,6 +102,8 @@ this.fetch(url, {
|
|
|
100
102
|
});
|
|
101
103
|
```
|
|
102
104
|
|
|
105
|
+
The configured `requestTimeout` (30s by default) applies when you pass no `signal` in the options, and so does the `Request`'s own signal; a `signal` passed in the options replaces both. `this.fetch()` is also aborted with `this.signal`, so a cancelled or timed-out call stops its requests.
|
|
106
|
+
|
|
103
107
|
> Don't `try/catch` around the fetch and swallow errors — let infrastructure errors propagate to the framework. Only use `this.fail` for **business-logic** errors. See [`error-handling.md`](./error-handling.md).
|
|
104
108
|
|
|
105
109
|
## Notifications: `this.notify` + `this.progress`
|
|
@@ -119,7 +123,7 @@ async execute(input: { items: string[] }) {
|
|
|
119
123
|
}
|
|
120
124
|
```
|
|
121
125
|
|
|
122
|
-
- `this.notify(msg, level?)` — sends `notifications/message` to the client (`debug`
|
|
126
|
+
- `this.notify(msg, level?)` — sends `notifications/message` to the client at any of the eight MCP levels (`debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`), only once the client has asked for that level or a lower one (`logging/setLevel`, or `_meta["io.modelcontextprotocol/logLevel"]` under 2026-07-28). Always-best-effort: returns `false` when nothing was sent.
|
|
123
127
|
- `this.progress(n, total?, msg?)` — sends `notifications/progress` IF the request had a progress token. Returns `false` when no token was provided (so the call costs almost nothing if nobody's listening).
|
|
124
128
|
- `this.mark(stage)` — server-side breadcrumb, surfaced in logs / metrics / traces. No client notification.
|
|
125
129
|
- `this.notifyResourceUpdated(uri)` — sends `notifications/resources/updated` to every session subscribed to `uri` (via `resources/subscribe`); no-op for non-subscribers. Call it when a tool mutates state that backs a `@Resource` so subscribers re-fetch.
|
|
@@ -77,6 +77,8 @@ inputSchema: {
|
|
|
77
77
|
}
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
`tools/list` describes what the caller sends: a field with `.default()` is not listed as required, and a field with `.transform()` is listed by its input type.
|
|
81
|
+
|
|
80
82
|
## Refinements
|
|
81
83
|
|
|
82
84
|
For cross-field validation, wrap individual fields:
|
|
@@ -5,7 +5,7 @@ description: Define the tool's output contract — Zod shape, primitives, media,
|
|
|
5
5
|
|
|
6
6
|
# `outputSchema` reference
|
|
7
7
|
|
|
8
|
-
`outputSchema` is **always required** ([rule](../rules/always-define-output-schema.md)). It declares what `execute()` returns and gives the framework permission to strip any fields you didn't declare
|
|
8
|
+
`outputSchema` is **always required** ([rule](../rules/always-define-output-schema.md)). It declares what `execute()` returns and gives the framework permission to strip any fields you didn't declare, which guards against accidental PII / token / debug-trace leaks in the result. The exception is a `_meta` object on the returned value: it is copied into the response's `_meta` without validation, so never put secrets there. A result that does not match fails the call with `_meta.code: 'INVALID_OUTPUT'`, and nothing from the rejected result is sent. A number field holding `Infinity`, `-Infinity`, or `NaN` fails the same way, unless the server sets `output: { allowNonFinite: true }`. JSON cannot encode those values, so with that setting the client receives `null` in their place.
|
|
9
9
|
|
|
10
10
|
## Supported shapes
|
|
11
11
|
|
|
@@ -17,6 +17,11 @@ description: Define the tool's output contract — Zod shape, primitives, media,
|
|
|
17
17
|
| **Media literal** | Binary / link content | `'image'`, `'audio'`, `'resource'`, `'resource_link'` |
|
|
18
18
|
| **Array of literals** | Multi-content response | `['string', 'image']` — text + image in one response |
|
|
19
19
|
|
|
20
|
+
Two runtime facts that follow from the shape:
|
|
21
|
+
|
|
22
|
+
- Without an `outputSchema`, a plain value is wrapped: `return 'hello'` sends `{"value":"hello"}` as text and `structuredContent: { value: 'hello' }` (numbers likewise, `{ value: 8 }`). Declare `outputSchema: 'string'` (or `'number'`) to send the bare value as text.
|
|
23
|
+
- `execute()` must return something. Returning `undefined` fails the call with `_meta.code: 'FLOW_EXITED_WITHOUT_OUTPUT'`; return an explicit result such as `{ ok: true }` from side-effect-only tools.
|
|
24
|
+
|
|
20
25
|
## Zod raw shape (most common)
|
|
21
26
|
|
|
22
27
|
```typescript
|
|
@@ -55,7 +55,7 @@ Hard deadline on a single execution.
|
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
- **Scope**: per call. Wraps the entire `execute()` invocation.
|
|
58
|
-
- **Behavior on timeout**: the framework throws an `ExecutionTimeoutError` (from `@frontmcp/guard`, code `'EXECUTION_TIMEOUT'`, HTTP status 408) and aborts
|
|
58
|
+
- **Behavior on timeout**: the framework throws an `ExecutionTimeoutError` (from `@frontmcp/guard`, code `'EXECUTION_TIMEOUT'`, HTTP status 408) and aborts `this.signal`, so `execute()` can observe the abort and pass the signal to cancellable work (see [Timeout and abort signals](#timeout-and-abort-signals)).
|
|
59
59
|
- **Default**: no timeout. Tools can hang forever unless `timeout` is set.
|
|
60
60
|
|
|
61
61
|
## Interaction
|
|
@@ -88,19 +88,18 @@ Order of effects per call:
|
|
|
88
88
|
|
|
89
89
|
## Timeout and abort signals
|
|
90
90
|
|
|
91
|
-
`
|
|
91
|
+
`this.signal` is aborted when the call is cancelled (including `tasks/cancel` for a task-augmented call) and when its `timeout` passes. `FrontMcpContext` has no `abortSignal` property, so don't reach for `this.context.abortSignal`.
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
`this.fetch()` is aborted with `this.signal` on its own, on top of its per-request timeout (default 30s). Pass `this.signal` to any other cancellable work, so a timed-out call stops instead of running on:
|
|
94
94
|
|
|
95
95
|
```typescript
|
|
96
|
-
async execute(input: {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return response.json();
|
|
96
|
+
async execute(input: { reportId: string }) {
|
|
97
|
+
const response = await this.fetch(`https://reports.example.com/${input.reportId}`);
|
|
98
|
+
return this.get(ReportRenderer).render(await response.json(), { signal: this.signal });
|
|
100
99
|
}
|
|
101
100
|
```
|
|
102
101
|
|
|
103
|
-
|
|
102
|
+
The deadline answers the client, but it cannot stop code that ignores the signal. Until `execute()` returns, the call keeps its concurrency slot.
|
|
104
103
|
|
|
105
104
|
## See also
|
|
106
105
|
|
|
@@ -33,7 +33,7 @@ class GetWeatherTool extends ToolContext {
|
|
|
33
33
|
|
|
34
34
|
That's it. The framework:
|
|
35
35
|
|
|
36
|
-
-
|
|
36
|
+
- Advertises the widget at `ui://widget/get_weather.html` in `tools/list` and renders it per call into that call's `_meta['ui/html']` (default `servingMode: 'auto'` → `inline`). With `servingMode: 'static'` it pre-compiles the widget at startup and serves it from `resources/read`.
|
|
37
37
|
- Auto-detects the connecting client — `resourceMode: 'inline'` for Claude (React bundled in), `'cdn'` for OpenAI / ChatGPT / Cursor (esm.sh import map, smaller payload).
|
|
38
38
|
- Emits `ui.csp` (if set) on the resource's `_meta.ui.csp` — Claude actually honors it (it ignores CSP declared on the tool).
|
|
39
39
|
|
|
@@ -85,6 +85,19 @@ Or use the FileSource form — it sidesteps the issue.
|
|
|
85
85
|
| `externals`, `dependencies` | — | CDN externals for FileSource widgets. |
|
|
86
86
|
| `customShell`, `invocationStatus`, `widgetCapabilities`, `prefersBorder`, `sandboxDomain`, `htmlResponsePrefix` | — | Platform-specific knobs. |
|
|
87
87
|
|
|
88
|
+
## Widget resources and per-call renders
|
|
89
|
+
|
|
90
|
+
- `resources/read ui://widget/{toolName}.html` serves only what was compiled at startup (`static`, the `hybrid` shell), rendered without caller data, or a data-free placeholder that gets the result through the bridge.
|
|
91
|
+
- An `inline` render embeds the call's input and output. It is returned only in that call's `_meta['ui/html']` and is never cached where `resources/read` can serve it, so one caller can't read another caller's widget (GHSA-rhr9-vhpf-jqp7).
|
|
92
|
+
- Hosts that load the widget via `resources/read` (MCP Apps hosts such as Claude) need `servingMode: 'static'` and a template that reads data from `window.FrontMcpBridge`; set `resourceMode: 'inline'` explicitly for Claude in static mode.
|
|
93
|
+
- The advertised URI percent-encodes the tool name (`app:tool` → `ui://widget/app%3Atool.html`). Encoded and raw forms both read back; a name that decodes to anything outside `A-Z a-z 0-9 _ - . / : @` is rejected.
|
|
94
|
+
|
|
95
|
+
## Escaping template results
|
|
96
|
+
|
|
97
|
+
- A string the template returns is inserted as markup when it looks like HTML — `template: (ctx) => ctx.output` renders any tags in the output. Escape untrusted fields with `ctx.helpers.escapeHtml`.
|
|
98
|
+
- Everything else is escaped: plain text as text, objects as JSON inside `<pre>`, chart configs and base64 PDFs (`JVBERi…`) as script data. A value that starts with `JVBERi` but isn't base64 is shown as text.
|
|
99
|
+
- `ctx.helpers.jsonEmbed(data)` writes `<`, `>`, `&`, U+2028 and U+2029 as `\uXXXX`, so it is safe inside an inline `<script>`.
|
|
100
|
+
|
|
88
101
|
## Path resolution gotcha (#444)
|
|
89
102
|
|
|
90
103
|
Bare `template: { file: './widget.tsx' }` resolves against `process.cwd()`, **not** the tool file. Always anchor:
|
|
@@ -215,6 +215,7 @@ other policy fields via `operator` (default AND).
|
|
|
215
215
|
import type { AuthorityGuardFn } from '@frontmcp/auth';
|
|
216
216
|
|
|
217
217
|
const requireActiveSubscription: AuthorityGuardFn = async (ctx) => {
|
|
218
|
+
if (ctx.user.sub === undefined) return 'sign-in required';
|
|
218
219
|
const active = await db.isSubscriptionActive(ctx.user.sub);
|
|
219
220
|
return active ? true : 'subscription is not active';
|
|
220
221
|
};
|
|
@@ -22,7 +22,7 @@ interface AuthoritiesEvaluator {
|
|
|
22
22
|
}
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
The `policy` parameter is whatever value is passed under the evaluator's key in the `custom` field. The `ctx` parameter provides the full evaluation context including user info, input, environment, and the relationship resolver.
|
|
25
|
+
The `policy` parameter is whatever value is passed under the evaluator's key in the `custom` field. The `ctx` parameter provides the full evaluation context including user info, input, environment, and the relationship resolver. `ctx.user.sub` is `undefined` for anonymous callers, so an evaluator that keys on the caller should decide explicitly what an anonymous caller gets.
|
|
26
26
|
|
|
27
27
|
The return value must be an `AuthoritiesResult`:
|
|
28
28
|
|
|
@@ -109,6 +109,9 @@ const tenantAllowlistGuard: AuthoritiesEvaluator = {
|
|
|
109
109
|
const activeSubscriptionGuard: AuthoritiesEvaluator = {
|
|
110
110
|
name: 'activeSubscription',
|
|
111
111
|
evaluate: async (_policy, ctx) => {
|
|
112
|
+
if (ctx.user.sub === undefined) {
|
|
113
|
+
return { granted: false, deniedBy: 'sign-in required', evaluatedPolicies: ['custom.activeSubscription'] };
|
|
114
|
+
}
|
|
112
115
|
const row = await db.query('SELECT active FROM subscriptions WHERE user_id = $1', [ctx.user.sub]);
|
|
113
116
|
const active = row?.active === true;
|
|
114
117
|
return {
|
|
@@ -279,6 +282,13 @@ export const featureFlagEvaluator: AuthoritiesEvaluator = {
|
|
|
279
282
|
name: 'featureFlag',
|
|
280
283
|
async evaluate(policy: unknown, ctx: AuthoritiesEvaluationContext): Promise<AuthoritiesResult> {
|
|
281
284
|
const { flag, inverse } = policy as FeatureFlagPolicy;
|
|
285
|
+
if (ctx.user.sub === undefined) {
|
|
286
|
+
return {
|
|
287
|
+
granted: false,
|
|
288
|
+
deniedBy: 'custom.featureFlag: sign-in required',
|
|
289
|
+
evaluatedPolicies: ['custom.featureFlag'],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
282
292
|
const enabled = await featureFlags.isEnabled(flag, ctx.user.sub);
|
|
283
293
|
const granted = inverse ? !enabled : enabled;
|
|
284
294
|
|
|
@@ -399,7 +409,8 @@ export const rateLimitEvaluator: AuthoritiesEvaluator = {
|
|
|
399
409
|
name: 'rateLimit',
|
|
400
410
|
async evaluate(policy: unknown, ctx: AuthoritiesEvaluationContext): Promise<AuthoritiesResult> {
|
|
401
411
|
const { max, windowSeconds } = policy as RateLimitPolicy;
|
|
402
|
-
|
|
412
|
+
// Anonymous callers share one bucket
|
|
413
|
+
const key = ctx.user.sub ?? 'anonymous';
|
|
403
414
|
const now = Date.now();
|
|
404
415
|
|
|
405
416
|
let entry = counters.get(key);
|
|
@@ -161,6 +161,8 @@ interface AbacCondition {
|
|
|
161
161
|
| `exists` | Value exists (not null/undefined) | `{ path: 'user.sub', op: 'exists', value: true }` |
|
|
162
162
|
| `matches` | Regex match | `{ path: 'claims.email', op: 'matches', value: '^.*@(acme\|corp)\\.com$' }` |
|
|
163
163
|
|
|
164
|
+
An anonymous caller (public mode, `allowAnonymous`, or an `anon:` session) has no `user.sub`, even when `claimsMapping.userId` points at another claim, and a subject that is not a string counts as anonymous too, so `{ path: 'user.sub', op: 'exists', value: true }` admits signed-in callers only. A caller whose identity carries no `sub` at all gets the string `claimsMapping.userId` resolves to as its `user.sub`, when there is one, and so passes `exists`. When `claimsMapping.userId` resolves to something other than a string, a signed-in caller keeps its own `sub`. An expected value that resolves to nothing (for example a missing `fromInput` field) never satisfies `eq` or `match`, even when the actual value is missing too. A denied `tools/call` returns an error result with `_meta.code: 'AUTHORITY_DENIED'`.
|
|
165
|
+
|
|
164
166
|
### Dynamic Value References
|
|
165
167
|
|
|
166
168
|
Instead of hardcoding values, reference runtime data from tool input or JWT claims.
|
|
@@ -8,7 +8,7 @@ features:
|
|
|
8
8
|
- 'Top-level `instructions` on `@FrontMcp` exposes a global system prompt to MCP clients'
|
|
9
9
|
- "`skillsConfig.injectInstructions: 'append'` adds the skill catalog summary after the user prompt"
|
|
10
10
|
- 'Dynamic skills are picked up because the composer runs on every initialize request'
|
|
11
|
-
- 'Catalog summary is bounded at 16 KB with a truncation footer pointing at skill://
|
|
11
|
+
- 'Catalog summary is bounded at 16 KB with a truncation footer pointing at skill://index.json'
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
# Inject Instructions on Initialize
|
|
@@ -38,8 +38,8 @@ import { MainApp } from './main.app';
|
|
|
38
38
|
mcpResources: true,
|
|
39
39
|
// 'append' (default) — the skill catalog summary is appended after instructions
|
|
40
40
|
// 'prepend' — summary first, then instructions
|
|
41
|
-
// 'replace' —
|
|
42
|
-
// 'off' — instructions
|
|
41
|
+
// 'replace' — instructions only; summary and channel hints dropped (falls back to 'append' if instructions is empty)
|
|
42
|
+
// 'off' — no summary; instructions and channel hints still sent
|
|
43
43
|
injectInstructions: 'append',
|
|
44
44
|
},
|
|
45
45
|
})
|
|
@@ -51,7 +51,7 @@ export default class FlightBotServer {}
|
|
|
51
51
|
- Top-level `instructions` on `@FrontMcp` exposes a global system prompt to MCP clients
|
|
52
52
|
- `skillsConfig.injectInstructions: 'append'` adds the skill catalog summary after the user prompt
|
|
53
53
|
- Dynamic skills are picked up because the composer runs on every initialize request
|
|
54
|
-
- Catalog summary is bounded at 16 KB with a truncation footer pointing at skill://
|
|
54
|
+
- Catalog summary is bounded at 16 KB with a truncation footer pointing at skill://index.json
|
|
55
55
|
|
|
56
56
|
## Related
|
|
57
57
|
|
|
@@ -71,6 +71,7 @@ class MyApp {}
|
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
- `provider` -- the authorization server URL. FrontMCP fetches JWKS from `{provider}/.well-known/jwks.json`.
|
|
74
|
+
- JWKS, discovery and CIMD client-id fetches are SSRF-guarded: a host that is, or resolves to, a loopback, private, link-local or cloud-metadata address is refused in any IPv4 or IPv6 spelling (IPv4-mapped, -translated, -compatible, NAT64 and 6to4 forms included; the local-use NAT64 prefix `64:ff9b:1::/48` is refused outright), every redirect hop is re-checked, and an opaque (status-0) redirect is refused. A `localhost` IdP is allowed outside production.
|
|
74
75
|
- `expectedAudience` -- the `aud` claim value that tokens must contain. Supported in **transparent, local, AND remote** modes (it lives on the shared/orchestrated auth options), so set it wherever FrontMCP verifies bearer tokens — not transparent-only.
|
|
75
76
|
- `allowAnonymous` (default `false`) -- when `true`, requests without a token get an anonymous session (scoped by `anonymousScopes`, default `['anonymous']`) instead of a 401.
|
|
76
77
|
|
|
@@ -64,7 +64,7 @@ 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
|
|
|
@@ -137,7 +137,7 @@ See [`skill-audit-log`](../../frontmcp-extensibility/references/skill-audit-log.
|
|
|
137
137
|
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
138
138
|
| Local dev, no skills | `skillsConfig` unset |
|
|
139
139
|
| Public server, hand-curated server prompt | `instructions: '...'`, `injectInstructions: 'off'` |
|
|
140
|
-
| Server with many dynamic skills | `injectInstructions: 'append'` (default) or `'
|
|
140
|
+
| Server with many dynamic skills | `injectInstructions: 'append'` (default) or `'prepend'` if skill guidance must lead the prompt |
|
|
141
141
|
| Multi-pod production | `cache: { enabled: true, redis: {...} }`, `audit: { signer: Rs256, store: StorageAdapterAuditStore }` |
|
|
142
142
|
| Compliance / forensic requirements | RS256 signer + persistent store + scheduled `verifyChain(...)` in CI |
|
|
143
143
|
|
|
@@ -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
|
|