@frontmcp/skills 1.7.2 → 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 (47) 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/examples/configure-throttle/server-level-rate-limit.md +1 -2
  17. package/catalog/frontmcp-config/examples/configure-throttle-guard-config/full-guard-config.md +2 -3
  18. package/catalog/frontmcp-config/references/configure-auth-modes.md +36 -13
  19. package/catalog/frontmcp-config/references/configure-auth.md +1 -0
  20. package/catalog/frontmcp-config/references/configure-http.md +10 -2
  21. package/catalog/frontmcp-config/references/configure-skills-http.md +2 -2
  22. package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +5 -5
  23. package/catalog/frontmcp-config/references/configure-throttle.md +97 -20
  24. package/catalog/frontmcp-deployment/SKILL.md +10 -6
  25. package/catalog/frontmcp-deployment/examples/deploy-to-cloudflare/worker-custom-domain.md +4 -7
  26. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +4 -0
  27. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +82 -39
  28. package/catalog/frontmcp-development/examples/create-plugin-hooks/caching-with-around.md +7 -6
  29. package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md +2 -2
  30. package/catalog/frontmcp-development/references/create-job.md +2 -2
  31. package/catalog/frontmcp-development/references/create-plugin-hooks.md +21 -17
  32. package/catalog/frontmcp-development/references/create-prompt.md +18 -16
  33. package/catalog/frontmcp-development/references/create-provider.md +3 -3
  34. package/catalog/frontmcp-development/references/create-resource.md +10 -8
  35. package/catalog/frontmcp-development/references/create-skill.md +7 -0
  36. package/catalog/frontmcp-development/references/decorators-guide.md +9 -8
  37. package/catalog/frontmcp-development/references/official-plugins.md +167 -6
  38. package/catalog/frontmcp-development/references/openapi-adapter.md +16 -0
  39. package/catalog/frontmcp-observability/references/metrics-endpoint.md +1 -1
  40. package/catalog/frontmcp-observability/references/structured-logging.md +35 -0
  41. package/catalog/frontmcp-setup/SKILL.md +8 -7
  42. package/catalog/frontmcp-testing/SKILL.md +12 -8
  43. package/catalog/frontmcp-testing/examples/test-e2e-handler/tool-call-and-error-e2e.md +3 -1
  44. package/catalog/frontmcp-testing/references/setup-testing.md +42 -9
  45. package/catalog/frontmcp-testing/references/test-e2e-handler.md +35 -0
  46. package/catalog/skills-manifest.json +4 -3
  47. 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
 
@@ -60,8 +60,7 @@ class ApiApp {}
60
60
  ipFilter: {
61
61
  allowList: ['10.0.0.0/8'],
62
62
  defaultAction: 'deny',
63
- trustProxy: true,
64
- trustedProxyDepth: 1,
63
+ // trustProxy is NOT read: use the FRONTMCP_TRUST_PROXY environment variable.
65
64
  },
66
65
  },
67
66
  })
@@ -21,7 +21,7 @@ Complete GuardConfig using every available field for maximum protection.
21
21
 
22
22
  ```typescript
23
23
  // src/server.ts
24
- import { FrontMcp, App } from '@frontmcp/sdk';
24
+ import { App, FrontMcp } from '@frontmcp/sdk';
25
25
 
26
26
  @App({ name: 'secure-app' })
27
27
  class SecureApp {}
@@ -76,8 +76,7 @@ class SecureApp {}
76
76
  allowList: ['10.0.0.0/8', '172.16.0.0/12'],
77
77
  denyList: ['192.168.1.100'],
78
78
  defaultAction: 'deny',
79
- trustProxy: true,
80
- trustedProxyDepth: 2,
79
+ // trustProxy is NOT read: use the FRONTMCP_TRUST_PROXY environment variable.
81
80
  },
82
81
  },
83
82
  })
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: configure-auth-modes
3
- description: Detailed comparison of public, transparent, local, and remote auth modes
3
+ description: Detailed comparison of public, static, transparent, local, and remote auth modes
4
4
  ---
5
5
 
6
6
  # Auth Modes Detailed Comparison
@@ -20,6 +20,29 @@ auth: {
20
20
 
21
21
  **Use when:** Development, internal tools, public APIs.
22
22
 
23
+ A JWT bearer is still verified against this instance's own HS256 secret. A bearer that is **not** a JWT is ignored and the request is served anonymously — public mode has no issuer or JWKS to verify it against, and a credentialed request must never fare worse than an anonymous one. For a first-class shared secret, use static mode below.
24
+
25
+ ## Static Mode
26
+
27
+ A fixed shared secret on every request — the shape non-OAuth MCP hosts expect (ChatGPT's custom-app connector calls it "Access token / API key"). No OAuth, no JWT, no JWKS.
28
+
29
+ ```typescript
30
+ auth: {
31
+ mode: 'static',
32
+ tokens: [process.env.MCP_AUTH_TOKEN!],
33
+ // header: 'authorization', // default
34
+ // scheme: 'Bearer', // default; '' for a bare-token header like x-api-key
35
+ // scopes: ['static'], // default
36
+ // realm: 'mcp', // default, used in the WWW-Authenticate challenge
37
+ }
38
+ ```
39
+
40
+ Tokens are compared in constant time over SHA-256 digests, so neither the value nor its length leaks by timing. A match yields a session whose `sub` is `static:<12 hex chars>` — a non-reversible digest prefix of the matching token, so audit logs can tell configured tokens apart without the secret appearing anywhere. Anything else, including a missing credential, is a `401` with a `WWW-Authenticate: Bearer realm="…"` challenge.
41
+
42
+ **Use when:** one shared secret is the right granularity and standing up OAuth 2.1 is not. Rotate by deploying with both the old and new token in `tokens`, then dropping the old one.
43
+
44
+ **Do not use when:** you need per-user identity, revocation, or progressive auth — use `local` or `remote`.
45
+
23
46
  ## Transparent Mode
24
47
 
25
48
  Server validates tokens from an upstream identity provider. Does not issue or refresh tokens.
@@ -67,7 +90,7 @@ Local mode also accepts `allowDefaultPublic` (default `false` — set `true` to
67
90
 
68
91
  > **Public origin (security):** pin `FRONTMCP_PUBLIC_URL` in production. The issuer / resource / OAuth-discovery URLs and the transparent expected audience derive from it rather than from request headers; `X-Forwarded-Host`/`X-Forwarded-Proto` are ignored unless `FRONTMCP_TRUST_PROXY=1` (a trusted proxy that strips client-supplied forwarded headers).
69
92
 
70
- **Progressive / incremental authorization** (opt-in via `incrementalAuth`): when enabled, the minted token carries an `authorized_apps` claim and a `tools/call` for an app NOT in that claim resolves to a `CallToolResult` with `isError: true` and `_meta.code === 'AUTHORIZATION_REQUIRED'` (fields: `authorization_required: true`, `app`, `tool`, `auth_url`, `required_scopes`, `session_mode`, `supports_incremental`). The client declares the initial grant on `/oauth/authorize?…&apps=crm` (omit `apps` to grant all apps) and expands it later by **following the `auth_url` from the failed call** — that URL carries a framework-signed, single-use `ticket` naming the target app and the prior grant, and the new token's claim is the **union** of the prior apps plus the target (the user identity and already-granted apps are preserved; upstream tokens stay server-side). Do NOT hand-assemble `…&mode=incremental&app=slack`: since 1.7.2 (GHSA-2c4g-9c8x-6m8g) an authorize without a valid ticket is an ordinary login and runs the full credential gate, and `/oauth/callback` ignores an `incremental=true` parameter entirely. Without an `incrementalAuth` block, no claim is minted and there is **no** app-level gating (allow-all preserved). `consent` (tool-level) and `incrementalAuth` (app-level) are independent.
93
+ **Progressive / incremental authorization** (opt-in via `incrementalAuth`): when enabled, the minted token carries an `authorized_apps` claim and a `tools/call` for an app NOT in that claim resolves to a `CallToolResult` with `isError: true` and `_meta.code === 'AUTHORIZATION_REQUIRED'` (fields: `authorization_required: true`, `app`, `tool`, `auth_url`, `required_scopes`, `session_mode`, `supports_incremental`). The client declares the initial grant on `/oauth/authorize?…&apps=crm` (omit `apps` to grant all apps) and expands it later by **following the `auth_url` from the failed call** — that URL carries a framework-signed, single-use `ticket` naming the target app and the prior grant, and the new token's claim is the **union** of the prior apps plus the target (the user identity and already-granted apps are preserved; upstream tokens stay server-side). Do NOT hand-assemble `…&mode=incremental&app=slack`: since 1.7.2 (GHSA-2c4g-9c8x-6m8g) an authorize without a valid ticket is an ordinary login and runs the full credential gate, and `/oauth/callback` ignores an `incremental=true` parameter entirely. Single use is enforced with a conditional write against the configured session storage, so a **multi-instance deployment needs shared storage** (Redis, or any adapter supporting `ifNotExists`) for that guarantee to hold across instances; a backend without conditional writes (Cloudflare KV) degrades to an in-memory guard that holds within one instance only and warns at first use. Without an `incrementalAuth` block, no claim is minted and there is **no** app-level gating (allow-all preserved). `consent` (tool-level) and `incrementalAuth` (app-level) are independent.
71
94
 
72
95
  To collect and verify your own credentials, add a declarative `login` (custom page fields / title / subject strategy) and an `authenticate(input, ctx)` verifier that returns `{ ok: true, sub?, claims? }` (custom claims are embedded in the token; reserved claims are stripped) or `{ ok: false, message }` (re-renders the login page; no code issued). Both are optional and default to the built-in email login. See `configure-auth.md` for a full example.
73
96
 
@@ -130,17 +153,17 @@ upstream-token access in tools, and an optional consent layer.
130
153
 
131
154
  ## Comparison Table
132
155
 
133
- | Feature | Public | Transparent | Local | Remote |
134
- | ------------------------ | ------------- | --------------- | ------------------------------- | --------------------------------- |
135
- | Token issuance | Anonymous JWT | None (upstream) | Self-signed (HS256) | Self-signed (HS256) |
136
- | Signing | HS256 secret | Upstream JWKS | HS256 secret (`JWT_SECRET`) | HS256 secret (`JWT_SECRET`) |
137
- | Session-token refresh | No | No | Yes | Yes |
138
- | Upstream-token refresh | n/a | n/a | On-demand (when wired) | Not yet wired (re-auth on expiry) |
139
- | Identity source | Anonymous | Upstream token | Login form / `authenticate()` | Upstream IdP user |
140
- | PKCE support | No | No | Yes | Yes |
141
- | Token persistence | n/a | n/a | memory / sqlite / redis | memory / sqlite / redis |
142
- | Consent (tool selection) | No | No | Optional (screen + enforcement) | Optional (screen + enforcement) |
143
- | Upstream OAuth providers | No | No | 0..N (declared `providers[]`) | Exactly 1 (mandatory) |
156
+ | Feature | Public | Static | Transparent | Local | Remote |
157
+ | ------------------------ | ------------- | -------------------- | --------------- | ------------------------------- | --------------------------------- |
158
+ | Token issuance | Anonymous JWT | None (opaque secret) | None (upstream) | Self-signed (HS256) | Self-signed (HS256) |
159
+ | Signing | HS256 secret | n/a | Upstream JWKS | HS256 secret (`JWT_SECRET`) | HS256 secret (`JWT_SECRET`) |
160
+ | Session-token refresh | No | No | No | Yes | Yes |
161
+ | Upstream-token refresh | n/a | n/a | n/a | On-demand (when wired) | Not yet wired (re-auth on expiry) |
162
+ | Identity source | Anonymous | Configured token | Upstream token | Login form / `authenticate()` | Upstream IdP user |
163
+ | PKCE support | No | No | No | Yes | Yes |
164
+ | Token persistence | n/a | n/a | n/a | memory / sqlite / redis | memory / sqlite / redis |
165
+ | Consent (tool selection) | No | No | No | Optional (screen + enforcement) | Optional (screen + enforcement) |
166
+ | Upstream OAuth providers | No | No | No | 0..N (declared `providers[]`) | Exactly 1 (mandatory) |
144
167
 
145
168
  > "Remote" still issues its own HS256 session token to the MCP client; it delegates **user authentication** to a single upstream IdP rather than delegating token signing. `GET /oauth/authorize` redirects straight to that IdP (no in-tree login page), and tools read the upstream token via `this.orchestration.getToken(id)`.
146
169
 
@@ -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
 
@@ -129,8 +129,10 @@ after the rebind the browser genuinely considers the request same-origin. Valida
129
129
  server-side defence.
130
130
 
131
131
  With no `allowedHosts` configured, the list is derived from what the process listens on: `localhost`,
132
- `127.0.0.1` and `[::1]`, each with and without the bound port, plus a specific bound NIC address.
133
- Matching is case-insensitive and treats `host` and `host:80`/`host:443` as equal.
132
+ `127.0.0.1` and `[::1]`, each with and without the bound port. Matching is case-insensitive and
133
+ treats `host` and `host:80`/`host:443` as equal. A loopback listener is reachable only under those
134
+ names, so the derived list is exact and is enforced as-is; a routable bind enforces nothing derived
135
+ until you name the public host, and the bound NIC address then joins the list alongside it.
134
136
 
135
137
  **Deployments behind a proxy need one line of config.** A routable bind (`0.0.0.0`, `::`, a specific
136
138
  NIC) is reached under a hostname the process cannot know, so a derived list is not enforced there —
@@ -155,6 +157,12 @@ ENV FRONTMCP_BIND_ADDRESS=all
155
157
  ENV FRONTMCP_ALLOWED_HOSTS=api.example.com,api.example.com:8443
156
158
  ```
157
159
 
160
+ A server bound to `socketPath` has no TCP host — the socket's filesystem permissions are the
161
+ boundary and clients send an arbitrary placeholder `Host` — so the _derived_ allow-list is skipped
162
+ there and needs no configuration. A rebound browser cannot reach a unix socket at all. An
163
+ **explicit** `allowedHosts` / `allowedOrigins` still applies to a socket server, and because the
164
+ client's `Host` is a placeholder it will reject every request — leave it unset.
165
+
158
166
  To turn it off entirely: `dnsRebindingProtection: { enabled: false }`.
159
167
 
160
168
  A request with **no** `Origin` header is allowed through — non-browser clients never send one, and a
@@ -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