@frontmcp/ui library to build professional-looking tool outputs.
Prerequisites:
- A working FrontMCP tool (see Your First Tool)
- Basic understanding of HTML/CSS
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:frontmcp create already have it through the frontmcp package. Without it, the call fails with an error that names the widget.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
Pointui.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 thehtml 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 displaypip- 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 fetchesui://widget/{toolName}.htmlviaresources/readhybrid- 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 itsui://resourcedirect-url/custom-url- Not implemented. Accepted by the schema, but the widget is served inline, as withinline; 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:
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 }:
widgetAccessible option has no effect yet (see Options that have no effect yet).
Template Context
The template function receives a context object with:Available Helpers
Trusted markup
Build the markup a template returns with thehtml 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
htmlvalues are inserted as-is and never escaped twice. Arrays are joined without a separator;null,undefinedandfalserender nothing. - Don’t pre-escape values with
escapeHtmlinsidehtml— they would be escaped twice. - Quote attribute values (
title="${value}"). Escaping keeps a value inside a quoted attribute, but it doesn’t validate URLs — checkhref/srcvalues 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))}:jsonEmbedoutput is safe in a script, andhtmlwould 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, sotemplate: (ctx) => ctx.output renders any tags in the output. Opt in to escaping plain string results per tool, or for the whole server:
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
.tsxfile) is trusted and is not sanitized. - Values interpolated into
html`…`are escaped. Values returned as plain strings from a template function are escaped only withescapeStringResults: true(see above). - Markdown strings are always escaped as described in the previous section.
- Tool output shown by a
.tsxwidget is rendered by React, which escapes text nodes.
Options that have no effect yet
Theseui 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 sendstheme: '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.resourceUrion the tool. FrontMCP does not emit_meta['openai/outputTemplate'] - Claude (MCP-UI) — only
cdnjs.cloudflare.comis reachable; preferresourceMode: 'inline'so the widget shell is self-contained, and pin anyexternalsto cdnjs URLs viadependencies - MCP Inspector — useful for local development; honors
servingMode: 'static' - Gemini / unknown hosts —
uiis ignored; the tool returns JSON only
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