Choosing How a Widget Is Served

IntermediateMCP 2026-07-28

Every call to a tool with a widget sends the widget along: by default, a whole HTML page in the result, rendered with that call's data, about 38 KB of it the same script every time. That's one way to get a page to a host. The other is to serve the page once, as a resource, and let the host hand it each call's result. This lesson covers both, how to choose one for every tool at once, what FrontMCP 1.9.4's other serving modes actually do, what a widget is allowed to load, and why a widget only gets the fields the tool's outputSchema declares.

You will learn

  • How an inline widget and a static widget reach the host, and what each costs
  • What each servingMode does in FrontMCP 1.9.4, and how to set one for every tool
  • How a static widget gets each call's data, and why it reads structuredContent
  • What the widget's Content Security Policy allows, and what ui.csp adds to it
  • Why a field the widget shows has to be declared in outputSchema

Inline: a page in every result

list_queue lists the open tickets for a support agent. Its widget is served the default way, inline: FrontMCP renders the template after each call and puts the page in the result's _meta["ui/html"]. (The widgets in this lesson set no colors of their own, so they take the host's: the bridge gives the page a color-scheme, as Your First Widget shows.) Open the Tests tab:

Open
import { Tool, ToolContext, 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>
      <h2>Open tickets</h2>
      <ul>${ctx.output.tickets.map((t) => ctx.helpers.html`<li>${t.id} ${t.title} (${t.priority})</li>`)}</ul>`,
  },
})
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.

Inline is the simplest mode. The page has the data in its markup before any script runs, and the line under the widget says where the Playground took it from: _meta["ui/html"]. It costs two things:

  1. Size. About 38 KB goes out with every result, most of it the bridge script. When the widget calls the tool itself to refresh, the bridge marks the call as its own and the answer comes back without a page, but not under the OpenAI Apps SDK, which can't carry the mark: there every refresh brings the whole page again.
  2. Hosts that read the resource. tools/list points every host at ui://widget/list_queue.html, and a host that loads the widget from there, as the MCP Apps extension describes, gets a page with the bridge and nothing else: FrontMCP only renders an inline template with a call's data, and a resource read has no call.

Static: one page, the data from the host

With servingMode: "static", FrontMCP renders the template once, when the server starts, and serves that page as the resource. Results carry no page at all. The host reads the resource, then sends the page each call's result, and a script in the page draws it:

Open
import { Tool, ToolContext, 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: {
    servingMode: "static",
    autoResize: true,
    // Rendered once, at startup: nothing here depends on a call.
    template: (ctx: TemplateContext<{}, Partial<Queue>>) => ctx.helpers.html`
      <style>body { font: 15px/1.45 system-ui, sans-serif; }</style>
      <h2>Open tickets</h2>
      <ul id="queue"></ul>
      <script>
        window.addEventListener("tool:result", (event) => {
          const { tickets } = event.detail.structuredContent;
          const items = tickets.map((t) => {
            const item = document.createElement("li");
            item.textContent = t.id + " " + t.title + " (" + t.priority + ")";
            return item;
          });
          document.getElementById("queue").replaceChildren(...items);
        });
      </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.

The widget looks the same, but the line under it now says the page came from resources/read ui://widget/list_queue.html. Open the Wire tab: the Playground read the resource once, and the tools/call result has no ui/html in it. Here is what happened:

  1. When the server started, FrontMCP called the template with ctx.output set to {} and kept the page. The page carries no data of its own: window.__mcpToolOutput is null, as the second test checks.
  2. The call's result went out without a page.
  3. The host read the tool's resourceUri and put that page in a frame. The Playground reads it once per server start, since it's the same for every call.
  4. When the bridge had finished its handshake, the host sent the page the call's result, and the bridge fired tool:result with it. event.detail.structuredContent is the object execute() returned.

The script builds the list with textContent, not innerHTML. ctx.helpers.html escaped what the template put in the page, but the data a static widget shows arrives later, in the browser, where nothing escapes it for you.

Choose static when the page is big, when the same widget shows many results, or when hosts that load widgets from the resource matter to you. Choose inline when the page is small and simplest wins, or when the widget needs the data before its scripts run. The Expense Dashboard is a static widget whose filters call the tool again for another month or team.

One mode for every tool

A server whose widgets are all static would need servingMode: "static" on every tool, and a tool that forgets it goes out inline. Set it once instead. @FrontMcp({ ui: { servingMode: "static" } }) is the mode of every tool that doesn't choose its own, and @App({ ui: { servingMode } }) is the mode of one app's tools. A tool's own servingMode wins, then its app's, then the server's. Here the help desk serves everything static, except a small badge that is cheap enough to send inline:

Open
import { App, FrontMcp, Tool, ToolContext, type TemplateContext } from "@frontmcp/sdk";

@Tool({
  name: "list_queue",
  description: "List the open support tickets, most urgent first.",
  inputSchema: {},
  ui: { template: (ctx: TemplateContext<{}, {}>) => ctx.helpers.html`<h2>Open tickets</h2>` },
})
class ListQueue extends ToolContext {
  async execute() {
    return { open: 2 };
  }
}

@Tool({
  name: "team_stats",
  description: "How many tickets each agent closed this week.",
  inputSchema: {},
  ui: { template: (ctx: TemplateContext<{}, {}>) => ctx.helpers.html`<h2>This week</h2>` },
})
class TeamStats extends ToolContext {
  async execute() {
    return { closed: { sam: 4, nour: 6 } };
  }
}

@Tool({
  name: "ticket_badge",
  description: "A one-line badge for a ticket.",
  inputSchema: {},
  ui: { servingMode: "inline", template: (ctx: TemplateContext<{}, { id: string }>) => ctx.helpers.html`<b>${ctx.output.id}</b>` },
})
class TicketBadge extends ToolContext {
  async execute() {
    return { id: "T-1" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [ListQueue, TeamStats, TicketBadge] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  ui: { servingMode: "static" },
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A tool that takes "static" from the server is rendered at startup, like one that sets it, so the pitfall above applies to it too: its template gets ctx.output set to {}. Both options take the same six values as a tool, and anything else stops the server at startup with Invalid option: expected one of "auto"|"inline"|"static"|"hybrid"|"direct-url"|"custom-url". Before 1.9.4, @FrontMcp and @App accepted a ui.servingMode and ignored it.

What the other modes do in 1.9.4

servingMode accepts six values, and FrontMCP 1.9.4 handles them like this:

servingModeThe tools/call resultThe resource
"auto" (the default), "inline"The page, rendered with this call's dataA page without data
"static"No pageThe page, rendered once at startup
"hybrid"For clients detected as MCP Apps hosts (the "ext-apps" platform) and two other platforms, a small ui/component object with no HTML or code in it. For every other client, no page.As "static"
"direct-url", "custom-url"As "inline": directPath and customWidgetUrl are ignoredAs "inline"

So there are two real choices, inline and static. "hybrid" is static with an extra object for a few platforms, and the URL modes are inline. (One platform FrontMCP recognizes gets no widget in any mode.) The server says so when it starts: the Logs tab of the example below has a warning for hybrid, that it sends a reference and not the component code, and one for direct-url, that it's not implemented and the widget is served inline.

FrontMCP guesses a client's platform from the name it sends. A client that declares the MCP Apps extension is "ext-apps" whatever its name, unless a mapping of yours says otherwise. The server below has a host that doesn't declare it, so it maps the host's name, acme-desk, to "ext-apps". The tests call the tools as acme-desk and as another client:

Open
import { App, FrontMcp, Tool, ToolContext, type TemplateContext } from "@frontmcp/sdk";

const heading = (ctx: TemplateContext<{}, {}>) => ctx.helpers.html`<h2>Open tickets</h2>`;

@Tool({ name: "hybrid_queue", description: "Open tickets, served hybrid.", inputSchema: {}, ui: { template: heading, servingMode: "hybrid" } })
class HybridQueue extends ToolContext {
  async execute() {
    return { open: 2 };
  }
}

@Tool({
  name: "url_queue",
  description: "Open tickets, served from a URL.",
  inputSchema: {},
  ui: { template: heading, servingMode: "direct-url", directPath: "/widgets/queue" },
})
class UrlQueue extends ToolContext {
  async execute() {
    return { open: 2 };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [HybridQueue, UrlQueue] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  transport: { platformDetection: { mappings: [{ pattern: "acme-desk", platform: "ext-apps" }] } },
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The object holds no HTML and no code, so a host still needs the page from the resource to show anything. How FrontMCP recognizes a platform, and what each one gets, is on Hosts and platforms.

What a widget can load

The page FrontMCP renders has a Content Security Policy of its own, in a <meta> tag. Unless the tool says otherwise, it allows scripts and styles written in the page or loaded from five CDNs (cdn.jsdelivr.net, cdnjs.cloudflare.com, fonts.googleapis.com, fonts.gstatic.com and esm.sh), images and fonts from those CDNs or data: URLs, and network requests to those CDNs only.

So a widget can't fetch() your API until the tool lists it. Say the ticket widget should show a ticket's history, which lives at https://api.helpdesk.example. The tool's ui.csp option is where you list that origin, and FrontMCP puts it into the page's policy. The widget below prints the connect-src its own page ended up with:

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

type Ticket = { id: string; title: string };

const template = (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`
  <style>body { font: 15px/1.45 system-ui, sans-serif; } code { font-size: 13px; }</style>
  <h2>${ctx.output.title}</h2>
  <p>This page lets <code>fetch()</code> reach:</p>
  <p><code id="allowed"></code></p>
  <script>
    const policy = document.querySelector('meta[http-equiv="Content-Security-Policy"]').content;
    const connect = policy.split("; ").find((directive) => directive.startsWith("connect-src "));
    document.getElementById("allowed").textContent = connect.slice("connect-src ".length);
  </script>`;

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  ui: {
    autoResize: true,
    // ✅ The widget may call the help desk's API itself
    csp: { connectDomains: ["https://api.helpdesk.example"] },
    template,
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

@Tool({
  name: "get_ticket_plain",
  description: "The same tool, with no csp.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  ui: { template },
})
export class GetTicketPlain extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

With the origin listed, a fetch("https://api.helpdesk.example/tickets/T-1/history") in the page is allowed through. Without it, the browser blocks the request before it leaves: fetch() rejects with Failed to fetch, and the console says Connecting to 'https://api.helpdesk.example/tickets/T-1/history' violates the following Content Security Policy directive: "connect-src https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://esm.sh". The action has been blocked. This was checked in Chromium, with a local server standing in for the API.

A few details of ui.csp matter:

  • Secure origins count, and local ones. https://api.helpdesk.example, a path like https://api.helpdesk.example/v1, a wildcard like https://*.helpdesk.example and a WebSocket like wss://live.helpdesk.example do, and so does http://localhost:3000 while you develop. Plain http:// to any other host doesn't: the server names it in a warning at startup and leaves it out of the page's policy.
  • Both lists add to the CDNs, which is why the test above sees six entries. connectDomains goes into connect-src. resourceDomains goes into the script, style, image and font sources, which is how an image from your own domain loads, and into connect-src too.
  • The browser isn't the only one asking. A host may build a policy of its own around the frame from the lists csp puts in tools/list and on the resource (_meta.ui.csp and _meta["ui/csp"], with your keys and snake-case copies, and _meta["openai/widgetCSP"] for the OpenAI Apps SDK, from the moment the server starts). The page's policy is what FrontMCP controls; a host's is the host's.
  • The API still has to allow the request. The widget's page doesn't share your API's origin, so a fetch() needs the API's CORS headers (Access-Control-Allow-Origin), whatever the policy says.

Changed in 1.9: connectDomains replaced the CDNs in connect-src, esm.sh apart, and only https: origins counted.

When the API needs a secret, or doesn't send CORS headers, a tool is the better route: the widget asks the host to call one, and your server, which can reach the API, answers. Press Show history:

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

type Ticket = { id: string; title: string };

@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`
      <style>body { font: 15px/1.45 system-ui, sans-serif; }</style>
      <h2>${ctx.output.title}</h2>
      <button id="history" data-id="${ctx.output.id}">Show history</button>
      <ol id="events"></ol>
      <script>
        document.getElementById("history").addEventListener("click", async (event) => {
          // ✅ Ask the host to call a tool: the server fetches the history.
          const result = await FrontMcpBridge.callTool("get_ticket_history", { id: event.currentTarget.dataset.id });
          const items = result.structuredContent.events.map((e) => {
            const item = document.createElement("li");
            item.textContent = e.at + ": " + e.what;
            return item;
          });
          document.getElementById("events").replaceChildren(...items);
        });
      </script>`,
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Images follow resourceDomains: an image from your own domain loads once it's listed there. Without that, send small images as data: URLs, or use one of the CDNs. The Tool UI reference has the whole policy.

The widget gets what outputSchema declares

A widget shows the tool's result, and the result is also what the model reads. outputSchema keeps them tidy: fields it doesn't declare are dropped before the result goes out, from structuredContent, from the text the model reads and from the data the page gets. get_ticket below declares four fields and returns the whole row from its store, internal notes included. Open the Tests tab:

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

const Ticket = { id: z.string(), title: z.string(), status: z.enum(["open", "closed"]), priority: z.enum(["high", "normal"]) };
type Card = { id: string; title: string; status: string; priority: string };

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The widget shows a title and a status, and the notes are nowhere: not in structuredContent, not in the text block the model reads, and not in the page, in window.__mcpToolOutput, where anyone who opens the frame's source could read them. outputSchema also still checks the fields it declares: T-2 has no priority, so its call fails with INVALID_OUTPUT, as it would without ui.

The other side of the same rule is that the widget gets nothing the schema leaves out. A template that reads ctx.output.priority shows nothing when priority isn't in outputSchema, even though the row has it. Declare every field the widget shows, and then the model gets it too: design one result that's right for both, and keep what neither should have out of it.

Changed in 1.8.7: with ui set, FrontMCP used to skip this step. A tool with an outputSchema sent whatever execute() returned, notes included, to the model and into the page, and the fix was to return only the declared fields, like Ticket.parse(row) with a Zod object. That's no longer needed, and a widget that read a field the schema doesn't declare now gets undefined for it.

Recap

  • Inline (the default) puts the page, rendered with the call's data, in every result: about 38 KB each time. Its resource is a page without data.
  • Static renders the page once at startup, with ctx.output set to {}, and serves it as the resource. The host sends each result, and the page draws it from the tool:result event's structuredContent, with textContent.
  • @FrontMcp({ ui: { servingMode } }) sets the mode of every tool that doesn't choose its own, and @App({ ui: { servingMode } }) that of one app's tools. The tool wins, then the app, then the server.
  • In 1.9.4, "hybrid" is static plus a code-free ui/component object for a few platforms, and "direct-url" and "custom-url" are inline. The server warns about all three at startup.
  • The page's own Content Security Policy allows the five CDNs, and the origins in ui.csp, secure or local: connectDomains for fetch(), resourceDomains for images, scripts and fonts. The API still needs CORS headers. A tool the widget calls avoids both.
  • outputSchema drops the fields it doesn't declare from the result, the text the model reads and the page, ui or not. Declare every field the widget shows.

Try some challenges

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

Challenge 1 of 2

Serve the queue once

The queue widget is served inline, so every call carries a page. Serve it statically: results should carry no page, and the page the host reads should still show the heading and draw the tickets the host sends it.

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: {
    template: (ctx: TemplateContext<{}, Queue>) => ctx.helpers.html`
      <h2>Open tickets</h2>
      <ul>${ctx.output.tickets.map((t) => ctx.helpers.html`<li>${t.id} ${t.title}</li>`)}</ul>`,
  },
})
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.