Tools with a UI

IntermediateMCP 2026-07-28

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

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:

Open
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;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

Ready to learn this topic?

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.

Read Your First Widget

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:

Open
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" }] };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

Ready to learn this topic?

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.

Read Choosing How a Widget Is Served

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:

list-queue.tool.ts
ui: { template: { file: join(__dirname, "queue.widget.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.

Ready to learn this topic?

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.

Read Building Widgets with React

Template or React?

If the widget…Write
Shows a result, maybe with a button or two that call toolsA template function, served inline
Is large, or shows many results with one pageA template function, served static, that draws each result
Has state: filters, per-row actions, loading and error statesA 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. Every ui option is in the Tool UI reference, what each host and platform gets is on Hosts and platforms, and the React hooks and components are in Widget components. After this chapter, Built-in Plugins and Adapters covers the plugins that come with FrontMCP: caching, memory, approval, feature flags, CodeCall and OpenAPI.