Skip to main content
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.
Prerequisites:

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

npm install @frontmcp/ui
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.
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.
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.
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.
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.

Step 2: Create a Tool with UI Template

The ui property in the @Tool decorator lets you define a template function that renders HTML:
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

ui.widgetDescription
string
Human-readable description of what the widget displays. Shown to users in UI-capable hosts.
ui.displayMode
'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
ui.servingMode
'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)
ui.template
function
A function that receives the execution context and returns an HTML string.

Available UI Components

Card

Wrap content in a styled container:
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:
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:
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:
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:
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:
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>`;
}
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: … }).

Available Helpers

HelperDescription
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>)
Always use helpers.escapeHtml() when rendering user-provided data to prevent XSS vulnerabilities.

Practical Example: Expense Summary

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 hostsui is ignored; the tool returns JSON only
The default servingMode: 'auto' selects the right mode per host. See the Tool UI reference for the full surface: serving modes, CSP, the window.FrontMcpBridge runtime, file-based .tsx widgets, and platform-specific troubleshooting.

Next Steps

React SDK Components

Pre-built React components

Tool Reference

Full @Tool decorator options

CodeCall CRM Demo

See UI in a full application

Create Prompts

Build prompts alongside tools