# Choosing How a Widget Is Served

> Inline and static widgets in FrontMCP, setting the mode once for a server or an app, what the other serving modes really do, how a static widget gets each call's data from the host, what the widget's Content Security Policy lets it load and what ui.csp adds to it, and why every field a widget shows has to be declared in outputSchema.

Source: https://frontmcp.dev/learn/choosing-how-a-widget-is-served

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](https://frontmcp.dev/learn/your-first-widget#following-the-hosts-theme) shows.) Open the **Tests** tab:

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

```ts tickets.ts
export type QueueItem = { id: string; title: string; priority: "high" | "normal" };
export type Queue = { tickets: QueueItem[] };

const tickets = [
  { 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" },
] as const;

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

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

test("every result carries a whole page", async ({ mcp }) => {
  const html: string = (await mcp.tools.call("list_queue", {})).raw._meta["ui/html"];
  expect(html.length).toBeGreaterThan(25_000);
  expect(html).toContain("<li>T-1 Cannot log in (high)</li>");
});

test("the widget's resource is a page without the data", async ({ mcp }) => {
  await mcp.tools.call("list_queue", {});
  const page = (await mcp.resources.read("ui://widget/list_queue.html")).text();
  expect(page).toContain("window.__mcpToolOutput = null;");
  expect(page).not.toContain("Cannot log in");
});
```

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:

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

```ts tickets.ts
export type QueueItem = { id: string; title: string; priority: "high" | "normal" };
export type Queue = { tickets: QueueItem[] };

const tickets = [
  { 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" },
] as const;

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

```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([]);
  expect(result.raw.structuredContent.tickets).toHaveLength(2);
});

test("the resource is the page, rendered once with empty data", async ({ mcp }) => {
  const page = (await mcp.resources.read("ui://widget/list_queue.html")).text();
  expect(page).toContain('<ul id="queue"></ul>');
  expect(page).toContain("window.__mcpToolOutput = null;");
});

test("tools/list points hosts at it, as before", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool._meta.ui.resourceUri).toBe("ui://widget/list_queue.html");
});
```

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.

> **Pitfall: A static template runs with empty data**
At startup there's no call, so `ctx.output` is `{}`. A static template that reads deeper, like `ctx.output.tickets.map(…)`, throws. The server logs `Failed to compile static widget for tool "list_queue": Cannot read properties of undefined (reading 'map')`, starts anyway, and serves the empty page for that tool. Type the output as `Partial<…>` so TypeScript reminds you, and render anything that depends on a call from the host's result.

> **Note**
The bridge also has `FrontMcpBridge.getToolOutput()`. In an inline page it returns what `execute()` returned, and in a static page `null`, until an MCP Apps host sends the result. Then it returns the result's `structuredContent`, the same object the event carries. A result that failed has none, and the output is the error's message as a string, so a widget that shows failures checks for a missing `structuredContent`, as the Expense Dashboard's script does. Before 1.8.7 the output became the result's `content` array, `[{ type: "text", text: "{…}" }]`, and widgets had to read `getStructuredContent()` or the event instead.

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](https://frontmcp.dev/examples/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:

```ts main.ts active
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 {}
```

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

async function uiKeys(mcp: any, name: string) {
  const result = await mcp.tools.call(name, {});
  return Object.keys(result.raw._meta).filter((k) => k.startsWith("ui/"));
}

test("tools that don't choose are served static", async ({ mcp }) => {
  expect(await uiKeys(mcp, "list_queue")).toEqual([]);
  expect(await uiKeys(mcp, "team_stats")).toEqual([]);
  expect((await mcp.resources.read("ui://widget/team_stats.html")).text()).toContain("<h2>This week</h2>");
});

test("a tool's own servingMode wins", async ({ mcp }) => {
  expect(await uiKeys(mcp, "ticket_badge")).toEqual(["ui/html", "ui/type", "ui/mimeType"]);
});
```

A tool that takes `"static"` from the server is rendered at startup, like one that sets it, so the [pitfall above](#static-one-page-the-data-from-the-host) 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:

| `servingMode` | The `tools/call` result | The resource |
| --- | --- | --- |
| `"auto"` (the default), `"inline"` | The page, rendered with this call's data | A page without data |
| `"static"` | No page | The 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 ignored | As `"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:

```ts main.ts active
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 {}
```

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

// Calls a tool as the client named `client`, and returns its result's ui/* keys.
async function uiKeys(mcp: any, client: string, name: string) {
  const body = await mcp.raw.request({
    method: "tools/call",
    params: { name, arguments: {}, _meta: { "io.modelcontextprotocol/clientInfo": { name: client, version: "1.0.0" } } },
  });
  return Object.fromEntries(Object.entries(body.result._meta).filter(([k]) => k.startsWith("ui/")));
}

test("hybrid: an MCP Apps host gets a component object, with no page", async ({ mcp }) => {
  const keys = await uiKeys(mcp, "acme-desk", "hybrid_queue");
  expect(keys["ui/component"]).toEqual({ type: "html", hash: expect.any(String), toolName: "hybrid_queue" });
  expect(keys["ui/html"]).toBeUndefined();
});

test("hybrid: other clients get nothing at all, as with static", async ({ mcp }) => {
  expect(await uiKeys(mcp, "my-mcp-client", "hybrid_queue")).toEqual({});
});

test("direct-url: the page is in the result, and directPath is ignored", async ({ mcp }) => {
  const keys = await uiKeys(mcp, "my-mcp-client", "url_queue");
  expect(keys["ui/html"]).toContain("<h2>Open tickets</h2>");
  expect(JSON.stringify(keys)).not.toContain("/widgets/queue");
});
```

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](https://frontmcp.dev/reference/ui/hosts#how-frontmcp-recognizes-the-client).

## 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:

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

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

const connectSrc = async (mcp: any, name: string) => {
  const html: string = (await mcp.tools.call(name, { id: "T-1" })).raw._meta["ui/html"];
  const policy = html.match(/Content-Security-Policy" content="([^"]*)"/)![1];
  return policy.match(/connect-src ([^;]*)/)![1].split(" ");
};

test("tools/list lists the origin for the host", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "get_ticket");
  expect(tool._meta.ui.csp).toEqual({ connectDomains: ["https://api.helpdesk.example"] });
});

test("the page's own policy allows it, next to the CDNs", async ({ mcp }) => {
  expect(await connectSrc(mcp, "get_ticket")).toEqual([
    "https://cdn.jsdelivr.net",
    "https://cdnjs.cloudflare.com",
    "https://fonts.googleapis.com",
    "https://fonts.gstatic.com",
    "https://esm.sh",
    "https://api.helpdesk.example",
  ]);
});

test("without csp, the page can reach the CDNs and nothing else", async ({ mcp }) => {
  expect(await connectSrc(mcp, "get_ticket_plain")).toEqual([
    "https://cdn.jsdelivr.net",
    "https://cdnjs.cloudflare.com",
    "https://fonts.googleapis.com",
    "https://fonts.gstatic.com",
    "https://esm.sh",
  ]);
});
```

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**:

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

```ts history.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "get_ticket_history",
  description: "What happened to a support ticket, oldest first.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  annotations: { readOnlyHint: true },
})
export class GetTicketHistory extends ToolContext {
  async execute({ id }: { id: string }) {
    // On a real server: this.fetch(`https://api.helpdesk.example/tickets/${id}/history`)
    return {
      events: [
        { at: "09:12", what: `${id} opened by Acme Corp` },
        { at: "09:40", what: "Assigned to Nour" },
      ],
    };
  }
}
```

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

test("the history comes through a tool", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket_history", { id: "T-1" });
  expect(result.json().events[0]).toEqual({ at: "09:12", what: "T-1 opened by Acme Corp" });
});

test("the widget never calls the API itself, so the tool lists no origin", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "get_ticket");
  expect(tool._meta.ui.csp).toBeUndefined();
});
```

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](https://frontmcp.dev/reference/ui#content-security-policy) 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:

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

```ts rows.ts
// Rows as the help desk's database returns them.
export const rows: Record<string, any> = {
  "T-1": { id: "T-1", title: "Cannot log in", status: "open", priority: "high", internalNotes: "Acme is late on two invoices" },
  "T-2": { id: "T-2", title: "Invoice total is wrong", status: "closed", internalNotes: "Third time this month" },
};
```

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

test("the notes never reach the model", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.raw.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open", priority: "high" });
  expect(result.text()).not.toContain("Acme is late on two invoices");
});

test("or the page", 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>");
  expect(html).not.toContain("internalNotes");
});

test("a declared field the row doesn't have fails the call", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-2" });
  expect(result).toBeError("INVALID_OUTPUT");
  expect(result.raw._meta["ui/html"]).toBeUndefined();
});
```

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: 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.

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

```ts list-queue.tool.ts solution
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",
    template: (ctx: TemplateContext<{}, Partial<Queue>>) => ctx.helpers.html`
      <h2>Open tickets</h2>
      <ul id="queue"></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 hidden
import { test, expect } from "@frontmcp/testing";

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

test("the page the host reads has the heading", async ({ mcp }) => {
  const page = (await mcp.resources.read("ui://widget/list_queue.html")).text();
  expect(page).toContain("<h2>Open tickets</h2>");
});

test("the page draws the tickets from the host's `structuredContent`", async ({ mcp }) => {
  const page = (await mcp.resources.read("ui://widget/list_queue.html")).text();
  const body = page.slice(page.lastIndexOf("<h2>"));
  expect(body).toContain("structuredContent");
});
```

**Hint:**
Setting `servingMode` is half of it. A static template runs once, at startup, with `ctx.output` set to `{}`: what does `ctx.output.tickets.map` do then? Draw the list in the browser instead, when the host sends the result.

**Solution:**
`servingMode: "static"` takes the page out of the results and serves it as the resource. The template no longer reads `ctx.output`, which is `{}` at startup, so it renders; before, it threw and the server served an empty page. The script listens for `tool:result` and builds the list from `event.detail.structuredContent` with `textContent`, so ticket titles stay text.

### Challenge: Show the priority
The ticket widget shows a title, a status and a priority line, but the priority line is always empty, though every ticket has one. Make the widget show it, without sending the internal notes.

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

const Ticket = z.object({ id: z.string(), title: z.string(), status: z.enum(["open", "closed"]) });
type Card = z.infer<typeof Ticket> & { 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>Status: ${ctx.output.status}</p>
      <p>Priority: ${ctx.output.priority}</p>`,
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return rows[id];
  }
}
```

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

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

@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 }, Ticket>) => ctx.helpers.html`
      <h2>${ctx.output.title}</h2>
      <p>Status: ${ctx.output.status}</p>
      <p>Priority: ${ctx.output.priority}</p>`,
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return rows[id];
  }
}
```

```ts rows.ts
export const rows: Record<string, any> = {
  "T-1": { id: "T-1", title: "Cannot log in", status: "open", priority: "high", internalNotes: "Acme is late on two invoices" },
  "T-3": { id: "T-3", title: "Login link expired", status: "open", priority: "normal", internalNotes: "Third time this month" },
};
```

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

test("the widget shows the ticket's priority", async ({ mcp }) => {
  const html: string = (await mcp.tools.call("get_ticket", { id: "T-3" })).raw._meta["ui/html"];
  expect(html).toContain("<p>Priority: normal</p>");
});

test("`priority` is in the result the model reads", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.raw.structuredContent.priority).toBe("high");
});

test("the internal notes still go nowhere", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-3" });
  expect(result.text()).not.toContain("Third time this month");
  expect((result.raw._meta["ui/html"] as string)).not.toContain("Third time this month");
});
```

**Hint:**
The template reads `ctx.output.priority`, and the row has it. What decides which fields of the row reach the template?

**Solution:**
The template gets what the tool's `outputSchema` declares, and `Ticket` leaves `priority` out, so `ctx.output.priority` is `undefined` and `html` renders nothing for it. Adding `priority` to the schema puts it in the result, the text the model reads and the page's data, while `internalNotes`, which the schema still doesn't declare, goes nowhere.
