---
title: Building Tool UI
slug: guides/building-tool-ui
description: Create rich HTML widgets for tool outputs using @frontmcp/ui components
icon: palette
keywords: ['frontmcp', 'mcp', 'model context protocol', 'guide', 'tutorial', 'how-to', 'tool ui', 'ui resources', 'typescript']
---

FrontMCP tools can render rich HTML widgets for display in OpenAI Apps, Claude Artifacts, and other UI-capable hosts. This guide shows you how to use the `@frontmcp/ui` library to build professional-looking tool outputs.

<Info>
**Prerequisites**:
- A working FrontMCP tool ([see Your First Tool](/frontmcp/guides/your-first-tool))
- Basic understanding of HTML/CSS
</Info>

## What You'll Build

A weather tool that displays temperature, conditions, and other data in a styled card with badges and description lists.

---

## Step 1: Install the UI Package

<CodeGroup>
```bash npm
npm install @frontmcp/ui
```

```bash pnpm
pnpm add @frontmcp/ui
```

```bash yarn
yarn add @frontmcp/ui
```

</CodeGroup>

<Info>
**Required for `.tsx`/`.jsx` FileSource widgets.** When you point `ui.template` at a `.tsx`/`.jsx` file (the recommended pattern for non-trivial widgets), FrontMCP injects an auto-generated React mount that imports `McpBridgeProvider` from `@frontmcp/ui/react`. Server-side bundling fails without this package installed. Match the version to `@frontmcp/sdk`. `react` and `react-dom` stay external and load from the CDN at runtime, so only `@frontmcp/ui` needs to be present on disk.
</Info>

<Warning>
**Anchor FileSource paths to the tool file.** Relative `template: { file: './widget.tsx' }` paths are resolved against `process.cwd()`, not the tool source's directory. A widget at `src/tools/foo.widget.tsx` referenced as `./foo.widget.tsx` from `src/tools/foo.tool.ts` will fail with `ENOENT` at tool-call time. Use `fileURLToPath(new URL('./widget.tsx', import.meta.url))` (from `node:url`) or pass an absolute path. In a **CommonJS** project, `import.meta.url` is unavailable — use `join(__dirname, 'widget.tsx')` (from `node:path`) instead.
</Warning>

<Info>
**`resourceMode` is host-detected.** Leave it unset and FrontMCP auto-switches to `'inline'` when the connecting client is Claude (#456), so the widget actually renders in Claude's sandboxed iframe — React is bundled into the widget itself (#454). Other hosts keep `'cdn'` for a smaller payload. Set `resourceMode` explicitly to override the detection. Auto-detection only applies to per-call rendering modes (inline / hybrid / lean); `servingMode: 'static'` widgets are pre-compiled at startup with no client context, so set `resourceMode: 'inline'` explicitly when targeting Claude in static mode.
</Info>

<Info>
**`ui.csp` is now emitted on the widget resource (#455).** FrontMCP attaches `ui.csp` to the `resources/read` content item's `_meta.ui.csp` (and `_meta['ui/csp']`) so MCP Apps hosts — particularly Claude — actually honor it. Previously the CSP only appeared on the tool listing, where Claude ignored it.
</Info>

<Info>
**Use the `*.widget.tsx` naming convention.** The scaffolded `tsconfig.json` excludes `**/*.widget.tsx` / `**/*.widget.jsx` from the server typecheck (`frontmcp init` also adds the excludes to existing tsconfigs). Widget sources are bundled separately by uipack/esbuild at render time, so the server tsconfig doesn't need `jsx: 'react-jsx'` or `@types/react` for them. Add a sibling `tsconfig.widget.json` if you want IDE typecheck for widget files.
</Info>

---

## Step 2: Create a Tool with UI Template

The `ui` property in the `@Tool` decorator lets you define a template function that renders HTML:

```typescript
import { Tool, ToolContext } from '@frontmcp/sdk';
import { card, descriptionList, badge } from '@frontmcp/ui';
import { z } from '@frontmcp/sdk';

const inputSchema = {
  location: z.string().describe('City name or location'),
  units: z.enum(['celsius', 'fahrenheit']).optional().describe('Temperature units'),
};

const outputSchema = z.object({
  location: z.string(),
  temperature: z.number(),
  units: z.enum(['celsius', 'fahrenheit']),
  conditions: z.string(),
  humidity: z.number(),
  windSpeed: z.number(),
});

@Tool({
  name: 'get_weather',
  description: 'Get current weather for a location',
  inputSchema,
  outputSchema,
  ui: {
    widgetDescription: 'Displays current weather conditions',
    displayMode: 'inline',
    servingMode: 'static',
    template: (ctx) => {
      const { output, helpers } = ctx;
      const tempSymbol = output.units === 'celsius' ? '°C' : '°F';

      // Build weather details using descriptionList component
      const weatherDetails = descriptionList(
        [
          { term: 'Humidity', description: `${output.humidity}%` },
          { term: 'Wind Speed', description: `${output.windSpeed} km/h` },
        ],
        { layout: 'grid', className: 'mt-4' }
      );

      // Build condition badge
      const conditionBadge = badge(helpers.escapeHtml(output.conditions), {
        variant: output.conditions === 'sunny' ? 'success' : 'secondary',
        size: 'md',
      });

      // Main content
      const content = `
        <div class="text-center py-6">
          <div class="text-5xl font-light mb-2">
            ${output.temperature}${tempSymbol}
          </div>
          <div class="flex justify-center">
            ${conditionBadge}
          </div>
        </div>
        ${weatherDetails}
      `;

      // Wrap in card component
      return card(content, {
        title: helpers.escapeHtml(output.location),
        subtitle: 'Current Weather',
        variant: 'elevated',
        size: 'md',
      });
    },
  },
})
export default class GetWeatherTool extends ToolContext {
  async execute(input: { location: string; units?: 'celsius' | 'fahrenheit' }) {
    // In production, call a real weather API
    return {
      location: input.location,
      temperature: 22,
      units: input.units || 'celsius',
      conditions: 'sunny',
      humidity: 55,
      windSpeed: 10,
    };
  }
}
```

---

## UI Configuration Options

<ParamField path="ui.widgetDescription" type="string">
  Human-readable description of what the widget displays. Shown to users in UI-capable hosts.
</ParamField>

<ParamField path="ui.displayMode" type="'inline' | 'fullscreen' | 'pip'">
  Preferred display mode (hint to the host — may be ignored):
  - `inline` - Rendered inline in the conversation (default)
  - `fullscreen` - Request fullscreen display
  - `pip` - Picture-in-picture
</ParamField>

<ParamField path="ui.servingMode" type="'auto' | 'inline' | 'static' | 'hybrid' | 'direct-url' | 'custom-url'">
  How the HTML is delivered to the client (default: `auto`):
  - `auto` - Auto-select per host (OpenAI / Claude / unknown)
  - `inline` - Embedded in tool response `_meta['ui/html']` (works everywhere)
  - `static` - Pre-compiled at startup; client fetches `ui://widget/{toolName}.html` via `resources/read`
  - `hybrid` - Shell pre-compiled; component code + data delivered per call in `_meta['ui/component']`
  - `direct-url` - Served from an HTTP path on the MCP server (`directPath`)
  - `custom-url` - Served from an external URL (`customWidgetUrl`, supports a `{token}` placeholder)
</ParamField>

<ParamField path="ui.template" type="function">
  A function that receives the execution context and returns an HTML string.
</ParamField>

---

## Available UI Components

### Card

Wrap content in a styled container:

```typescript
import { card } from '@frontmcp/ui';

card('<p>Card content</p>', {
  title: 'Card Title',
  subtitle: 'Optional subtitle',
  variant: 'elevated',  // 'default' | 'elevated' | 'outlined'
  size: 'md',           // 'sm' | 'md' | 'lg'
});
```

### Badge

Display status or category labels:

```typescript
import { badge } from '@frontmcp/ui';

badge('Active', {
  variant: 'success',  // 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info'
  size: 'md',          // 'sm' | 'md' | 'lg'
});
```

### Description List

Show key-value pairs:

```typescript
import { descriptionList } from '@frontmcp/ui';

descriptionList([
  { term: 'Status', description: 'Active' },
  { term: 'Created', description: '2024-01-15' },
  { term: 'Owner', description: 'john@example.com' },
], {
  layout: 'grid',      // 'stacked' | 'grid' | 'inline'
  className: 'mt-4',
});
```

### Button

Create styled buttons:

```typescript
import { button } from '@frontmcp/ui';

button('Submit', {
  variant: 'primary',  // 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
  type: 'submit',      // 'button' | 'submit' | 'reset'
});
```

### Form and Input

Build forms with validation:

```typescript
import { form, input, button } from '@frontmcp/ui';

form(`
  ${input({ name: 'email', label: 'Email', type: 'email', required: true })}
  ${input({ name: 'message', label: 'Message', type: 'textarea' })}
  ${button('Send', { type: 'submit', variant: 'primary' })}
`, {
  action: '/api/contact',
  method: 'post',
});
```

---

## Template Context

The template function receives a context object with:

```typescript
import { type TemplateContext } from '@frontmcp/sdk';

// Annotate `ctx` explicitly — see the TypeScript note below.
template: (ctx: TemplateContext<MyInput, MyOutput>) => {
  // Input that was passed to the tool
  const { location, units } = ctx.input;

  // Output from execute()
  const { temperature, conditions } = ctx.output;

  // Helper functions
  const { escapeHtml, formatDate, formatCurrency, uniqueId, jsonEmbed } = ctx.helpers;

  // Always escape user-provided strings!
  return `<p>${escapeHtml(location)}</p>`;
}
```

<Warning>
**TypeScript: `ctx` must be annotated explicitly (TS7006).** Under `strict` / `noImplicitAny`, `template: (ctx) => …` fails with `Parameter 'ctx' implicitly has an 'any' type`. The `ui.template` field is a union of multiple callable shapes (`TemplateBuilderFn | string | ((props: any) => any) | FileSource`), so TypeScript can't pick a single contextual type for the arrow's parameter. Either annotate `ctx: TemplateContext<MyInput, MyOutput>` (imported from `@frontmcp/sdk`), or move the widget into its own `.tsx` file and use the `FileSource` form (`template: { file: … }`).
</Warning>

### Available Helpers

| Helper                            | Description                                                          |
| --------------------------------- | -------------------------------------------------------------------- |
| `escapeHtml(str)`                 | Escape HTML entities to prevent XSS (handles `null`/`undefined`)     |
| `formatDate(date, format?)`       | Format a date (accepts `Date` or ISO string)                         |
| `formatCurrency(amount, ccy?)`    | ISO-4217 currency formatting (defaults to `'USD'`)                   |
| `uniqueId(prefix?)`               | Generate a unique ID for DOM elements                                |
| `jsonEmbed(data)`                 | Safely embed JSON in an inline `<script>` (escapes `</script>`)      |

<Warning>
Always use `helpers.escapeHtml()` when rendering user-provided data to prevent XSS vulnerabilities.
</Warning>

---

## Practical Example: Expense Summary

```typescript
import { Tool, ToolContext } from '@frontmcp/sdk';
import { card, descriptionList, badge } from '@frontmcp/ui';
import { z } from '@frontmcp/sdk';

@Tool({
  name: 'expense_summary',
  description: 'Get expense summary for a user',
  inputSchema: { userId: z.string() },
  outputSchema: z.object({
    userName: z.string(),
    totalExpenses: z.number(),
    pendingCount: z.number(),
    approvedCount: z.number(),
    status: z.enum(['under_budget', 'at_budget', 'over_budget']),
  }),
  ui: {
    widgetDescription: 'Expense summary dashboard',
    displayMode: 'inline',
    servingMode: 'static',
    template: (ctx) => {
      const { output, helpers } = ctx;

      const statusColors = {
        under_budget: 'success',
        at_budget: 'warning',
        over_budget: 'error',
      };

      const statusBadge = badge(
        output.status.replace('_', ' ').toUpperCase(),
        { variant: statusColors[output.status], size: 'lg' }
      );

      const details = descriptionList([
        { term: 'Total', description: helpers.formatCurrency(output.totalExpenses, 'USD') },
        { term: 'Pending', description: String(output.pendingCount) },
        { term: 'Approved', description: String(output.approvedCount) },
      ], { layout: 'grid' });

      return card(`
        <div class="flex justify-between items-center mb-4">
          <h3 class="text-lg font-semibold">${helpers.escapeHtml(output.userName)}</h3>
          ${statusBadge}
        </div>
        ${details}
      `, {
        variant: 'elevated',
        size: 'md',
      });
    },
  },
})
export default class ExpenseSummaryTool extends ToolContext {
  async execute(input: { userId: string }) {
    // Fetch from database in production
    return {
      userName: 'John Doe',
      totalExpenses: 1234.56,
      pendingCount: 3,
      approvedCount: 12,
      status: 'under_budget' as const,
    };
  }
}
```

---

## Platform Considerations

Different MCP hosts have different capabilities and network policies:

- **OpenAI Apps SDK** — any CDN reachable; widget URI surfaces under `_meta['openai/outputTemplate']`
- **Claude (MCP-UI)** — **only `cdnjs.cloudflare.com` is reachable**; prefer `resourceMode: 'inline'` so the widget shell is self-contained, and pin any `externals` to cdnjs URLs via `dependencies`
- **MCP Inspector** — useful for local development; honors `servingMode: 'static'`
- **Gemini / unknown hosts** — `ui` is ignored; the tool returns JSON only

The default `servingMode: 'auto'` selects the right mode per host. See the [Tool UI reference](/frontmcp/servers/tools#tool-ui) for the full surface: serving modes, CSP, the `window.FrontMcpBridge` runtime, file-based `.tsx` widgets, and platform-specific troubleshooting.

---

## Next Steps

<CardGroup cols={2}>
  <Card title="React SDK Components" icon="react" href="/frontmcp/react/components">
    Pre-built React components
  </Card>

  <Card title="Tool Reference" icon="wrench" href="/frontmcp/servers/tools">
    Full @Tool decorator options
  </Card>

  <Card title="CodeCall CRM Demo" icon="users" href="/frontmcp/guides/codecall-crm-demo">
    See UI in a full application
  </Card>

  <Card title="Create Prompts" icon="message" href="/frontmcp/guides/prompts-and-resources">
    Build prompts alongside tools
  </Card>
</CardGroup>
