Prerequisites
- A FrontMCP server project set up with
@frontmcp/sdk
- Node.js 22 or newer (Node 24 Active LTS recommended to match the dev runtime)
Installation
The simplest way to add a widget is with an HTML template function:
import { Tool, ToolContext } from '@frontmcp/sdk';
import { z } from 'zod';
@Tool({
name: 'say_hello',
description: 'Greet a user',
inputSchema: {
name: z.string().describe('Name to greet'),
},
ui: {
template: (ctx) => `
<div class="p-6 bg-white rounded-xl shadow-lg">
<h1 class="text-2xl font-bold text-gray-800">
Hello, ${ctx.helpers.escapeHtml(ctx.output.name)}!
</h1>
<p class="text-gray-600 mt-2">
Welcome to FrontMCP UI
</p>
</div>
`,
},
})
export class HelloTool extends ToolContext {
async execute(input: { name: string }) {
return { name: input.name };
}
}
Always use ctx.helpers.escapeHtml() for user-provided content to prevent XSS attacks.
Step 2: Use Template Helpers
The template context provides several helper functions:
ui: {
template: (ctx) => {
const { input, output, helpers } = ctx;
return `
<div class="p-4">
<!-- Escape HTML to prevent XSS -->
<p>${helpers.escapeHtml(output.message)}</p>
<!-- Format dates -->
<p>Created: ${helpers.formatDate(output.createdAt)}</p>
<!-- Format currency -->
<p>Total: ${helpers.formatCurrency(output.amount, 'USD')}</p>
<!-- Generate unique IDs -->
<input id="${helpers.uniqueId('input')}" type="text" />
<!-- Safely embed JSON -->
<script>
const data = ${helpers.jsonEmbed(output.data)};
</script>
</div>
`;
},
}
Available Helpers
| Helper | Description |
|---|
escapeHtml(str) | Escape HTML special characters |
formatDate(date, format?) | Format a date for display |
formatCurrency(amount, currency?) | Format a number as currency |
uniqueId(prefix?) | Generate a unique DOM ID |
jsonEmbed(data) | Safely embed JSON in HTML |
Step 3: Use Pre-built Components
@frontmcp/ui provides a library of styled components:
src/tools/user-profile.tool.ts
import { Tool, ToolContext } from '@frontmcp/sdk';
import { card, badge, button, alert } from '@frontmcp/ui';
import { z } from 'zod';
@Tool({
name: 'get_user',
description: 'Get user profile',
inputSchema: {
userId: z.string(),
},
ui: {
template: (ctx) => {
const { output, helpers } = ctx;
return card(`
<div class="flex items-center gap-4">
<img
src="${helpers.escapeHtml(output.avatar)}"
class="w-16 h-16 rounded-full"
alt="Avatar"
/>
<div>
<h2 class="text-xl font-bold">
${helpers.escapeHtml(output.name)}
</h2>
<p class="text-gray-600">
${helpers.escapeHtml(output.email)}
</p>
</div>
${badge(output.role, { variant: 'primary', pill: true })}
</div>
`, {
title: 'User Profile',
footer: button('Edit Profile', { variant: 'outline', size: 'sm' }),
});
},
},
})
export class GetUserTool extends ToolContext {
async execute(input: { userId: string }) {
return {
name: 'John Doe',
email: '[email protected]',
avatar: 'https://example.com/avatar.jpg',
role: 'Admin',
};
}
}
Complete Weather Example
Here’s a full example combining multiple components:
src/tools/weather.tool.ts
import { Tool, ToolContext } from '@frontmcp/sdk';
import { card, badge, alert } from '@frontmcp/ui';
import { z } from 'zod';
const inputSchema = {
location: z.string().describe('City name'),
units: z.enum(['celsius', 'fahrenheit']).optional(),
};
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: {
template: (ctx) => {
const { output, helpers } = ctx;
const tempUnit = output.units === 'celsius' ? '°C' : '°F';
const weatherIcons: Record<string, string> = {
sunny: '☀️',
cloudy: '☁️',
rainy: '🌧️',
foggy: '🌫️',
};
const icon = weatherIcons[output.conditions.toLowerCase()] || '🌤️';
return card(`
<div class="text-center">
<div class="text-6xl mb-4">${icon}</div>
<div class="text-4xl font-bold mb-2">
${output.temperature}${tempUnit}
</div>
${badge(output.conditions, { variant: 'info' })}
</div>
<div class="grid grid-cols-2 gap-4 mt-6">
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-sm text-gray-500">Humidity</div>
<div class="text-lg font-semibold">${output.humidity}%</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-sm text-gray-500">Wind</div>
<div class="text-lg font-semibold">${output.windSpeed} km/h</div>
</div>
</div>
`, {
title: helpers.escapeHtml(output.location),
subtitle: 'Current Weather',
});
},
displayMode: 'inline',
widgetDescription: 'Displays current weather conditions',
},
})
export class GetWeatherTool extends ToolContext {
async execute(input: { location: string; units?: 'celsius' | 'fahrenheit' }) {
// In production, call a weather API here
const units = input.units || 'celsius';
return {
location: input.location,
temperature: units === 'celsius' ? 18 : 64,
units,
conditions: 'Cloudy',
humidity: 65,
windSpeed: 12,
};
}
}
- Start your MCP server in development mode
- Connect to it via an MCP client (OpenAI, Claude, etc.)
- Call your tool and see the widget rendered
Use the displayMode option to control how your widget appears:
inline - Rendered inline with the chat
fullscreen - Takes over the full viewport
pip - Picture-in-picture mode (OpenAI only)
Try the UI Demo
Spin up the UI demo server
pnpm nx serve demo-e2e-ui --port 3003
This server exposes multiple tools with different UI types (React, MDX, HTML, Markdown), transparent auth defaults, and publicMode-friendly settings so you can hit it from the MCP Inspector.Run the UI regression tests
The test suite relies on @frontmcp/testing’s UIAssertions helpers (toHaveRenderedHtml, toNotContainRawContent, assertValidUI) so you can copy/paste the assertions into your own projects.The Jest output should confirm every test as passing and report that rendered HTML contains widget metadata instead of the mdx-fallback.
Keep these demos around as golden references; updating them first helps catch regressions in new template renderers or auth transport tweaks before they land in production apps.
Next Steps