@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.
Files changed (38) hide show
  1. package/catalog/create-tool/examples/08-tool-with-provider-injection.md +2 -2
  2. package/catalog/create-tool/examples/22-tool-with-ui-html-template.md +7 -7
  3. package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +3 -3
  4. package/catalog/create-tool/references/decorator-options.md +1 -0
  5. package/catalog/create-tool/references/elicitation.md +9 -0
  6. package/catalog/create-tool/references/error-handling.md +10 -6
  7. package/catalog/create-tool/references/execution-context.md +7 -3
  8. package/catalog/create-tool/references/input-schema.md +2 -0
  9. package/catalog/create-tool/references/output-schema.md +6 -1
  10. package/catalog/create-tool/references/throttling.md +7 -8
  11. package/catalog/create-tool/references/ui-widgets.md +14 -1
  12. package/catalog/frontmcp-authorities/SKILL.md +1 -0
  13. package/catalog/frontmcp-authorities/references/custom-evaluators.md +13 -2
  14. package/catalog/frontmcp-authorities/references/rbac-abac-rebac.md +2 -0
  15. package/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md +4 -4
  16. package/catalog/frontmcp-config/references/configure-auth.md +1 -0
  17. package/catalog/frontmcp-config/references/configure-skills-http.md +2 -2
  18. package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +5 -5
  19. package/catalog/frontmcp-config/references/configure-throttle.md +49 -22
  20. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +4 -0
  21. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +1 -1
  22. package/catalog/frontmcp-development/examples/create-plugin-hooks/caching-with-around.md +7 -6
  23. package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md +2 -2
  24. package/catalog/frontmcp-development/references/create-plugin-hooks.md +21 -17
  25. package/catalog/frontmcp-development/references/create-prompt.md +18 -16
  26. package/catalog/frontmcp-development/references/create-provider.md +3 -3
  27. package/catalog/frontmcp-development/references/create-resource.md +10 -8
  28. package/catalog/frontmcp-development/references/create-skill.md +7 -0
  29. package/catalog/frontmcp-development/references/decorators-guide.md +9 -8
  30. package/catalog/frontmcp-development/references/official-plugins.md +77 -6
  31. package/catalog/frontmcp-development/references/openapi-adapter.md +2 -1
  32. package/catalog/frontmcp-observability/references/metrics-endpoint.md +1 -1
  33. package/catalog/frontmcp-observability/references/structured-logging.md +35 -0
  34. package/catalog/frontmcp-testing/examples/test-e2e-handler/tool-call-and-error-e2e.md +3 -1
  35. package/catalog/frontmcp-testing/references/setup-testing.md +9 -9
  36. package/catalog/frontmcp-testing/references/test-e2e-handler.md +35 -0
  37. package/catalog/skills-manifest.json +3 -2
  38. 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 DependencyNotFoundError if not registered
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 `DependencyNotFoundError` if not registered. Use when the tool genuinely requires the dep.
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 `</script>`) |
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 `</script>`)"
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 `</script>`)
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` does.
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 for ops.
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 | Client sees |
107
- | -------------------------------------- | ----------------------------------------------------------------------- |
108
- | `new PublicMcpError('Quota exceeded')` | `{ code: -32603, message: 'Quota exceeded' }` |
109
- | `new Error('Quota exceeded')` | `{ code: -32603, message: 'Internal error' }` (the message is REDACTED) |
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
- 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.
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 `DependencyNotFoundError` if not registered. |
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 DependencyNotFoundError if USER_SERVICE isn't registered in scope
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` / `info` / `warning` / `error`). Always-best-effort.
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 — the safety net against accidental PII / token / debug-trace leaks.
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 the wrapped execution. The abort is internal to the timeout guard — it is **not** surfaced into `execute()` as a readable signal, so don't expect to observe it from inside the tool body.
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
- `timeout` does **not** hand your `execute()` an abort signal — the abort lives inside the timeout guard and is not exposed on the context. `FrontMcpContext` has no `abortSignal` property, so don't reach for `this.context.abortSignal`.
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
- The only tool-level abort signal is `this.signal`, and it is populated **only** for task-augmented `tools/call` invocations (cancelled via `tasks/cancel`) — not by `timeout`. It is `undefined` for ordinary calls, so guard for that:
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: { url: string }) {
97
- // this.signal is defined only for task-augmented calls; undefined otherwise
98
- const response = await this.fetch(input.url, { signal: this.signal });
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
- For ordinary calls, rely on `this.fetch`'s own per-request timeout (default 30s) to bound in-flight HTTP work; `timeout` then caps the overall `execute()` duration.
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
- - Pre-compiles the widget at startup, registers it at `ui://widget/get_weather.html`.
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
- const key = `${ctx.user.sub}`;
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://catalog'
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' — summary only (skills drive the entire system prompt)
42
- // 'off' — instructions sent as-is, no summary
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://catalog
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; the footer points clients at `skill://index.json` and `skill://<skillPath>/SKILL.md` for full content (SEP-2640 — singular scheme).
67
+ The catalog summary is built by `composeInitializeInstructions(...)` and `buildSkillsCatalogSummary(...)` (exported from `@frontmcp/sdk`). It is bounded at **16 KB** with a truncation footer. Its header and footer point clients at `skill://index.json` (SEP-2640 — singular scheme), which lists each skill's `skill://<skillPath>/SKILL.md` URI. With `mcpResources: false` no `skill://` resource is served, so they point at the `skills/load` and `skills/search` methods instead, and `sep2640InInstructions` is ignored.
68
68
 
69
69
  > **Dynamic skills:** because the composer recomputes the summary on every `initialize` request, skills registered after server boot **are** picked up automatically.
70
70
 
@@ -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 `'replace'` if you want skills to drive the entire prompt |
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; // default: false
56
- trustedProxyDepth?: number; // default: 1
54
+ defaultAction?: 'allow' | 'deny'; // default: 'allow'; also applies when no client IP is known
55
+ trustProxy?: boolean; // NOT read (startup warning) -- set FRONTMCP_TRUST_PROXY
56
+ trustedProxyDepth?: number; // NOT read (startup warning) -- set FRONTMCP_TRUSTED_PROXY_DEPTH
57
57
  }
58
58
  ```
59
59
 
@@ -61,11 +61,11 @@ interface IpFilterConfig {
61
61
 
62
62
  - **`'global'`**: Single counter shared by all clients. Protects total server capacity.
63
63
  - **`'ip'`**: Separate counter per client IP. Fair per-client limiting.
64
- - **`'session'`**: Separate counter per MCP session. Fair per-session limiting.
64
+ - **`'session'`**: Separate counter per MCP session the server verified; a `mcp-session-id` it does not accept is ignored. Fair per-session limiting. A request without a verified session (every MCP 2026-07-28 request, or a rejected session id) falls back to the signed-in user; anonymous callers share one `anonymous` counter.
65
65
 
66
66
  ## Priority Order
67
67
 
68
- 1. IP filter (allow/deny) — checked first
68
+ 1. IP filter (allow/deny) — checked first, on every HTTP route except health probes and `/metrics`
69
69
  2. Global rate limit — checked second
70
70
  3. Global concurrency — checked third
71
71
  4. Per-tool rate limit — checked per tool
@@ -45,7 +45,7 @@ Protect your FrontMCP server with rate limiting, concurrency control, execution
45
45
  partitionBy: 'global', // shared across all clients
46
46
  },
47
47
 
48
- // Global concurrency limit
48
+ // Global concurrency limit (a tool called with this.callTool() runs inside its caller's slot)
49
49
  globalConcurrency: {
50
50
  maxConcurrent: 50,
51
51
  partitionBy: 'global',
@@ -112,15 +112,38 @@ class ExpensiveQueryTool extends ToolContext {
112
112
  }
113
113
  ```
114
114
 
115
- ## `ipFilter` is enforced on every request
115
+ ## `ipFilter` is enforced on every HTTP route
116
116
 
117
- `allowList`, `denyList` and `defaultAction` are checked at the start of the request pipeline,
118
- before the rate-limit check and before authentication. A rejected client gets HTTP 403 with
119
- JSON-RPC error `-32001`.
117
+ `allowList`, `denyList` and `defaultAction` are checked by the `checkIpFilter` stage that starts
118
+ every HTTP-facing flow, before the rate-limit check and before authentication:
119
+
120
+ - the MCP endpoint (`<entryPath>`, `/sse`, `/message`) -- rejected with HTTP 403 and JSON-RPC
121
+ error `-32001`;
122
+ - `/oauth/*`, `/.well-known/*`, `llm.txt` / `llm_full.txt`, the skills HTTP API, and custom
123
+ `http.routes` (before `auth: true` verification) -- rejected with HTTP 403 and
124
+ `{ "error": "forbidden", "message": "Client IP rejected by ipFilter" }`.
125
+
126
+ Health and readiness probes (`/healthz`, `/readyz`, `/health`) and `/metrics` (bearer-token
127
+ protected) are exempt. `IpBlockedError` / `IpNotAllowedError` are not thrown by the built-in
128
+ filter.
129
+
130
+ A request whose client IP cannot be established matches neither list and gets
131
+ `defaultAction` -- with `'deny'` it is rejected. An IPv4-mapped peer (`::ffff:203.0.113.7`,
132
+ how a dual-stack Node socket reports an IPv4 client) matches IPv4 rules.
120
133
 
121
134
  An `ipFilter` block works on its own -- you do not need to configure a `global` rate limit
122
135
  alongside it for the filter to run.
123
136
 
137
+ ### Where the client IP comes from
138
+
139
+ | Runtime | Client IP |
140
+ | ------------------------------------------ | ------------------------------------------------------------------ |
141
+ | Node / Express / serverless handler | Socket peer |
142
+ | Behind a declared proxy | `X-Forwarded-For` (`FRONTMCP_TRUST_PROXY=true`, see below) |
143
+ | Cloudflare Workers (incl. Durable Objects) | `CF-Connecting-IP` -- trusted only when running on Workers |
144
+ | Deno | `info.remoteAddr` -- use `Deno.serve(handler)` |
145
+ | Bun | `server.requestIP(request)` -- use `Bun.serve({ fetch: handler })` |
146
+
124
147
  ## `partitionBy: 'ip'` needs a declared trusted proxy
125
148
 
126
149
  The client IP comes from the socket peer address. `X-Forwarded-For` and `X-Real-IP` are set
@@ -155,6 +178,9 @@ so the socket peer is used instead.
155
178
  > With depth `1` and no `X-Forwarded-For` at all, `X-Real-IP` is used -- the single-hop nginx
156
179
  > convention. It is never consulted alongside a chain, because a caller can send both.
157
180
 
181
+ On Cloudflare Workers, Deno and Bun the web-fetch adapter supplies the platform's peer
182
+ address (table above), so edge callers get their own buckets too.
183
+
158
184
  When no IP can be established the request falls back to the authenticated user
159
185
  (`user:<userId>`), and to a single `ip:unresolved` partition when there is no user either. It
160
186
  never keys on the session id: `mcp-session-id` is caller-supplied and a request without one is
@@ -188,19 +214,20 @@ is what takes callers out of it.
188
214
 
189
215
  ### IpFilterConfig
190
216
 
191
- | Field | Type | Default | Description |
192
- | ------------------- | ------------------- | --------- | ------------------------------------------------ |
193
- | `allowList` | `string[]` | — | Allowed IPs or CIDR ranges |
194
- | `denyList` | `string[]` | — | Blocked IPs or CIDR ranges |
195
- | `defaultAction` | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list |
196
- | `trustProxy` | `boolean` | `false` | **Not read.** Use `FRONTMCP_TRUST_PROXY` |
197
- | `trustedProxyDepth` | `number` | `1` | **Not read.** Use `FRONTMCP_TRUSTED_PROXY_DEPTH` |
217
+ | Field | Type | Default | Description |
218
+ | ------------------- | ------------------- | --------- | ------------------------------------------------------------------ |
219
+ | `allowList` | `string[]` | — | Allowed IPs or CIDR ranges |
220
+ | `denyList` | `string[]` | — | Blocked IPs or CIDR ranges |
221
+ | `defaultAction` | `'allow' \| 'deny'` | `'allow'` | Action when IP matches neither list, or no client IP is known |
222
+ | `trustProxy` | `boolean` | `false` | **Not read** (startup warning). Use `FRONTMCP_TRUST_PROXY` |
223
+ | `trustedProxyDepth` | `number` | `1` | **Not read** (startup warning). Use `FRONTMCP_TRUSTED_PROXY_DEPTH` |
198
224
 
199
225
  ## Partition Strategies
200
226
 
201
227
  - **`'global'`** — Single shared counter for all clients. Use for global capacity limits.
202
228
  - **`'ip'`** — Separate counter per client IP. Use for per-client rate limiting.
203
- - **`'session'`** — Separate counter per MCP session. Use for per-session fairness.
229
+ - **`'session'`** — Separate counter per MCP session the server verified; a `mcp-session-id` it does not accept is ignored. Use for per-session fairness. A request without a verified session (every MCP 2026-07-28 request, stateless HTTP, or a rejected session id) falls back to the signed-in user; anonymous callers share one `anonymous` counter. A `throttle.global` partitioned by session, user or a function is checked right after authentication; one partitioned by `'ip'` or `'global'` is checked before it.
230
+ - **`'userId'`** — Separate counter per signed-in user. Anonymous callers fall back to the session, as above.
204
231
 
205
232
  ## Distributed Rate Limiting
206
233
 
@@ -246,7 +273,7 @@ done
246
273
 
247
274
  ### Configuration
248
275
 
249
- - [ ] `throttle.enabled` is set to `true` in the `@FrontMcp` decorator
276
+ - [ ] `throttle.enabled` is set to `true` in the `@FrontMcp` decorator for the server-level options (`global`, `globalConcurrency`, the `default*` settings, `ipFilter`). A tool's own `rateLimit`/`concurrency` apply without it; `throttle.enabled: false` turns every guard off
250
277
  - [ ] `global.maxRequests` and `global.windowMs` are set to reasonable production values
251
278
  - [ ] `defaultTimeout.executeMs` is configured to prevent runaway tool executions
252
279
  - [ ] IP filter `defaultAction` matches your security posture (`allow` for open, `deny` for restricted)
@@ -266,17 +293,17 @@ done
266
293
 
267
294
  - [ ] Sending requests beyond the rate limit returns HTTP 429
268
295
  - [ ] Blocked IPs receive HTTP 403
269
- - [ ] Tool executions that exceed `executeMs` are terminated and return a timeout error
296
+ - [ ] Tool executions that exceed `executeMs` return an `EXECUTION_TIMEOUT` error and abort `this.signal` and the tool's pending `this.fetch()` requests; the tool passes `this.signal` to other cancellable work so it stops too
270
297
 
271
298
  ## Troubleshooting
272
299
 
273
- | Problem | Cause | Solution |
274
- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
275
- | Rate limits not enforced across instances | In-memory storage used with multiple server replicas | Configure `storage: { type: 'redis' }` in the throttle block to share counters |
276
- | All requests rejected with 403 | `ipFilter.defaultAction` set to `'deny'` without any `allowList` entries | Add the allowed IP ranges to `allowList` or change `defaultAction` to `'allow'` |
277
- | Tools timing out unexpectedly | `defaultTimeout.executeMs` too low for the tool's normal execution time | Increase the global default or set a per-tool `timeout.executeMs` override |
278
- | `X-Forwarded-For` header ignored | No trusted proxy declared. `ipFilter.trustProxy` / `trustedProxyDepth` are accepted by the schema but NOT read -- client-IP extraction happens in the SDK context layer, before guard config is reachable | Set the `FRONTMCP_TRUST_PROXY=true` and `FRONTMCP_TRUSTED_PROXY_DEPTH` environment variables instead |
279
- | Rate limit resets not aligned with expectations | `windowMs` misunderstood as a sliding window when it is a fixed window | The window is fixed; all counters reset at the end of each `windowMs` interval |
300
+ | Problem | Cause | Solution |
301
+ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
302
+ | Rate limits not enforced across instances | In-memory storage used with multiple server replicas | Configure `storage: { type: 'redis' }` in the throttle block to share counters |
303
+ | All requests rejected with 403 | `ipFilter.defaultAction` set to `'deny'` without any `allowList` entries, or the runtime reports no client IP (a custom fetch wrapper that drops the second handler argument on Deno/Bun) | Add the allowed IP ranges to `allowList`, pass the platform's second argument through to the handler, or change `defaultAction` to `'allow'` |
304
+ | Tools timing out unexpectedly | `defaultTimeout.executeMs` too low for the tool's normal execution time | Increase the global default or set a per-tool `timeout.executeMs` override |
305
+ | `X-Forwarded-For` header ignored | No trusted proxy declared. `ipFilter.trustProxy` / `trustedProxyDepth` are accepted by the schema but NOT read -- client-IP extraction happens in the SDK context layer, before guard config is reachable | Set the `FRONTMCP_TRUST_PROXY=true` and `FRONTMCP_TRUSTED_PROXY_DEPTH` environment variables instead |
306
+ | Rate limit resets not aligned with expectations | `windowMs` misunderstood as a sliding window when it is a fixed window | The window is fixed; all counters reset at the end of each `windowMs` interval |
280
307
 
281
308
  ## Examples
282
309
 
@@ -75,6 +75,10 @@ export default createEdgeMcp({
75
75
  is the **Cron Trigger** entrypoint that pulls a fresh bundle and hot-swaps it.
76
76
  Managed mode requires the optional peer `@frontmcp/plugin-skilled-openapi`.
77
77
 
78
+ The pull sends `authToken` as a bearer token and never follows a redirect, so
79
+ `endpoint` must serve the bundle directly; a 3xx (or a status-0
80
+ `opaqueredirect`) fails the pull.
81
+
78
82
  This path is bundled by **wrangler** (not `frontmcp build`), so you maintain
79
83
  `wrangler.toml` yourself — it needs a `[[kv_namespaces]] binding = "BUNDLE_CACHE"`
80
84
  and a `[triggers] crontabs = [...]` (the `managed.pollIntervalMs` option is
@@ -245,7 +245,7 @@ createEdgeMcp({
245
245
  - **Bundle size**: Workers have a 1 MB compressed / 10 MB uncompressed limit (paid plan: 10 MB / 30 MB). Review dependencies and remove unused packages to reduce bundle size.
246
246
  - **CPU time**: 10 ms CPU time on free plan, 30 seconds on paid. Long-running operations must be optimized or use Durable Objects.
247
247
  - **No native modules**: `better-sqlite3` and other native Node.js modules are not available. Use KV, D1, or Upstash Redis for storage.
248
- - **Streaming**: Streamable HTTP works, including SSE responses (`POST` with `Accept: text/event-stream`) and the server→client SSE `GET` stream. The worker uses the SDK's `WebStandardStreamableHTTPServerTransport` — which **is** the standard Streamable HTTP transport (the Node `StreamableHTTPServerTransport` is a thin `req`/`res` wrapper over it, so there's one engine). **Server→client notifications** on the standalone `GET` stream require **stateful sessions** — set `sessions: {}` and bind the `SessionDurableObject` (Durable Object) per `Mcp-Session-Id`. Without it the worker is stateless and the `GET` stream can't deliver pushed notifications.
248
+ - **Streaming**: Streamable HTTP works, including SSE responses (`POST` with `Accept: text/event-stream`) and the server→client SSE `GET` stream. The worker uses the SDK's `WebStandardStreamableHTTPServerTransport` — which **is** the standard Streamable HTTP transport (the Node `StreamableHTTPServerTransport` is a thin `req`/`res` wrapper over it, so there's one engine). **Server→client notifications** on the standalone `GET` stream require **stateful sessions** — set `sessions: {}` and bind the `SessionDurableObject` (Durable Object) per `Mcp-Session-Id`. Without it the worker is stateless and the `GET` stream can't deliver pushed notifications. A stateless worker also returns no `Mcp-Session-Id` to a session-based (2025-06-18) client; each of its requests is served on its own.
249
249
 
250
250
  ### Stateful sessions (Durable Object)
251
251