Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive guide for building production-ready MCP servers with tools, resources, prompts, and React widgets using mcp-use.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/widgets/basics.md
1# Widget Basics23Widgets are React components that provide visual UI for MCP tools. They let users browse, compare, and interact with data visually.45**Use widgets for:** Product lists, calendars, dashboards, search results, file browsers, any visual data representation67---89## When to Use Widgets1011**Use a widget when:**12- ✅ Browsing or comparing multiple items13- ✅ Visual representation improves understanding (charts, images, layouts)14- ✅ Interactive selection is easier visually than through text15- ✅ User needs to see data structure at a glance1617**Use plain tool (no widget) when:**18- ❌ Output is simple text or a single value19- ❌ No visual representation adds value20- ❌ Quick conversational response is sufficient2122**When in doubt:** Use a widget. It makes the experience better.2324---2526## Minimal Widget2728### 1. Create Tool with Widget Config2930```typescript31// index.ts32import { MCPServer, widget, text } from "mcp-use/server";33import { z } from "zod";3435const server = new MCPServer({36name: "my-server",37version: "1.0.0"38});3940server.tool(41{42name: "show-weather",43description: "Display weather for a city",44schema: z.object({45city: z.string().describe("City name")46}),47widget: {48name: "weather-display", // Must match filename: resources/weather-display.tsx49invoking: "Fetching weather...", // Optional: shown while loading50invoked: "Weather loaded" // Optional: shown when complete51}52},53async ({ city }) => {54const data = await getWeather(city);5556return widget({57props: {58city: data.city,59temp: data.temperature,60conditions: data.conditions,61icon: data.icon62},63output: text(`Weather in ${city}: ${data.temperature}°C, ${data.conditions}`)64});65}66);67```6869### 2. Create Widget Component7071```tsx72// resources/weather-display.tsx73import { McpUseProvider, useWidget, type WidgetMetadata } from "mcp-use/react";74import { z } from "zod";7576const propsSchema = z.object({77city: z.string(),78temp: z.number(),79conditions: z.string(),80icon: z.string()81});8283export const widgetMetadata: WidgetMetadata = {84description: "Display weather information for a city",85props: propsSchema,86exposeAsTool: false // ← Critical: prevents duplicate tool registration87};8889type Props = z.infer<typeof propsSchema>;9091export default function WeatherDisplay() {92const { props, isPending } = useWidget<Props>();9394if (isPending) {95return (96<McpUseProvider autoSize>97<div>Loading weather...</div>98</McpUseProvider>99);100}101102return (103<McpUseProvider autoSize>104<div style={{ padding: 20 }}>105<h2>{props.city}</h2>106<img src={props.icon} alt={props.conditions} width={64} />107<div style={{ fontSize: 48 }}>{props.temp}°C</div>108<p>{props.conditions}</p>109</div>110</McpUseProvider>111);112}113```114115**Key requirements:**1161. Export `widgetMetadata` with props schema1172. Infer type from schema and pass to `useWidget<Props>()`1183. `exposeAsTool` defaults to `false` — correct when pairing with a custom tool1194. Wrap root in `<McpUseProvider autoSize>`1205. **Always check `isPending` before accessing `props`**121122**Production builds (`mcp-use build`):** Never use bare `useWidget()` without a props generic — fields default to `unknown` and TypeScript will fail (e.g. TS2322). If you use `callTool` from `useWidget()`, treat `structuredContent` and nested values as `unknown` until you parse with Zod, narrow with `typeof`/`Array.isArray`, or assign to typed variables; do not pass `unknown` directly as JSX children or string props.123124---125126## Widget Metadata127128The `widgetMetadata` export defines your widget's contract:129130```typescript131export const widgetMetadata: WidgetMetadata = {132description: "Brief description of what this widget displays",133props: z.object({134// Define all props the widget expects135id: z.string(),136title: z.string(),137count: z.number(),138items: z.array(z.object({139name: z.string(),140value: z.number()141}))142}),143exposeAsTool: false // Default; omit or set explicitly when pairing with a custom tool144};145```146147**Fields:**148- `description` - What the widget displays/does149- `props` - Zod schema defining expected props shape150- `exposeAsTool` - Set to `true` to auto-register as a tool (default: `false`)151- `metadata.invoking` - Status text shown in inspector while tool runs (auto-default: `"Loading {name}..."`)152- `metadata.invoked` - Status text shown in inspector after tool completes (auto-default: `"{name} ready"`)153154```typescript155export const widgetMetadata: WidgetMetadata = {156description: "Display weather information for a city",157props: propsSchema,158metadata: {159invoking: "Fetching weather...", // Shimmer text while tool runs160invoked: "Weather loaded", // Static text when complete161csp: { connectDomains: ["https://api.weather.com"] },162},163};164```165166These status texts appear as animated shimmer text (pending) and static text (complete) in the MCP Inspector and ChatGPT. The values also flow to `openai/toolInvocation/invoking`/`invoked` in tool metadata automatically.167168---169170## useWidget() Hook171172The `useWidget()` hook provides access to props and widget state:173174```typescript175const {176props, // Widget props from tool response177isPending, // True while props are loading178setState, // Update widget state179state, // Current widget state180} = useWidget();181```182183**To call tools from a widget**, use the dedicated `useCallTool()` hook — see [interactivity.md](interactivity.md).184185### props186Data passed from tool's `widget({ props })` response:187188```typescript189const { props } = useWidget();190191// Access props after isPending check192if (!isPending) {193console.log(props.city); // "Tokyo"194console.log(props.temp); // 28195}196```197198**Always check `isPending` before accessing `props`:**199```typescript200❌ const { props } = useWidget();201return <div>{props.city}</div>; // Error! props undefined while loading202203✅ const { props, isPending } = useWidget();204if (isPending) return <div>Loading...</div>;205return <div>{props.city}</div>; // Safe206```207208### isPending209Boolean indicating if props are still loading.210211**CRITICAL:** Widgets render **before** the tool completes execution. On first render:212- `isPending` is `true`213- `props` is an empty object `{}`214- Accessing `props` fields will cause errors215216**Widget Lifecycle:**2171. Widget mounts immediately when tool is called → `isPending = true`, `props = {}`2182. Tool executes and returns `widget({ props })`2193. Widget re-renders → `isPending = false`, `props` contains data220221```typescript222const { isPending } = useWidget();223224if (isPending) {225return (226<McpUseProvider autoSize>227<div>Loading...</div>228</McpUseProvider>229);230}231232// Now safe to access props - guaranteed to have data233```234235**Multiple patterns for handling isPending:**236237```typescript238// ✅ Pattern 1: Early return (recommended)239if (isPending) return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;240return <McpUseProvider autoSize><div>{props.data}</div></McpUseProvider>;241242// ✅ Pattern 2: Conditional rendering243return (244<McpUseProvider autoSize>245{isPending ? <div>Loading...</div> : <div>{props.data}</div>}246</McpUseProvider>247);248249// ✅ Pattern 3: Optional chaining (when props might be undefined)250return <McpUseProvider autoSize><div>{props?.data ?? "Loading..."}</div></McpUseProvider>;251```252253---254255## McpUseProvider256257**Required wrapper** for all widgets. Provides context and handles iframe sizing.258259```typescript260import { McpUseProvider } from "mcp-use/react";261262export default function MyWidget() {263const { props, isPending } = useWidget();264265if (isPending) {266return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;267}268269return (270<McpUseProvider autoSize>271<div>272{/* Your widget content */}273</div>274</McpUseProvider>275);276}277```278279**Props:**280- `autoSize={true}` - Automatically resize iframe to content (recommended)281- `autoSize={false}` - Fixed height, widget handles scrolling282283**Must wrap:**284- ✅ Every return path (including loading states)285- ✅ Root element of component286287---288289## Props Handling Patterns290291### Simple Props292```typescript293export const widgetMetadata: WidgetMetadata = {294props: z.object({295message: z.string(),296count: z.number()297}),298exposeAsTool: false299};300301export default function SimpleWidget() {302const { props, isPending } = useWidget();303304if (isPending) return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;305306return (307<McpUseProvider autoSize>308<div>309<p>{props.message}</p>310<p>Count: {props.count}</p>311</div>312</McpUseProvider>313);314}315```316317### Array Props318```typescript319export const widgetMetadata: WidgetMetadata = {320props: z.object({321items: z.array(z.object({322id: z.string(),323name: z.string()324}))325}),326exposeAsTool: false327};328329export default function ListWidget() {330const { props, isPending } = useWidget();331332if (isPending) return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;333334return (335<McpUseProvider autoSize>336<ul>337{props.items.map(item => (338<li key={item.id}>{item.name}</li>339))}340</ul>341</McpUseProvider>342);343}344```345346### Nested Props347```typescript348export const widgetMetadata: WidgetMetadata = {349props: z.object({350user: z.object({351name: z.string(),352profile: z.object({353bio: z.string(),354avatar: z.string()355})356})357}),358exposeAsTool: false359};360361export default function ProfileWidget() {362const { props, isPending } = useWidget();363364if (isPending) return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;365366const { user } = props;367368return (369<McpUseProvider autoSize>370<div>371<img src={user.profile.avatar} alt={user.name} />372<h2>{user.name}</h2>373<p>{user.profile.bio}</p>374</div>375</McpUseProvider>376);377}378```379380### Optional Props381```typescript382export const widgetMetadata: WidgetMetadata = {383props: z.object({384title: z.string(),385subtitle: z.string().optional(), // May be undefined386items: z.array(z.string())387}),388exposeAsTool: false389};390391export default function FlexibleWidget() {392const { props, isPending } = useWidget();393394if (isPending) return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;395396return (397<McpUseProvider autoSize>398<div>399<h1>{props.title}</h1>400{props.subtitle && <h2>{props.subtitle}</h2>}401<ul>402{props.items.map((item, i) => <li key={i}>{item}</li>)}403</ul>404</div>405</McpUseProvider>406);407}408```409410---411412## File Location413414Widgets live in `resources/` directory:415416```417my-server/418├── index.ts # Server code419├── resources/420│ ├── weather-display.tsx # Widget component421│ ├── product-list.tsx422│ └── calendar-view.tsx423└── package.json424```425426**Naming convention:**427- Use kebab-case for widget names428- Tool config: `widget: { name: "weather-display" }`429- File: `resources/weather-display.tsx`430431---432433## TypeScript Types434435For type safety, infer props type from schema:436437⚠️ **CRITICAL:** Always define your Zod schema in a separate constant before `widgetMetadata`. Never infer types from `widgetMetadata.props` - TypeScript will lose type information and the result will be `unknown`.438439```typescript440import { z } from "zod";441import { McpUseProvider, useWidget, type WidgetMetadata } from "mcp-use/react";442443const propsSchema = z.object({444city: z.string(),445temp: z.number(),446conditions: z.string()447});448449export const widgetMetadata: WidgetMetadata = {450description: "Display weather",451props: propsSchema,452exposeAsTool: false453};454455type Props = z.infer<typeof propsSchema>;456457export default function WeatherWidget() {458const { props, isPending } = useWidget<Props>();459460if (isPending) return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;461462// Now props is fully typed!463return (464<McpUseProvider autoSize>465<div>466<h2>{props.city}</h2> {/* ✓ TypeScript knows this is string */}467<p>{props.temp}°C</p> {/* ✓ TypeScript knows this is number */}468</div>469</McpUseProvider>470);471}472```473474---475476## Common Mistakes477478### ❌ Missing isPending Check479```typescript480// ❌ Bad - props undefined during loading481export default function BadWidget() {482const { props } = useWidget();483484return (485<McpUseProvider autoSize>486<div>{props.title}</div> {/* Error! */}487</McpUseProvider>488);489}490491// ✅ Good492export default function GoodWidget() {493const { props, isPending } = useWidget();494495if (isPending) return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;496497return (498<McpUseProvider autoSize>499<div>{props.title}</div>500</McpUseProvider>501);502}503```504505### ❌ Missing McpUseProvider506```typescript507// ❌ Bad - Missing provider508export default function BadWidget() {509const { props, isPending } = useWidget();510511if (isPending) return <div>Loading...</div>;512513return <div>{props.title}</div>; {/* Won't render correctly */}514}515516// ✅ Good517export default function GoodWidget() {518const { props, isPending } = useWidget();519520if (isPending) return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;521522return (523<McpUseProvider autoSize>524<div>{props.title}</div>525</McpUseProvider>526);527}528```529530### `exposeAsTool` — default is `false`531```typescript532// ✅ Default — widget is a resource only, exposed via a custom tool533export const widgetMetadata: WidgetMetadata = {534description: "...",535props: z.object({ ... })536// exposeAsTool defaults to false537};538539// ✅ Explicit opt-in to auto-registration540export const widgetMetadata: WidgetMetadata = {541description: "...",542props: z.object({ ... }),543exposeAsTool: true // Auto-registers widget as a tool544};545```546547### ❌ Missing Type Parameter on useWidget548```typescript549// ❌ Bad - props is UnknownObject, no autocomplete or type safety550const propsSchema = z.object({551title: z.string(),552count: z.number()553});554555export default function BadWidget() {556const { props } = useWidget(); // props is UnknownObject557return <div>{props.title}</div>; // No IDE support, runtime errors possible558}559560// ✅ Good - props is fully typed with IDE support561const propsSchema = z.object({562title: z.string(),563count: z.number()564});565566type Props = z.infer<typeof propsSchema>;567568export default function GoodWidget() {569const { props } = useWidget<Props>(); // props is properly typed570return <div>{props.title}</div>; // Full autocomplete and type checking571}572```573574### ❌ Inferring Type from widgetMetadata.props575```typescript576// ❌ Bad - Type inference fails, Props is unknown577export const widgetMetadata: WidgetMetadata = {578description: "...",579props: z.object({580title: z.string(),581count: z.number()582}) // Inline schema definition583};584585type Props = z.infer<typeof widgetMetadata.props>; // Props is unknown!586587export default function BadWidget() {588const { props } = useWidget<Props>();589return <div>{props.title}</div>; // No autocomplete, no type safety590}591592// ✅ Good - Extract schema first for proper type inference593const propsSchema = z.object({594title: z.string(),595count: z.number()596});597598export const widgetMetadata: WidgetMetadata = {599description: "...",600props: propsSchema // Reference the schema variable601};602603type Props = z.infer<typeof propsSchema>; // Props is properly typed!604605export default function GoodWidget() {606const { props } = useWidget<Props>();607return <div>{props.title}</div>; // Full autocomplete and type checking608}609```610611**Why this happens:** The `WidgetMetadata` type is generic, so TypeScript can't preserve the specific Zod schema type when defined inline. Always extract your schema to a separate constant before using it in `widgetMetadata`.612613---614615## Testing Widgets616617Use the inspector to test widgets during development:6186191. Start dev server: `npm run dev`6202. Open inspector: `http://localhost:3000/inspector`6213. Click "List Tools" → Find your tool6224. Click "Call Tool" → Enter test input6235. Widget renders in inspector624625**Quick iteration:**626- Change widget code → Auto-reload627- Adjust props schema → Update tool call input628- Test edge cases (empty lists, missing optional props)629630---631632## Complete Example633634```typescript635// index.ts636import { MCPServer, widget, text } from "mcp-use/server";637import { z } from "zod";638639const server = new MCPServer({640name: "product-server",641version: "1.0.0"642});643644server.tool(645{646name: "search-products",647description: "Search products by keyword",648schema: z.object({649query: z.string().describe("Search query")650}),651widget: {652name: "product-list",653invoking: "Searching products...",654invoked: "Products loaded"655}656},657async ({ query }) => {658const products = await searchProducts(query);659660return widget({661props: {662products,663query,664totalCount: products.length665},666output: text(`Found ${products.length} products matching "${query}"`)667});668}669);670671server.listen();672```673674```tsx675// resources/product-list.tsx676import { McpUseProvider, useWidget, type WidgetMetadata } from "mcp-use/react";677import { z } from "zod";678679export const widgetMetadata: WidgetMetadata = {680description: "Display product search results",681props: z.object({682products: z.array(z.object({683id: z.string(),684name: z.string(),685price: z.number(),686image: z.string()687})),688query: z.string(),689totalCount: z.number()690}),691exposeAsTool: false692};693694export default function ProductList() {695const { props, isPending } = useWidget();696697if (isPending) {698return (699<McpUseProvider autoSize>700<div style={{ padding: 20 }}>Loading products...</div>701</McpUseProvider>702);703}704705return (706<McpUseProvider autoSize>707<div style={{ padding: 20 }}>708<h2>Search: "{props.query}"</h2>709<p>Found {props.totalCount} products</p>710711<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 16 }}>712{props.products.map(product => (713<div key={product.id} style={{ border: "1px solid #ddd", padding: 12, borderRadius: 8 }}>714<img src={product.image} alt={product.name} style={{ width: "100%", height: 150, objectFit: "cover" }} />715<h3 style={{ fontSize: 16, margin: "8px 0" }}>{product.name}</h3>716<p style={{ fontSize: 18, fontWeight: "bold" }}>${product.price}</p>717</div>718))}719</div>720</div>721</McpUseProvider>722);723}724```725726---727728## Next Steps729730- **Manage widget state** → [state.md](state.md)731- **Add interactivity** → [interactivity.md](interactivity.md)732- **Style with themes** → [ui-guidelines.md](ui-guidelines.md)733- **Advanced patterns** → [advanced.md](advanced.md)734