# Building Widgets with React

> Widgets as React components in .tsx files. What a project needs to bundle them, the props and @frontmcp/ui/react hooks a widget gets, calling tools with useCallTool, and the theme, size and loading details. The widget files run on a Node server, not in the Playground.

Source: https://frontmcp.dev/learn/building-widgets-with-react

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](#pointing-a-tool-at-a-widget-file), 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:

```ts list-queue.tool.ts active
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() };
  }
}
```

```ts close-ticket.tool.ts
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 };
  }
}
```

```ts tickets.ts
export type Ticket = { id: string; title: string; priority: "high" | "normal" | "low"; status: "open" | "closed" };
export type Queue = { tickets: Omit<Ticket, "status">[] };

export const tickets = new Map<string, Ticket>([
  ["T-1", { id: "T-1", title: "Cannot log in", priority: "high", status: "open" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "closed" }],
  ["T-3", { id: "T-3", title: "Login link expired", priority: "normal", status: "open" }],
  ["T-4", { id: "T-4", title: "Password reset email missing", priority: "high", status: "open" }],
]);

export function openTickets(): Queue["tickets"] {
  return [...tickets.values()].filter((t) => t.status === "open").map(({ id, title, priority }) => ({ id, title, priority }));
}
```

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:

```ts 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() };
  }
}
```

```tsx 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:

```ts list-queue.tool.ts active
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() };
  }
}
```

```ts tickets.ts
type Ticket = { id: string; title: string; priority: "high" | "normal" | "low"; status: "open" | "closed" };

const tickets: Ticket[] = [
  { id: "T-1", title: "Cannot log in", priority: "high", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "closed" },
  { id: "T-3", title: "Login link expired", priority: "normal", status: "open" },
];

export function openTickets() {
  return tickets.filter((t) => t.status === "open").map(({ id, title, priority }) => ({ id, title, priority }));
}
```

```ts no-page.test.ts
import { test, expect } from "@frontmcp/testing";

test("the call succeeds, but its result has no page", async ({ mcp }) => {
  const result = await mcp.tools.call("list_queue", {});
  expect(result).toBeSuccessful();
  expect(result.raw._meta["ui/html"]).toBeUndefined();
});

test("tools/list still says the tool has a React widget", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool._meta).toEqual({ ui: { resourceUri: "ui://widget/list_queue.html" }, "frontmcp/type": "react" });
});
```

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](https://frontmcp.dev/reference/ui/hosts#the-openai-apps-sdk)). In 1.9.3 `output` is enough, and this still works:

```tsx 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:

```ts list-queue.tool.ts active
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() };
  }
}
```

```ts tickets.ts
type Ticket = { id: string; title: string; priority: "high" | "normal" | "low"; status: "open" | "closed" };

const tickets: Ticket[] = [
  { id: "T-1", title: "Cannot log in", priority: "high", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "closed" },
  { id: "T-3", title: "Login link expired", priority: "normal", status: "open" },
];

export function openTickets() {
  return tickets.filter((t) => t.status === "open").map(({ id, title, priority }) => ({ id, title, priority }));
}
```

```ts data.test.ts
import { test, expect } from "@frontmcp/testing";

const queue = { tickets: [{ id: "T-1", title: "Cannot log in", priority: "high" }, { id: "T-3", title: "Login link expired", priority: "normal" }] };

test("structuredContent is the object the widget gets as `output`", async ({ mcp }) => {
  const result = await mcp.tools.call("list_queue", {});
  expect(result.raw.structuredContent).toEqual(queue);
});

test("the model reads the same object, as text", async ({ mcp }) => {
  const result = await mcp.tools.call("list_queue", {});
  expect(result.raw.content).toEqual([{ type: "text", text: JSON.stringify(queue) }]);
});
```

Return an object, not an array: a tool that returns an array sends `structuredContent` as `{ "value": [...] }` ([Shaping Tool Results](https://frontmcp.dev/learn/shaping-tool-results#what-comes-back-from-toolscall)), and the widget has to know that. The other hooks for the call's data, `useToolOutput()` and `useToolInput()`, are in the [Widget components reference](https://frontmcp.dev/reference/ui/components#hooks).

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:

```tsx
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`.

> **Pitfall: useCallTool returns a tuple**
`useCallTool()` returns an array, not an object, so `const { call, loading } = useCallTool("close_ticket")` gives `undefined` for both. Destructure an array: `const [close, { data, loading, error }, reset] = useCallTool("close_ticket")`.

The widget gets back whatever the host got from your server, so what `close_ticket` returns matters here too. Its errors should be `PublicMcpError`s, 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:

```ts close-ticket.tool.ts active
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 };
  }
}
```

```ts get-ticket.tool.ts
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";
import { tickets, type Ticket } from "./tickets";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  ui: { template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`<h2>${ctx.output.title}</h2>` },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return tickets.get(id)!;
  }
}
```

```ts tickets.ts
export type Ticket = { id: string; title: string; priority: "high" | "normal" | "low"; status: "open" | "closed" };

export const tickets = new Map<string, Ticket>([
  ["T-1", { id: "T-1", title: "Cannot log in", priority: "high", status: "open" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "closed" }],
]);
```

```ts calls.test.ts
import { test, expect } from "@frontmcp/testing";

test("success: data.structuredContent is the tool's object", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result.raw.structuredContent).toEqual({ id: "T-1", status: "closed" });
});

test("a failed tool: data.isError, with the message in content", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-2" });
  expect(result.raw.isError).toBe(true);
  expect(result.raw.content).toEqual([{ type: "text", text: "T-2 is already closed." }]);
});

test("close_ticket has no ui, so its results carry no page", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result.raw._meta["ui/html"]).toBeUndefined();
});

test("a tool with ui sends its page with a model's call", async ({ mcp }) => {
  const html: string = (await mcp.tools.call("get_ticket", { id: "T-1" })).raw._meta["ui/html"];
  expect(html.length).toBeGreaterThan(25_000);
});

test("a call the bridge marks as the widget's gets the data without the page", async ({ mcp }) => {
  const body = await mcp.raw.request({
    method: "tools/call",
    params: { name: "get_ticket", arguments: { id: "T-1" }, _meta: { "frontmcp/widgetCall": true } },
  });
  expect(body.result.structuredContent.title).toBe("Cannot log in");
  expect(body.result._meta["ui/html"]).toBeUndefined();
});
```

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](https://frontmcp.dev/reference/ui/hosts#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](https://frontmcp.dev/learn/your-first-widget#following-the-hosts-theme)), 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()`:

```tsx 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`:

| `resourceMode` | The page | When to use it |
| --- | --- | --- |
| `"cdn"`, the default for most clients | About 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](https://frontmcp.dev/reference/ui/components#components), with every hook. The [Expense Dashboard](https://frontmcp.dev/examples/expense-dashboard#running-it-for-real) shows a whole dashboard written this way, and what it took to run it.

> **Note**
`@frontmcp/react` sounds like it belongs on this page, but it's something else: a React client that runs a FrontMCP server inside a React app. It isn't for widgets. It has [a reference of its own](https://frontmcp.dev/reference/react). Widgets use `@frontmcp/ui/react`.

## 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 `PublicMcpError`s 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: Return an object the widget can read
The queue widget reads the tickets like this:

```tsx
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`.

```ts list-queue.tool.ts
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();
  }
}
```

```ts list-queue.tool.ts solution
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() };
  }
}
```

```ts tickets.ts
type Ticket = { id: string; title: string; priority: "high" | "normal" | "low"; status: "open" | "closed" };

const tickets: Ticket[] = [
  { id: "T-1", title: "Cannot log in", priority: "high", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", priority: "normal", status: "closed" },
  { id: "T-4", title: "Password reset email missing", priority: "high", status: "open" },
];

export function openTickets() {
  return tickets.filter((t) => t.status === "open").map(({ id, title, priority }) => ({ id, title, priority }));
}
```

```ts shape.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("`structuredContent.tickets` holds the open tickets", async ({ mcp }) => {
  const result = await mcp.tools.call("list_queue", {});
  expect(result.raw.structuredContent).toEqual({
    tickets: [
      { id: "T-1", title: "Cannot log in", priority: "high" },
      { id: "T-4", title: "Password reset email missing", priority: "high" },
    ],
  });
});

test("`tools/list` declares `tickets` as an array", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool.outputSchema?.properties?.tickets?.type).toBe("array");
});
```

**Hint:**
What does FrontMCP put in `structuredContent` when `execute()` returns an array? The widget wants an object with a `tickets` field.

**Solution:**
An array arrives as `{ "value": [...] }`, so `queue.tickets` was `undefined`. Returning `{ tickets: openTickets() }` gives the widget the object it reads, and `outputSchema: { tickets: z.array(QueueItem) }` shows its shape in `tools/list` and checks every result against it.

### Challenge: Fail so the widget can say why
The widget shows `data.content[0].text` under a row when `data.isError` is set. But `close_ticket` reports success for a ticket that's already closed, and for one that doesn't exist. Make it fail for both, with messages the widget can show as they are, in production too.

```ts close-ticket.tool.ts
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) ticket.status = "closed";
    return { id, status: "closed" as const };
  }
}
```

```ts close-ticket.tool.ts solution
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 };
  }
}
```

```ts tickets.ts
type Ticket = { id: string; title: string; status: "open" | "closed" };

export const tickets = new Map<string, Ticket>([
  ["T-1", { id: "T-1", title: "Cannot log in", status: "open" }],
  ["T-2", { id: "T-2", title: "Invoice total is wrong", status: "closed" }],
]);
```

```ts close.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("closing an open ticket returns `{ id, status: \"closed\" }`", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result).toBeSuccessful();
  expect(result.raw.structuredContent).toEqual({ id: "T-1", status: "closed" });
});

test("closing T-2, which is closed, fails with `T-2 is already closed.`", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-2" });
  expect(result).toBeError("PUBLIC_ERROR");
  expect(result.text()).toBe("T-2 is already closed.");
});

test("closing T-9 fails with `There's no ticket T-9.`", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-9" });
  expect(result).toBeError("PUBLIC_ERROR");
  expect(result.text()).toBe("There's no ticket T-9.");
});
```

**Hint:**
A tool fails with `this.fail()`. Which error class keeps its message in production, where FrontMCP hides the message of a plain `Error`?

**Solution:**
`this.fail(new PublicMcpError(…))` ends the call with `isError: true` and the message as the result's only text block, so the widget's `data.content[0].text` is exactly `T-2 is already closed.` A plain `Error` would work in development, but in production FrontMCP hides its message, and the widget would show a generic one.

### Challenge: Keep the widget's calls small
Someone copied `get_ticket`'s `ui` onto `close_ticket`, so every result of `close_ticket` now carries a whole page. Under the OpenAI Apps SDK, where the bridge can't mark the widget's own calls, that's every click on a widget's Close button too, a page the widget throws away. Make `close_ticket` results small again, and keep `get_ticket`'s widget.

```ts close-ticket.tool.ts
import { PublicMcpError, Tool, ToolContext, z, type TemplateContext } 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") },
  ui: { template: (ctx: TemplateContext<{ id: string }, { id: string }>) => ctx.helpers.html`<p>${ctx.output.id} closed</p>` },
})
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}.`));
    ticket.status = "closed";
    return { id, status: "closed" as const };
  }
}
```

```ts close-ticket.tool.ts solution
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}.`));
    ticket.status = "closed";
    return { id, status: "closed" as const };
  }
}
```

```ts get-ticket.tool.ts
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";
import { tickets, type Ticket } from "./tickets";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  ui: { template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`<h2>${ctx.output.title}</h2><p>${ctx.output.status}</p>` },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return tickets.get(id)!;
  }
}
```

```ts tickets.ts
export type Ticket = { id: string; title: string; status: "open" | "closed" };

export const tickets = new Map<string, Ticket>([["T-1", { id: "T-1", title: "Cannot log in", status: "open" }]]);
```

```ts small.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("`close_ticket` results carry no page", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result).toBeSuccessful();
  expect(Object.keys(result.raw._meta ?? {}).filter((k) => k.startsWith("ui/"))).toEqual([]);
});

test("`close_ticket` isn't listed with a widget", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "close_ticket");
  expect(tool?._meta?.ui).toBeUndefined();
});

test("`get_ticket` still has its widget", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.raw._meta["ui/html"]).toContain("<h2>Cannot log in</h2>");
});
```

**Hint:**
Which tool does a person look at, and which one does only the widget call?

**Solution:**
Without `ui`, `close_ticket` answers with its `content` and `structuredContent` only, a few dozen bytes instead of about 38 KB, whatever the host. The widget reads `data.structuredContent.status` and never needed the page. `get_ticket`, which people look at, keeps its widget.
