# Tool UI

> A tool's ui option: the templates it takes, what FrontMCP adds to tools/list, tools/call and the ui://widget resource, each serving mode and the server and app defaults for it, the widget's Content Security Policy, and what changes about the result.

Source: https://frontmcp.dev/reference/ui

A tool's `ui` option gives its result a widget: an HTML page that a host able to show one renders next to the answer, while the model keeps reading the same result. FrontMCP renders your template on every call and sends the page in the result's `_meta["ui/html"]`, advertises a `ui://widget/<tool>.html` resource in `tools/list`, and serves that resource with `resources/read`. What a host does with each of these, and what the widget can do once it's open, is on [Hosts and platforms](https://frontmcp.dev/reference/ui/hosts). Building the page from React components is on [Widget components](https://frontmcp.dev/reference/ui/components). [Your First Widget](https://frontmcp.dev/learn/your-first-widget) teaches it step by step.

```ts
@Tool({ ..., ui: { template, servingMode?, csp?, escapeStringResults?, resourceUri?, invocationStatus?,
                   preferredHeight?, minHeight?, maxHeight?, aspectRatio?, autoResize?, widgetCapabilities?, resourceMode?,
                   widgetAccessible?, widgetDescription?, prefersBorder?, sandboxDomain?, displayMode? } })

@FrontMcp({ ..., ui?: { escapeStringResults?, servingMode? }, extApps?: { enabled?, hostCapabilities? } })

@App({ ..., ui?: { servingMode? } })
```

---

## Reference

### The `ui` option

Set `ui` on [`@Tool`](https://frontmcp.dev/reference/sdk/tool). The tool keeps working for every client: `ui` adds a page to the result and leaves its `content` and `structuredContent` for the model.

```ts get-weather.tool.ts
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

type Input = { city: string };
type Output = { city: string; tempC: number; conditions: string };

@Tool({
  name: "get_weather",
  description: "Current weather for a city",
  inputSchema: { city: z.string() },
  outputSchema: { city: z.string(), tempC: z.number(), conditions: z.string() },
  ui: {
    template: (ctx: TemplateContext<Input, Output>) => ctx.helpers.html`
      <div class="weather">
        <h2>${ctx.output.city}</h2>
        <p>${ctx.output.tempC}°C, ${ctx.output.conditions}</p>
      </div>`,
  },
})
export class GetWeather extends ToolContext {
  async execute({ city }: Input): Promise<Output> {
    return { city, tempC: 21, conditions: "Sunny" };
  }
}
```

Annotate `ctx`. `template` accepts several kinds of value, so TypeScript can't infer the parameter, and `(ctx) => …` fails with `TS7006: Parameter 'ctx' implicitly has an 'any' type` under `strict`. [See more examples below.](#usage)

#### Options

| Option | Type | Default | What FrontMCP 1.9.4 does with it |
| --- | --- | --- | --- |
| `template` | see [Templates](#templates) | | **Required.** The widget's content. |
| `servingMode` | `"auto"`, `"inline"`, `"static"`, `"hybrid"`, `"direct-url"`, `"custom-url"` | the app's or the server's `ui.servingMode`, else `"auto"` | How the page reaches the host. See [Serving modes](#serving-modes). |
| `csp` | `{ connectDomains?, resourceDomains? }` | | The origins the widget needs. Listed for the host in `tools/list` and on the resource, also as `_meta["openai/widgetCSP"]`, and added to the page's own policy, for `https:` and `wss:` origins, and `http:` and `ws:` on localhost. See [Content Security Policy](#content-security-policy). |
| `escapeStringResults` | `boolean` | the server's `ui.escapeStringResults` | `false` renders a plain string the template returns as markup, instead of escaping it. See [Escaping string results](#escaping-string-results). |
| `resourceUri` | `string` | `ui://widget/<tool>.html` | The URI `tools/list` advertises. `resources/read` serves it, and `ui://widget/<tool>.html` too. See [The widget resource](#the-widget-resource). |
| `invocationStatus` | `{ invoking?, invoked? }` | | Text for a host to show while the tool runs and after, sent in `tools/list` as `_meta["ui/toolInvocation/invoking"]` and `_meta["ui/toolInvocation/invoked"]`. |
| `preferredHeight`, `minHeight`, `maxHeight` | `number` (pixels) or a CSS length | | Size hints. See [Sizing](#sizing). |
| `aspectRatio` | `string` or `number` | | The same. |
| `autoResize` | `boolean` | on, once any size hint is set | The same. |
| `widgetCapabilities` | `{ toolListChanged?, supportsPartialInput? }` | | Sent in `tools/list` as `_meta.ui.capabilities`, only to [MCP Apps clients on a session](https://frontmcp.dev/reference/ui/hosts#how-frontmcp-recognizes-the-client). |
| `resourceMode` | `"cdn"` or `"inline"` | `"cdn"`; `"inline"` for clients detected as `"claude"` | For `.tsx` templates: load React from `esm.sh`, or bundle it into the page. See [Widget components](https://frontmcp.dev/reference/ui/components#resourcemode-cdn-or-inline). |
| `externals`, `dependencies`, `fileBundleOptions`, `customShell` | | | Bundling options for `.tsx` templates. Not covered here. |
| `widgetAccessible` | `boolean` | | Sent in `tools/list` as `_meta["openai/widgetAccessible"]`. The OpenAI Apps SDK lets a widget call tools with `window.openai.callTool()` only when it's `true`; an MCP Apps host doesn't read it. |
| `widgetDescription` | `string` | | What the widget shows, for the model. On the resource as `_meta["openai/widgetDescription"]`, for the OpenAI Apps SDK. |
| `prefersBorder` | `boolean` | | Asks the host to draw a border around the frame. On the resource as `_meta.ui.prefersBorder` and `_meta["openai/widgetPrefersBorder"]`. |
| `sandboxDomain` | `string` | | The origin the host should load the widget from. On the resource as `_meta.ui.domain` and `_meta["openai/widgetDomain"]`. |
| `displayMode` | `"inline"`, `"fullscreen"`, `"pip"` | `"inline"` | The display mode the page asks the host for once its bridge has connected, if the host offers it ([Display modes](https://frontmcp.dev/reference/ui/hosts#display-modes)). `"inline"` asks for nothing. |

[Telling the host about the widget](#telling-the-host-about-the-widget) shows all five. These are accepted and change nothing in 1.9.4, neither in what the client receives nor in the page: `contentSecurity`, `hydrate`, `runtimeOptions`, `mdxComponents`, `bundlingMode`, `uiType`, `htmlResponsePrefix`, `customWidgetUrl` and `directPath`. In particular `contentSecurity` doesn't sanitize anything: a `<script>`, an `onclick` or a `javascript:` link in trusted markup reaches the page as written, with or without it.

At startup the server names the ones a tool sets, except the last two: ``Tool "get_weather": `ui.hydrate`, `ui.bundlingMode`, `ui.uiType` are accepted but not used yet, so they have no effect.`` `customWidgetUrl` and `directPath` are named only with the serving modes that would use them ([Serving modes](#serving-modes)).

Changed in 1.9.3: `widgetAccessible`, `widgetDescription`, `prefersBorder`, `sandboxDomain` and `displayMode` were accepted and ignored, and the startup warning named them too.

#### Templates

FrontMCP decides what a `template` is from its value:

| `template` | `ui/type` | The page |
| --- | --- | --- |
| A function whose name doesn't start with a capital, like `` (ctx) => ctx.helpers.html`…` `` | `"html"` | Called with [`ctx`](#the-template-context) on every call. What it returns is the page's `<body>`: markup built with `html`, or a plain string, which is escaped and shown as text ([Escaping string results](#escaping-string-results)). |
| A string containing `<` and `>` | `"html"` | The string, as written. It's your markup, so it's never escaped. |
| Any other string | `"markdown"` | Converted to HTML. See [Markdown templates](#markdown-templates). |
| `{ file: "/abs/path/weather.widget.tsx" }` | `"react"` | A React component file, bundled with esbuild. See [Widget components](https://frontmcp.dev/reference/ui/components#building-a-widget-file). |
| A function whose name starts with a capital | `"react"`, whatever it returns | Called like any other template function. What it returns is the page, if it's markup. |
| A React component: a function that returns a React element, a class with `render()`, or `React.memo()` | `"react"`; `"auto"` for `React.memo()`, and `"html"` for a function named in lower case | An empty `<div id="root">`. The page shows "Loading widget..." and nothing else. The server logs a warning once per tool: at startup for a class, a `React.memo()` and a capitalized function whose code calls `jsx()` or `React.createElement()`, and on the first call for any other function. |

> **Pitfall: A React component passed as template doesn't render**
`template: WeatherCard`, where `WeatherCard` returns JSX, sends the page without it: an empty `<div id="root">`. FrontMCP has no source file to bundle, and says so when the server starts: ``Tool "get_weather": `ui.template` is a React component reference, which cannot be bundled (there is no source file to compile), so the widget would render an empty page. Use `template: { file: './widget.tsx' }` instead.`` Put the component in a `.tsx` file and pass `{ file }`.

Changed in 1.8.7: a function named with a capital, like `const WeatherTemplate = (ctx) => …`, used to be taken for a React component, never called, and answered with the empty page. It's called now, and its markup is the page. The tool is still listed as `"react"` in `frontmcp/type` and `ui/type`.

#### Markdown templates

A string with no `<` and `>` in it is Markdown, and FrontMCP converts it to HTML once the page is built:

| Markdown | Becomes |
| --- | --- |
| `# Title` to `###### Title` | `<h1>` to `<h6>` |
| Lines of text | `<p>`, the lines joined with spaces |
| `**bold**`, `*emphasis*`, `` `code` `` | `<strong>`, `<em>`, `<code>` |
| `[label](https://example.com)` | `<a href="…" rel="noopener noreferrer">` for `http:`, `https:`, `mailto:`, `/` and `#` links. Any other link, like `javascript:`, is dropped and its label stays. |
| `- item`, `* item`, `1. item` | `<ul>` or `<ol>` |
| A fenced code block | `<pre><code>`, escaped |

Everything else stays text: block quotes, tables, rules, images and nested lists show their markers. All text is escaped, so a `<` alone in a Markdown string shows as `&lt;`. A string with both `<` and `>`, though, is taken for HTML and is put into the page as written, so Markdown can't carry raw HTML. MDX isn't converted.

#### The template context

`ctx` is a `TemplateContext<In, Out>`, exported as a type by `@frontmcp/sdk`:

| Field | Value |
| --- | --- |
| `input` | The call's arguments. |
| `output` | What `execute()` returned. A tool that returns a string gets the string. With an `outputSchema`, only the fields it declares: [Fields the schema doesn't declare](#fields-the-schema-doesnt-declare). |
| `structuredContent` | Always `undefined` in 1.9.4. |
| `helpers` | The helpers below. |

| Helper | What it does |
| --- | --- |
| `` html`…` `` | Builds trusted markup. Each interpolated value is escaped unless it's trusted markup itself; arrays are joined with nothing between them; `null`, `undefined` and `false` render nothing. |
| `trustedHtml(markup)` | Marks markup you wrote or sanitized yourself as trusted, so `html` inserts it as is. Never pass it tool output. |
| `escapeHtml(value)` | Escapes `&`, `<`, `>`, `"` and `'`. `null` and `undefined` become `""`. Inside `html` it escapes twice. |
| `jsonEmbed(data)` | JSON that is safe inside a `<script>`: `<`, `>` and `&` become `\u003c`, `\u003e` and `\u0026`. Wrap it in `trustedHtml` to put it in an `html` template. |
| `formatCurrency(amount, currency = "USD")` | `formatCurrency(12.5, "EUR")` is `"€12.50"`. |
| `formatDate(date, format?)` | A `Date` or ISO string in the server's locale and time zone. |
| `uniqueId(prefix = "mcp")` | `"w-1"`, `"w-2"`, `"mcp-3"`, …: one counter for the whole process, so the next call goes on from `4`. |

`html`, `trustedHtml` and `isTrustedHtml` are also exported by `@frontmcp/uipack`, for markup built in other files ([Widget components](https://frontmcp.dev/reference/ui/components#frontmcpuipack)).

#### What the client receives

In `tools/list`, the tool gets a `_meta`:

```json
{
  "ui": { "resourceUri": "ui://widget/get_weather.html" },
  "frontmcp/type": "html"
}
```

`_meta.ui` also carries `csp` and the [size hints](#sizing) when you set them, a tool with `widgetAccessible` adds `"openai/widgetAccessible"`, and static and hybrid tools add `"frontmcp/cdn": { "base": "https://esm.sh", "dependencies": [] }`. A tool name with an app prefix is percent-encoded: `north:get_weather` is advertised as `ui://widget/north%3Aget_weather.html`. A custom [`resourceUri`](#the-ui-option) is advertised as written.

In the `tools/call` result, served [inline](#serving-modes), `_meta` gets:

| Key | Value |
| --- | --- |
| `ui/html` | The page: a whole HTML document. Even a one-line template makes about 38 KB, most of it [the bridge script](https://frontmcp.dev/reference/ui/hosts#the-bridge). |
| `ui/type` | `"html"`, `"markdown"` or `"react"`, from [Templates](#templates). |
| `ui/mimeType` | `"text/html;profile=mcp-app"`. |
| `ui/preferredHeight`, `ui/minHeight`, `ui/maxHeight`, `ui/aspectRatio` | The size hints you set. |

The page embeds the call: a script sets `window.__mcpToolName`, `window.__mcpToolInput` and `window.__mcpToolOutput` (with `<` written as `\u003c`), and `window.__mcpStructuredContent`, which is always `null`. A `displayMode` other than `"inline"` adds `window.__mcpDisplayMode`.

#### Fields the schema doesn't declare

A tool with an `outputSchema` sends only the fields it declares, at every level, whether or not it has a `ui`: `structuredContent`, the text the model reads, and the page's `ctx.output` and `window.__mcpToolOutput` all lose the rest. A tool that returns a whole database row, and declares three of its fields, sends those three ([Declaring what the widget gets](#declaring-what-the-widget-gets)). A field the schema requires but the tool didn't return still fails the call, with `_meta.code` `INVALID_OUTPUT`.

Changed in 1.8.7: with `ui` set, FrontMCP used to skip this step, and sent whatever `execute()` returned, undeclared fields included, to the model and into the page. A widget that read a field the schema doesn't declare now gets `undefined` for it: declare the field.

#### The widget resource

`resources/templates/list` gains one template, `static-widget`: `ui://widget/{toolName}.html`, MIME type `text/html;profile=mcp-app`. `resources/list` doesn't change. `resources/read` of `ui://widget/<tool>.html`, with the name encoded or not, or of the tool's custom `resourceUri`, returns one `text` content with that MIME type:

| Tool | The page |
| --- | --- |
| Served `"static"` or `"hybrid"`, set on the tool or [inherited](#defaults-for-the-server-and-an-app) | Rendered once, when the server starts. The template gets `input` and `output` both `{}`, and the page sets `window.__mcpToolInput` and `window.__mcpToolOutput` to `null`, so the widget waits for the host's data. |
| Any other mode | A page without your template: the bridge, an empty body, and `window.__mcpToolOutput = null`. |
| No tool with a `ui` by that name | An error: `-32602` `Resource not found: ui://widget/<name>.html`. A `ui://` URI that isn't `ui://widget/<name>.html` or a tool's `resourceUri` fails the same way. |

A read never returns another call's data. With `csp` set, the content carries it as `_meta.ui.csp` and `_meta["ui/csp"]`, with your fields and a snake-case copy of each, `{ connectDomains, resourceDomains, connect_domains, resource_domains }`, and as `_meta["openai/widgetCSP"]` in snake case only, from the moment the server starts. `prefersBorder`, `sandboxDomain` and `widgetDescription` add their fields to the same `_meta` ([The `ui` option](#the-ui-option)); a tool with none of these has no `_meta` on its resource.

Changed in 1.9.3: `_meta.ui.csp` and `_meta["ui/csp"]` had only the snake-case fields, while a host that follows the MCP Apps spec reads `connectDomains` and `resourceDomains`. There was no `openai/widgetCSP`.

Changed in 1.8.7: an inline tool's resource used to carry it only once the tool had been called on that server process, a custom `resourceUri` failed with `Resource not found`, and any `ui://widget/<name>.html` read back an empty page, whether or not a tool had that name.

Changed in 1.9: a static page used to set `window.__mcpToolOutput = {}`, and the bridge took that `{}` for the call's output: a `.tsx` widget rendered with `output` set to `{}`, and under the OpenAI Apps SDK the output was `{}` until the host assigned a new `toolOutput` ([The OpenAI Apps SDK](https://frontmcp.dev/reference/ui/hosts#the-openai-apps-sdk)).

### Serving modes

`servingMode` decides where the page goes. A tool that doesn't set it takes its app's default, else the server's, else `"auto"` ([below](#defaults-for-the-server-and-an-app)). Clients FrontMCP detects as `"gemini"` get no widget in any mode; for the others:

| `servingMode` | The `tools/call` result | `resources/read` |
| --- | --- | --- |
| `"auto"` (default), `"inline"` | `_meta["ui/html"]`, rendered with this call's data. | The empty page. |
| `"static"` | No `ui/*` keys. | The page rendered at startup, with empty data. The widget reads the call's result from the host. |
| `"hybrid"` | For clients detected as `"openai"`, `"cursor"` or `"ext-apps"`: `_meta["ui/component"]` is `{ type, hash, toolName }`, with `ui/type` and `ui/mimeType`. It holds no HTML and no component code. For every other client, no `ui/*` keys. | As `"static"`. |
| `"direct-url"`, `"custom-url"` | As `"inline"`. `directPath` and `customWidgetUrl` are ignored. | The empty page. |

At startup the server warns about the three modes that aren't what their names suggest, once per tool: ``Tool "x": `ui.servingMode: 'direct-url'` is not implemented; the widget is served inline, as with `servingMode: 'inline'`.`` (and the same for `'custom-url'`), and ``Tool "x": `ui.servingMode: 'hybrid'` sends only a reference in `_meta['ui/component']` ({ type, hash, toolName }), not the component code; the widget is rendered from its `ui://` resource.``

A static template runs at startup with `ctx.output` set to `{}`. One that reads deeper, like `ctx.output.city.toUpperCase()`, throws: the server logs `Failed to compile static widget for tool "…"`, still starts, and serves the empty page for that tool.

How each detected platform treats the page is on [Hosts and platforms](https://frontmcp.dev/reference/ui/hosts#what-each-platform-gets).

#### Defaults for the server and an app

`@FrontMcp({ ui: { servingMode } })` sets the mode of every tool that doesn't set its own, and `@App({ ui: { servingMode } })` that of the app's tools. The tool's own `servingMode` wins, then its app's, then the server's ([One serving mode for every tool](#one-serving-mode-for-every-tool)). A tool that inherits `"static"` is rendered at startup and served from its resource, as if it said `"static"` itself, and `tools/list` gives it the same `_meta`.

Both take the same six values. Any other value throws a `ZodError` where the class is decorated, so the server doesn't start: `Invalid option: expected one of "auto"|"inline"|"static"|"hybrid"|"direct-url"|"custom-url"`, at the path `ui.servingMode`. A default of `"hybrid"`, `"direct-url"` or `"custom-url"` gets the startup warning once, for the server or the app, not once per tool: ``App "desk" `@App({ ui })`: `ui.servingMode: 'custom-url'` is not implemented; tools that inherit it are served inline, as with `servingMode: 'inline'`.``, and the same with `` `@FrontMcp({ ui })` `` for the server's.

New in 1.9.4: before, `ui.servingMode` on `@FrontMcp` or `@App` was accepted and ignored, whatever its value, and each tool set its own.

### Content Security Policy

`csp` tells the host which origins the widget needs, and the page's own policy follows it. `connectDomains` is for `fetch`, XHR and WebSocket requests, `resourceDomains` for images, scripts, fonts and styles, and for requests too. FrontMCP lists them in `tools/list` (`_meta.ui.csp`, as you wrote them) and on the resource (`_meta.ui.csp` and `_meta["ui/csp"]`, as you wrote them and in snake case, and `_meta["openai/widgetCSP"]`, in snake case), and builds the page's `<meta http-equiv="Content-Security-Policy">` from them.

Without `csp`, every widget gets the same policy: `default-src 'none'`; scripts and styles from `'self'`, `'unsafe-inline'` and five CDNs (`cdn.jsdelivr.net`, `cdnjs.cloudflare.com`, `fonts.googleapis.com`, `fonts.gstatic.com`, `esm.sh`); images and fonts from `'self'`, `data:` and those CDNs; `connect-src` those CDNs only; `object-src 'self' data:`.

With `csp`, your origins are added to the CDNs:

| Directive | What it becomes |
| --- | --- |
| `script-src`, `style-src`, `img-src`, `font-src` | As above, plus your `resourceDomains`. |
| `connect-src` | The five CDNs, your `resourceDomains` and your `connectDomains`. |

These origins reach the page's policy: `https:` and `wss:` ones, like `https://api.example.com`, a path like `https://api.example.com/v1`, `wss://live.example.com`, or a wildcard subdomain like `https://*.example.com`; and `http:` and `ws:` ones on `localhost`, `127.0.0.1` or `[::1]`, like `http://localhost:3000`. Anything else, like `http://api.example.com` or a bare host name, is left out of the page's policy, and so is a value with a space, a `;`, a `,` or a quote in it, which could otherwise add a directive of its own. The server names each one once, at startup: ``Tool "get_forecast_local": `ui.csp.connectDomains` origin "http://api.weather.example" is not an https:// or wss:// origin (or http:// / ws:// on localhost), so the widget page's Content-Security-Policy leaves it out.`` `tools/list` and the resource still list every origin as you wrote it.

A browser enforces the page's policy, and a host may enforce one of its own around the frame, built from the lists it reads: `csp` is how a widget tells it what to allow.

Changed in 1.8.7: `csp` used to be only listed for the host. The page's policy was the same for every widget, and a `fetch()` to your API was blocked with `violates the following Content Security Policy directive: "connect-src https://cdn.jsdelivr.net …"` even when the host allowed the origin.

Changed in 1.9: `connectDomains` used to replace four of the five CDNs in `connect-src`, leaving `https://esm.sh`, and only `https:` origins counted: `wss://` and `http://localhost` origins were left out, with a `console.warn` saying `Invalid CSP domain ignored`.

### Escaping string results

A template function's result is markup only when it's built with `html` or `trustedHtml`. A plain string is text: FrontMCP escapes all of it, tags included, so `` `<p>${x}</p>` `` shows `<p>` and `</p>` on the page and `x` can't add tags. `escapeStringResults`, set on the tool or for the whole server with `@FrontMcp({ ui: { escapeStringResults } })`, changes that, and the tool's setting wins:

| `escapeStringResults` | A plain string, like `` `<p>${x}</p>` `` | `html` or `trustedHtml` markup |
| --- | --- | --- |
| unset | Escaped, and shown as text. If it has `<` and `>` in it, FrontMCP logs a notice once per tool name. | Rendered as markup |
| `true` | Escaped, and shown as text, without the notice | Rendered as markup |
| `false` | Rendered as markup, so `x` can inject tags | Rendered as markup |

The notice reads `[frontmcp] The UI template of tool "get_weather" returned a plain string containing markup. Since 1.9.2, FrontMCP HTML-escapes plain string results by default, so it is shown as text. Build the markup with ctx.helpers.html…`. A static string template, `template: "<p>…</p>"`, is your markup and is never escaped, whatever `escapeStringResults` says.

[`create()`](https://frontmcp.dev/reference/sdk/create) takes the server-level option too: `create({ ui: { escapeStringResults: false }, tools })` renders every tool's plain strings as markup. Changed in 1.9.3: `create()` dropped a server-level `ui`, so `escapeStringResults` had to be set on each tool.

Before 1.9.2, an unset `escapeStringResults` rendered a plain string as markup, and logged a warning that a later version would escape it. A template that relied on that now shows its tags as text: build it with `html`, or set `escapeStringResults: false`.

### Sizing

`preferredHeight`, `minHeight`, `maxHeight`, `aspectRatio` and `autoResize` go to three places: `_meta.ui` in `tools/list`, `_meta["ui/preferredHeight"]` and the others in the call result (not `autoResize`), and `window.__mcpWidgetSizing` in the page, which sets them as CSS on the page. A number is pixels, a string any CSS length.

With a size hint set, a widget in an [MCP Apps host](https://frontmcp.dev/reference/ui/hosts#mcp-apps-hosts) reports its size with the standard `ui/notifications/size-changed` notification, `{ width, height }` in pixels: once the handshake has settled, and again whenever the content changes the height. `autoResize: false` turns that off, for a `.tsx` widget too, and a tool with no size hint reports nothing. The height is that of the whole `<html>` element, margins included, so it goes down as well as up, and a `maxHeight` in pixels caps it ([The size](https://frontmcp.dev/reference/ui/hosts#the-size)).

Changed in 1.8.6: the page measured `#root` or `<body>`, which left margins out, and its first report could be lost, since it went out before the host had answered the bridge's first message.

Changed in 1.8.7: the page used to send FrontMCP's own `ui/setSize` request, which a host that implements only the MCP Apps spec never handled. A host that handled only `ui/setSize` now needs to handle the notification.

### Theme

When the host says its theme is `light` or `dark`, the bridge sets `<meta name="color-scheme">` and `<html data-theme>` on the page, and calls the `onContextChange` callbacks. A page with no `color-scheme` of its own then follows the host: the browser's default colors, the frame's background and `light-dark()` in your CSS all use the host's scheme, with no script. A `color-scheme` in the page's own CSS wins over the bridge's ([The theme](https://frontmcp.dev/reference/ui/hosts#the-theme)).

### When rendering fails

A template that throws doesn't fail the call. The result goes out without `ui/*` keys, and the server logs `applyUI: UI rendering failed` with the error. The same happens to a `.tsx` template that can't be bundled.

### `@FrontMcp` options

| Option | Default | Description |
| --- | --- | --- |
| `ui.escapeStringResults` | unset | The default for every tool's `escapeStringResults`. |
| `ui.servingMode` | unset | The default for every tool's `servingMode`. An app's `@App({ ui: { servingMode } })` wins over it for that app's tools. See [Defaults for the server and an app](#defaults-for-the-server-and-an-app). |
| `extApps.enabled` | `true` | With `false`, `ui/*` requests on session-based HTTP answer `-32601` `Method not found: ui/…`. See [`ui/*` requests](https://frontmcp.dev/reference/ui/hosts#ui-requests-on-a-session). |
| `extApps.hostCapabilities` | `{ serverToolProxy: true, logging: true }` | What `ui/initialize` advertises. `openLink`, `modelContextUpdate` and `widgetTools` are advertised as `false` whatever you set, since FrontMCP can't do them; setting one to `true` logs `onExtApps: extApps.hostCapabilities openLink, modelContextUpdate, widgetTools are not supported yet and are not advertised`. |

#### Caveats

- Serving a widget inline puts about 38 KB into every result of the tool. A widget that calls the tool itself gets the data without the page, when its host passes on the marker the bridge puts on the call; under the OpenAI Apps SDK it gets the page ([Calling a tool from a widget](https://frontmcp.dev/reference/ui/hosts#calling-a-tool-from-a-widget)).
- A client on MCP 2026-07-28 that declares the MCP Apps extension in its capabilities is the `"ext-apps"` platform, whatever its name, unless one of your `platformDetection.mappings` matches the name ([How FrontMCP recognizes the client](https://frontmcp.dev/reference/ui/hosts#how-frontmcp-recognizes-the-client)).
- A test can check a widget's page with `@frontmcp/testing`'s UI matchers, like `toHaveRenderedHtml()`: see [UI matchers](https://frontmcp.dev/reference/testing/matchers#ui-matchers).

---

## Usage

### Adding a widget to a tool

`get_weather` renders a card. The Playground calls it, and the Call tab shows the result, the page in `_meta["ui/html"]` included. The tests read what a client receives in `tools/list` and in the call.

```ts get-weather.tool.ts active
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

type Input = { city: string };
type Output = { city: string; tempC: number; conditions: string };

@Tool({
  name: "get_weather",
  description: "Current weather for a city",
  inputSchema: { city: z.string() },
  outputSchema: { city: z.string(), tempC: z.number(), conditions: z.string() },
  ui: {
    template: (ctx: TemplateContext<Input, Output>) => ctx.helpers.html`
      <div class="weather">
        <h2>${ctx.output.city}</h2>
        <p>${ctx.output.tempC}°C, ${ctx.output.conditions}</p>
      </div>`,
    invocationStatus: { invoking: "Checking the weather…", invoked: "Weather loaded" },
  },
})
export class GetWeather extends ToolContext {
  async execute({ city }: Input): Promise<Output> {
    return { city, tempC: 21, conditions: "Sunny" };
  }
}
```

```ts widget.test.ts
import { test, expect } from "@frontmcp/testing";

test("tools/list advertises the widget resource", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool._meta).toEqual({
    ui: { resourceUri: "ui://widget/get_weather.html" },
    "ui/toolInvocation/invoking": "Checking the weather…",
    "ui/toolInvocation/invoked": "Weather loaded",
    "frontmcp/type": "html",
  });
});

test("the model's result is unchanged", async ({ mcp }) => {
  const result = await mcp.tools.call("get_weather", { city: "Oslo" });
  expect(result.json()).toEqual({ city: "Oslo", tempC: 21, conditions: "Sunny" });
  expect(result.raw.structuredContent).toEqual({ city: "Oslo", tempC: 21, conditions: "Sunny" });
});

test("the page comes in _meta", async ({ mcp }) => {
  const { _meta } = (await mcp.tools.call("get_weather", { city: "Oslo" })).raw;
  expect(_meta["ui/type"]).toBe("html");
  expect(_meta["ui/mimeType"]).toBe("text/html;profile=mcp-app");
  expect(_meta["ui/html"]).toMatch(/^<!DOCTYPE html>/);
  expect(_meta["ui/html"]).toContain("<h2>Oslo</h2>");
  expect(_meta["ui/html"]).toContain('window.__mcpToolOutput = {"city":"Oslo","tempC":21,"conditions":"Sunny"};');
});

test("interpolated values are escaped", async ({ mcp }) => {
  const { _meta } = (await mcp.tools.call("get_weather", { city: "<img src=x onerror=alert(1)>" })).raw;
  expect(_meta["ui/html"]).toContain("<h2>&lt;img src=x onerror=alert(1)&gt;</h2>");
});
```

### Template kinds

`notes` gives a Markdown string, which FrontMCP converts. `mixed` has a `<b>` tag in its string, so it's taken for HTML and isn't converted. `capital` is a template function with a capital in its name, which works like any other. `component` is a class with `render()`, which FrontMCP takes for a React component it can't bundle: its page is an empty `<div id="root">`, and the Logs tab has the warning.

```ts templates.ts active
import { Tool, ToolContext, type TemplateContext } from "@frontmcp/sdk";

function Card(ctx: TemplateContext<{}, {}>) {
  return ctx.helpers.html`<p>from a capitalized function</p>`;
}

class Widget {
  render() {
    return null;
  }
}

@Tool({
  name: "notes",
  description: "Release notes, as Markdown",
  inputSchema: {},
  ui: { template: "# Release notes\n\nSome **bold**, `code` and a [link](https://example.com).\n\n- one\n- two\n\nA [bad link](javascript:alert(1)) and a < sign." },
})
export class Notes extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

@Tool({ name: "mixed", description: "A string with a tag in it", inputSchema: {}, ui: { template: "# Not converted\n\n<b>raw</b>" } })
export class Mixed extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

@Tool({ name: "capital", description: "A capitalized template function", inputSchema: {}, ui: { template: Card } })
export class Capital extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

@Tool({ name: "component", description: "A React component reference", inputSchema: {}, ui: { template: Widget as any } })
export class Component extends ToolContext {
  async execute() {
    return { ok: true };
  }
}
```

```ts templates.test.ts
import { test, expect } from "@frontmcp/testing";

const page = async (mcp: any, name: string) => (await mcp.tools.call(name, {})).raw._meta;
const body = (html: string) => html.slice(html.indexOf("<body>") + 6, html.indexOf("</body>")).replace(/<script>[\s\S]*?<\/script>/g, "").trim();

test("a Markdown string is converted to HTML", async ({ mcp }) => {
  const meta = await page(mcp, "notes");
  expect(meta["ui/type"]).toBe("markdown");
  const html = body(meta["ui/html"]);
  expect(html).toContain("<h1>Release notes</h1>");
  expect(html).toContain("<strong>bold</strong>");
  expect(html).toContain("<code>code</code>");
  expect(html).toContain('<a href="https://example.com" rel="noopener noreferrer">link</a>');
  expect(html).toContain("<ul><li>one</li><li>two</li></ul>");
});

test("an unsafe link is dropped and a bare < is escaped", async ({ mcp }) => {
  const html = body((await page(mcp, "notes"))["ui/html"]);
  expect(html).not.toContain("javascript:");
  expect(html).toContain("A bad link) and a &lt; sign.");
});

test("a string with a tag in it is HTML, as written", async ({ mcp }) => {
  const meta = await page(mcp, "mixed");
  expect(meta["ui/type"]).toBe("html");
  expect(body(meta["ui/html"])).toBe("# Not converted\n\n<b>raw</b>");
});

test("a function with a capital in its name is called, and listed as react", async ({ mcp }) => {
  const meta = await page(mcp, "capital");
  expect(body(meta["ui/html"])).toBe("<p>from a capitalized function</p>");
  expect(meta["ui/type"]).toBe("react");
  const tool = (await mcp.tools.list()).find((t) => t.name === "capital");
  expect(tool._meta["frontmcp/type"]).toBe("react");
});

test("a React component reference renders an empty root", async ({ mcp }) => {
  const meta = await page(mcp, "component");
  expect(body(meta["ui/html"])).toBe('<div id="root"></div>');
});
```

### Reading the widget as a resource

A host that loads widgets with `resources/read` gets the page from the resource instead of from the result. For an inline tool that page is empty, since only a call has data. `report` is served statically: its page is rendered once at startup, with empty data, and its call results carry no page at all. `card` advertises a `resourceUri` of its own, which reads back the same page as `ui://widget/card.html`.

```ts tools.ts active
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

type Report = { title: string; rows: number };

@Tool({
  name: "report",
  description: "Build the weekly report",
  inputSchema: { week: z.number() },
  ui: {
    servingMode: "static",
    // Runs at startup with ctx.output = {}: read fields defensively.
    template: (ctx: TemplateContext<{ week: number }, Partial<Report>>) =>
      ctx.helpers.html`<h1 id="title">${ctx.output.title ?? "Loading…"}</h1>`,
  },
})
export class BuildReport extends ToolContext {
  async execute({ week }: { week: number }) {
    return { title: `Week ${week}`, rows: 42 };
  }
}

@Tool({
  name: "get_weather",
  description: "Current weather for a city",
  inputSchema: { city: z.string() },
  ui: { template: (ctx: TemplateContext<{ city: string }, { city: string }>) => ctx.helpers.html`<p>${ctx.output.city}</p>` },
})
export class GetWeather extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city };
  }
}

@Tool({
  name: "card",
  description: "A card with a resource URI of its own",
  inputSchema: {},
  ui: { resourceUri: "ui://my-app/card.html", template: (ctx: TemplateContext<{}, {}>) => ctx.helpers.html`<p id="card">A card</p>` },
})
export class Card extends ToolContext {
  async execute() {
    return { ok: true };
  }
}
```

```ts resource.test.ts
import { test, expect } from "@frontmcp/testing";

test("the widget template is listed, not the widgets", async ({ mcp }) => {
  expect(await mcp.resources.list()).toEqual([]);
  expect(await mcp.resources.listTemplates()).toContainResourceTemplate("ui://widget/{toolName}.html");
});

test("a static widget is rendered once, with empty data", async ({ mcp }) => {
  const page = await mcp.resources.read("ui://widget/report.html");
  expect(page).toHaveMimeType("text/html;profile=mcp-app");
  expect(page.text()).toContain('<h1 id="title">Loading…</h1>');
  expect(page.text()).toContain("window.__mcpToolOutput = null;");
});

test("a static tool's result carries no page", async ({ mcp }) => {
  const result = await mcp.tools.call("report", { week: 3 });
  expect(result.json()).toEqual({ title: "Week 3", rows: 42 });
  expect(Object.keys(result.raw._meta).filter((k) => k.startsWith("ui/"))).toEqual([]);
});

test("an inline tool's resource has no data", async ({ mcp }) => {
  await mcp.tools.call("get_weather", { city: "Oslo" });
  const page = await mcp.resources.read("ui://widget/get_weather.html");
  expect(page.text()).toContain("window.__mcpToolOutput = null;");
  expect(page.text()).not.toContain("Oslo");
});

test("tools/list advertises a custom resourceUri as written, and both URIs read", async ({ mcp }) => {
  const card = (await mcp.tools.list()).find((tool) => tool.name === "card");
  expect(card._meta.ui.resourceUri).toBe("ui://my-app/card.html");
  const custom = (await mcp.resources.read("ui://my-app/card.html")).text();
  const standard = (await mcp.resources.read("ui://widget/card.html")).text();
  expect(custom).toBe(standard);
  expect(custom).toContain('window.__mcpToolName = "card";');
});

test("a widget no tool has is an error", async ({ mcp }) => {
  const page = await mcp.resources.read("ui://widget/no_such_tool.html");
  expect(page).toBeError(-32602);
  expect(page.error.message).toBe("Resource not found: ui://widget/no_such_tool.html");
  expect(await mcp.resources.read("ui://other/card.html")).toBeError(-32602);
});
```

### Choosing a serving mode per platform

The same template, served three ways, called by two clients. The tests name the client in the request's client info, as a real client does: `openai-mcp` is detected as `"openai"`, `my-mcp-client` as `"generic-mcp"`, and `gemini-cli` as `"gemini"`.

```ts tools.ts active
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

const card = (ctx: TemplateContext<{ city: string }, { city: string }>) => ctx.helpers.html`<p>${ctx.output.city}</p>`;

@Tool({ name: "inline_card", description: "Inline widget", inputSchema: { city: z.string() }, ui: { template: card } })
export class InlineCard extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city };
  }
}

@Tool({ name: "static_card", description: "Static widget", inputSchema: { city: z.string() }, ui: { template: card, servingMode: "static" } })
export class StaticCard extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city };
  }
}

@Tool({ name: "hybrid_card", description: "Hybrid widget", inputSchema: { city: z.string() }, ui: { template: card, servingMode: "hybrid" } })
export class HybridCard extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city };
  }
}
```

```ts modes.test.ts
import { test, expect } from "@frontmcp/testing";

// Calls a tool as the client named `client`, and returns the ui/* keys of the result.
async function uiKeys(mcp: any, client: string, name: string) {
  const body = await mcp.raw.request({
    method: "tools/call",
    params: { name, arguments: { city: "Oslo" }, _meta: { "io.modelcontextprotocol/clientInfo": { name: client, version: "1.0.0" } } },
  });
  return Object.keys(body.result._meta).filter((k) => k.startsWith("ui/"));
}

test("inline: the page is in every result", async ({ mcp }) => {
  expect(await uiKeys(mcp, "my-mcp-client", "inline_card")).toEqual(["ui/html", "ui/type", "ui/mimeType"]);
  expect(await uiKeys(mcp, "openai-mcp", "inline_card")).toEqual(["ui/html", "ui/type", "ui/mimeType"]);
});

test("static: no page in the result", async ({ mcp }) => {
  expect(await uiKeys(mcp, "openai-mcp", "static_card")).toEqual([]);
});

test("hybrid: a component payload, for some platforms only", async ({ mcp }) => {
  expect(await uiKeys(mcp, "openai-mcp", "hybrid_card")).toEqual(["ui/component", "ui/type", "ui/mimeType"]);
  expect(await uiKeys(mcp, "my-mcp-client", "hybrid_card")).toEqual([]);
});

test("the component payload has no code in it", async ({ mcp }) => {
  const body = await mcp.raw.request({
    method: "tools/call",
    params: { name: "hybrid_card", arguments: { city: "Oslo" }, _meta: { "io.modelcontextprotocol/clientInfo": { name: "openai-mcp", version: "1.0.0" } } },
  });
  expect(body.result._meta["ui/component"]).toEqual({ type: "html", hash: expect.any(String), toolName: "hybrid_card" });
});

test("`gemini-cli` gets no widget at all", async ({ mcp }) => {
  expect(await uiKeys(mcp, "gemini-cli", "inline_card")).toEqual([]);
});
```

### One serving mode for every tool

`@FrontMcp({ ui: { servingMode: "static" } })` serves every tool static, unless its app or the tool itself says otherwise. `get_weather` takes the server's mode, `get_alerts` sets `"inline"` itself, and the `forecasts` app serves its tools inline. The tests read what each one sends.

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

const card = (ctx: TemplateContext<{ city: string }, Partial<{ city: string }>>) =>
  ctx.helpers.html`<p id="city">${ctx.output.city ?? "Waiting for the host"}</p>`;

@Tool({ name: "get_weather", description: "Current weather for a city", inputSchema: { city: z.string() }, ui: { template: card } })
class GetWeather extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city };
  }
}

@Tool({ name: "get_alerts", description: "Weather alerts for a city", inputSchema: { city: z.string() }, ui: { template: card, servingMode: "inline" } })
class GetAlerts extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city };
  }
}

@Tool({ name: "get_forecast", description: "Tomorrow's forecast for a city", inputSchema: { city: z.string() }, ui: { template: card } })
class GetForecast extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city };
  }
}

@App({ id: "weather", name: "Weather", tools: [GetWeather, GetAlerts] })
class WeatherApp {}

@App({ id: "forecasts", name: "Forecasts", tools: [GetForecast], ui: { servingMode: "inline" } })
class ForecastsApp {}

@FrontMcp({
  info: { name: "weather", version: "1.0.0" },
  apps: [WeatherApp, ForecastsApp],
  ui: { servingMode: "static" },
})
export default class Server {}
```

```ts defaults.test.ts
import { test, expect } from "@frontmcp/testing";

async function uiKeys(mcp: any, name: string) {
  const result = await mcp.tools.call(name, { city: "Oslo" });
  return Object.keys(result.raw._meta).filter((k) => k.startsWith("ui/"));
}

test("a tool that sets nothing takes the server's static mode", async ({ mcp }) => {
  expect(await uiKeys(mcp, "get_weather")).toEqual([]);
  const page = (await mcp.resources.read("ui://widget/get_weather.html")).text();
  expect(page).toContain('<p id="city">Waiting for the host</p>');
});

test("the tool's own servingMode wins", async ({ mcp }) => {
  expect(await uiKeys(mcp, "get_alerts")).toEqual(["ui/html", "ui/type", "ui/mimeType"]);
});

test("the app's default wins over the server's", async ({ mcp }) => {
  expect(await uiKeys(mcp, "get_forecast")).toEqual(["ui/html", "ui/type", "ui/mimeType"]);
});
```

### Letting a widget reach an API

`csp` lists the origins the widget needs, and the page's own policy allows them, next to the CDNs. `get_forecast` asks for an API and an image host, over HTTPS. `get_forecast_local` lists a local API, a WebSocket and `http://api.weather.example`: the page allows the first two, and plain HTTP to another host only reaches the hosts, in `tools/list`. The **Logs** tab has the server's warning about it.

```ts get-forecast.tool.ts active
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

type Forecast = { city: string; days: { day: string; tempC: number }[] };

const template = (ctx: TemplateContext<{ city: string }, Forecast>) => ctx.helpers.html`
  <h2>${ctx.output.city}</h2>
  <ul>${ctx.output.days.map((d) => ctx.helpers.html`<li>${d.day}: ${d.tempC}°C</li>`)}</ul>`;

const forecast = (city: string): Forecast => ({ city, days: [{ day: "Mon", tempC: 19 }, { day: "Tue", tempC: 17 }, { day: "Wed", tempC: 21 }] });

@Tool({
  name: "get_forecast",
  description: "Three-day forecast for a city",
  inputSchema: { city: z.string() },
  ui: { csp: { connectDomains: ["https://api.weather.example"], resourceDomains: ["https://img.weather.example"] }, template },
})
export class GetForecast extends ToolContext {
  async execute({ city }: { city: string }): Promise<Forecast> {
    return forecast(city);
  }
}

@Tool({
  name: "get_forecast_local",
  description: "The same forecast, from a local API",
  inputSchema: { city: z.string() },
  ui: { csp: { connectDomains: ["http://localhost:3000", "wss://live.weather.example", "http://api.weather.example"] }, template },
})
export class GetForecastLocal extends ToolContext {
  async execute({ city }: { city: string }): Promise<Forecast> {
    return forecast(city);
  }
}

@Tool({
  name: "get_forecast_plain",
  description: "The same forecast, with no csp",
  inputSchema: { city: z.string() },
  ui: { template },
})
export class GetForecastPlain extends ToolContext {
  async execute({ city }: { city: string }): Promise<Forecast> {
    return forecast(city);
  }
}
```

```ts csp.test.ts
import { test, expect } from "@frontmcp/testing";

const policy = async (mcp: any, name: string) => {
  const html: string = (await mcp.tools.call(name, { city: "Oslo" })).raw._meta["ui/html"];
  const directives = html.match(/Content-Security-Policy" content="([^"]*)"/)![1].replaceAll("&#39;", "'").split("; ");
  return Object.fromEntries(directives.map((d) => [d.split(" ")[0], d.split(" ").slice(1)]));
};

test("tools/list names the origins", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "get_forecast");
  expect(tool._meta.ui.csp).toEqual({ connectDomains: ["https://api.weather.example"], resourceDomains: ["https://img.weather.example"] });
});

test("so does the resource, before any call, in snake case too", async ({ mcp }) => {
  const openai = { connect_domains: ["https://api.weather.example"], resource_domains: ["https://img.weather.example"] };
  const csp = { ...openai, connectDomains: ["https://api.weather.example"], resourceDomains: ["https://img.weather.example"] };
  const page = await mcp.resources.read("ui://widget/get_forecast.html");
  expect(page.raw.contents[0]._meta).toEqual({ ui: { csp }, "ui/csp": csp, "openai/widgetCSP": openai });
});

const cdns = ["https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.googleapis.com", "https://fonts.gstatic.com", "https://esm.sh"];

test("the page's own policy allows them, next to the CDNs", async ({ mcp }) => {
  const directives = await policy(mcp, "get_forecast");
  expect(directives["connect-src"]).toEqual([...cdns, "https://img.weather.example", "https://api.weather.example"]);
  expect(directives["img-src"]).toEqual(["'self'", "data:", ...cdns, "https://img.weather.example"]);
  expect(directives["script-src"]).toContain("https://img.weather.example");
  expect(directives["script-src"]).not.toContain("https://api.weather.example");
});

test("without csp, connect-src is the five CDNs", async ({ mcp }) => {
  const directives = await policy(mcp, "get_forecast_plain");
  expect(directives["connect-src"]).toEqual(cdns);
  expect(directives["img-src"]).not.toContain("https://img.weather.example");
});

test("plain http reaches the page's policy only on localhost, though hosts are told about every origin", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "get_forecast_local");
  expect(tool._meta.ui.csp.connectDomains).toEqual(["http://localhost:3000", "wss://live.weather.example", "http://api.weather.example"]);
  const directives = await policy(mcp, "get_forecast_local");
  expect(directives["connect-src"]).toEqual([...cdns, "http://localhost:3000", "wss://live.weather.example"]);
});

test("arrays of markup are joined", async ({ mcp }) => {
  const html: string = (await mcp.tools.call("get_forecast", { city: "Oslo" })).raw._meta["ui/html"];
  expect(html).toContain("<ul><li>Mon: 19°C</li><li>Tue: 17°C</li><li>Wed: 21°C</li></ul>");
});
```

This was checked in Chromium, with a local server at `http://localhost:47604` named in the lists and the page served from another local port: the widget's `fetch()` and `<img>` loaded. With no `csp`, both were blocked, and the console said `violates the following Content Security Policy directive`. With `connectDomains` alone the `fetch()` loaded and the image was blocked. With `resourceDomains` alone both loaded, since its origins are in `connect-src` too.

### Escaping string results

`plain` returns a plain string, so FrontMCP escapes all of it: the page shows the `<p>` tags and the city's `<b>` as text, and the **Logs** tab has the notice. `built` uses `html`, which escapes the value and keeps the markup. `raw` sets `escapeStringResults: false`, so its string is rendered as markup, and a city name can inject tags.

<Playground call={{ tool: "plain", arguments: { city: "<b>Oslo</b>" } }}>

```ts tools.ts active
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

type Ctx = TemplateContext<{ city: string }, { city: string }>;

@Tool({ name: "plain", description: "Plain string", inputSchema: { city: z.string() }, ui: { template: (ctx: Ctx) => `<p>${ctx.output.city}</p>` } })
export class Plain extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city };
  }
}

@Tool({
  name: "built",
  description: "Built with html",
  inputSchema: { city: z.string() },
  ui: { template: (ctx: Ctx) => ctx.helpers.html`<p>${ctx.output.city}</p>` },
})
export class Built extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city };
  }
}

@Tool({
  name: "raw",
  description: "Plain string, rendered as markup",
  inputSchema: { city: z.string() },
  // 🚩 the city can add tags to the page
  ui: { template: (ctx: Ctx) => `<p>${ctx.output.city}</p>`, escapeStringResults: false },
})
export class Raw extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city };
  }
}
```

```ts escaping.test.ts
import { test, expect } from "@frontmcp/testing";

const body = async (mcp: any, name: string) => {
  const html: string = (await mcp.tools.call(name, { city: "<b>Oslo</b>" })).raw._meta["ui/html"];
  return html.slice(html.indexOf("<body>") + 6, html.indexOf("</body>")).trim();
};

test("a plain string is escaped, tags included", async ({ mcp }) => {
  expect(await body(mcp, "plain")).toBe("&lt;p&gt;&lt;b&gt;Oslo&lt;/b&gt;&lt;/p&gt;");
});

test("html escapes the value and keeps the markup", async ({ mcp }) => {
  expect(await body(mcp, "built")).toBe("<p>&lt;b&gt;Oslo&lt;/b&gt;</p>");
});

test("escapeStringResults: false renders the string as markup", async ({ mcp }) => {
  expect(await body(mcp, "raw")).toBe("<p><b>Oslo</b></p>");
});
```

For the whole server, `@FrontMcp({ ..., ui: { escapeStringResults: false } })` renders every tool's plain strings as markup, and a tool with `escapeStringResults: true` still escapes its own.

### Declaring what the widget gets

`get_user` declares `name` in its `outputSchema` and returns a password hash as well. The model's result, its text and the page all keep to the declared field. `get_user_missing` fails the other way: a field the schema requires isn't there.

```ts tools.ts active
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

const users = { "u-1": { name: "Ada Lovelace", passwordHash: "$2b$10$abc" } } as Record<string, { name: string; passwordHash: string }>;
const card = (ctx: TemplateContext<{ id: string }, { name: string }>) => ctx.helpers.html`<p>${ctx.output.name}</p>`;

@Tool({ name: "get_user", description: "Look up a user", inputSchema: { id: z.string() }, outputSchema: { name: z.string() }, ui: { template: card } })
export class GetUser extends ToolContext {
  async execute({ id }: { id: string }) {
    return users[id]; // the whole record
  }
}

@Tool({ name: "get_user_missing", description: "Look up a user", inputSchema: { id: z.string() }, outputSchema: { name: z.string(), email: z.string() }, ui: { template: card } })
export class GetUserMissing extends ToolContext {
  async execute({ id }: { id: string }) {
    return users[id]; // no email
  }
}
```

```ts fields.test.ts
import { test, expect } from "@frontmcp/testing";

test("the undeclared field never reaches the model", async ({ mcp }) => {
  const result = await mcp.tools.call("get_user", { id: "u-1" });
  expect(result.raw.structuredContent).toEqual({ name: "Ada Lovelace" });
  expect(result.text()).not.toContain("$2b$10$abc");
});

test("or the page", async ({ mcp }) => {
  const html: string = (await mcp.tools.call("get_user", { id: "u-1" })).raw._meta["ui/html"];
  expect(html).toContain("<p>Ada Lovelace</p>");
  expect(html).toContain('window.__mcpToolOutput = {"name":"Ada Lovelace"};');
  expect(html).not.toContain("$2b$10$abc");
});

test("a declared field the tool didn't return fails the call", async ({ mcp }) => {
  const result = await mcp.tools.call("get_user_missing", { id: "u-1" });
  expect(result).toBeError("INVALID_OUTPUT");
  expect(result.raw._meta["ui/html"]).toBeUndefined();
});
```

### Sizing the widget

`chart` sets size hints, and `chart_fixed` turns `autoResize` off.

```ts chart.tool.ts active
import { Tool, ToolContext, type TemplateContext } from "@frontmcp/sdk";

@Tool({
  name: "chart",
  description: "Sales chart",
  inputSchema: {},
  ui: {
    preferredHeight: 320,
    maxHeight: "80vh",
    aspectRatio: "16 / 9",
    template: (ctx: TemplateContext<{}, { points: number[] }>) => ctx.helpers.html`<p>${ctx.output.points.length} points</p>`,
  },
})
export class Chart extends ToolContext {
  async execute() {
    return { points: [3, 5, 8] };
  }
}

@Tool({
  name: "chart_fixed",
  description: "Sales chart in a frame of fixed height",
  inputSchema: {},
  ui: {
    preferredHeight: 320,
    autoResize: false,
    template: (ctx: TemplateContext<{}, { points: number[] }>) => ctx.helpers.html`<p>${ctx.output.points.length} points</p>`,
  },
})
export class ChartFixed extends ToolContext {
  async execute() {
    return { points: [3, 5, 8] };
  }
}
```

```ts sizing.test.ts
import { test, expect } from "@frontmcp/testing";

test("tools/list carries the hints in _meta.ui", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool._meta.ui).toEqual({ resourceUri: "ui://widget/chart.html", preferredHeight: 320, maxHeight: "80vh", aspectRatio: "16 / 9" });
});

test("autoResize: false is listed too, and tells the page not to report", async ({ mcp }) => {
  const fixed = (await mcp.tools.list()).find((tool) => tool.name === "chart_fixed");
  expect(fixed._meta.ui).toEqual({ resourceUri: "ui://widget/chart_fixed.html", preferredHeight: 320, autoResize: false });
  const html: string = (await mcp.tools.call("chart_fixed", {})).raw._meta["ui/html"];
  expect(html).toContain('window.__mcpWidgetSizing = {"preferredHeight":320,"autoResize":false};');
});

test("the result and the page carry them too", async ({ mcp }) => {
  const { _meta } = (await mcp.tools.call("chart", {})).raw;
  expect(_meta["ui/preferredHeight"]).toBe(320);
  expect(_meta["ui/maxHeight"]).toBe("80vh");
  expect(_meta["ui/html"]).toContain('window.__mcpWidgetSizing = {"preferredHeight":320,"maxHeight":"80vh","aspectRatio":"16 / 9"};');
});
```

### Telling the host about the widget

`show_queue` sets the five options a host reads. `widgetAccessible` goes into `tools/list`, three more go onto the widget resource, under the MCP Apps names and the OpenAI Apps SDK's, and `displayMode` goes into the page, which asks the host for full screen once its bridge has connected.

```ts show-queue.tool.ts active
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

type Queue = { team: string; open: number };

@Tool({
  name: "show_queue",
  description: "Show a team's support queue",
  inputSchema: { team: z.string() },
  ui: {
    template: (ctx: TemplateContext<{ team: string }, Queue>) => ctx.helpers.html`<h2>${ctx.output.team}</h2><p>${ctx.output.open} open tickets</p>`,
    widgetAccessible: true,
    widgetDescription: "The team's open tickets, with a button to close each one",
    prefersBorder: true,
    sandboxDomain: "https://queue.desk.example",
    displayMode: "fullscreen",
  },
})
export class ShowQueue extends ToolContext {
  async execute({ team }: { team: string }): Promise<Queue> {
    return { team, open: 3 };
  }
}
```

```ts host-options.test.ts
import { test, expect } from "@frontmcp/testing";

test("tools/list says the widget may call tools", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool._meta["openai/widgetAccessible"]).toBe(true);
});

test("the resource carries the border, the domain and the description", async ({ mcp }) => {
  const page = await mcp.resources.read("ui://widget/show_queue.html");
  expect(page.raw.contents[0]._meta).toEqual({
    ui: { prefersBorder: true, domain: "https://queue.desk.example" },
    "openai/widgetPrefersBorder": true,
    "openai/widgetDomain": "https://queue.desk.example",
    "openai/widgetDescription": "The team's open tickets, with a button to close each one",
  });
});

test("the page asks for full screen", async ({ mcp }) => {
  const html: string = (await mcp.tools.call("show_queue", { team: "billing" })).raw._meta["ui/html"];
  expect(html).toContain('window.__mcpDisplayMode = "fullscreen";');
});
```

An MCP Apps host that offers full screen gets `ui/request-display-mode`, `{ mode: "fullscreen" }`, right after the handshake, and under the OpenAI Apps SDK the bridge calls `window.openai.requestDisplayMode({ mode: "fullscreen" })`; both were checked in Chromium. The Playground's host offers only inline, so the bridge asks it for nothing and the widget stays inline ([Display modes](https://frontmcp.dev/reference/ui/hosts#display-modes)).

### When the template fails

A template that throws costs the widget, not the call.

```ts get-weather.tool.ts active
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

@Tool({
  name: "get_weather",
  description: "Current weather for a city",
  inputSchema: { city: z.string() },
  ui: {
    // 🚩 `forecast` isn't in the output
    template: (ctx: TemplateContext<{ city: string }, any>) => ctx.helpers.html`<p>${ctx.output.forecast.today}</p>`,
  },
})
export class GetWeather extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city, tempC: 21 };
  }
}
```

```ts failure.test.ts
import { test, expect } from "@frontmcp/testing";

test("the call succeeds without a page", async ({ mcp }) => {
  const result = await mcp.tools.call("get_weather", { city: "Oslo" });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({ city: "Oslo", tempC: 21 });
  expect(Object.keys(result.raw._meta).filter((k) => k.startsWith("ui/"))).toEqual([]);
});
```

The server's log has `applyUI: UI rendering failed` and the error, `Cannot read properties of undefined (reading 'today')`.

---

## Troubleshooting

### The widget shows "Loading widget..." and never renders

The page has an empty `<div id="root">` and nothing mounts in it:

- The template is a React component, not a `{ file }`: a function that returns JSX, a class with `render()` or `React.memo()`. FrontMCP has no source to bundle, and logs ``ui.template` is a React component reference, which cannot be bundled`` once, at startup or on the tool's first call ([Templates](#templates)). Move the component to a `.tsx` file.
- It's a `.tsx` template and React couldn't load: in the default `resourceMode: "cdn"` the page imports React from `esm.sh`, and a host or network that blocks it leaves the page on "Loading widget...". `resourceMode: "inline"` bundles React into the page ([Widget components](https://frontmcp.dev/reference/ui/components#resourcemode-cdn-or-inline)).

### The result has no `ui/html`

- The template threw, or its `.tsx` file couldn't be bundled. The server logged `applyUI: UI rendering failed` ([When rendering fails](#when-rendering-fails)).
- `servingMode` is `"static"` or `"hybrid"`, which send no page in the result. A tool that doesn't set it takes its app's `ui.servingMode`, else the server's ([Defaults for the server and an app](#defaults-for-the-server-and-an-app)).
- The client was detected as `"gemini"`, which gets no widget.

### `Dynamic require of "path" is not supported`

Logged with `applyUI: UI rendering failed` for a `{ file }` template in the Playground, which runs FrontMCP in the browser. FrontMCP bundles widget files with esbuild from the file system, so only a server on Node can. See [Widget components](https://frontmcp.dev/reference/ui/components#building-a-widget-file).

Changed in 1.9: a Node server that loads `@frontmcp/sdk` as an ES module (`"type": "module"`) failed the same way. ES module and CommonJS servers both bundle widget files now.

### `FileSource widget "…" needs esbuild, which is not installed`

The server logged it with `applyUI: UI rendering failed` for a `{ file }` template. `esbuild` is an optional peer of `@frontmcp/uipack`, loaded when the tool is called: install it as a dependency ([Widget components](https://frontmcp.dev/reference/ui/components#filesource-widget--needs-esbuild-which-is-not-installed)).

### `FileSource widget not found (ENOENT): no file at "…"`

The compiled tool's `join(__dirname, …)` points where no widget file was copied. The message names the path, and the file under `src/` when the same path exists there. `frontmcp build` copies widget files into its output; `tsc` doesn't ([Widget components](https://frontmcp.dev/reference/ui/components#building-a-widget-file)).

### The widget's frame doesn't fit its content

The page reports its height only when the tool has a size hint, like `autoResize: true`, and not with `autoResize: false`. It sends the `ui/notifications/size-changed` notification, so the host has to handle that, or it keeps its default frame ([Sizing](#sizing)).

### `FileSource widget "./weather.widget.tsx" not found at "…"`

The server logged it with `applyUI: UI rendering failed`. A relative `file` is resolved from the process's working directory, not from the tool's file. Pass an absolute path, like `join(__dirname, "weather.widget.tsx")` in CommonJS.

### `Failed to compile static widget for tool "…"`

A static or hybrid template threw at startup, where `ctx.output` is `{}`. Read fields defensively, like `ctx.output.title ?? "Loading…"`. Until then the tool's resource is the empty page.

### `Resource not found: ui://widget/…`

`resources/read` answers `-32602` for a `ui://` URI that no tool with a `ui` owns: a tool name that doesn't exist or has no `ui`, or a URI that is neither `ui://widget/<tool>.html` nor a tool's `resourceUri`. Read the URI `tools/list` advertises ([The widget resource](#the-widget-resource)).

### The widget can't fetch my API

The browser logged `violates the following Content Security Policy directive: "connect-src https://cdn.jsdelivr.net …"`. The origin isn't in the tool's `ui.csp.connectDomains`, or it's one the page's policy leaves out, like plain HTTP to a host other than `localhost`, and the server named it at startup with ``… is not an https:// or wss:// origin (or http:// / ws:// on localhost), so the widget page's Content-Security-Policy leaves it out`` ([Content Security Policy](#content-security-policy)). Add an HTTPS origin, or send the data in the tool's result, or have the widget call a tool.

### The widget shows its own tags, like `<p>` and `<h2>`, as text

The template returns a plain string, which FrontMCP escapes, tags included, and the server logged ``The UI template of tool "…" returned a plain string containing markup``. Build the markup with `ctx.helpers.html`, which escapes only the values you interpolate ([Escaping string results](#escaping-string-results)). `escapeStringResults: false` renders the string as markup instead, and lets any value in it add tags to the page.

### My Markdown template shows tags, or its syntax

A string with both `<` and `>` is HTML, and goes into the page as written, so its Markdown isn't converted. And only headings, paragraphs, bold, emphasis, code, links, lists and fenced code are converted: block quotes, tables, images and rules show as text ([Markdown templates](#markdown-templates)).

### `ui.… are accepted but not used yet, so they have no effect`

The tool sets options that FrontMCP 1.9.4 reads and ignores ([the `ui` option](#the-ui-option)). Remove them, or keep them for a later version knowing they change nothing. `widgetAccessible`, `widgetDescription`, `prefersBorder`, `sandboxDomain` and `displayMode` are used since 1.9.3, and aren't named any more.

### A field is missing from the widget's data

The tool has an `outputSchema` that doesn't declare it, and FrontMCP removed it from the result and from the page. Declare the field in the schema ([Fields the schema doesn't declare](#fields-the-schema-doesnt-declare)).

### `TS7006: Parameter 'ctx' implicitly has an 'any' type`

Annotate the template's parameter: `(ctx: TemplateContext<Input, Output>) => …`, with `TemplateContext` imported from `@frontmcp/sdk`.
