Building Widgets with React

IntermediateMCP 2026-07-28

A template function builds a page from a string, and for a card that's all it takes. A widget with state is different: a queue you can filter, buttons that show they're working, an error next to the row it belongs to. As a template, that becomes a script inside a string, with no types and every change to the page made by hand. FrontMCP can build a widget from a React component in a .tsx file instead. It bundles the file, mounts the component with the call's data, and @frontmcp/ui/react gives it hooks to read the result and call tools through the host.

The Playground can't run React widgets: FrontMCP bundles a widget file from the file system, on a Node server, and the Playground runs FrontMCP in your browser (below, it shows what happens). So the widget files in this lesson are code blocks, and each one was run for real: bundled by a FrontMCP 1.9.3 server on Node, and rendered in Chromium in an MCP Apps host like the Playground's. The Playgrounds run what does run here: the tools, and the calls widgets make to them.

You will learn

  • When a widget is worth writing in React
  • How to point a tool at a .tsx file, and what the project needs to bundle it
  • What the component gets as props, and what to check before it draws them
  • How to call a tool with useCallTool(), and handle each way a call can end
  • How to follow the host's theme, size the widget, and choose where React is loaded from

When a template gets too big

Here is the queue widget as a template function. It lists the open tickets, filters them to high priority, and closes a ticket from its row. Try it:

Open
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";
import { openTickets, type Queue } from "./tickets";

@Tool({
  name: "list_queue",
  description: "List the open support tickets, most urgent first.",
  inputSchema: {},
  ui: {
    template: (ctx: TemplateContext<{}, Queue>) => ctx.helpers.html`
      <style>body { font: 15px/1.45 system-ui, sans-serif; }</style>
      <label><input type="checkbox" id="high-only"> High priority only</label>
      <ul id="queue">
        ${ctx.output.tickets.map((t) => ctx.helpers.html`
          <li data-priority="${t.priority}">
            ${t.id} ${t.title} <button data-close="${t.id}">Close</button> <span class="note"></span>
          </li>`)}
      </ul>
      <script>
        document.getElementById("high-only").addEventListener("change", (event) => {
          for (const row of document.querySelectorAll("#queue li")) {
            row.hidden = event.target.checked && row.dataset.priority !== "high";
          }
        });
        document.getElementById("queue").addEventListener("click", async (event) => {
          const button = event.target.closest("button[data-close]");
          if (!button) return;
          const note = button.nextElementSibling;
          button.disabled = true;
          button.textContent = "Closing…";
          try {
            const result = await FrontMcpBridge.callTool("close_ticket", { id: button.dataset.close });
            if (!result.isError) return button.replaceWith("Closed");
            note.textContent = result.content[0].text;
          } catch (error) {
            note.textContent = error.message;
          }
          button.disabled = false;
          button.textContent = "Close";
        });
      </script>`,
  },
})
export class ListQueue extends ToolContext {
  async execute(): Promise<Queue> {
    return { tickets: openTickets() };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

It works, and it's already hard to change. The rows are written twice: once as HTML on the server, and again, piece by piece, by the script as they change. Each state a row can be in, closing, closed or failed, is a DOM edit made by hand, and the filter is a loop over elements. None of the script is type-checked, because it's a string. Add a "reassign" button and a count of open tickets, and it gets worse. That's the kind of widget worth writing in React.

Pointing a tool at a widget file

Put the component in a file of its own, export it as the default, and point ui.template at the file with { file } and an absolute path:

list-queue.tool.ts
import { join } from "node:path";
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { openTickets } from "./tickets";

const QueueItem = z.object({ id: z.string(), title: z.string(), priority: z.enum(["high", "normal", "low"]) });

@Tool({
  name: "list_queue",
  description: "List the open support tickets, most urgent first.",
  inputSchema: {},
  outputSchema: { tickets: z.array(QueueItem) },
  ui: { template: { file: join(__dirname, "queue.widget.tsx") } },
})
export class ListQueue extends ToolContext {
  async execute() {
    return { tickets: openTickets() };
  }
}
queue.widget.tsx
type Ticket = { id: string; title: string; priority: "high" | "normal" | "low" };
type Queue = { tickets: Ticket[] };

export default function QueueWidget({ output }: { output: Queue | null }) {
  if (!output?.tickets) return <p>Loading the queue…</p>;
  return (
    <ul>
      {output.tickets.map((t) => (
        <li key={t.id}>
          {t.id} {t.title} ({t.priority})
        </li>
      ))}
    </ul>
  );
}

When the tool is called, FrontMCP bundles the file with esbuild, wraps the component in McpBridgeProvider from @frontmcp/ui/react, and sends a page that mounts it into <div id="root">, with ui/type set to "react". The component gets three props: output, the call's result as execute() returned it; input, the call's arguments; and loading, which is true for the first render only. The project needs a few things for this to work:

  • @frontmcp/ui, even for a widget that imports nothing from it, because the code FrontMCP adds to mount your component does: npm install @frontmcp/ui. It brings React, React DOM, MUI and Emotion along as its peers.
  • esbuild, which a project made with frontmcp create already has, through the frontmcp CLI. In any other project, install it too, as a dependency and not a dev dependency, since FrontMCP loads it when the tool is called and @frontmcp/uipack only lists it as an optional peer. Without it every call logs applyUI: UI rendering failed with FileSource widget "…/queue.widget.tsx" needs esbuild, which is not installed, and the result has no page.
  • A Node server: CommonJS, which is what frontmcp create makes, or an ES module ("type": "module"), with import.meta.dirname in place of __dirname. Changed in 1.9: an ES module server couldn't bundle widget files, and logged Dynamic require of "path" is not supported.
  • An absolute path. A relative file is resolved from the process's working directory, not from the tool's file, so use join(__dirname, …). __dirname is the folder the compiled tool runs from: frontmcp build copies the .widget.tsx files into its output, next to the bundle for --target node, but if you compile with tsc, copy them next to the output yourself. Otherwise the call logs applyUI: UI rendering failed with FileSource widget not found (ENOENT): no file at "…/dist/queue.widget.tsx", and the message names the file under src/ when the same path exists there.
  • Keep widget files out of the server's own build. The server's tsc doesn't need them, and would need JSX settings and React's types. A frontmcp create project's tsconfig.json already excludes **/*.widget.tsx.

This is what the Playground does with that tool. It runs FrontMCP in your browser, which has no file system to bundle a widget from. The Logs tab counts one error:

Open
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { openTickets } from "./tickets";

const QueueItem = z.object({ id: z.string(), title: z.string(), priority: z.enum(["high", "normal", "low"]) });

@Tool({
  name: "list_queue",
  description: "List the open support tickets, most urgent first.",
  inputSchema: {},
  outputSchema: { tickets: z.array(QueueItem) },
  // In a project: join(__dirname, "queue.widget.tsx")
  ui: { template: { file: "/help-desk/src/queue.widget.tsx" } },
})
export class ListQueue extends ToolContext {
  async execute() {
    return { tickets: openTickets() };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The log has applyUI: UI rendering failed with Dynamic require of "path" is not supported, and the result went out without a page: a widget that can't be built costs the widget, not the call. On a Node server the same tool sends a page of about 59 KB.

Reading the call's data

The widget above shows the open tickets in Chromium, in an MCP Apps host like the Playground's. The page starts with output set to what execute() returned. Then the host sends the call's result, as MCP Apps hosts do, and the bridge replaces the output with that result's structuredContent: the same object, so the component renders the same tickets again. The check is on the field the widget needs, output?.tickets, and not on output itself. A static widget's output is null until the host sends a result, which ?. handles, but a result that failed makes it a string, the error's message, which is truthy.

useStructuredContent() returns the host's object too: the result's structuredContent from an MCP Apps host, window.openai.toolOutput from the OpenAI Apps SDK, and undefined before the host has sent anything. Widgets written for 1.8 read it first, since a static page started with output set to {}, which in the OpenAI Apps SDK stayed {} until the host assigned a new output (Hosts and platforms). In 1.9.3 output is enough, and this still works:

queue.widget.tsx
import { useStructuredContent } from "@frontmcp/ui/react";

type Ticket = { id: string; title: string; priority: "high" | "normal" | "low" };
type Queue = { tickets: Ticket[] };

export default function QueueWidget({ output }: { output: Queue | null }) {
  const queue = useStructuredContent<Queue>() ?? (output?.tickets ? output : null);
  if (!queue) return <p>Loading the queue…</p>;
  return (
    <ul>
      {queue.tickets.map((t) => (
        <li key={t.id}>
          {t.id} {t.title} ({t.priority})
        </li>
      ))}
    </ul>
  );
}

The widget reads the object the tool returned, so that's what the tool has to get right. The Playground can run this side:

Open
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { openTickets } from "./tickets";

const QueueItem = z.object({ id: z.string(), title: z.string(), priority: z.enum(["high", "normal", "low"]) });

@Tool({
  name: "list_queue",
  description: "List the open support tickets, most urgent first.",
  inputSchema: {},
  outputSchema: { tickets: z.array(QueueItem) },
})
export class ListQueue extends ToolContext {
  async execute() {
    return { tickets: openTickets() };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Return an object, not an array: a tool that returns an array sends structuredContent as { "value": [...] } (Shaping Tool Results), and the widget has to know that. The other hooks for the call's data, useToolOutput() and useToolInput(), are in the Widget components reference.

Changed in 1.8.7: after the host's result, output used to be the result's content array, [{ type: "text", text: "{\"tickets\":[…]}" }], and a widget that checked output?.tickets showed Loading the queue… for good. Widgets read useStructuredContent() first to get around it. Both work now.

Calling a tool with useCallTool

Each row gets a Close button. useCallTool(name) returns a function that calls the tool through the host, and the state of the last call:

function TicketRow({ ticket }: { ticket: Ticket }) {
  const [close, { data, loading, error }] = useCallTool<{ id: string }, { id: string; status: "closed" }>("close_ticket");
  const closed = data?.structuredContent?.status === "closed";
  return (
    <li>
      {ticket.id} {ticket.title}{" "}
      {closed ? (
        <em>Closed</em>
      ) : (
        <button disabled={loading} onClick={() => close({ id: ticket.id })}>
          {loading ? "Closing…" : "Close"}
        </button>
      )}
      {data?.isError && <p role="alert">{data.content[0]?.text}</p>}
      {error && <p role="alert">{error.message}</p>}
    </li>
  );
}

A call can end three ways, and each has its place in the state:

  • It succeeds. data is the host's whole result, so the tool's object is data.structuredContent.
  • The tool fails. That's still a result: data.isError is true and the message is in data.content. Closing a ticket someone else closed a minute ago shows T-1 is already closed. under its row.
  • The call can't be made, like in a host that didn't finish its handshake. Then error is set, with a message like Tool calls not supported on this platform (ext-apps), and close() resolves with null.

The widget gets back whatever the host got from your server, so what close_ticket returns matters here too. Its errors should be PublicMcpErrors, so the message the widget shows survives production. And it's better without a ui of its own. The bridge marks the widget's call with _meta["frontmcp/widgetCall"], and when a tool with a widget gets a marked call, FrontMCP answers with the data and no page, provided the host passes that marker on. Under the OpenAI Apps SDK, callTool() can't send it, and there a tool with a widget sends its page, about 38 KB, into the widget on every click. This Playground has one tool of each kind, and the last test calls get_ticket the way the bridge does:

Open
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { tickets } from "./tickets";

@Tool({
  name: "close_ticket",
  description: "Close a support ticket by its id.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.get(id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    if (ticket.status === "closed") this.fail(new PublicMcpError(`${id} is already closed.`));
    ticket.status = "closed";
    return { id, status: "closed" as const };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

For the server, a call from a widget is an ordinary tools/call from the host, with the host's identity and every check your tools already have (Calling a tool from a widget).

The theme, the size, and where React comes from

The bridge sets the page's <meta name="color-scheme"> and <html data-theme> from the host's theme, as it does for a template (Your First Widget), so the browser's default colors and light-dark() in your CSS follow the host without code. When the component has to know, useTheme() returns "light" or "dark": the host's answer to the handshake, then each change the host sends.

In an MCP Apps host the component renders once before the host has answered the handshake, and again with its answer: useTheme(), useCapability("canCallTools") and useHostContext() start as "light", false and the page's defaults, and update by themselves. A button that waits for useCapability("canCallTools") shows once the host can call tools, and stays away in a host that never answers. Handling error from call(), as above, covers the rest.

Here is the whole widget, with the color of a high priority ticket picked from useTheme():

queue.widget.tsx
import { useState } from "react";
import { useCallTool, useTheme } from "@frontmcp/ui/react";

type Ticket = { id: string; title: string; priority: "high" | "normal" | "low" };
type Queue = { tickets: Ticket[] };

export default function QueueWidget({ output }: { output: Queue | null }) {
  const [highOnly, setHighOnly] = useState(false);
  const theme = useTheme();

  if (!output?.tickets) return <p>Loading the queue…</p>;
  const shown = highOnly ? output.tickets.filter((t) => t.priority === "high") : output.tickets;
  const urgent = theme === "dark" ? "#ff8a80" : "#b42318";
  return (
    <section style={{ font: "15px/1.45 system-ui, sans-serif" }}>
      <label>
        <input type="checkbox" checked={highOnly} onChange={(e) => setHighOnly(e.target.checked)} /> High priority only
      </label>
      <ul>
        {shown.map((ticket) => (
          <TicketRow key={ticket.id} ticket={ticket} urgent={urgent} />
        ))}
      </ul>
    </section>
  );
}

function TicketRow({ ticket, urgent }: { ticket: Ticket; urgent: string }) {
  const [close, { data, loading, error }] = useCallTool<{ id: string }, { id: string; status: "closed" }>("close_ticket");
  const closed = data?.structuredContent?.status === "closed";
  return (
    <li>
      {ticket.id} {ticket.title} <span style={{ color: ticket.priority === "high" ? urgent : undefined }}>({ticket.priority})</span>{" "}
      {closed ? (
        <em>Closed</em>
      ) : (
        <button disabled={loading} onClick={() => close({ id: ticket.id })}>
          {loading ? "Closing…" : "Close"}
        </button>
      )}
      {data?.isError && <p role="alert">{data.content[0]?.text}</p>}
      {error && <p role="alert">{error.message}</p>}
    </li>
  );
}

With autoResize: true added to the tool's ui, this widget reports its height when it starts, and again when the filter or a closed ticket changes it. Nothing in the file is there for the size: the page measures its whole <html> from outside, margins included, so the body's margin and the list's own count. In a dark host the text and the buttons use the dark scheme, and useTheme() is "dark" as soon as the host has answered, with no code to ask the bridge.

Changed in 1.8.6: useTheme() used to stay "light" in an MCP Apps host until the host sent a change, because the handshake's context was merged without telling anyone. Widgets asked the bridge themselves, with useMcpBridge() on bridge:ready. The page also measured #root, which left out the body's margin, and could lose its first height, so widgets set document.body.style.margin = "0" and sent the first height themselves. None of that is needed now. Changed in 1.8.7: useCapability() and useHostContext() update too. They used to stay at false and null.

Where React comes from is the other thing to decide, with ui.resourceMode:

resourceModeThe pageWhen to use it
"cdn", the default for most clientsAbout 59 KB. An import map loads React 19.2.4 from esm.sh.Hosts whose frames can reach esm.sh
"inline"About 730 KB, React bundled in. Nothing is loaded from outside.Hosts that block outside scripts, and static widgets meant for them

A "cdn" page whose frame can't reach esm.sh stays on Loading widget... for good, with nothing in the page to say why. Both were run in Chromium, with esm.sh blocked for the second.

@frontmcp/ui also has React components built on MUI, like Card, Badge, Button and Table, with a theme provider for them. They make a page of about 460 KB. They're in the Widget components reference, with every hook. The Expense Dashboard shows a whole dashboard written this way, and what it took to run it.

Recap

  • Write a widget in React when it has state: filters, per-row actions, loading and error states. A card is fine as a template function.
  • ui: { template: { file: join(__dirname, "queue.widget.tsx") } } points a tool at a component file, exported as the default. The server, CommonJS or ES module, needs @frontmcp/ui, esbuild, and the widget file next to the compiled tool, which frontmcp build copies for you. The Playground can't bundle one.
  • The component gets output, input and loading. In an MCP Apps host, output becomes the result's structuredContent once the host sends the result. Check for the fields you need, not for output: an error's text is truthy. useStructuredContent() is the host's object too.
  • const [call, { data, loading, error }] = useCallTool(name): data is the whole result, data.isError means the tool failed, and error means the call couldn't be made. Give the tools a widget calls PublicMcpErrors and no ui: the bridge marks a widget's own calls so they come back without a page, but not under the OpenAI Apps SDK.
  • In an MCP Apps host, useTheme(), useCapability() and useHostContext() follow the host's handshake, and the page follows its theme by itself with light-dark() colors. Handle error from call() for a host that never answers. autoResize: true sizes the frame, with no margin or first-report code.
  • resourceMode: "cdn" loads React from esm.sh; "inline" bundles it, for hosts that can't reach it.

Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press Check.

Challenge 1 of 3

Return an object the widget can read

The queue widget reads the tickets like this:

const queue = useStructuredContent<{ tickets: Ticket[] }>();
return <ul>{queue?.tickets.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;

But list_queue returns an array, and the widget shows nothing. Make the tool return what the widget reads, and declare that shape so clients see it in tools/list.

Open
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { openTickets } from "./tickets";

@Tool({
  name: "list_queue",
  description: "List the open support tickets, most urgent first.",
  inputSchema: {},
})
export class ListQueue extends ToolContext {
  async execute() {
    return openTickets();
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.