# Tools with a UI

> How a FrontMCP tool gives its result a widget, an HTML page a host shows next to the answer. Writing one with a template, choosing how it's served, and building it in React, with what works today.

Source: https://frontmcp.dev/learn/tools-with-a-ui

Every tool so far answers the model, and the model tells the person what it found. Some results are for the person more than the model: a ticket they want to see at a glance, a queue they want to sort, a button they'd rather press than type "close it". A tool's `ui` option gives its result a **widget**, an HTML page that a host able to show one renders next to the answer, while the model reads the same result as before. The widget can call your tools through the host, follow its theme and fit its frame. This chapter builds widgets for the help desk, in FrontMCP 1.9.4, and points out what a widget can't do there yet.

**In this chapter**
- [How to give a tool a widget, and make it call a tool back](https://frontmcp.dev/learn/your-first-widget)
- [How a widget reaches the host, what it may load, and what data it carries](https://frontmcp.dev/learn/choosing-how-a-widget-is-served)
- [How to build a widget as a React component](https://frontmcp.dev/learn/building-widgets-with-react)

## Your first widget

`ui.template` is a function FrontMCP calls after every call, with the call's input and output. What it returns, built with `ctx.helpers.html` so every value is escaped, becomes the page. The result's `content` and `structuredContent` don't change, and the page travels next to them, in `_meta["ui/html"]`. The Playground's **Widget** tab shows the page as an MCP Apps host would. Press **Close ticket**:

```ts get-ticket.tool.ts active
import { PublicMcpError, 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, with its status and customer.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  ui: {
    template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`
      <article style="padding: 12px 16px; border-radius: 8px; background: #f6f7f9; color: #23272f; font: 15px system-ui">
        <p>${ctx.output.id} · ${ctx.output.customer}</p>
        <h2>${ctx.output.title}</h2>
        <p>Status: <span id="status">${ctx.output.status}</span></p>
        <button data-tool-call="close_ticket" data-tool-args="${JSON.stringify({ id: ctx.output.id })}">Close ticket</button>
      </article>
      <script>
        document.addEventListener("tool:success", (event) => {
          const result = event.detail.result;
          document.getElementById("status").textContent = result.isError ? result.content[0].text : result.structuredContent.status;
        });
      </script>`,
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}
```

```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.find((t) => t.id === 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: ticket.status };
  }
}
```

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

export const tickets: Ticket[] = [
  { id: "T-1", title: "Cannot log in", status: "open", customer: "Acme Corp" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed", customer: "Globex" },
];
```

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

test("the model's result is unchanged", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in", status: "open", customer: "Acme Corp" });
});

test("the page travels next to it", async ({ mcp }) => {
  const html: string = (await mcp.tools.call("get_ticket", { id: "T-1" })).raw._meta["ui/html"];
  expect(html).toContain("<h2>Cannot log in</h2>");
});
```

The button's click goes from the page's bridge script to the host, and from the host to your server as an ordinary `tools/call`: the **Wire** tab has it, tagged **widget**.

Read more: [Your First Widget](https://frontmcp.dev/learn/your-first-widget)
Learn when a result deserves a widget, how to write a template and why to escape with `html`, what a host receives, how a widget calls tools, and how to follow the host's theme, fit the frame and open links.

## Choosing how a widget is served

By default a widget is served **inline**: every result carries the whole page, about 38 KB, rendered with that call's data. A **static** widget is rendered once, when the server starts, and served as the resource `ui://widget/<tool>.html`. The host reads it once, then hands the page each result, which the page draws itself:

```ts list-queue.tool.ts active
import { Tool, ToolContext, type TemplateContext } from "@frontmcp/sdk";

type Queue = { tickets: { id: string; title: string }[] };

@Tool({
  name: "list_queue",
  description: "List the open support tickets, most urgent first.",
  inputSchema: {},
  ui: {
    servingMode: "static",
    // Runs once, at startup, with ctx.output = {}.
    template: (ctx: TemplateContext<{}, Partial<Queue>>) => ctx.helpers.html`
      <h2 style="font: 600 18px system-ui">Open tickets</h2>
      <ul id="queue" style="font: 15px system-ui"></ul>
      <script>
        window.addEventListener("tool:result", (event) => {
          const items = event.detail.structuredContent.tickets.map((t) => {
            const item = document.createElement("li");
            item.textContent = t.id + " " + t.title;
            return item;
          });
          document.getElementById("queue").replaceChildren(...items);
        });
      </script>`,
  },
})
export class ListQueue extends ToolContext {
  async execute(): Promise<Queue> {
    return { tickets: [{ id: "T-1", title: "Cannot log in" }, { id: "T-3", title: "Login link expired" }] };
  }
}
```

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

test("results carry no page", async ({ mcp }) => {
  const result = await mcp.tools.call("list_queue", {});
  expect(Object.keys(result.raw._meta).filter((k) => k.startsWith("ui/"))).toEqual([]);
});

test("the resource is the page", async ({ mcp }) => {
  expect((await mcp.resources.read("ui://widget/list_queue.html")).text()).toContain('<ul id="queue" style="font: 15px system-ui"></ul>');
});
```

In FrontMCP 1.9.4, those are the two real choices: `"hybrid"` is static with a small extra object for a few platforms, and the URL modes are served inline, and the server warns about both at startup. A server that serves every widget one way says so once, with `@FrontMcp({ ui: { servingMode: "static" } })` or `@App({ ui: { servingMode } })`, and a tool's own `servingMode` still wins. Two more things matter wherever the page comes from. The page's own Content Security Policy lets it reach a few CDNs and the origins the tool lists in `ui.csp`, so list the ones the widget calls. And with an `outputSchema`, the widget gets only the fields it declares, as the model does: declare every field the widget shows.

Read more: [Choosing How a Widget Is Served](https://frontmcp.dev/learn/choosing-how-a-widget-is-served)
Learn what inline and static widgets cost, how a static widget reads each result and why it reads `structuredContent`, how to set one mode for every tool, what every serving mode does in 1.9.4, what the widget's Content Security Policy allows, and why the widget's data has to be declared in `outputSchema`.

## Building widgets with React

A widget with state, like a queue with filters and a Close button on each row, is easier as a React component than as a script in a string. Point `ui.template` at a `.tsx` file and FrontMCP bundles it, and mounts the default export with the call's data. Hooks from `@frontmcp/ui/react` read the result and call tools through the host:

```ts list-queue.tool.ts
ui: { template: { file: join(__dirname, "queue.widget.tsx") } },
```

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

The Playground can't run this: FrontMCP bundles widget files from the file system, on a Node server, and the Playground runs FrontMCP in your browser. The lesson's widgets were run in Node projects with FrontMCP 1.9.4, CommonJS and ES module, and rendered in Chromium, and its Playgrounds cover what runs here: the tools, and the calls a widget makes.

Read more: [Building Widgets with React](https://frontmcp.dev/learn/building-widgets-with-react)
Learn what a project needs to bundle a widget file, what the component gets as props and why to check for the fields it needs, how to call tools with `useCallTool()` and handle each outcome, and how to handle the theme, the size and where React is loaded from.

## Template or React?

| If the widget… | Write |
| --- | --- |
| Shows a result, maybe with a button or two that call tools | A template function, served inline |
| Is large, or shows many results with one page | A template function, served static, that draws each result |
| Has state: filters, per-row actions, loading and error states | A React component in a `.tsx` file, on a Node server |

Whatever you choose, the result is still what the model reads, and many hosts show no widget at all. Make the tool useful without its page first.

## What's next?

Start the chapter with [Your First Widget](https://frontmcp.dev/learn/your-first-widget). Every `ui` option is in the [Tool UI reference](https://frontmcp.dev/reference/ui), what each host and platform gets is on [Hosts and platforms](https://frontmcp.dev/reference/ui/hosts), and the React hooks and components are in [Widget components](https://frontmcp.dev/reference/ui/components). After this chapter, [Built-in Plugins and Adapters](https://frontmcp.dev/learn/built-in-plugins-and-adapters) covers the plugins that come with FrontMCP: caching, memory, approval, feature flags, CodeCall and OpenAPI.
