@frontmcp/skills 1.8.1 → 1.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/catalog/create-tool/SKILL.md +1 -1
- package/catalog/create-tool/examples/22-tool-with-ui-html-template.md +21 -10
- package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +9 -6
- package/catalog/create-tool/references/availability.md +11 -9
- package/catalog/create-tool/references/execution-context.md +10 -9
- package/catalog/create-tool/references/function-style-builder.md +1 -1
- package/catalog/create-tool/references/ui-widgets.md +34 -9
- package/catalog/frontmcp-auth-ui/references/custom-auth-ui.md +2 -2
- package/catalog/frontmcp-authorities/SKILL.md +31 -9
- package/catalog/frontmcp-authorities/references/authority-profiles.md +12 -6
- package/catalog/frontmcp-authorities/references/rbac-abac-rebac.md +2 -0
- package/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md +2 -0
- package/catalog/frontmcp-config/references/configure-auth-modes.md +3 -1
- package/catalog/frontmcp-config/references/configure-auth.md +11 -8
- package/catalog/frontmcp-config/references/configure-skills-http.md +15 -0
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +22 -1
- package/catalog/frontmcp-development/examples/openapi-adapter/authenticated-adapter-with-polling.md +7 -3
- package/catalog/frontmcp-development/references/create-plugin-hooks.md +2 -0
- package/catalog/frontmcp-development/references/create-plugin.md +41 -7
- package/catalog/frontmcp-development/references/create-provider.md +7 -0
- package/catalog/frontmcp-development/references/official-plugins.md +75 -39
- package/catalog/frontmcp-development/references/openapi-adapter.md +21 -4
- package/catalog/frontmcp-production-readiness/references/common-checklist.md +2 -1
- package/catalog/skills-manifest.json +7 -5
- package/package.json +1 -1
|
@@ -199,7 +199,7 @@ If a request seems to conflict with an inherited default (e.g., "wrap `inputSche
|
|
|
199
199
|
|
|
200
200
|
11. Should the result render as a widget in the host UI?
|
|
201
201
|
YES → ui: { template, … }
|
|
202
|
-
├── Quick HTML → ui: { template: (ctx) =>
|
|
202
|
+
├── Quick HTML → ui: { template: (ctx) => ctx.helpers.html`<div>…</div>` }
|
|
203
203
|
│ See: examples/22-tool-with-ui-html-template.md
|
|
204
204
|
├── React widget (file) → ui: { template: { file: widgetPath } }
|
|
205
205
|
│ See: examples/23-tool-with-ui-filesource-tsx.md
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
name: 22-tool-with-ui-html-template
|
|
3
3
|
level: intermediate
|
|
4
4
|
description: "Tool with an inline HTML function template — `ui: { template: (ctx) => '<div>…</div>' }` — for a quick widget that doesn't need a separate `.tsx` file."
|
|
5
|
-
tags: [ui, ui-widgets, html-template,
|
|
5
|
+
tags: [ui, ui-widgets, html-template, html-tag, escapeStringResults, TemplateContext]
|
|
6
6
|
features:
|
|
7
|
-
- 'Adding a `ui:` block with a function template
|
|
7
|
+
- 'Adding a `ui:` block with a function template that returns markup built with the `ctx.helpers.html` tagged template'
|
|
8
8
|
- 'Annotating `ctx` explicitly to dodge the TS7006 inference gap on the union `ui.template` type'
|
|
9
|
-
- "
|
|
9
|
+
- "Letting `ctx.helpers.html` escape every interpolated value so tool output can't inject markup into the widget"
|
|
10
|
+
- 'Opting in to `escapeStringResults: true` so a plain string result is escaped — the default from FrontMCP 1.9'
|
|
10
11
|
- 'Reading from `ctx.output` and `ctx.helpers` — the typed runtime context the template renderer hands you'
|
|
11
12
|
---
|
|
12
13
|
|
|
@@ -14,7 +15,7 @@ features:
|
|
|
14
15
|
|
|
15
16
|
Tool with an inline HTML function template — `ui: { template: (ctx) => '<div>…</div>' }` — for a quick widget that doesn't need a separate `.tsx` file.
|
|
16
17
|
|
|
17
|
-
For widgets that don't need React / state / interactivity, an inline function template is the simplest form. Read `ctx.output
|
|
18
|
+
For widgets that don't need React / state / interactivity, an inline function template is the simplest form. Read `ctx.output` and return markup built with the `ctx.helpers.html` tagged template — it escapes every value you interpolate.
|
|
18
19
|
|
|
19
20
|
## Code
|
|
20
21
|
|
|
@@ -38,13 +39,16 @@ type Out = { city: string; temperatureF: number; conditions: string };
|
|
|
38
39
|
outputSchema,
|
|
39
40
|
ui: {
|
|
40
41
|
widgetDescription: 'Current weather card',
|
|
41
|
-
|
|
42
|
+
// `html` escapes interpolated values — no manual escapeHtml needed
|
|
43
|
+
template: (ctx: TemplateContext<In, Out>) => ctx.helpers.html`
|
|
42
44
|
<div style="padding:16px;font-family:system-ui;border-radius:12px;background:#f5f7fa">
|
|
43
|
-
<h2 style="margin:0 0 8px">${ctx.
|
|
45
|
+
<h2 style="margin:0 0 8px">${ctx.output.city}</h2>
|
|
44
46
|
<p style="font-size:48px;margin:0">${ctx.output.temperatureF}°F</p>
|
|
45
|
-
<p style="margin:8px 0 0">${ctx.
|
|
47
|
+
<p style="margin:8px 0 0">${ctx.output.conditions}</p>
|
|
46
48
|
</div>
|
|
47
49
|
`,
|
|
50
|
+
// Escape any plain string result; `html` results stay markup (the default from FrontMCP 1.9)
|
|
51
|
+
escapeStringResults: true,
|
|
48
52
|
},
|
|
49
53
|
})
|
|
50
54
|
export class ShowWeatherCardTool extends ToolContext {
|
|
@@ -56,11 +60,16 @@ export class ShowWeatherCardTool extends ToolContext {
|
|
|
56
60
|
|
|
57
61
|
## What This Demonstrates
|
|
58
62
|
|
|
59
|
-
- Adding a `ui:` block with a function template
|
|
63
|
+
- Adding a `ui:` block with a function template that returns markup built with the `ctx.helpers.html` tagged template
|
|
60
64
|
- Annotating `ctx` explicitly to dodge the TS7006 inference gap on the union `ui.template` type
|
|
61
|
-
-
|
|
65
|
+
- Letting `ctx.helpers.html` escape every interpolated value so tool output can't inject markup into the widget
|
|
66
|
+
- Opting in to `escapeStringResults: true` so a plain string result is escaped — the default from FrontMCP 1.9
|
|
62
67
|
- Reading from `ctx.output` and `ctx.helpers` — the typed runtime context the template renderer hands you
|
|
63
68
|
|
|
69
|
+
## Why `html` instead of a plain template literal
|
|
70
|
+
|
|
71
|
+
A plain string a template returns is rendered as markup when it looks like HTML, so `` `<p>${ctx.output.note}</p>` `` lets tool output inject tags unless you remember `escapeHtml` on every field. `ctx.helpers.html` escapes each interpolated value for you (nested `html` values and `ctx.helpers.trustedHtml(markup)` pass through as markup), and its result renders as markup whether or not `escapeStringResults` is on. Don't also call `escapeHtml` inside `html` — the value would be escaped twice. See [`ui-widgets.md`](../references/ui-widgets.md#trusted-markup-and-escaping-template-results).
|
|
72
|
+
|
|
64
73
|
## Why annotate `ctx` explicitly
|
|
65
74
|
|
|
66
75
|
`ui.template` is a union of multiple callable shapes (`TemplateBuilderFn | string | ((props: any) => any) | FileSource`). TypeScript can't pick a single contextual type for the arrow's parameter, so `template: (ctx) => …` fails under `strict` / `noImplicitAny` with TS7006:
|
|
@@ -78,7 +87,7 @@ Two ways out:
|
|
|
78
87
|
|
|
79
88
|
- Tiny widget — a card, a table row, a status badge
|
|
80
89
|
- No state / interactivity
|
|
81
|
-
- No external CSS / fonts / scripts beyond
|
|
90
|
+
- No external CSS / fonts / scripts beyond static markup
|
|
82
91
|
|
|
83
92
|
Move to a `.tsx` FileSource widget the moment you reach for React, useState, event handlers, or anything beyond static markup.
|
|
84
93
|
|
|
@@ -86,6 +95,8 @@ Move to a `.tsx` FileSource widget the moment you reach for React, useState, eve
|
|
|
86
95
|
|
|
87
96
|
| Helper | Purpose |
|
|
88
97
|
| ------------------------------ | ------------------------------------------------------------- |
|
|
98
|
+
| `` html`…` `` | Build trusted markup; interpolated values are escaped |
|
|
99
|
+
| `trustedHtml(markup)` | Mark markup you produced or sanitized yourself as trusted |
|
|
89
100
|
| `escapeHtml(str)` | Escape HTML entities; returns `''` for null/undefined |
|
|
90
101
|
| `formatDate(date, format?)` | Locale-formatted date |
|
|
91
102
|
| `formatCurrency(amount, ccy?)` | ISO-4217 currency formatting |
|
|
@@ -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
|
-
-
|
|
9
|
+
- 'Building the markup with `ctx.helpers.html` and embedding initial data into the inline `<script>` via `trustedHtml(jsonEmbed(...))` (`jsonEmbed` escapes `<`, `>` and `&`)'
|
|
10
10
|
- 'Surfacing in-flight status via `invocationStatus.invoking` / `invoked` so the host UI shows feedback'
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -67,12 +67,14 @@ type Out = { symbol: string; priceUsd: number; asOf: string };
|
|
|
67
67
|
connectDomains: ['https://api.market.example'],
|
|
68
68
|
},
|
|
69
69
|
template: (ctx: TemplateContext<In, Out>) => {
|
|
70
|
-
const
|
|
71
|
-
|
|
70
|
+
const { html, trustedHtml, jsonEmbed } = ctx.helpers;
|
|
71
|
+
// jsonEmbed output is script-safe; trustedHtml keeps `html` from HTML-escaping its quotes
|
|
72
|
+
const initial = trustedHtml(jsonEmbed(ctx.output));
|
|
73
|
+
return html`
|
|
72
74
|
<div style="padding:16px;font-family:system-ui">
|
|
73
|
-
<h2 style="margin:0">${ctx.
|
|
75
|
+
<h2 style="margin:0">${ctx.output.symbol}</h2>
|
|
74
76
|
<p id="price" style="font-size:36px;margin:8px 0">$${ctx.output.priceUsd.toFixed(2)}</p>
|
|
75
|
-
<p id="asof" style="color:#666;margin:0">As of ${ctx.
|
|
77
|
+
<p id="asof" style="color:#666;margin:0">As of ${ctx.output.asOf}</p>
|
|
76
78
|
<button id="refresh" style="margin-top:12px;padding:8px 16px">Refresh</button>
|
|
77
79
|
<script>
|
|
78
80
|
(function () {
|
|
@@ -116,7 +118,7 @@ export class ShowQuoteTool extends ToolContext {
|
|
|
116
118
|
|
|
117
119
|
- Restricting the widget's outbound `fetch` via `ui.csp.connectDomains` (emitted on the resource per #455)
|
|
118
120
|
- Opting the widget into cross-tool calls with `widgetAccessible: true` and using `window.FrontMcpBridge.callTool(name, args)` instead of host-specific APIs
|
|
119
|
-
-
|
|
121
|
+
- Building the markup with `ctx.helpers.html` and embedding initial data into the inline `<script>` via `trustedHtml(jsonEmbed(...))` (`jsonEmbed` escapes `<`, `>` and `&`)
|
|
120
122
|
- Surfacing in-flight status via `invocationStatus.invoking` / `invoked` so the host UI shows feedback
|
|
121
123
|
|
|
122
124
|
## Why these choices
|
|
@@ -125,3 +127,4 @@ export class ShowQuoteTool extends ToolContext {
|
|
|
125
127
|
- **`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
128
|
- **`window.FrontMcpBridge.callTool` not `window.openai.callTool`** — the bridge handles host detection. `window.openai.*` works on OpenAI Apps SDK but breaks everywhere else.
|
|
127
129
|
- **`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`.
|
|
130
|
+
- **`html` with `trustedHtml(jsonEmbed(...))`** — `ctx.helpers.html` escapes the symbol and timestamp for you, so tool output can't inject markup. Inside the `<script>` the JSON must stay as-is: `html` would HTML-escape its quotes, so the script-safe `jsonEmbed` output is wrapped with `trustedHtml`. The result renders the same with `escapeStringResults: true`, which FrontMCP 1.9 makes the default.
|
|
@@ -26,15 +26,15 @@ On Linux / Windows servers, this tool simply doesn't exist — it's not in `tool
|
|
|
26
26
|
|
|
27
27
|
## Axes
|
|
28
28
|
|
|
29
|
-
| Axis | Values | Source
|
|
30
|
-
| ------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
31
|
-
| `os` | `'darwin'`, `'linux'`, `'win32'` | `process.platform` (since #417 — was previously `platform`)
|
|
32
|
-
| `runtime` | `'node'`, `'browser'`, `'edge'`, `'bun'`, `'deno'` | Detected at boot
|
|
33
|
-
| `deployment` | `'serverless'`, `'standalone'`, `'distributed'`, `'browser'` | Detected from `frontmcp.config` / env
|
|
34
|
-
| `provider` | `'bare'`, `'docker'`, `'vercel'`, `'lambda'`, `'cloudflare'`, `'netlify'`, `'azure'`, `'gcp'`, `'fly'`, `'render'`, `'railway'` | Auto-detected; override with `FRONTMCP_PROVIDER=<name>`
|
|
35
|
-
| `target` | `'cli'`, `'node'`, `'vercel'`, `'lambda'`, `'cloudflare'`, `'browser'`, `'sdk'`, `'mcpb'`, `'distributed'` | Set by `frontmcp build --target <x>`; `'unknown'` in dev
|
|
36
|
-
| `surface` | `'mcp'`, `'cli'`, `'agent'`, `'job'`, `'http-trigger'` | Per-call axis — which entry point is invoking the tool
|
|
37
|
-
| `env` | `'production'`, `'development'`, `'test'` | `process.env.NODE_ENV`
|
|
29
|
+
| Axis | Values | Source |
|
|
30
|
+
| ------------ | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
31
|
+
| `os` | `'darwin'`, `'linux'`, `'win32'` | `process.platform` (since #417 — was previously `platform`) |
|
|
32
|
+
| `runtime` | `'node'`, `'browser'`, `'edge'`, `'bun'`, `'deno'` | Detected at boot |
|
|
33
|
+
| `deployment` | `'serverless'`, `'standalone'`, `'distributed'`, `'browser'` | Detected from `frontmcp.config` / env |
|
|
34
|
+
| `provider` | `'bare'`, `'docker'`, `'vercel'`, `'lambda'`, `'cloudflare'`, `'netlify'`, `'azure'`, `'gcp'`, `'fly'`, `'render'`, `'railway'` | Auto-detected; override with `FRONTMCP_PROVIDER=<name>` |
|
|
35
|
+
| `target` | `'cli'`, `'node'`, `'vercel'`, `'lambda'`, `'cloudflare'`, `'browser'`, `'sdk'`, `'mcpb'`, `'distributed'` | Set by `frontmcp build --target <x>`; `'unknown'` in dev |
|
|
36
|
+
| `surface` | `'mcp'`, `'cli'`, `'agent'`, `'job'`, `'http-trigger'` | Per-call axis — which entry point is invoking the tool (only `'mcp'` and `'cli'` are tagged today) |
|
|
37
|
+
| `env` | `'production'`, `'development'`, `'test'` | `process.env.NODE_ENV` |
|
|
38
38
|
|
|
39
39
|
## Semantics
|
|
40
40
|
|
|
@@ -100,6 +100,8 @@ These are fine for ergonomic branching. For tools that **shouldn't exist at all*
|
|
|
100
100
|
|
|
101
101
|
This is the safest way to expose internal-only tools that you want an agent / job to call but don't want a user to invoke from a chat UI.
|
|
102
102
|
|
|
103
|
+
An MCP client (and the in-process client of a CLI build, surface `'cli'`) never sees such a tool: it is absent from `tools/list`, and `tools/call` answers `Tool "rotate_secrets" not found`, exactly as for a tool that doesn't exist. Resources, resource templates, prompts, agents and skills (including the skills HTTP endpoints, which count as `'mcp'`) follow the same rule, and CodeCall applies its caller's surface to the tools it reaches. In-process dispatch (`this.callTool()`, an agent's own tools) carries no surface and is not restricted. `'agent'`, `'job'` and `'http-trigger'` are reserved: nothing tags them yet, so agents, jobs and HTTP triggers (all in-process) pass every `surface` check. The process-wide axes (`os`, `runtime`, ...) answer `EntryUnavailableError` instead.
|
|
104
|
+
|
|
103
105
|
## See also
|
|
104
106
|
|
|
105
107
|
- [`21-tool-with-availability-constraints`](../examples/21-tool-with-availability-constraints.md)
|
|
@@ -38,15 +38,16 @@ description: What ToolContext provides at runtime — this.get, this.fetch, this
|
|
|
38
38
|
|
|
39
39
|
## `this.context` (FrontMcpContext)
|
|
40
40
|
|
|
41
|
-
| Property
|
|
42
|
-
|
|
|
43
|
-
| `requestId`
|
|
44
|
-
| `sessionId`
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
41
|
+
| Property | Type | Description |
|
|
42
|
+
| ------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
43
|
+
| `requestId` | `string` | Unique ID for this request |
|
|
44
|
+
| `sessionId` | `string` | Session identifier (for stateful transports) |
|
|
45
|
+
| `verifiedSessionId` | `string \| undefined` | `sessionId` when the server verified that session; `undefined` for a stateless request (shared stateless id, or the per-request id of a request without `mcp-session-id`). Key per-session state on it and fall back to the authenticated principal |
|
|
46
|
+
| `scopeId` | `string` | Scope identifier (for multi-app servers) |
|
|
47
|
+
| `authInfo` | `AuthInfo` | Raw validated access token — `token`, `clientId`, `scopes`, `expiresAt?`, `resource?`, `extra?`. For user identity / JWT claims use `this.auth` (`this.auth.user.sub`, `this.auth.claims['…']`) instead. |
|
|
48
|
+
| `traceContext` | `TraceContext` | Distributed-tracing context (propagated to `this.fetch` automatically) |
|
|
49
|
+
| `timestamp` | `number` | Request start timestamp |
|
|
50
|
+
| `metadata` | `RequestMetadata` | Request headers, client IP, MCP client name/version |
|
|
50
51
|
|
|
51
52
|
## DI: `this.get` vs `this.tryGet`
|
|
52
53
|
|
|
@@ -104,7 +104,7 @@ const ShowCard = tool({
|
|
|
104
104
|
inputSchema: { text: z.string() },
|
|
105
105
|
outputSchema: { text: z.string() },
|
|
106
106
|
ui: {
|
|
107
|
-
template: (ctx) => `<div>${ctx.
|
|
107
|
+
template: (ctx) => ctx.helpers.html`<div>${ctx.output.text}</div>`,
|
|
108
108
|
},
|
|
109
109
|
})((input) => ({ text: input.text }));
|
|
110
110
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ui-widgets
|
|
3
|
-
description: @Tool({ ui }) — template formats, servingMode, host-detect resourceMode, CSP, widgetAccessible, MCP Apps spec.
|
|
3
|
+
description: @Tool({ ui }) — template formats, trusted markup (html / escapeStringResults), servingMode, host-detect resourceMode, CSP, widgetAccessible, MCP Apps spec.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Tool UI widgets
|
|
@@ -42,7 +42,7 @@ That's it. The framework:
|
|
|
42
42
|
| Format | Shape | When |
|
|
43
43
|
| ---------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
44
44
|
| **FileSource (recommended)** | `{ file: widgetPath }` | `.tsx` / `.jsx` / `.html` source files. Anchor with `import.meta.url`. |
|
|
45
|
-
| **Function** |
|
|
45
|
+
| **Function** | `` (ctx) => ctx.helpers.html`…` `` | Quick demo / one-liner HTML. Annotate `ctx: TemplateContext<In, Out>` ([why](#typescript-gotcha-ts7006)). |
|
|
46
46
|
| **HTML / MDX string** | `'<div>…</div>'` or `'# Title\n<Card />'` | Static markup; pair with `mdxComponents` for MDX. |
|
|
47
47
|
| **React component** | `MyWidget` | SSR React. Set `hydrate: false` (default) for Claude/ChatGPT. |
|
|
48
48
|
|
|
@@ -56,8 +56,7 @@ Inline `template: (ctx) => …` under `strict` fails with `Parameter 'ctx' impli
|
|
|
56
56
|
import { type TemplateContext } from '@frontmcp/sdk';
|
|
57
57
|
|
|
58
58
|
ui: {
|
|
59
|
-
template: (ctx: TemplateContext<MyInput, MyOutput>) =>
|
|
60
|
-
`<div>${ctx.helpers.escapeHtml(ctx.output.label)}</div>`,
|
|
59
|
+
template: (ctx: TemplateContext<MyInput, MyOutput>) => ctx.helpers.html`<div>${ctx.output.label}</div>`,
|
|
61
60
|
}
|
|
62
61
|
```
|
|
63
62
|
|
|
@@ -77,6 +76,7 @@ Or use the FileSource form — it sidesteps the issue.
|
|
|
77
76
|
| `autoResize` | `true` | Auto-report content height to the host via a debounced `ResizeObserver` on `#root`. Set `false` to opt out (CSS still applies). |
|
|
78
77
|
| `csp` | — | `{ connectDomains?, resourceDomains? }` — emitted on the resource content's `_meta.ui.csp` (#455). Claude honors CSP only here. |
|
|
79
78
|
| `contentSecurity` | strict | `{ allowUnsafeLinks?, allowInlineScripts?, bypassSanitization? }` — keep defaults. |
|
|
79
|
+
| `escapeStringResults` | unset | `true` escapes plain string results of a template function; `html` / `trustedHtml` stay markup. Default in 1.9. |
|
|
80
80
|
| `widgetAccessible` | `false` | `true` exposes `window.FrontMcpBridge.callTool` in the widget. |
|
|
81
81
|
| `resourceUri` | auto | Override the `ui://widget/{toolName}.html` URI. |
|
|
82
82
|
| `uiType` | `'auto'` | Force `'html'` / `'react'` / `'mdx'` / `'markdown'`. |
|
|
@@ -92,11 +92,36 @@ Or use the FileSource form — it sidesteps the issue.
|
|
|
92
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
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
94
|
|
|
95
|
-
##
|
|
95
|
+
## Trusted markup and escaping template results
|
|
96
96
|
|
|
97
|
-
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
Build function-template markup with the `ctx.helpers.html` tagged template. Literal parts stay markup; every interpolated value is HTML-escaped unless it is itself trusted markup:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
template: (ctx: TemplateContext<In, Out>) => {
|
|
101
|
+
const { html } = ctx.helpers;
|
|
102
|
+
return html`
|
|
103
|
+
<h2>${ctx.output.title}</h2>
|
|
104
|
+
<ul>${ctx.output.items.map((item) => html`<li>${item.name}</li>`)}</ul>
|
|
105
|
+
`;
|
|
106
|
+
},
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- Nested `html` values are never escaped twice; arrays are joined without a separator; `null` / `undefined` / `false` render nothing. Don't pre-escape with `escapeHtml` inside `html` (double escaping). Quote attribute values; escaping doesn't validate `href` / `src` URLs.
|
|
110
|
+
- `ctx.helpers.trustedHtml(markup)` marks markup you produced or sanitized yourself as trusted. Never wrap raw tool output or user input.
|
|
111
|
+
- In an inline `<script>`, embed data with `${trustedHtml(jsonEmbed(data))}` — `jsonEmbed` writes `<`, `>`, `&`, U+2028 and U+2029 as `\uXXXX`, so it is safe in a script, and `html` would otherwise HTML-escape its quotes.
|
|
112
|
+
- `html`, `trustedHtml`, `isTrustedHtml` and `TrustedHtml` are also exported from `@frontmcp/uipack`; `TrustedHtml` is re-exported from `@frontmcp/sdk`.
|
|
113
|
+
|
|
114
|
+
A **plain string** a template function returns is rendered as markup when it looks like HTML — `template: (ctx) => ctx.output` renders any tags in the output. `escapeStringResults` opts in to escaping it:
|
|
115
|
+
|
|
116
|
+
| `escapeStringResults` | Plain string result | `html` / `trustedHtml` result |
|
|
117
|
+
| --------------------- | --------------------------------------------------- | ----------------------------- |
|
|
118
|
+
| unset (1.8 default) | Rendered as markup; one-time notice logged per tool | Markup |
|
|
119
|
+
| `true` | Escaped, shown as text | Markup |
|
|
120
|
+
| `false` | Rendered as markup, no notice | Markup |
|
|
121
|
+
|
|
122
|
+
Set it per tool (`ui: { escapeStringResults: true }`) or server-wide (`@FrontMcp({ ui: { escapeStringResults: true } })`; the tool setting wins). **FrontMCP 1.9 escapes plain string results by default** — return `html` / `trustedHtml` from every template function and set `escapeStringResults: true` to migrate now.
|
|
123
|
+
|
|
124
|
+
Everything else is always 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. A static string template (`template: '<div>…</div>'`) is author markup and is never escaped.
|
|
100
125
|
|
|
101
126
|
## Path resolution gotcha (#444)
|
|
102
127
|
|
|
@@ -132,7 +157,7 @@ When the widget needs to read tool data or invoke other tools, the bridge IIFE i
|
|
|
132
157
|
|
|
133
158
|
```typescript
|
|
134
159
|
ui: {
|
|
135
|
-
template: (ctx) => `
|
|
160
|
+
template: (ctx) => ctx.helpers.html`
|
|
136
161
|
<button id="refresh">Refresh</button>
|
|
137
162
|
<script>
|
|
138
163
|
document.getElementById('refresh').onclick = async () => {
|
|
@@ -108,7 +108,7 @@ npm install @frontmcp/ui react react-dom
|
|
|
108
108
|
|
|
109
109
|
### React hooks + wrapper + client mount
|
|
110
110
|
|
|
111
|
-
- **`useAuthFlow()`** — the flow-state fields above plus a `submitFinish` handler. `<form onSubmit={submitFinish}>` `preventDefault`s, serializes the form, attaches `pending_auth_id` + `csrf` + the slot marker,
|
|
111
|
+
- **`useAuthFlow()`** — the flow-state fields above plus a `submitFinish` handler. `<form onSubmit={submitFinish}>` `preventDefault`s, serializes the form, attaches `pending_auth_id` + `csrf` + the slot marker, and submits it to the callback as a real form navigation, so the browser follows the OAuth redirect.
|
|
112
112
|
- **`useExtraField(name)`** — `{ onSubmit, result, pending }` for an `auth.extras` form. On success it merges the returned `addedItems` back into context.
|
|
113
113
|
- **`useAddedItems(name)`** — the server-side accumulator for a named extra, reactively.
|
|
114
114
|
- **`<AuthPageWrapper>`** — outer chrome that reads the injected state once, provides it via context, and (by default) renders the enclosing `<form>` with the `pending_auth_id` + `csrf` hidden fields so a no-JS submit still works. Pass `renderForm={false}` to supply your own forms.
|
|
@@ -131,7 +131,7 @@ There is **no `/oauth/ui/:slot.js` route** — the component is transpiled serve
|
|
|
131
131
|
## Security — the framework owns it
|
|
132
132
|
|
|
133
133
|
- **CSRF**: the server mints a per-pending-authorization token, stores it (echoed into `csrfToken`), and verifies it on the finish submit and every `auth.extras` POST with a constant-time compare. Your component never generates or checks it.
|
|
134
|
-
- **CSP + anti-clickjacking**: the auth-UI HTML ships with a strict CSP — `default-src 'self'; script-src 'self' 'unsafe-inline' https://esm.sh; connect-src 'self' https://esm.sh; style-src 'self' 'unsafe-inline' https://esm.sh; img-src 'self' data: https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'` — plus `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-
|
|
134
|
+
- **CSP + anti-clickjacking**: the auth-UI HTML ships with a strict CSP — `default-src 'self'; script-src 'self' 'unsafe-inline' https://esm.sh; connect-src 'self' https://esm.sh; style-src 'self' 'unsafe-inline' https://esm.sh; img-src 'self' data: https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' <redirect origin>` (the validated `redirect_uri`'s origin, plus the upstream authorization endpoints on the provider-selection page) — plus `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin` (a POST of the form carries its `Origin`, which the callback checks), `Cache-Control: no-store`. The finish submit is a POST (`submitMethod: 'POST'`). It allows `https://esm.sh` (deps) + `'unsafe-inline'` (the JSON-escaped state script) but **NOT `'unsafe-eval'`** — the transform is server-side, never in the browser.
|
|
135
135
|
- **No PII**: the injected state carries OAuth client identifiers + control fields only.
|
|
136
136
|
- **Fail-safe**: a component that can't be transpiled (missing / invalid `.tsx`) is logged (error cached so a broken file isn't retried each request) and **falls back to the built-in page** — a broken custom page can't take the server down.
|
|
137
137
|
|
|
@@ -208,8 +208,9 @@ export default class SensitiveActionTool extends ToolContext { ... }
|
|
|
208
208
|
|
|
209
209
|
For dynamic, async authorization that does not warrant a reusable custom evaluator, use the
|
|
210
210
|
`guards` field. Each guard receives the same `AuthoritiesEvaluationContext` and returns
|
|
211
|
-
`true` on grant, or `false`/a denial string on deny.
|
|
212
|
-
|
|
211
|
+
`true` on grant, or `false`/a denial string on deny. Only `true` grants: anything else
|
|
212
|
+
(`undefined` from a guard that forgot to `return`, `null`, `0`, an object) denies. Guards run
|
|
213
|
+
in sequence and combine with other policy fields via `operator` (default AND).
|
|
213
214
|
|
|
214
215
|
```typescript
|
|
215
216
|
import type { AuthorityGuardFn } from '@frontmcp/auth';
|
|
@@ -293,8 +294,9 @@ authorities: {
|
|
|
293
294
|
|
|
294
295
|
- **Deny on load/read** — loading a gated skill the caller can't access throws
|
|
295
296
|
`AuthorityDeniedError` (MCP code `-32003`), the same as a denied `tools/call`.
|
|
296
|
-
Covers `skills/load` (MCP)
|
|
297
|
-
reads (SEP-2640),
|
|
297
|
+
Covers `skills/load` (MCP) and `skill://<path>/SKILL.md` and `skill://<path>/<file>`
|
|
298
|
+
reads (SEP-2640). Over HTTP, `GET /skills/{id}` answers 404 for such a skill, as
|
|
299
|
+
for an unknown one.
|
|
298
300
|
- **Filter on discovery** — gated skills the caller can't access are removed from
|
|
299
301
|
`skills/search` / `skills/list` (MCP), the `skill://index.json` discovery index and
|
|
300
302
|
skill-path autocomplete (SEP-2640), and `GET /skills` (HTTP).
|
|
@@ -312,14 +314,34 @@ Two limitations to design around:
|
|
|
312
314
|
filtering and will hide the skill from discovery. Use role/permission/claims
|
|
313
315
|
authorities for discoverable skills; input-dependent policies still enforce at
|
|
314
316
|
load time. (Same limitation applies to tools/resources/prompts.)
|
|
315
|
-
- **HTTP skills discovery
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
317
|
+
- **HTTP skills discovery follows `skillsConfig.auth`.** With `'inherit'` (the default),
|
|
318
|
+
`GET /skills`, `/llm.txt` and `/llm_full.txt` run the server's own auth, and gated
|
|
319
|
+
skills are evaluated against the caller it verified. `'api-key'`, `'bearer'` and
|
|
320
|
+
`'public'` surface no claims, so there gated skills are left out of every listing and
|
|
321
|
+
`GET /skills/{id}` answers 404 for them. Ungated skills are unaffected.
|
|
319
322
|
|
|
320
323
|
Boot-time fail-fast covers skills too: a `@Skill` with `authorities` but no configured
|
|
321
324
|
authorities engine fails server startup with `AuthConfigurationError`, exactly like a
|
|
322
|
-
tool
|
|
325
|
+
tool, resource, resource template, prompt, agent, or a tool declared inside an agent
|
|
326
|
+
(hidden entries included).
|
|
327
|
+
|
|
328
|
+
### Rules that check nothing are refused
|
|
329
|
+
|
|
330
|
+
Startup fails with `AuthConfigurationError: Invalid authorities rule: …` when an entry's or a
|
|
331
|
+
profile's rule checks nothing or isn't what it looks like, and `AuthoritiesEngine.evaluate()`
|
|
332
|
+
denies such a rule if it runs anyway (even under `not`):
|
|
333
|
+
|
|
334
|
+
- `{}`, `{ roles: {} }`, `{ roles: { all: [] } }`, `allOf: []`, `guards: []`
|
|
335
|
+
- a misspelled field (`role:`), a profile name inside `allOf`/`anyOf`, an `operator` other than `'AND'`/`'OR'`
|
|
336
|
+
- an ABAC condition with no `value`, or one the operator can't use: `exists` needs `true`/`false`;
|
|
337
|
+
`in`/`notIn` a non-empty list; `gt`/`gte`/`lt`/`lte` a number; `startsWith`/`endsWith`/`matches`
|
|
338
|
+
a string (a `{ fromInput }` / `{ fromClaims }` reference works for all but `exists`)
|
|
339
|
+
|
|
340
|
+
To leave an entry open, remove its `authorities`. There is no opt-out.
|
|
341
|
+
|
|
342
|
+
`@Agent({ authorities })` gates the agent's `invoke_<id>` tool like any tool (hidden from
|
|
343
|
+
`tools/list`, refused on `tools/call`), and tools declared inside an agent are checked against
|
|
344
|
+
the caller the agent runs for, also with `execution.useToolFlow: false`.
|
|
323
345
|
|
|
324
346
|
## Scenario Routing Table
|
|
325
347
|
|
|
@@ -253,7 +253,7 @@ The authorities system does not only enforce on execution. The built-in `filterB
|
|
|
253
253
|
- `tools/list` only returns tools the current user is authorized to call
|
|
254
254
|
- `resources/list` only returns resources the current user can read
|
|
255
255
|
- `prompts/list` only returns prompts the current user can get
|
|
256
|
-
- Skills are filtered on every discovery surface: `skills/search` / `skills/list`, the SEP-2640 `skill://index.json` index + skill-path autocomplete, and `GET /skills`. Loading a gated skill the caller can't access
|
|
256
|
+
- Skills are filtered on every discovery surface: `skills/search` / `skills/list`, the SEP-2640 `skill://index.json` index + skill-path autocomplete, and `GET /skills`. Loading a gated skill the caller can't access via `skills/load` or a `skill://…` read is denied with `AuthorityDeniedError` (`-32003`); over HTTP, `GET /skills/{id}` answers 404, as for an unknown skill.
|
|
257
257
|
|
|
258
258
|
This filtering happens automatically. No additional configuration is needed. Entries without an `authorities` field are always visible.
|
|
259
259
|
|
|
@@ -265,11 +265,17 @@ time, where input is available). For entries (especially **skills**) that must r
|
|
|
265
265
|
discoverable, gate them with role/permission/claims-based authorities such as
|
|
266
266
|
`authorities: 'admin'` or `{ roles: { any: ['admin'] } }`.
|
|
267
267
|
|
|
268
|
-
**HTTP skills discovery
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
skills without `authorities` are served over
|
|
268
|
+
**HTTP skills discovery follows `skillsConfig.auth`.** With `'inherit'` (the default),
|
|
269
|
+
`GET /skills`, `/llm.txt` and `/llm_full.txt` run the server's own auth, and gated skills
|
|
270
|
+
are evaluated against the caller it verified. `'api-key'`, `'bearer'` and `'public'`
|
|
271
|
+
surface no claims, so there authority-gated skills are left out of every listing and
|
|
272
|
+
`GET /skills/{id}` answers 404 for them; skills without `authorities` are served over
|
|
273
|
+
HTTP unchanged.
|
|
274
|
+
|
|
275
|
+
**A profile must check something.** A profile whose rule checks nothing (`{}`,
|
|
276
|
+
`{ roles: { all: [] } }`, a misspelled field, a profile name inside `anyOf`, an ABAC
|
|
277
|
+
condition without a usable `value`) fails startup with `Invalid authorities rule: profile
|
|
278
|
+
"<name>": …`, and the engine denies it.
|
|
273
279
|
|
|
274
280
|
## Profile Design Guidelines
|
|
275
281
|
|
|
@@ -163,6 +163,8 @@ interface AbacCondition {
|
|
|
163
163
|
|
|
164
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
165
|
|
|
166
|
+
Every condition needs a `value` the operator can use: `exists` takes `true` or `false`, `in`/`notIn` a non-empty list, `gt`/`gte`/`lt`/`lte` a number, and `startsWith`/`endsWith`/`matches` a string; a `{ fromInput }` / `{ fromClaims }` reference works for all but `exists`. A condition without one (for example `{ path: 'user.sub', op: 'exists' }`, which would admit every caller without a `sub`) fails startup with `Invalid authorities rule`, and the engine denies it.
|
|
167
|
+
|
|
166
168
|
### Dynamic Value References
|
|
167
169
|
|
|
168
170
|
Instead of hardcoding values, reference runtime data from tool input or JWT claims.
|
|
@@ -8,6 +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
|
+
- 'The catalog only names skills the initializing caller may see (skill authorities and the `skills:filter` flow, e.g. feature flags)'
|
|
11
12
|
- 'Catalog summary is bounded at 16 KB with a truncation footer pointing at skill://index.json'
|
|
12
13
|
---
|
|
13
14
|
|
|
@@ -51,6 +52,7 @@ export default class FlightBotServer {}
|
|
|
51
52
|
- Top-level `instructions` on `@FrontMcp` exposes a global system prompt to MCP clients
|
|
52
53
|
- `skillsConfig.injectInstructions: 'append'` adds the skill catalog summary after the user prompt
|
|
53
54
|
- Dynamic skills are picked up because the composer runs on every initialize request
|
|
55
|
+
- The catalog only names skills the initializing caller may see (skill authorities and the `skills:filter` flow, e.g. feature flags)
|
|
54
56
|
- Catalog summary is bounded at 16 KB with a truncation footer pointing at skill://index.json
|
|
55
57
|
|
|
56
58
|
## Related
|
|
@@ -86,7 +86,7 @@ Signing is **HS256 with a symmetric `JWT_SECRET`** (no key pair). Set a stable `
|
|
|
86
86
|
|
|
87
87
|
Local mode also accepts `allowDefaultPublic` (default `false` — set `true` to admit tokenless requests as anonymous instead of returning 401), `anonymousScopes` (default `['anonymous']` — scopes for those anonymous sessions), and `expectedAudience` (reject tokens minted for a different `aud`).
|
|
88
88
|
|
|
89
|
-
> **Client registration (security):**
|
|
89
|
+
> **Client registration (security):** `requireRegisteredClients` defaults to `true` (local/remote): every client must be registered (DCR / `dcr.clients`) or a CIMD client-id URL, so `redirect_uri` is exact-matched (OAuth 2.1) — this prevents auth-code interception via an attacker-chosen redirect. Set it to `false` only for local development. The server grants only the scopes in `allowedScopes` (default: the OpenID scopes). Confidential clients (`token_endpoint_auth_method: client_secret_basic`/`client_secret_post`) are authenticated with a constant-time `client_secret` check on both the code-exchange and refresh grants (Basic header or body param).
|
|
90
90
|
|
|
91
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).
|
|
92
92
|
|
|
@@ -141,6 +141,8 @@ Endpoints are derived from `provider` using standard OIDC paths
|
|
|
141
141
|
non-standard IdPs, override them with
|
|
142
142
|
`providerConfig.{authEndpoint,tokenEndpoint,userInfoEndpoint,jwksUri}`.
|
|
143
143
|
|
|
144
|
+
> **MCP client registration (upgrade note):** `requireRegisteredClients` defaults to `true` in remote mode too (it was `false` in 1.8.2 and earlier). Remote mode has no `dcr` block (no pre-registered clients) and FrontMCP's own `/oauth/register` is off in production, so a production remote server with the defaults admits only MCP clients that use a CIMD client-id URL; an unregistered plain `client_id` gets a 400 `Unknown client_id` page. Migrate clients to CIMD, or set `requireRegisteredClients: false` for local development only (an unregistered client's `redirect_uri` can't be checked). FrontMCP grants only the scopes in `allowedScopes` (default: the OpenID scopes), unrelated to `scopes` (what it asks the IdP for).
|
|
145
|
+
|
|
144
146
|
**Deferred (not yet wired):** upstream **Dynamic Client Registration**
|
|
145
147
|
(`providerConfig.dcrEnabled` / `registrationEndpoint`) — a pre-registered
|
|
146
148
|
`clientId` is required; and upstream **token auto-refresh** — once the upstream
|
|
@@ -140,7 +140,9 @@ Declare upstream providers to make multi-provider orchestration a turnkey local
|
|
|
140
140
|
class Server {}
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
-
`UpstreamProviderOptions` fields: `id` (required; used in `getToken(id)`), `authorizationEndpoint`/`authorizeUrl` (required), `tokenEndpoint`/`tokenUrl` (required), `clientId` (required), `clientSecret?`, `scopes?`, `name?`, `userInfoEndpoint?`, `jwksUri?`. The per-provider callback URL is auto-computed as `${issuer}/oauth/provider/${id}/callback` — register that URL with each provider.
|
|
143
|
+
`UpstreamProviderOptions` fields: `id` (required; used in `getToken(id)`), `authorizationEndpoint`/`authorizeUrl` (required), `tokenEndpoint`/`tokenUrl` (required), `clientId` (required), `clientSecret?`, `scopes?`, `name?`, `userInfoEndpoint?`, `jwksUri?`, `issuer?`, `additionalIssuers?`. The per-provider callback URL is auto-computed as `${issuer}/oauth/provider/${id}/callback` — register that URL with each provider.
|
|
144
|
+
|
|
145
|
+
The provider's identity comes from its `id_token` only when it verifies against `jwksUri` with `iss` = the provider's `issuer` (or `additionalIssuers`), `aud` containing `clientId`, and a valid `exp`. A provider with no `issuer` never has its `id_token` used (one IdP key set can sign many tenants' tokens); FrontMCP asks `userInfoEndpoint` instead, and with neither the sign-in fails. With `issuer` set, a callback whose RFC 9207 `iss` names another server gets a 400. Set `issuer` for OIDC providers.
|
|
144
146
|
|
|
145
147
|
Tools read downstream tokens through the `this.orchestration` context extension (available in `local`/`remote` mode):
|
|
146
148
|
|
|
@@ -474,13 +476,14 @@ If the vault is not configured, accessing `this.authProviders` throws (`AuthProv
|
|
|
474
476
|
|
|
475
477
|
## Troubleshooting
|
|
476
478
|
|
|
477
|
-
| Problem
|
|
478
|
-
|
|
|
479
|
-
| `JWKS fetch failed` error on startup
|
|
480
|
-
| Tokens rejected with `invalid audience`
|
|
481
|
-
| Sessions lost after server restart
|
|
482
|
-
| Local-mode tokens invalid after restart
|
|
483
|
-
| OAuth redirect fails in local dev
|
|
479
|
+
| Problem | Cause | Solution |
|
|
480
|
+
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
481
|
+
| `JWKS fetch failed` error on startup | The `provider` URL is unreachable or does not serve `/.well-known/jwks.json` | Verify the provider URL is correct and accessible from the server; check network/firewall rules |
|
|
482
|
+
| Tokens rejected with `invalid audience` | The `expectedAudience` value does not match the `aud` claim in the token | Align the `expectedAudience` config with the audience value your identity provider sets in tokens |
|
|
483
|
+
| Sessions lost after server restart | Using the default in-memory session store in production | Switch to Redis or Vercel KV session store via `configure-session` reference |
|
|
484
|
+
| Local-mode tokens invalid after restart | `JWT_SECRET` unset (random per-process secret) and/or `tokenStorage: 'memory'` | Set a stable `JWT_SECRET`; use `tokenStorage: { sqlite: { path } }` or `{ redis }` to persist codes/refresh tokens |
|
|
485
|
+
| OAuth redirect fails in local dev | `remote` mode requires HTTPS and reachable callback URLs | Set `NODE_ENV=development` to relax HTTPS requirements, or use a local OAuth mock server |
|
|
486
|
+
| Sign-in refused: "started in another browser" | The sign-in binding cookie is missing: `frontmcp_signin_<id>`, or `__Host-frontmcp_signin_<id>` over https (only that name counts there, so a sibling subdomain cannot plant it) | Start and finish the sign-in in one browser with cookies on, at the issuer's host; behind a proxy, pin `FRONTMCP_PUBLIC_URL` and forward `x-forwarded-proto` on every request |
|
|
484
487
|
|
|
485
488
|
## Examples
|
|
486
489
|
|
|
@@ -68,6 +68,8 @@ The catalog summary is built by `composeInitializeInstructions(...)` and `buildS
|
|
|
68
68
|
|
|
69
69
|
> **Dynamic skills:** because the composer recomputes the summary on every `initialize` request, skills registered after server boot **are** picked up automatically.
|
|
70
70
|
|
|
71
|
+
> **Per caller:** the summary (and the SEP-2640 `skill://` hints under `sep2640InInstructions`) is composed for the client that initializes, like `skills/list`: it only names skills whose `authorities` that caller satisfies and that the hookable `skills:filter` flow keeps, so a flag-disabled skill's name and description are left out. With nothing gating a skill the instructions are unchanged. Transports use `composeCallerInstructions(scope, { ctx })`, exported from `@frontmcp/sdk` for custom transports.
|
|
72
|
+
|
|
71
73
|
## Skills HTTP Authentication
|
|
72
74
|
|
|
73
75
|
```typescript
|
|
@@ -96,6 +98,19 @@ explicitly to opt out of authentication; use `'api-key'` or `'bearer'` to
|
|
|
96
98
|
override the inherited policy with a Skills-specific one. **In production,
|
|
97
99
|
set `auth` explicitly so the policy is visible at the call site.**
|
|
98
100
|
|
|
101
|
+
With `'inherit'`, `/skills`, `/llm.txt` and `/llm_full.txt` need the same credential
|
|
102
|
+
as the MCP endpoint (401/403 otherwise; only a public-mode server lets everyone in),
|
|
103
|
+
and skills with `authorities` are listed only for a caller whose verified claims
|
|
104
|
+
satisfy them. The other modes surface no claims, so gated skills are never served
|
|
105
|
+
over HTTP there, and `GET /skills/<gated id>` answers 404.
|
|
106
|
+
|
|
107
|
+
Custom routes that guard skills content should call `authorizeSkillHttpRequest(scope,
|
|
108
|
+
skillsConfig, request)` from `@frontmcp/sdk`, which covers every mode.
|
|
109
|
+
`createSkillHttpAuthValidator()` is only for an explicit `'api-key'` or `'bearer'`
|
|
110
|
+
(it returns `null` only for `'public'`); its validator only sees headers, so it
|
|
111
|
+
refuses every request under `'inherit'` or an unset `auth`, and code that treated
|
|
112
|
+
`null` as "no auth needed" must switch to `authorizeSkillHttpRequest`.
|
|
113
|
+
|
|
99
114
|
## Skills HTTP Caching
|
|
100
115
|
|
|
101
116
|
```typescript
|
|
@@ -27,6 +27,13 @@ short AgentScript program in the Worker isolate, where each `callTool(actionId,
|
|
|
27
27
|
input)` invokes a loaded skill's operation. The bundle is pulled from a SaaS
|
|
28
28
|
endpoint, cached in KV, and refreshed on a Cron Trigger.
|
|
29
29
|
|
|
30
|
+
The meta-tools only show what the caller may use: a skill whose
|
|
31
|
+
`requiredAuthorities` the caller doesn't satisfy is left out of `search_skill`
|
|
32
|
+
(and its catalog) and is `SKILL_NOT_FOUND` for `load_skill`, and `load_skill`
|
|
33
|
+
leaves out actions the caller can never run. When the server configures
|
|
34
|
+
`authorities`, bundle rules are evaluated with the server's engine (its
|
|
35
|
+
`claimsMapping`, resolvers and custom evaluators).
|
|
36
|
+
|
|
30
37
|
For the conceptual picture, see [Skills-Only Deployment](https://docs.agentfront.dev/frontmcp/features/skills-only-deployment).
|
|
31
38
|
For the production-ready decorator build, see [`deploy-to-cloudflare.md`](./deploy-to-cloudflare.md).
|
|
32
39
|
|
|
@@ -53,6 +60,8 @@ For the production-ready decorator build, see [`deploy-to-cloudflare.md`](./depl
|
|
|
53
60
|
|
|
54
61
|
```ts
|
|
55
62
|
// worker.ts — the real API is createEdgeMcp (not createWorker)
|
|
63
|
+
import { env } from 'cloudflare:workers';
|
|
64
|
+
|
|
56
65
|
import { createEdgeMcp, kvBundleCacheFromEnv } from '@frontmcp/edge';
|
|
57
66
|
|
|
58
67
|
export default createEdgeMcp({
|
|
@@ -61,7 +70,10 @@ export default createEdgeMcp({
|
|
|
61
70
|
tasks: { enabled: false },
|
|
62
71
|
managed: {
|
|
63
72
|
endpoint: 'https://cloud.example.com/v1/bundles/acme',
|
|
64
|
-
|
|
73
|
+
// The pull JWT the SaaS issued for this server (iss = expectedIssuer, aud includes
|
|
74
|
+
// expectedAudience, with exp), kept in a Worker secret:
|
|
75
|
+
// npx wrangler secret put FRONTMCP_PULL_TOKEN
|
|
76
|
+
authToken: env.FRONTMCP_PULL_TOKEN,
|
|
65
77
|
expectedAudience: 'acme-mcp',
|
|
66
78
|
jwksUrl: 'https://cloud.example.com/.well-known/jwks.json',
|
|
67
79
|
expectedIssuer: 'https://cloud.example.com',
|
|
@@ -79,6 +91,15 @@ The pull sends `authToken` as a bearer token and never follows a redirect, so
|
|
|
79
91
|
`endpoint` must serve the bundle directly; a 3xx (or a status-0
|
|
80
92
|
`opaqueredirect`) fails the pull.
|
|
81
93
|
|
|
94
|
+
Before every pull, `authToken` itself is verified: it must be a JWT signed by a
|
|
95
|
+
key served at `jwksUrl` (fetched without the token, redirects refused), with
|
|
96
|
+
`iss` equal to `expectedIssuer`, an `aud` that includes `expectedAudience` (and
|
|
97
|
+
a `resource` claim that does too, when it has one), and not expired. A token
|
|
98
|
+
that fails is refused with `[saas-source] pull token rejected: …`: nothing is
|
|
99
|
+
pulled, and the KV cache is **not** used in its place. An unreachable JWKS, or
|
|
100
|
+
one with no usable signing key (no RSA, EC or OKP public key for signatures),
|
|
101
|
+
counts as an ordinary pull failure, so the cache fallback still applies.
|
|
102
|
+
|
|
82
103
|
This path is bundled by **wrangler** (not `frontmcp build`), so you maintain
|
|
83
104
|
`wrangler.toml` yourself — it needs a `[[kv_namespaces]] binding = "BUNDLE_CACHE"`
|
|
84
105
|
and a `[triggers] crontabs = [...]` (the `managed.pollIntervalMs` option is
|
package/catalog/frontmcp-development/examples/openapi-adapter/authenticated-adapter-with-polling.md
CHANGED
|
@@ -20,8 +20,11 @@ Demonstrates configuring authentication (API key and bearer token) and automatic
|
|
|
20
20
|
|
|
21
21
|
```typescript
|
|
22
22
|
// src/server.ts
|
|
23
|
-
import { FrontMcp, App } from '@frontmcp/sdk';
|
|
24
23
|
import { OpenapiAdapter } from '@frontmcp/adapters';
|
|
24
|
+
import { App, FrontMcp } from '@frontmcp/sdk';
|
|
25
|
+
|
|
26
|
+
// Your code: returns a token issued for the upstream API (secrets manager, token exchange, ...)
|
|
27
|
+
declare function getEvolvingApiToken(ctx: unknown): Promise<string>;
|
|
25
28
|
|
|
26
29
|
@App({
|
|
27
30
|
name: 'integrations',
|
|
@@ -51,8 +54,9 @@ import { OpenapiAdapter } from '@frontmcp/adapters';
|
|
|
51
54
|
name: 'evolving-api',
|
|
52
55
|
url: 'https://api.example.com/openapi.json',
|
|
53
56
|
baseUrl: 'https://api.example.com',
|
|
54
|
-
securityResolver: (tool, ctx) => {
|
|
55
|
-
|
|
57
|
+
securityResolver: async (tool, ctx) => {
|
|
58
|
+
// A credential issued for this API (never the caller's own ctx.authInfo.token)
|
|
59
|
+
return { jwt: await getEvolvingApiToken(ctx) };
|
|
56
60
|
},
|
|
57
61
|
polling: {
|
|
58
62
|
intervalMs: 300000, // Re-fetch spec every 5 minutes
|
|
@@ -190,11 +190,13 @@ Both `@Will` and `@Did` (and `@Around`) accept an optional options object:
|
|
|
190
190
|
@Will('execute', {
|
|
191
191
|
priority: 10, // Lower runs first (default: 0)
|
|
192
192
|
filter: (ctx) => ctx.toolName !== 'health_check', // Predicate to skip
|
|
193
|
+
appliesTo: 'own-app', // Reach of an app plugin's hook (default: 'own-app')
|
|
193
194
|
})
|
|
194
195
|
```
|
|
195
196
|
|
|
196
197
|
- **priority** (`number`) - Execution order when multiple hooks target the same stage. Lower values run first, for `@Will`, `@Did` and `@Around` alike. Default: `0`.
|
|
197
198
|
- **filter** (`(ctx) => boolean`) - A predicate that receives the flow context. Return `false` to skip this hook for the current invocation.
|
|
199
|
+
- **appliesTo** (`'own-app' | 'uncovered-apps'`) - A hook of a plugin installed on an app runs, in `tools/call`, `resources/read`, `prompts/get` and `completion/complete`, only for that app's entries (`'own-app'`). With `'uncovered-apps'` it also runs for the entries of any app that has no instance of the same hook (same class and method) of its own or from a server-level plugin. Use it for gates an entry's metadata asks for (approval, feature flags), so the entry is not left ungated when the plugin sits on another app. Server-level plugins' hooks, and list-flow hooks, already run for every app.
|
|
198
200
|
|
|
199
201
|
## Examples
|
|
200
202
|
|
|
@@ -307,6 +307,39 @@ Register with `init()`:
|
|
|
307
307
|
class MyServer {}
|
|
308
308
|
```
|
|
309
309
|
|
|
310
|
+
### Option-derived providers are registered before nested plugins
|
|
311
|
+
|
|
312
|
+
`dynamicProviders(options)` and `init({ providers })` are registered **before** the plugin's nested `plugins` are built, for both `init(options)` and `init({ inject, useFactory })` (the factory runs first). A nested plugin can inject them:
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
@Plugin({
|
|
316
|
+
name: 'my-plugin',
|
|
317
|
+
plugins: [
|
|
318
|
+
AuditPlugin.init({
|
|
319
|
+
inject: () => [MyServiceToken],
|
|
320
|
+
useFactory: (service: MyService) => ({ channel: service.auditChannel }),
|
|
321
|
+
}),
|
|
322
|
+
],
|
|
323
|
+
})
|
|
324
|
+
export default class MyPlugin extends DynamicPlugin<MyPluginOptions, MyPluginOptionsInput> {
|
|
325
|
+
/* dynamicProviders() returns MyServiceToken as above */
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
The reverse does not work: an option-derived provider cannot inject a provider that a nested plugin exports.
|
|
330
|
+
|
|
331
|
+
### Installing the same plugin in several apps
|
|
332
|
+
|
|
333
|
+
Each app that installs a plugin gets its own copy of the plugin's providers, including CONTEXT-scoped ones. Tools, resources and prompts resolve the nearest definition in their own hierarchy (plugin, then app, then server). So `this.myService` in app A uses A's options even when app B installs `MyPlugin.init()` with different options:
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
@App({ id: 'billing', plugins: [MyPlugin.init({ endpoint: 'https://billing.example.com' })], tools: [RefundTool] })
|
|
337
|
+
class BillingApp {}
|
|
338
|
+
|
|
339
|
+
@App({ id: 'ops', plugins: [MyPlugin.init({ endpoint: 'https://ops.example.com' })], tools: [DeployTool] })
|
|
340
|
+
class OpsApp {}
|
|
341
|
+
```
|
|
342
|
+
|
|
310
343
|
## Step 5: Extend Metadata and Execution Context
|
|
311
344
|
|
|
312
345
|
FrontMCP provides two extension mechanisms for plugins: **metadata augmentation** (add fields to decorators) and **context extensions** (add properties to `this` in tools/resources/prompts).
|
|
@@ -458,13 +491,14 @@ plugins/
|
|
|
458
491
|
|
|
459
492
|
## Common Patterns
|
|
460
493
|
|
|
461
|
-
| Pattern | Correct
|
|
462
|
-
| ------------------------------ |
|
|
463
|
-
| Context extension registration | `contextExtensions: [{ property: 'auditLog', token: AuditLoggerToken }]` in metadata
|
|
464
|
-
| Type augmentation | `declare module '@frontmcp/sdk' { interface ExecutionContextBase { ... } }` in a separate file
|
|
465
|
-
| Provider types | `Token<AuditLogger> = Symbol('AuditLogger')` with typed token
|
|
466
|
-
| Plugin scope | `scope: 'app'` (default) for app-scoped behavior
|
|
467
|
-
| Dynamic options | Extend `DynamicPlugin<TOptions, TInput>` with `static dynamicProviders()`
|
|
494
|
+
| Pattern | Correct | Incorrect | Why |
|
|
495
|
+
| ------------------------------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
496
|
+
| Context extension registration | `contextExtensions: [{ property: 'auditLog', token: AuditLoggerToken }]` in metadata | `Object.defineProperty(ExecutionContextBase.prototype, ...)` manually | SDK handles runtime installation; manual modification causes ordering issues |
|
|
497
|
+
| Type augmentation | `declare module '@frontmcp/sdk' { interface ExecutionContextBase { ... } }` in a separate file | Skipping the augmentation and casting `this` in tools | Without augmentation, TypeScript cannot type-check `this.auditLog` |
|
|
498
|
+
| Provider types | `Token<AuditLogger> = Symbol('AuditLogger')` with typed token | `provide: Symbol('AuditLogger')` without type annotation | Typed tokens enable compile-time DI resolution checking |
|
|
499
|
+
| Plugin scope | `scope: 'app'` (default) for app-scoped behavior | `scope: 'server'` when hooks should only apply to one app | Server scope fires hooks for all apps in a gateway; default to app |
|
|
500
|
+
| Dynamic options | Extend `DynamicPlugin<TOptions, TInput>` with `static dynamicProviders()` | Constructing providers in the constructor body | `dynamicProviders` runs before instantiation, enabling proper DI wiring |
|
|
501
|
+
| Nested plugin needs options | Nested `Plugin.init({ inject: () => [HostToken], useFactory })` injecting a `dynamicProviders` token | Resolving the host's option-derived provider by reading global state | Option-derived providers are registered before nested plugins are built |
|
|
468
502
|
|
|
469
503
|
## Verification Checklist
|
|
470
504
|
|
|
@@ -104,6 +104,13 @@ export const databaseProvider = AsyncProvider({
|
|
|
104
104
|
});
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
`scope` is `ProviderScope.GLOBAL` (the default: one instance per process/worker, right for
|
|
108
|
+
pools and clients) or `ProviderScope.CONTEXT` (one instance per request). A client that opens a
|
|
109
|
+
session (MCP before 2026-07-28) keeps one CONTEXT instance for the session the server verified;
|
|
110
|
+
any other caller, such as one with a token under 2026-07-28, gets new instances on every request,
|
|
111
|
+
never another caller's. State that must outlive a request belongs in a GLOBAL provider or in
|
|
112
|
+
storage keyed by the caller's identity.
|
|
113
|
+
|
|
107
114
|
## Step 3: Register in @App or @FrontMcp
|
|
108
115
|
|
|
109
116
|
```typescript
|
|
@@ -160,8 +160,9 @@ One policy decides every CodeCall surface: `codecall:search`, `codecall:describe
|
|
|
160
160
|
```typescript
|
|
161
161
|
CodeCallPlugin.init({
|
|
162
162
|
mode: 'codecall_only',
|
|
163
|
-
// `tool` is { name, appId, source, description, tags
|
|
164
|
-
|
|
163
|
+
// `tool` is { name, fullName, appId, source, description, tags, annotations, metadata }, read-only;
|
|
164
|
+
// `name` is the tool's own name, never `<appId>:<name>`
|
|
165
|
+
includeTools: (tool) => !tool.name.startsWith('admin:') && !tool.annotations?.destructiveHint,
|
|
165
166
|
directCalls: {
|
|
166
167
|
enabled: true,
|
|
167
168
|
allowedTools: ['users:list', 'crm:users:get'], // bare name, or `<appId>:<name>` to pin one app
|
|
@@ -174,6 +175,9 @@ CodeCallPlugin.init({
|
|
|
174
175
|
- `codecall:searchSkills` and `codecall:searchKnowledge` run the SDK's `skills:filter` flow, so a skill a plugin withholds there (a flag-disabled skill, for one) is absent from both.
|
|
175
176
|
- `directCalls.allowedTools` and `directCalls.filter` only narrow the base policy; listing a withheld tool does not make it callable. Unlisted tools are refused.
|
|
176
177
|
- Hiding a tool from search is not the control; the refusal at execution is. Do not rely on `visibleInListTools` or search ranking to protect a tool.
|
|
178
|
+
- `includeTools` and `directCalls.filter` receive the same object, with the tool's `annotations` and declared `metadata` (`tool.metadata?.annotations` is the same object as `tool.annotations`). It is a deep read-only copy, so a filter cannot change what the next decision reads.
|
|
179
|
+
- Namespace bindings (`mail.send({...})` for a tool named `mail.send`) are AgentScript wrappers over `callTool()` inside the sandbox: they count toward `vm.maxSteps` and pass the rate limit and suspicious-sequence checks exactly like `callTool('mail.send', {...})`. A binding with no argument sends `{}`.
|
|
180
|
+
- `codecall:execute` results never include a `stack`, in any environment. In `runtime_error`, `syntax_error` and `tool_error` messages, stack frames are dropped and absolute paths (POSIX, Windows, UNC, `file:` URLs, quoted paths) become `[path]`; other URLs are kept.
|
|
177
181
|
|
|
178
182
|
### Power Features
|
|
179
183
|
|
|
@@ -277,17 +281,21 @@ class MyTool extends ToolContext {
|
|
|
277
281
|
|
|
278
282
|
### Memory Scopes
|
|
279
283
|
|
|
280
|
-
- `session` --
|
|
284
|
+
- `session` -- Default scope. With a verified session, valid only for that session and cleared
|
|
285
|
+
when it ends. Without one (stateless transport, MCP 2026-07-28), it belongs to the authenticated
|
|
286
|
+
principal and lasts across that principal's requests until its TTL, not per request.
|
|
281
287
|
- `user` -- Persists for the user across sessions. Tied to user identity.
|
|
282
|
-
- `tool` -- Scoped to a specific tool
|
|
288
|
+
- `tool` -- Scoped to a specific tool plus the same identity as `session` (the verified session,
|
|
289
|
+
else the authenticated principal). Isolated per tool.
|
|
283
290
|
- `global` -- Shared across all sessions and users. Use carefully.
|
|
284
291
|
|
|
285
|
-
**`session`, `tool`, and `user` scopes require a per-client identity.**
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
292
|
+
**`session`, `tool`, and `user` scopes require a per-client identity.** `session` and `tool`
|
|
293
|
+
memory belong to the session the server verified, never to an `mcp-session-id` the client merely
|
|
294
|
+
sends. A stateless HTTP transport (shared `__stateless__` id), MCP 2026-07-28 (no sessions) and an
|
|
295
|
+
unverified `mcp-session-id` carry no session identity: `session` and `tool` scope fall back to the
|
|
296
|
+
authenticated principal, and an unauthenticated request without a verified session is refused
|
|
297
|
+
with a `RememberIdentityError` rather than given a namespace shared with other clients. `user`
|
|
298
|
+
scope is refused with no authenticated user. If the data really is shared, use `scope: 'global'`.
|
|
291
299
|
|
|
292
300
|
**Set `REMEMBER_SECRET` on every instance that shares a store.** All scopes, `session` and
|
|
293
301
|
`tool` included, derive their encryption key from that secret plus the scope identity. A
|
|
@@ -405,11 +413,14 @@ authInfo.extra.approvalContext = { type: 'project', identifier: resolvedProjectI
|
|
|
405
413
|
### How the check decides
|
|
406
414
|
|
|
407
415
|
1. `skipApproval: true`, or approval not required: the tool runs.
|
|
408
|
-
2. A recorded **denial** for the caller (session or
|
|
409
|
-
denial outranks pre-approved contexts and any
|
|
416
|
+
2. A recorded **denial** for the caller (session, user, time-limited or context scope): refused
|
|
417
|
+
with state `denied`. A denial outranks pre-approved contexts and any approval.
|
|
410
418
|
3. The session context is one of `preApprovedContexts`: the tool runs.
|
|
411
419
|
4. `alwaysPrompt: true`: refused with state `pending`.
|
|
412
|
-
5.
|
|
420
|
+
5. An approval for the caller that the tool's policy accepts: the tool runs. The caller's session,
|
|
421
|
+
user, time-limited and context approvals all count (a context approval only when the session
|
|
422
|
+
carries that context); its scope must be in `allowedScopes`, and it must be younger than
|
|
423
|
+
`maxTtlMs`, however it was stored.
|
|
413
424
|
6. Otherwise refused with state `pending` (or `expired`).
|
|
414
425
|
|
|
415
426
|
A refused call throws `ApprovalRequiredError`; the client receives an error result.
|
|
@@ -417,18 +428,26 @@ A refused call throws `ApprovalRequiredError`; the client receives an error resu
|
|
|
417
428
|
Approvals are looked up by the tool's full name, `<owner id>:<tool name>`, so pass that name to
|
|
418
429
|
`this.approval` grant and check methods. The owner is the app that declares the tool, or the
|
|
419
430
|
adapter or plugin that provides it (`my-app:file_write` for a tool declared on app `my-app`,
|
|
420
|
-
`github-api:create_issue` for one its `github-api` adapter provides). Session approvals belong to
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
store
|
|
431
|
+
`github-api:create_issue` for one its `github-api` adapter provides). Session approvals belong to a
|
|
432
|
+
session the server verified (`FrontMcpContext.verifiedSessionId`). A stateless request has none -- it
|
|
433
|
+
carries the shared stateless session id, or sends no `mcp-session-id` and runs under a fresh
|
|
434
|
+
per-request id -- so it is keyed by the authenticated principal (`authInfo.extra.userId`, then
|
|
435
|
+
`authInfo.extra.sub`, then `authInfo.clientId`): a grant made in one stateless request is found by
|
|
436
|
+
the same principal's next request and by no other principal. A stateless call with no principal
|
|
437
|
+
cannot hold a session approval. Releases up to 1.8.1 keyed a request without `mcp-session-id` by
|
|
438
|
+
its per-request id, so the grant was never found again
|
|
439
|
+
([#597](https://github.com/agentfront/frontmcp/issues/597)).
|
|
440
|
+
|
|
441
|
+
Installed on an app, `ApprovalPlugin` gates that app's tools (including those its adapters and
|
|
442
|
+
plugins provide) against its own store, so two apps can each install it with separate stores. It
|
|
443
|
+
also gates, against its store, the `approval` tools of apps with no approval plugin of their own,
|
|
444
|
+
so such a tool never runs ungated because the plugin sits on another app (releases up to 1.8.2 ran
|
|
445
|
+
them for anyone). Installed on the server, it gates every tool; a tool several plugins gate must
|
|
446
|
+
pass each store's check, and a denial in any of them refuses the call.
|
|
447
|
+
`this.approval` resolves the `ApprovalService` of the nearest `ApprovalPlugin` -- the one the
|
|
448
|
+
tool's own app installed, otherwise the server's -- so with two apps each installing it, a grant
|
|
449
|
+
or check in one app's tool uses that app's store. Releases up to 1.8.1 resolved the store of the
|
|
450
|
+
app registered last ([#600](https://github.com/agentfront/frontmcp/issues/600)).
|
|
432
451
|
|
|
433
452
|
### Using `this.approval` in Tools
|
|
434
453
|
|
|
@@ -451,8 +470,11 @@ class DangerousActionTool extends ToolContext {
|
|
|
451
470
|
// await this.approval.getSessionApprovals() -- List session approvals
|
|
452
471
|
// await this.approval.getUserApprovals() -- List user approvals
|
|
453
472
|
// await this.approval.grantUserApproval('tool-id') -- Persist across sessions
|
|
454
|
-
// await this.approval.grantTimeLimitedApproval('tool-id', 60000) -- Auto-expire
|
|
455
|
-
// await this.approval.revokeApproval('tool-id') -- Revoke
|
|
473
|
+
// await this.approval.grantTimeLimitedApproval('tool-id', 60000) -- Auto-expire (ttlMs > 0)
|
|
474
|
+
// await this.approval.revokeApproval('tool-id') -- Revoke the caller's session, user,
|
|
475
|
+
// time-limited and context approvals;
|
|
476
|
+
// returns whether any was revoked
|
|
477
|
+
// (recorded denials are kept)
|
|
456
478
|
|
|
457
479
|
return { content: [{ type: 'text', text: 'Action completed' }] };
|
|
458
480
|
}
|
|
@@ -472,6 +494,8 @@ import { ApprovalScope } from '@frontmcp/plugin-approval';
|
|
|
472
494
|
category: 'write',
|
|
473
495
|
riskLevel: 'medium', // 'low' | 'medium' | 'high' | 'critical'
|
|
474
496
|
approvalMessage: 'Allow file writing for this session?',
|
|
497
|
+
allowedScopes: [ApprovalScope.SESSION, ApprovalScope.TIME_LIMITED],
|
|
498
|
+
maxTtlMs: 60 * 60 * 1000,
|
|
475
499
|
},
|
|
476
500
|
})
|
|
477
501
|
class FileWriteTool extends ToolContext {
|
|
@@ -479,6 +503,12 @@ class FileWriteTool extends ToolContext {
|
|
|
479
503
|
}
|
|
480
504
|
```
|
|
481
505
|
|
|
506
|
+
- `allowedScopes` is enforced: granting another scope through `this.approval` throws
|
|
507
|
+
`ApprovalScopeNotAllowedError`, and a stored approval of another scope does not open the gate.
|
|
508
|
+
- `maxTtlMs` is enforced: a longer `grantTimeLimitedApproval()` throws `ApprovalOperationError`,
|
|
509
|
+
grants without a TTL get `maxTtlMs`, and no approval counts beyond `grantedAt + maxTtlMs`.
|
|
510
|
+
- A `ttlMs` of 0, a negative number, `NaN` or `Infinity` throws, and a time-limited grant needs one.
|
|
511
|
+
|
|
482
512
|
When `approval.required` is `true`, the plugin automatically intercepts tool execution and checks approval status before allowing the tool to run.
|
|
483
513
|
|
|
484
514
|
---
|
|
@@ -496,9 +526,11 @@ response to everyone else.
|
|
|
496
526
|
|
|
497
527
|
The identity is the subject your auth layer puts in `authInfo.extra` (`sub` / `userId`), then
|
|
498
528
|
the client id, then the session. For a request the SDK verified, the client id is the token's
|
|
499
|
-
`sub`, or `anon:<id>` for an anonymous session.
|
|
500
|
-
|
|
501
|
-
|
|
529
|
+
`sub`, or `anon:<id>` for an anonymous session. Only a session the server verified
|
|
530
|
+
(`FrontMcpContext.verifiedSessionId`) is an identity: the session id the stateless HTTP transport
|
|
531
|
+
gives every request, the fresh per-request id of a request without `mcp-session-id`, and an
|
|
532
|
+
`mcp-session-id` the server did not accept are not. A call with no identity at all gets a key of
|
|
533
|
+
its own rather than one shared with every other identity-less caller.
|
|
502
534
|
|
|
503
535
|
Set `keyByIdentity: false` **only** when every caller would get byte-identical output: public
|
|
504
536
|
reference data, a currency table, a static document.
|
|
@@ -753,13 +785,17 @@ The plugin hooks into listing and execution flows for tools, resources, resource
|
|
|
753
785
|
|
|
754
786
|
| Capability | Hidden from | Refused on |
|
|
755
787
|
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
756
|
-
| Tool | `tools/list`
|
|
788
|
+
| Tool | `tools/list`, and the `toolName` completion of `ui://widget/{toolName}.html` | `tools/call` |
|
|
757
789
|
| Resource | `resources/list` | `resources/read`, `completion/complete` |
|
|
758
790
|
| Resource template | `resources/templates/list` | `resources/read` of any URI it matches, `completion/complete` |
|
|
759
791
|
| Prompt | `prompts/list` | `prompts/get`, `completion/complete` |
|
|
760
792
|
| Skill | `skills/search`, `skills/list`, `skill://index.json`, its `skill://<path>/SKILL.md` entry in `resources/list`, `GET /skills`, `/llm.txt`, `/llm_full.txt`, `codecall:searchSkills`, `codecall:searchKnowledge` | `skills/load`, `skill://<path>/SKILL.md` and its files, `GET /skills/{id}` (same answer as a nonexistent skill) |
|
|
761
793
|
|
|
762
|
-
|
|
794
|
+
The `toolName` completion of `ui://widget/{toolName}.html` runs the caller's `tools:list-tools` flow, so it offers only the UI tools `tools/list` shows that caller -- a flagged-off tool is left out there too ([#596](https://github.com/agentfront/frontmcp/issues/596)).
|
|
795
|
+
|
|
796
|
+
The skill catalog in the `initialize` instructions (and the SEP-2640 `skill://` hints under `skillsConfig.sep2640InInstructions`) is filtered for the initializing client too, so a flag-disabled skill's name and description never appear there ([#603](https://github.com/agentfront/frontmcp/issues/603)). A skill's `skill://<path>/SKILL.md` entry in `resources/list` is gated by the skill that path serves now -- replace a skill at the same path and the entry takes the new skill's flag ([#606](https://github.com/agentfront/frontmcp/issues/606)).
|
|
797
|
+
|
|
798
|
+
Installed on an `@App`, the gates cover every capability that app provides, including tools, resources and prompts contributed by its adapters (e.g. an OpenAPI adapter) and plugins. Its tool, resource, prompt and completion gates also cover the flagged capabilities of apps with no feature-flag plugin of their own (as its list filters already did), but not those of an app that installs its own -- install it in `@FrontMcp({ plugins })` to gate every app with one adapter. Releases up to 1.8.2 hid another app's flagged-off tool from `tools/list` but still ran it when called by name. Resources and prompts served outside every app (the SEP-2640 `skill://` resources) are gated by every installed copy. Skills are gated through the `skills:filter` flow, which every skill surface runs -- as the calling user on every transport, stdio and in-memory included; custom plugins can hook `Did('filterSkills')` on it the same way, and reuse `filterServableSkills(scope, skills)` from `@frontmcp/sdk` to serve skills from a surface of their own.
|
|
763
799
|
|
|
764
800
|
---
|
|
765
801
|
|
|
@@ -808,7 +844,7 @@ interface DashboardPluginOptionsInput {
|
|
|
808
844
|
basePath?: string; // Default: '/dashboard'
|
|
809
845
|
auth?: {
|
|
810
846
|
enabled?: boolean; // Default: false
|
|
811
|
-
token?: string; //
|
|
847
|
+
token?: string; // Bearer / x-frontmcp-dashboard-token; ?token=xxx for the page only
|
|
812
848
|
};
|
|
813
849
|
cdn?: {
|
|
814
850
|
entrypoint?: string; // Custom UI bundle URL
|
|
@@ -820,9 +856,9 @@ interface DashboardPluginOptionsInput {
|
|
|
820
856
|
}
|
|
821
857
|
```
|
|
822
858
|
|
|
823
|
-
- `enabled` -- When omitted, the dashboard is automatically enabled in development (`NODE_ENV !== 'production'`) and disabled in production.
|
|
824
|
-
- `basePath` -- URL path where the dashboard is served. Default: `'/dashboard'`.
|
|
825
|
-
- `auth.enabled` / `auth.token` -- Gate the dashboard
|
|
859
|
+
- `enabled` -- When omitted, the dashboard is automatically enabled in development (`NODE_ENV !== 'production'`) and disabled in production. Disabled means disabled everywhere: the page and the dashboard's MCP endpoint answer 404, and its tools refuse with `DASHBOARD_DISABLED` on every transport (`createDirect` and stdio included). To keep the dashboard in production, set `enabled: true` with `auth`.
|
|
860
|
+
- `basePath` -- URL path where the dashboard page is served. Default: `'/dashboard'`. The page's MCP client talks to the dashboard app's own route (`/dashboard`, after the server's `entryPath`), which `basePath` does not move.
|
|
861
|
+
- `auth.enabled` / `auth.token` -- Gate the dashboard on a shared secret: the page, its MCP endpoint and its tools. The page takes `Authorization: Bearer <token>` (preferred) or `?token=<value>`, and sets an HttpOnly `SameSite=Strict` cookie (an HMAC of the token, never the token) that its own MCP client uses. MCP clients send `Authorization: Bearer <token>`, or `x-frontmcp-dashboard-token: <token>` when the server's own auth uses `Authorization`; `?token=` is refused there. Without the token the MCP endpoint answers 401 (`WWW-Authenticate: Bearer realm="frontmcp-dashboard"`). `enabled: true` without a `token` is a **startup error** — the server refuses to boot rather than serve an "authenticated" dashboard with nothing to check. The token is compared in constant time and is never embedded in the served page.
|
|
826
862
|
- `cdn` -- Override default CDN URLs for the dashboard UI bundle and its dependencies. Useful for air-gapped environments.
|
|
827
863
|
|
|
828
864
|
### Security
|
|
@@ -832,12 +868,12 @@ interface DashboardPluginOptionsInput {
|
|
|
832
868
|
The dashboard's MCP scope **inherits the server's authentication**. Its introspection tools (`dashboard:graph`, `dashboard:list-tools`, `dashboard:list-resources`) reach the root scope and enumerate every app, tool, resource and prompt on the server — including names, descriptions and (on request) schemas. Two consequences:
|
|
833
869
|
|
|
834
870
|
- On an authenticated server (`local`, `remote`, `transparent`, `orchestrated`), the dashboard requires the same credential as everything else.
|
|
835
|
-
- On a **public** server
|
|
871
|
+
- On a **public** server, `auth.token` is what keeps the inventory private: it gates the page, the dashboard's MCP endpoint (SSE stream and POSTs included) and the tools. Releases up to 1.8.2 gated only the page, answered MCP with the dashboard disabled, and pointed the page at `<basePath>/sse`.
|
|
836
872
|
|
|
837
873
|
Three further limitations worth knowing:
|
|
838
874
|
|
|
839
|
-
- **The bundled page cannot authenticate itself against a non-public server.**
|
|
840
|
-
- The token is accepted as `Authorization: Bearer <token>` (scheme matched case-insensitively)
|
|
875
|
+
- **The bundled page cannot authenticate itself against a non-public server.** Its cookie carries the dashboard token only; the browser client opens `EventSource(sseUrl)` and POSTs with no `Authorization` header, and the server's own authentication reads its credential from that header. So on a server with `local`/`remote`/`transparent`/`orchestrated` auth the page loads but the in-page graph, tool list and SSE stream get `401`. Run the dashboard on a public/development server, or put it behind a proxy that injects a credential — scoped to the dashboard's own MCP routes (`/dashboard/sse` and `/dashboard/message`, after the server's `entryPath`) and holding no grant beyond the dashboard scope, since injecting a server credential across the MCP endpoint would let any page on that origin issue arbitrary authenticated JSON-RPC. Failing closed here is deliberate — the alternative is the `mode: 'public'` scope that GHSA-rgxj-434m-vxh3 was about.
|
|
876
|
+
- The token is accepted as `Authorization: Bearer <token>` (scheme matched case-insensitively), as `x-frontmcp-dashboard-token` on the MCP endpoint, through the page's cookie, and as `?token=` for the page only. Prefer a header: a URL token lands in browser history, `Referer` headers and access logs.
|
|
841
877
|
- Dashboard options are **process-wide**. Two `@FrontMcp` servers built in one process that configure the dashboard with CONFLICTING auth now throw at registration rather than silently sharing the last token; a differing `basePath` or `cdn` logs a warning. Run one dashboard per process, or call `resetDashboardOptions()` between serial constructions.
|
|
842
878
|
|
|
843
879
|
---
|
|
@@ -89,8 +89,9 @@ OpenapiAdapter.init({
|
|
|
89
89
|
name: 'my-api',
|
|
90
90
|
url: 'https://api.example.com/openapi.json',
|
|
91
91
|
baseUrl: 'https://api.example.com',
|
|
92
|
-
securityResolver: (tool, ctx) => {
|
|
93
|
-
|
|
92
|
+
securityResolver: async (tool, ctx) => {
|
|
93
|
+
// A credential issued for the API, never the caller's own ctx.authInfo.token
|
|
94
|
+
return { jwt: await getApiToken(ctx) };
|
|
94
95
|
},
|
|
95
96
|
});
|
|
96
97
|
|
|
@@ -110,17 +111,33 @@ OpenapiAdapter.init({
|
|
|
110
111
|
url: 'https://api.example.com/openapi.json',
|
|
111
112
|
baseUrl: 'https://api.example.com',
|
|
112
113
|
headersMapper: (ctx, headers) => {
|
|
113
|
-
|
|
114
|
+
const tenantId = ctx.authInfo.user?.tenantId;
|
|
115
|
+
if (tenantId) headers.set('x-tenant-id', tenantId);
|
|
114
116
|
return headers;
|
|
115
117
|
},
|
|
116
118
|
});
|
|
119
|
+
|
|
120
|
+
// Opt-in token passthrough (High Risk) — only when the API accepts tokens issued for this MCP server
|
|
121
|
+
OpenapiAdapter.init({
|
|
122
|
+
name: 'same-issuer-api',
|
|
123
|
+
url: 'https://api.example.com/openapi.json',
|
|
124
|
+
baseUrl: 'https://api.example.com',
|
|
125
|
+
passthroughCallerToken: true,
|
|
126
|
+
});
|
|
117
127
|
```
|
|
118
128
|
|
|
129
|
+
With no `authProviderMapper`, `securityResolver` or `staticAuth`, the adapter sends **no** credentials: operations that require auth fail with `Authentication required for tool '…'` and a `SECURITY WARNING` is logged at startup. The caller's MCP token (`ctx.authInfo.token`) is never forwarded implicitly — not by default, and not when an `authProviderMapper` function returns `undefined` — because passing it to another API is token passthrough, which the MCP specification forbids. `passthroughCallerToken: true` is the explicit opt-in, used only after every other credential source came up empty.
|
|
130
|
+
|
|
119
131
|
| Risk Level | Strategy | Description |
|
|
120
132
|
| ---------- | ------------------------------------------ | ---------------------------------------------------- |
|
|
121
133
|
| LOW | `authProviderMapper` or `securityResolver` | Auth from user context, not exposed to clients |
|
|
122
|
-
| MEDIUM | `staticAuth`, `additionalHeaders
|
|
134
|
+
| MEDIUM | `staticAuth`, `additionalHeaders`, or none | Static credentials, or no credentials at all |
|
|
123
135
|
| HIGH | `includeSecurityInInput: true` | Auth fields exposed to MCP clients (not recommended) |
|
|
136
|
+
| HIGH | `passthroughCallerToken: true` | The MCP client's own token is sent to the API |
|
|
137
|
+
|
|
138
|
+
`passthroughCallerToken: true` scores HIGH alongside an `authProviderMapper` too (the token is sent when no mapper function returns a credential); only a `securityResolver` or a non-empty `staticAuth` leaves it unused.
|
|
139
|
+
|
|
140
|
+
Resolution order: `securityResolver` → `authProviderMapper` → `staticAuth` (fills every credential no mapper function returned; a mapped value wins) → `passthroughCallerToken`. A security scheme with no `authProviderMapper` entry is refused at startup unless `staticAuth` or `passthroughCallerToken` covers it (the latter sends the caller's token for it and logs a `SECURITY WARNING`).
|
|
124
141
|
|
|
125
142
|
## Spec Polling
|
|
126
143
|
|
|
@@ -19,7 +19,8 @@ These checks apply to ALL deployment targets. Run them first, then proceed to yo
|
|
|
19
19
|
- [ ] Tool-level authorization is enforced where needed (ApprovalPlugin or custom)
|
|
20
20
|
- [ ] OAuth redirect URIs are restricted to known domains
|
|
21
21
|
- [ ] `FRONTMCP_PUBLIC_URL` is pinned to the canonical origin — issuer / resource / OAuth-discovery URLs and the transparent-mode expected audience derive from it, not from request headers. `X-Forwarded-Host`/`X-Forwarded-Proto` are ignored by default; only set `FRONTMCP_TRUST_PROXY=1` behind a proxy that strips client-supplied forwarded headers
|
|
22
|
-
- [ ] `auth.requireRegisteredClients
|
|
22
|
+
- [ ] `auth.requireRegisteredClients` left at its default `true` (local/remote) so unknown clients can't present an attacker-chosen `redirect_uri` (auth-code interception). Clients register via DCR / `dcr.clients` / CIMD; a production remote-mode server has neither DCR nor `dcr.clients`, so its MCP clients must use CIMD
|
|
23
|
+
- [ ] `auth.allowedScopes` lists the scopes the server may grant (local/remote); anything else a client asks for is dropped
|
|
23
24
|
- [ ] Transparent mode sets `auth.expectedAudience` (bind tokens to this resource) and validates issuer (`providerConfig.verifyIssuer`, default on)
|
|
24
25
|
|
|
25
26
|
### CORS Configuration
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
},
|
|
75
75
|
{
|
|
76
76
|
"name": "ui-widgets",
|
|
77
|
-
"description": "@Tool({ ui }) \u2014 template formats, servingMode, host-detect resourceMode, CSP, widgetAccessible, MCP Apps spec."
|
|
77
|
+
"description": "@Tool({ ui }) \u2014 template formats, trusted markup (html / escapeStringResults), servingMode, host-detect resourceMode, CSP, widgetAccessible, MCP Apps spec."
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
80
|
"name": "annotations",
|
|
@@ -337,11 +337,12 @@
|
|
|
337
337
|
"name": "22-tool-with-ui-html-template",
|
|
338
338
|
"level": "intermediate",
|
|
339
339
|
"description": "Tool with an inline HTML function template \u2014 `ui: { template: (ctx) => '<div>\u2026</div>' }` \u2014 for a quick widget that doesn't need a separate `.tsx` file.",
|
|
340
|
-
"tags": ["ui", "ui-widgets", "html-template", "
|
|
340
|
+
"tags": ["ui", "ui-widgets", "html-template", "html-tag", "escapeStringResults", "TemplateContext"],
|
|
341
341
|
"features": [
|
|
342
|
-
"Adding a `ui:` block with a function template
|
|
342
|
+
"Adding a `ui:` block with a function template that returns markup built with the `ctx.helpers.html` tagged template",
|
|
343
343
|
"Annotating `ctx` explicitly to dodge the TS7006 inference gap on the union `ui.template` type",
|
|
344
|
-
"
|
|
344
|
+
"Letting `ctx.helpers.html` escape every interpolated value so tool output can't inject markup into the widget",
|
|
345
|
+
"Opting in to `escapeStringResults: true` so a plain string result is escaped \u2014 the default from FrontMCP 1.9",
|
|
345
346
|
"Reading from `ctx.output` and `ctx.helpers` \u2014 the typed runtime context the template renderer hands you"
|
|
346
347
|
]
|
|
347
348
|
},
|
|
@@ -365,7 +366,7 @@
|
|
|
365
366
|
"features": [
|
|
366
367
|
"Restricting the widget's outbound `fetch` via `ui.csp.connectDomains` (emitted on the resource per #455)",
|
|
367
368
|
"Opting the widget into cross-tool calls with `widgetAccessible: true` and using `window.FrontMcpBridge.callTool(name, args)` instead of host-specific APIs",
|
|
368
|
-
"
|
|
369
|
+
"Building the markup with `ctx.helpers.html` and embedding initial data into the inline `<script>` via `trustedHtml(jsonEmbed(...))` (`jsonEmbed` escapes `<`, `>` and `&`)",
|
|
369
370
|
"Surfacing in-flight status via `invocationStatus.invoking` / `invoked` so the host UI shows feedback"
|
|
370
371
|
]
|
|
371
372
|
},
|
|
@@ -1069,6 +1070,7 @@
|
|
|
1069
1070
|
"Top-level `instructions` on `@FrontMcp` exposes a global system prompt to MCP clients",
|
|
1070
1071
|
"`skillsConfig.injectInstructions: 'append'` adds the skill catalog summary after the user prompt",
|
|
1071
1072
|
"Dynamic skills are picked up because the composer runs on every initialize request",
|
|
1073
|
+
"The catalog only names skills the initializing caller may see (skill authorities and the `skills:filter` flow, e.g. feature flags)",
|
|
1072
1074
|
"Catalog summary is bounded at 16 KB with a truncation footer pointing at skill://index.json"
|
|
1073
1075
|
]
|
|
1074
1076
|
},
|