Skip to main content
FrontMCP tools can render rich HTML widgets for display in OpenAI Apps, Claude Artifacts, and other UI-capable hosts. This guide shows you how to use the @frontmcp/ui library to build professional-looking tool outputs.
Prerequisites:

What You’ll Build

A weather tool that displays temperature, conditions, and other data in a styled card with a badge.

Step 1: Install the UI Package

Required for .tsx/.jsx FileSource widgets. When you point ui.template at a .tsx/.jsx file (the recommended pattern for non-trivial widgets), FrontMCP injects an auto-generated React mount that imports McpBridgeProvider from @frontmcp/ui/react. Server-side bundling fails without this package installed. Match the version to @frontmcp/sdk. react and react-dom stay external and load from the CDN at runtime, so only @frontmcp/ui needs to be present on disk.
.tsx/.jsx widgets also need esbuild at runtime. @frontmcp/uipack loads esbuild on demand to bundle the widget when the tool is called, so it has to be installed where the server runs — as a dependency, not a devDependency:
Projects created with frontmcp create already have it through the frontmcp package. Without it, the call fails with an error that names the widget.
Anchor FileSource paths to the tool file. Relative template: { file: './widget.tsx' } paths are resolved against process.cwd(), not the tool source’s directory. A widget at src/tools/foo.widget.tsx referenced as ./foo.widget.tsx from src/tools/foo.tool.ts will fail with ENOENT at tool-call time. Use fileURLToPath(new URL('./widget.tsx', import.meta.url)) (from node:url) or pass an absolute path. In a CommonJS project, import.meta.url is unavailable — use join(__dirname, 'widget.tsx') (from node:path) instead.The widget file is read when the tool is called, from where the compiled tool resolves that path — so it has to ship with the build. frontmcp build copies *.widget.tsx / *.widget.jsx files into the output (next to the bundle for the bundled node, cli, lambda and vercel targets, so keep widget file names unique). A plain tsc build doesn’t copy them.
resourceMode is host-detected. Leave it unset and FrontMCP auto-switches to 'inline' when the connecting client is Claude (#456), so the widget actually renders in Claude’s sandboxed iframe — React is bundled into the widget itself (#454). Other hosts keep 'cdn' for a smaller payload. Set resourceMode explicitly to override the detection. Auto-detection only applies to per-call rendering modes (inline / hybrid / lean); servingMode: 'static' widgets are pre-compiled at startup with no client context, so set resourceMode: 'inline' explicitly when targeting Claude in static mode.
ui.csp is now emitted on the widget resource (#455). FrontMCP attaches ui.csp to the resources/read content item’s _meta.ui.csp (and _meta['ui/csp']) so MCP Apps hosts — particularly Claude — actually honor it. Previously the CSP only appeared on the tool listing, where Claude ignored it.
Use the *.widget.tsx naming convention. The scaffolded tsconfig.json excludes **/*.widget.tsx / **/*.widget.jsx from the server typecheck (frontmcp init also adds the excludes to existing tsconfigs). Widget sources are bundled separately by uipack/esbuild at render time, so the server tsconfig doesn’t need jsx: 'react-jsx' or @types/react for them. Add a sibling tsconfig.widget.json if you want IDE typecheck for widget files.

Step 2: Create a Tool with UI Template

Point ui.template at a .tsx widget file. The widget is a React component that receives the tool’s output (and loading) and is built from the @frontmcp/ui/components library.
weather.widget.tsx
weather.tool.ts
@frontmcp/ui has no card(), badge(), descriptionList(), button(), form() or input() functions. It is a React library: the components live at @frontmcp/ui/components (Card, Badge, Button, Alert, Avatar, Modal, Table, TextField, Select, List, Loader) and the bridge hooks at @frontmcp/ui/react. Older examples that import those lower-case helpers from @frontmcp/ui do not compile.

Inline HTML alternative

For a one-off widget with no React, return markup from the template function. Build it with the html tagged template so every interpolated value is escaped:

UI Configuration Options

string
Human-readable description of what the widget displays. Shown to users in UI-capable hosts.
'inline' | 'fullscreen' | 'pip'
Preferred display mode (hint to the host — may be ignored):
  • inline - Rendered inline in the conversation (default)
  • fullscreen - Request fullscreen display
  • pip - Picture-in-picture
'auto' | 'inline' | 'static' | 'hybrid' | 'direct-url' | 'custom-url'
How the HTML is delivered to the client (default: auto):
  • auto - Auto-select per host (OpenAI / Claude / unknown)
  • inline - Embedded in tool response _meta['ui/html'] (works everywhere)
  • static - Pre-compiled at startup; client fetches ui://widget/{toolName}.html via resources/read
  • hybrid - Shell pre-compiled at startup; each call carries only a reference in _meta['ui/component'] ({ type, hash, toolName }) — no component code and no data. The widget is still rendered from its ui:// resource
  • direct-url / custom-url - Not implemented. Accepted by the schema, but the widget is served inline, as with inline; startup logs a warning
resources/read never returns another call’s render. ui://widget/{toolName}.html serves only HTML compiled at startup (static, and the hybrid shell), rendered without any caller’s data; for other modes it serves a data-free placeholder that receives the result through the bridge. A per-call inline render embeds that call’s input and output, so it is returned only in that call’s _meta['ui/html'] and is never stored where resources/read can reach it. Hosts that load the widget with resources/read (MCP Apps hosts such as Claude) should use servingMode: 'static' with a template that reads its data from window.FrontMcpBridge.The tool name in the URI is percent-encoded, as tools/list advertises it: a namespaced app:tool is ui://widget/app%3Atool.html. Both the encoded and the raw form read back; a name that decodes to anything other than tool-name characters (letters, digits, _ - . / : @) is rejected.
function
A function that receives the execution context and returns markup — preferably built with the ctx.helpers.html tagged template (see Trusted markup), or a string.
boolean
HTML-escape a plain string returned by the template function; html / trustedHtml results still render as markup. Unset (the 1.8 default) renders strings that look like HTML as markup and logs a one-time notice per tool; false keeps that behaviour without the notice. Overrides the server-wide @FrontMcp({ ui: { escapeStringResults } }) default. FrontMCP 1.9 escapes string results by default. See Escaping string results.

Available UI Components

Widgets use the React components from @frontmcp/ui/components:
The full set is Alert, Avatar, Badge, Button, Card, List, Loader, Modal, Select, Table and TextField. Each has an exported <Name>Props type.

Widget hooks

@frontmcp/ui/react gives a widget access to the host through the MCP bridge: McpBridgeProvider (added for you around a .tsx FileSource widget), useToolInput, useToolOutput, useStructuredContent, useCallTool, useTheme, useDisplayMode, useHostContext, useSendMessage and useOpenLink. useCallTool returns a tuple [call, state, reset], not an object. state is { data, loading, error, called }:
Whether the host lets a widget call tools is the host’s decision; the widgetAccessible option has no effect yet (see Options that have no effect yet).

Template Context

The template function receives a context object with:
TypeScript: ctx must be annotated explicitly (TS7006). Under strict / noImplicitAny, template: (ctx) => … fails with Parameter 'ctx' implicitly has an 'any' type. The ui.template field is a union of multiple callable shapes (TemplateBuilderFn | string | ((props: any) => any) | FileSource), so TypeScript can’t pick a single contextual type for the arrow’s parameter. Either annotate ctx: TemplateContext<MyInput, MyOutput> (imported from @frontmcp/sdk), or move the widget into its own .tsx file and use the FileSource form (template: { file: … }).

Available Helpers

Trusted markup

Build the markup a template returns with the html tagged template from ctx.helpers. Its literal parts are your markup; every value you interpolate is HTML-escaped unless it is itself trusted markup, so tool output can’t inject tags:
  • Nested html values are inserted as-is and never escaped twice. Arrays are joined without a separator; null, undefined and false render nothing.
  • Don’t pre-escape values with escapeHtml inside html — they would be escaped twice.
  • Quote attribute values (title="${value}"). Escaping keeps a value inside a quoted attribute, but it doesn’t validate URLs — check href / src values yourself.
  • Wrap markup that is already safe — HTML you generated or sanitized yourself — with ctx.helpers.trustedHtml(markup). Never wrap raw tool output or user input.
  • Inside an inline <script>, embed data with ${trustedHtml(jsonEmbed(data))}: jsonEmbed output is safe in a script, and html would otherwise HTML-escape its quotes.
html, trustedHtml, isTrustedHtml and the TrustedHtml type are also exported from @frontmcp/uipack; TrustedHtml is re-exported from @frontmcp/sdk.

Escaping string results

A template function that returns a plain string has it rendered as markup when it looks like HTML, so template: (ctx) => ctx.output renders any tags in the output. Opt in to escaping plain string results per tool, or for the whole server:
The default changes in FrontMCP 1.9: plain string results will be escaped. To migrate, return html / trustedHtml from every template function, then set escapeStringResults: true to confirm none still returns a plain markup string. Until then, each tool whose template returns a plain markup string logs a notice once, naming the tool.
Other results are always escaped: plain text is shown as text, an object as JSON inside <pre>, and a chart config or base64 PDF (JVBERi…) is embedded as script data. A value that starts with the PDF signature but is not base64 is shown as text. A static string template (template: '<div>…</div>') is your own markup and is never escaped.

Practical Example: Expense Summary

expense.widget.tsx
expense.tool.ts

Markdown and MDX strings

A string template that does not contain both < and > is treated as Markdown and converted to HTML on the server. The converter supports headings, paragraphs, fenced code, bullet and numbered lists, and inline bold, italic, code and links. Raw HTML in the Markdown is escaped, and a link is kept only when its target starts with http:, https:, mailto:, / or # — otherwise only the link text is kept. A string that contains both < and > is treated as HTML and used as written. MDX is not compiled: JSX tags in a string reach the client as HTML, {output.field} expressions are not evaluated, and mdxComponents is ignored. For anything interactive, use a .tsx FileSource widget.

Sanitization

  • Markup you author (a template function’s literal parts, an HTML string, a .tsx file) is trusted and is not sanitized.
  • Values interpolated into html`…` are escaped. Values returned as plain strings from a template function are escaped only with escapeStringResults: true (see above).
  • Markdown strings are always escaped as described in the previous section.
  • Tool output shown by a .tsx widget is rendered by React, which escapes text nodes.

Options that have no effect yet

These ui options pass schema validation but nothing reads them, so setting one changes nothing. Startup logs a single warning per tool that lists them: widgetDescription, widgetAccessible, displayMode, prefersBorder, sandboxDomain, contentSecurity, hydrate, runtimeOptions, mdxComponents, bundlingMode, uiType, htmlResponsePrefix. servingMode: 'direct-url' and 'custom-url' are also not implemented (the widget is served inline), and 'hybrid' sends only a reference (see UI Configuration Options). There is no dual HTML payload for Claude: the tool result carries no extra HTML text block, so htmlResponsePrefix does nothing.

Theme

The widget page follows the host’s theme. When an MCP Apps host sends theme: 'light' or 'dark' — in its ui/initialize answer, or later in ui/notifications/host-context-changed — the bridge writes it to <meta name="color-scheme"> and <html data-theme>. The frame’s background and form controls then match the host, and your CSS can key off the attribute:
window.FrontMcpBridge.getTheme() returns the same value, and onContextChange listeners fire for the handshake context as well as later changes. When the host sends no theme, nothing is written. A widget that only has light styles can opt out with :root { color-scheme: light }, which takes precedence over the meta tag.

Platform Considerations

Different MCP hosts have different capabilities and network policies:
  • OpenAI Apps SDK — any CDN reachable; the widget is advertised like on every other host, through _meta.ui.resourceUri on the tool. FrontMCP does not emit _meta['openai/outputTemplate']
  • Claude (MCP-UI) — only cdnjs.cloudflare.com is reachable; prefer resourceMode: 'inline' so the widget shell is self-contained, and pin any externals to cdnjs URLs via dependencies
  • MCP Inspector — useful for local development; honors servingMode: 'static'
  • Gemini / unknown hosts — ui is ignored; the tool returns JSON only
The default servingMode: 'auto' selects the right mode per host. See the Tool UI reference for the full surface: serving modes, CSP, the window.FrontMcpBridge runtime, file-based .tsx widgets, and platform-specific troubleshooting.

Next Steps

React SDK Components

Pre-built React components

Tool Reference

Full @Tool decorator options

CodeCall CRM Demo

See UI in a full application

Create Prompts

Build prompts alongside tools