Widget components
A widget is a template function or a React component in a file of its own. For a file, FrontMCP bundles it with esbuild, wraps it in McpBridgeProvider, and mounts it into the page with the call's data as props. The React side comes from @frontmcp/ui: hooks that read the call and call tools through the host, eleven components built on MUI, a theme, and content renderers. @frontmcp/uipack is the React-free core FrontMCP builds every page with; its markup helpers and renderToolTemplate() work outside the server too. Building Widgets with React teaches it step by step.
@Tool({ ..., ui: { template: { file: join(__dirname, "weather.widget.tsx") }, resourceMode?: "cdn" | "inline" } })
// weather.widget.tsx
export default function WeatherWidget({ output, input, loading }) { … }
import { useStructuredContent, useToolOutput, useToolInput, useCallTool, … } from "@frontmcp/ui/react";
import { Card, Badge, Button, Table, … } from "@frontmcp/ui/components";
import { FrontMcpThemeProvider } from "@frontmcp/ui/theme";
import { html, trustedHtml, renderToolTemplate } from "@frontmcp/uipack";
Reference
The Playground can't import @frontmcp/ui or @frontmcp/uipack, or bundle a widget file, so only one example on this page runs in it. Every other example was run for real with FrontMCP 1.9.2, React 19 and MUI 7: widgets bundled by a server in Node, in a CommonJS project and in an ES module one, and rendered in Chromium. With 1.9.3, widgets that read the call and call tools with the hooks below were bundled and rendered again, in both kinds of project; the MUI components weren't. The hooks and components were also rendered in jsdom and with React's server renderer on 1.8.7; @frontmcp/ui 1.9.2 is the same code.
Building a widget file
Install @frontmcp/ui in the server's project, even for a widget that doesn't import it: the code FrontMCP adds to mount your component imports McpBridgeProvider from @frontmcp/ui/react. npm 7 and later install its peers, react, react-dom, @mui/material, @emotion/react and @emotion/styled, along with it.
npm install @frontmcp/uiPoint ui.template at the file with an absolute path, and export the component as the default:
import { join } from "node:path";
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@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: { file: join(__dirname, "weather.widget.tsx") } },
})
export class GetWeather extends ToolContext {
async execute({ city }: { city: string }) {
return { city, tempC: 21, conditions: "Sunny" };
}
}type Weather = { city: string; tempC: number; conditions: string };
export default function WeatherWidget({ output }: { output: Weather | null }) {
if (!output?.city) return <p>Loading…</p>;
return (
<div className="weather">
<h2>{output.city}</h2>
<p>{output.tempC}°C, {output.conditions}</p>
</div>
);
}In Chromium the page shows Oslo, 21°C, Sunny, opened on its own and in an MCP Apps host. It checks for the field it needs, output?.city, and not for output: a failed result's error text is truthy too (The props). See more examples below.
The server can be CommonJS, which is what frontmcp create makes, or an ES module ("type": "module"), where the path is join(import.meta.dirname, "weather.widget.tsx"). Changed in 1.9: an ES module server couldn't bundle widget files. Every call logged applyUI: UI rendering failed with Dynamic require of "path" is not supported, and the result had no page.
- A relative
fileis resolved from the working directory, not from the tool's file: usejoin(__dirname, …), orimport.meta.dirnamein an ES module. With a plaintscbuild,__dirnameis a folder underdist/, so copy the widget files there too. Otherwise every call logsapplyUI: UI rendering failedwithFileSource widget not found (ENOENT): no file at "…", and when the same path exists undersrc/, the message names that file. frontmcp buildcopies the*.widget.tsxand*.widget.jsxfiles into its output, next to the bundle for--target node, and printscopied 1 widget source file (*.widget.tsx/jsx) to dist/node. Theinstall-<name>.shscript it writes copies the bundle, the manifest and the runner only, so an installed build has no widgets, and the staging folder of--target mcpbhas none either. Other local files a widget imports aren't copied: a widget withimport { greet } from "./format"fails in the built server withFailed to bundle FileSource "…/dist/node/hello.widget.tsx"andCould not resolve "./format", so keep a widget in one file.- FrontMCP bundles the file with
esbuild, which@frontmcp/uipacklists as an optional peer dependency, so nothing installs it for you. A project fromfrontmcp createhas it, through thefrontmcppackage; anywhere else, install it as a dependency, not a dev dependency, since it's loaded when the tool is called. Without it every call logsapplyUI: UI rendering failedwithFileSource widget "…" needs esbuild, which is not installed. - The file is bundled when the tool is called, or at startup for a static tool. Imports come from the server's
node_modules. A page with MUI in it is about 460 KB. - Name the file
*.widget.tsxand leave it out of the server's owntscbuild, which would otherwise need"jsx"settings and React's types.
The props
FrontMCP renders your default export inside McpBridgeProvider, with three props:
| Prop | Value |
|---|---|
output | The call's output. In an inline page, what execute() returned. In a static page, null until a host sends a result. After an MCP Apps host's result, its structuredContent, or the error's message when the tool failed. With the OpenAI Apps SDK, window.openai.toolOutput: in a static page from the start, and in any page each new one the host assigns. |
input | The call's arguments, read once from the page: null in a static page. useToolInput() has the arguments the host sends. |
loading | true while there's no output, which in a static page is until the host sends a result; false from then on. |
Check for the fields you need rather than for output itself: a failed result's error text is truthy.
Changed in 1.8.7: after an MCP Apps host's result, output used to be the result's content array, [{ type: "text", text: "{…}" }], and widgets read useStructuredContent() first to get the object. It's the object now, and useStructuredContent() still returns it.
Changed in 1.9: a static page used to start with output and input set to {} and loading false, so a widget that checked if (!output) drew the empty object. With the OpenAI Apps SDK, output stayed {} until the host assigned a new toolOutput.
resourceMode: "cdn" or "inline"
resourceMode | The page | Chosen by default for |
|---|---|---|
"cdn" | About 59 KB. React 19.2.4, react/jsx-runtime and react-dom/client come from esm.sh through an import map. | Every client except "claude", and every static widget, which is built before any client connects. |
"inline" | About 730 KB, React included. Nothing is loaded from outside, MUI widgets included. | Clients detected as "claude", when the widget is served inline. |
Where esm.sh can't be reached, a "cdn" page stays on "Loading widget...". Set resourceMode: "inline" for hosts that block outside scripts, and for static widgets meant for them.
Hooks
@frontmcp/ui/react (also exported from @frontmcp/ui) reads the call and talks to the host through the page's bridge. A widget file is already inside McpBridgeProvider.
| Hook | Returns |
|---|---|
useToolOutput<T>() | The call's output: the embedded output, then whatever the host sends as a result (its structuredContent, or a failed result's error text). null outside a provider. |
useStructuredContent<T>() | The structuredContent of the result an MCP Apps host sent, or the window.openai.toolOutput of the OpenAI Apps SDK. undefined before one arrives, and in an inline page no host has sent a result to. |
useToolInput<T>() | The call's arguments: the page's, then the arguments of each tool-input the host sends. |
useCallTool<In, Out>(name, options?) | [call, { data, loading, error, called }, reset]. call(args) calls the tool through the host and resolves with the host's whole result, or null on failure. The call is marked as the widget's own, so a tool with a widget answers it without its page (Calling a tool from a widget). options are onSuccess, onError and resetOnToolChange (default true). |
useToolCalls({ key: toolName, … }) | { key: { call, data, loading, error, reset }, … }: useCallTool for several tools. |
useSendMessage() | [send(text), { loading, error, sent }]: sends a message to the conversation, where the host allows it. |
useOpenLink() | open(url), through the host. |
useTheme(), useDisplayMode() | "light" or "dark", and "inline", "fullscreen" or "pip". They follow what the host says: its answer to the handshake, then each host-context-changed. |
useHostContext() | What the host said about itself: { theme, displayMode, locale, userAgent, safeArea, viewport, availableDisplayModes? }, from its answer to the handshake, then each host-context-changed. |
useCapability(name) | Whether the bridge's adapter can do name, like "canCallTools". It's false until an MCP Apps host answers the handshake, and then true for what the host advertised. |
useMcpBridgeContext(), useMcpBridge() | { bridge, loading, error, ready, adapterId, capabilities, revision } (revision goes up when the handshake finishes), and the bridge itself. |
McpBridgeProvider takes config, onReady and onError. In a FrontMCP page it uses the page's window.FrontMcpBridge, and ready is true from the first render, before an MCP Apps host has answered the handshake. Outside a provider the hooks return empty values, and call() fails with Bridge not initialized.
The widget renders before an MCP Apps host answers ui/initialize, so its first render has useCapability("canCallTools") false, useTheme() "light" and useHostContext() as the page's own defaults. The answer makes the hooks render again, so a button that waits for useCapability("canCallTools") appears once the host can call tools, and stays hidden in a host that never answers. useSendMessage() and useOpenLink() don't wait: they ask the host, and error has its refusal, like ui/message isn't supported (code: -32601).
Changed in 1.8.7: useCapability() stayed false and useHostContext() stayed null for good in an MCP Apps host, so a button behind useCapability("canCallTools") never showed, and widgets read bridge.getHostContext() themselves.
Changed in 1.8.6: useTheme() used to stay "light" when the handshake said "dark" until the host sent a change. The handshake's hostContext now reaches the bridge's onContextChange listeners, so it's the theme the host answered, and the bridge also sets the page's color-scheme from it (Hosts and platforms).
A tool that fails in the host usually comes back as a result with isError: true, in data, not in error. error is for calls that couldn't be made, like Tool calls not supported on this platform (ext-apps) when the host didn't answer the handshake.
Components
@frontmcp/ui/components, and one entry point per component (@frontmcp/ui/components/Card), are React components built on MUI. Put them inside FrontMcpThemeProvider.
| Component | Props | Renders |
|---|---|---|
Button | variant ("primary", "secondary", "danger", "ghost"), size, loading, disabled, startIcon, endIcon, fullWidth, type, onClick, children | An MUI Button; loading adds a spinner. |
Card | title, subtitle, headerActions, footer, elevation, clickable, onClick, slotProps, children | An MUI Card with a header when there's a title. |
Alert | severity ("success", "info", "warning", "error"), title, dismissible, onDismiss, icon, children | An MUI Alert. |
Badge | label (required), variant ("default", "primary", "success", "warning", "error", "info"), size, dot, removable, onRemove, icon | An MUI Chip. |
Avatar | src, alt, size, variant, children | An MUI Avatar; without src or children, a person icon. |
Modal | open, onClose (both required), title, actions, maxWidth, fullWidth, slotProps, children | An MUI Dialog; nothing while closed. |
Table | columns ({ key, label, align? }[]), rows, size, stickyHeader, maxHeight | An MUI Table in a container. |
TextField | label, value, defaultValue, onChange(value), type, multiline, rows, error, helperText, placeholder, disabled, required, fullWidth, size | An MUI TextField. onChange gets the value, not the event. |
Select | options ({ value, label }[]), label, value, defaultValue, onChange(value), placeholder, error, helperText, disabled, required, fullWidth, size | An MUI select TextField. |
List | items ({ id, primary, secondary?, icon?, onClick?, divider? }[]), dense | An MUI List. |
Loader | variant ("spinner", "bar", "skeleton", "overlay"), label, determinate, value, size, color, skeletonShape, skeletonWidth, skeletonHeight, skeletonLines, open, contained, custom | A spinner, a progress bar, skeleton lines or a backdrop. |
LoaderProvider custom={({ variant, label, value }) => …} replaces every Loader under it with your own; useLoaderContext() reads it.
These are React components. The HTML-string functions the upstream guides show, like card(), badge() and descriptionList() from @frontmcp/ui, don't exist in 1.9.3 (Troubleshooting).
Theme
@frontmcp/ui/theme:
| Export | Description |
|---|---|
FrontMcpThemeProvider | theme? and children. Applies the MUI theme and a baseline. |
createFrontMcpTheme(config?) | An MUI theme from { palette?: { primary, secondary, success, warning, error, info, background, surface, text }, typography?: { fontFamily, monoFontFamily, fontSize }, shape?: { borderRadius }, mode?: "light" | "dark" }. |
defaultTheme, darkTheme | Ready-made MUI themes; darkTheme.palette.mode is "dark". |
useFrontMcpTheme() | The current theme. |
SYSTEM_FONT_STACK, MONO_FONT_STACK | The font stacks the themes use. |
Renderers
@frontmcp/ui/renderer shows content of a detected kind. detectContentType(value) guesses the kind:
| Value | Kind |
|---|---|
"a,b\n1,2" | "csv" |
"graph TD; A-->B" | "mermaid" |
"$x^2$" | "math" |
"https://x.com/a.png", a data:image/… URI | "image" |
'{"type":"bar","data":[]}' | "chart" |
anything else, "# Title" included | "html" |
registerAllRenderers() registers the renderers, which renderContent(value, options?) needs: pdf, chart, flow, map, mermaid, math, image, video, audio, csv, jsx, mdx and html, in that order of priority. The chart, map, PDF and diagram libraries are optional peers of @frontmcp/ui, not installed with it. Each renderer is also its own entry point, like @frontmcp/ui/renderer/csv.
@frontmcp/uipack
The package FrontMCP builds pages with, installed with @frontmcp/sdk. Its root works from CommonJS and from ES modules.
| Export | Description |
|---|---|
html`…`, trustedHtml(markup), isTrustedHtml(value) | The same markup builder as ctx.helpers, for templates split into functions in other files. html returns a TrustedHtml object; String() gives the markup. |
escapeHtml(value), safeJsonForScript(value) | escapeHtml is ctx.helpers.escapeHtml. safeJsonForScript is JSON with < and > escaped, for a <script>. |
createTemplateHelpers() | A new ctx.helpers. |
renderToolTemplate({ toolName, input, output, template, platformType?, resourceMode?, sizing?, escapeStringResults?, csp? }) | Renders a template the way a call does, without a server: { html, uiType, hash, size, meta }, where meta has ui/html, ui/type and ui/mimeType. csp is the tool's ui.csp. Use it to test a template. |
buildShell(content, { toolName, csp?, input?, output?, includeBridge?, title?, sizing? }) | Wraps markup in a page: { html, hash, size }. csp makes the same policy as on a call: your origins are added to the CDNs. Without the bridge, a page is about 1.3 KB. |
MCP_APPS_MIME_TYPE | "text/html;profile=mcp-app". |
Other entry points
@frontmcp/ui/bridge: the bridge as a class,FrontMcpBridge, with its adapters andgenerateBridgeIIFE(), for pages FrontMCP doesn't build.@frontmcp/ui/runtime:detectContentType, andtranspileJsx()with the Babel loader it uses.@frontmcp/ui/auth: components for the sign-in pages, on Custom login UI.
Usage
Using the components in a widget
card.widget.tsx puts the result in a Card with a Badge, inside the theme. The tool is the one above, with this file as template.
import { Badge, Card } from "@frontmcp/ui/components";
import { FrontMcpThemeProvider } from "@frontmcp/ui/theme";
type Weather = { city: string; tempC: number; conditions: string };
export default function WeatherCard({ output }: { output: Weather | null }) {
if (!output?.city) return null;
return (
<FrontMcpThemeProvider>
<Card title={output.city} subtitle="Current weather">
<p>{output.tempC}°C</p>
<Badge label={output.conditions} variant="success" />
</Card>
</FrontMcpThemeProvider>
);
}The page, about 460 KB with resourceMode: "cdn" and 1.1 MB with "inline", shows Oslo, Current weather, 21°C and a Sunny chip in Chromium, with "inline" even with esm.sh blocked.
Calling a tool from a React widget
The button calls get_weather through the host, and data is the host's whole result. Until a call has answered, the widget shows output.
import { useCallTool } from "@frontmcp/ui/react";
type Weather = { city: string; tempC: number };
export default function Refreshable({ output }: { output: Weather | null }) {
const [refresh, { data, loading, error }] = useCallTool<{ city: string }, Weather>("get_weather");
const weather = data?.structuredContent ?? (output?.city ? output : null);
return (
<div>
<p>{weather ? `${weather.city}: ${weather.tempC}°C` : "No data yet"}</p>
<button disabled={loading} onClick={() => refresh({ city: "Bergen" })}>Bergen</button>
{error && <p role="alert">{error.message}</p>}
{data?.isError && <p role="alert">The tool failed.</p>}
</div>
);
}In an MCP Apps host, the paragraph shows Oslo: 21°C, from the embedded output or the host's result, and clicking sends tools/call for get_weather to the host, which calls the server as itself (Calling a tool from a widget); the paragraph then shows Bergen: 12°C. In a frame whose parent doesn't answer, clicking shows Tool calls not supported on this platform (ext-apps). Both were run in Chromium.
Splitting a template into functions
Template functions don't need React. html from @frontmcp/uipack builds the same trusted markup as ctx.helpers.html, so pieces of a widget can live in their own file and be tested on their own:
import { html } from "@frontmcp/uipack";
export const badge = (text: string) => html`<span class="badge">${text}</span>`;
export const forecast = (days: { day: string; tempC: number }[]) =>
html`<ul>${days.map((d) => html`<li>${d.day}: ${d.tempC}°C ${d.tempC > 20 ? badge("warm") : null}</li>`)}</ul>`;import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";
import { forecast } from "./weather-parts";
type Forecast = { city: string; days: { day: string; tempC: number }[] };
@Tool({
name: "get_forecast",
description: "Three-day forecast",
inputSchema: { city: z.string() },
ui: { template: (ctx: TemplateContext<{ city: string }, Forecast>) => ctx.helpers.html`<h2>${ctx.output.city}</h2>${forecast(ctx.output.days)}` },
})
export class GetForecast extends ToolContext {
async execute({ city }: { city: string }): Promise<Forecast> {
return { city, days: [{ day: "Mon", tempC: 19 }, { day: "Tue", tempC: 23 }] };
}
}The page's body is <h2>Oslo</h2><ul><li>Mon: 19°C </li><li>Tue: 23°C <span class="badge">warm</span></li></ul>: markup from @frontmcp/uipack's html is trusted by ctx.helpers.html, so it isn't escaped twice.
Without @frontmcp/uipack in your imports, pass ctx.helpers to the pieces instead. The Playground can run this one:
import type { TemplateContext } from "@frontmcp/sdk";
type Helpers = TemplateContext["helpers"];
export const badge = (h: Helpers, text: string) => h.html`<span class="badge">${text}</span>`;
export const forecast = (h: Helpers, days: { day: string; tempC: number }[]) =>
h.html`<ul>${days.map((d) => h.html`<li>${d.day}: ${d.tempC}°C ${d.tempC > 20 ? badge(h, "warm") : null}</li>`)}</ul>`;Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Testing a template without a server
renderToolTemplate() renders a template as a call would, so a unit test can check the markup:
import { test } from "node:test";
import assert from "node:assert/strict";
import { renderToolTemplate } from "@frontmcp/uipack";
import { forecast } from "./weather-parts";
test("warm days get a badge", () => {
const { html, uiType, meta } = renderToolTemplate({
toolName: "get_forecast",
input: { city: "Oslo" },
output: { city: "Oslo", days: [{ day: "Tue", tempC: 23 }] },
template: (ctx: any) => ctx.helpers.html`${forecast(ctx.output.days)}`,
});
assert.equal(uiType, "html");
assert.equal(meta["ui/mimeType"], "text/html;profile=mcp-app");
assert.match(html, /<li>Tue: 23°C <span class="badge">warm<\/span><\/li>/);
});Troubleshooting
Dynamic require of "path" is not supported
Logged with applyUI: UI rendering failed for a .tsx template in the Playground, which runs FrontMCP in the browser. Widget files are bundled from the file system, by a server on Node. Before 1.9, an ES module server on Node failed with the same message (Building a widget file); since 1.9 it bundles them.
FileSource widget "…" needs esbuild, which is not installed
Logged with applyUI: UI rendering failed, and the result has no page. FrontMCP loads esbuild when the tool is called, and it's an optional peer of @frontmcp/uipack, so npm install esbuild in the server's project, as a dependency and not a dev dependency.
FileSource widget not found (ENOENT): no file at "…"
The widget file isn't where the compiled tool looks: join(__dirname, …) points into the build output, where tsc doesn't copy *.widget.tsx files. The message says so, and names the file under src/ when the same path exists there. Copy the widgets next to the output, or build with frontmcp build.
FileSource widget "…" requires the @frontmcp/ui package
The full message goes on: which provides the React bridge mount that's injected at bundle time. Install it (e.g. npm install @frontmcp/ui or yarn add @frontmcp/ui) and try again. Install @frontmcp/ui in the server's project.
Module '"@frontmcp/ui"' has no exported member 'card'
Or, at runtime, The requested module '@frontmcp/ui' does not provide an export named 'card'. @frontmcp/ui 1.9.3 has no HTML-string components: card, badge, button, descriptionList, form and input are gone. Use the React components in a widget file, or write the markup with html.
My widget renders with empty data
- It checks
if (!output), which passes for the error text of a failed result, so the widget draws it. Check for a field, likeoutput?.city. - It reads a field that
outputSchemadoesn't declare, which FrontMCP removed from the result (Fields the schema doesn't declare).
The page stays on "Loading widget..."
React didn't load from esm.sh. Set resourceMode: "inline" (resourceMode).
Bridge not initialized
A hook's call() ran outside McpBridgeProvider: in a component rendered outside the widget file's tree, or in a test. Wrap it in McpBridgeProvider.