Tool 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. Building the page from React components is on Widget components. Your First Widget teaches it step by step.
@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. The tool keeps working for every client: ui adds a page to the result and leaves its content and structuredContent for the model.
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.
Options
| Option | Type | Default | What FrontMCP 1.9.4 does with it |
|---|---|---|---|
template | see 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. |
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. | |
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. |
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. |
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. | |
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. | |
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. |
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). "inline" asks for nothing. |
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).
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 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). |
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. |
{ file: "/abs/path/weather.widget.tsx" } | "react" | A React component file, bundled with esbuild. See Widget components. |
| 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. |
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 <. 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. |
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).
What the client receives
In tools/list, the tool gets a _meta:
{
"ui": { "resourceUri": "ui://widget/get_weather.html" },
"frontmcp/type": "html"
}
_meta.ui also carries csp and the size hints 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 is advertised as written.
In the tools/call result, served inline, _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. |
ui/type | "html", "markdown" or "react", from 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). 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 | 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); 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).
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). 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.
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). 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() 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 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).
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).
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. |
extApps.enabled | true | With false, ui/* requests on session-based HTTP answer -32601 Method not found: ui/…. See ui/* requests. |
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).
- 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 yourplatformDetection.mappingsmatches the name (How FrontMCP recognizes the client). - A test can check a widget's page with
@frontmcp/testing's UI matchers, liketoHaveRenderedHtml(): see 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.
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" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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".
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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);
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Sizing the widget
chart sets size hints, and chart_fixed turns autoResize off.
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] };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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).
When the template fails
A template that throws costs the widget, not the call.
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 withrender()orReact.memo(). FrontMCP has no source to bundle, and logsui.template` is a React component reference, which cannot be bundledonce, at startup or on the tool's first call (Templates). Move the component to a.tsxfile. - It's a
.tsxtemplate and React couldn't load: in the defaultresourceMode: "cdn"the page imports React fromesm.sh, and a host or network that blocks it leaves the page on "Loading widget...".resourceMode: "inline"bundles React into the page (Widget components).
The result has no ui/html
- The template threw, or its
.tsxfile couldn't be bundled. The server loggedapplyUI: UI rendering failed(When rendering fails). servingModeis"static"or"hybrid", which send no page in the result. A tool that doesn't set it takes its app'sui.servingMode, else the server's (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.
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).
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).
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).
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 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). 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). 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).
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). 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).
TS7006: Parameter 'ctx' implicitly has an 'any' type
Annotate the template's parameter: (ctx: TemplateContext<Input, Output>) => …, with TemplateContext imported from @frontmcp/sdk.