Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
DEPRECATED: Replaced by mcp-app-builder. Previously used to build MCP servers with tools, resources, and prompts via mcp-use.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/widgets/interactivity.md
1# Widget Interactivity23Widgets interact with the outside world using hooks from `mcp-use/react`. `useCallTool()` provides tool calling with built-in state management. `sendFollowUpMessage` from `useWidget()` triggers LLM conversation turns.45**Use `useCallTool()` for:** Creating items, updating data, triggering actions, submitting forms6**Use `sendFollowUpMessage` for:** Asking the AI to analyze, compare, summarize, or respond based on widget context78---910## useCallTool() Basics1112`useCallTool()` provides a TanStack Query-like state machine for calling MCP tools:1314```tsx15import { useCallTool } from "mcp-use/react";1617const { callTool, callToolAsync, isPending, isSuccess, isError, data, error } =18useCallTool("tool-name");1920// Fire-and-forget with optional callbacks21callTool({ param: "value" }, {22onSuccess: (result) => console.log(result.structuredContent),23onError: (err) => console.error(err),24onSettled: () => hideSpinner(),25});2627// Or async/await28const result = await callToolAsync({ param: "value" });29```3031**State flags:**3233| Property | Description |34|---|---|35| `isPending` | Tool is executing |36| `isSuccess` | Succeeded — `data` is available |37| `isError` | Failed — `error` is available |38| `isIdle` | No call made yet |39| `callTool` | Fire-and-forget; optional `onSuccess`/`onError`/`onSettled` callbacks |40| `callToolAsync` | Returns `Promise<CallToolResult>` |4142**Type inference:** When using `mcp-use dev`, types for tool names, inputs, and outputs are auto-generated to `.mcp-use/tool-registry.d.ts`. The hook is fully typed with autocomplete.4344---4546## Simple Button Action4748```tsx49import { McpUseProvider, useWidget, useCallTool, type WidgetMetadata } from "mcp-use/react";50import { z } from "zod";5152export const widgetMetadata: WidgetMetadata = {53description: "Todo list with actions",54props: z.object({55todos: z.array(z.object({56id: z.string(),57title: z.string(),58completed: z.boolean()59}))60}),61exposeAsTool: false62};6364export default function TodoList() {65const { props, isPending: isLoading } = useWidget();66const { callTool, isPending } = useCallTool("toggle-todo");6768if (isLoading) {69return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;70}7172return (73<McpUseProvider autoSize>74<div>75{props.todos.map(todo => (76<div key={todo.id} style={{ display: "flex", gap: 8, padding: 8 }}>77<input78type="checkbox"79checked={todo.completed}80onChange={() => callTool({ id: todo.id, completed: !todo.completed })}81disabled={isPending}82/>83<span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>84{todo.title}85</span>86</div>87))}88</div>89</McpUseProvider>90);91}92```9394**Corresponding tool:**95```typescript96server.tool(97{98name: "toggle-todo",99description: "Toggle todo completion status",100schema: z.object({101id: z.string(),102completed: z.boolean()103})104},105async ({ id, completed }) => {106await updateTodo(id, { completed });107return text(`Todo ${completed ? "completed" : "uncompleted"}`);108}109);110```111112---113114## Form Submission115116`isPending` from `useCallTool` replaces manual `submitting` state:117118```tsx119import { useState } from "react";120import { McpUseProvider, useWidget, useCallTool } from "mcp-use/react";121122export default function CreateItemWidget() {123const { props, isPending: isLoading } = useWidget();124const { callTool, isPending } = useCallTool("create-todo");125const [title, setTitle] = useState("");126127if (isLoading) {128return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;129}130131const handleSubmit = (e: React.FormEvent) => {132e.preventDefault();133if (!title.trim()) return;134135callTool({ title }, {136onSuccess: () => setTitle(""),137onError: () => alert("Failed to create todo"),138});139};140141return (142<McpUseProvider autoSize>143<div style={{ padding: 20 }}>144<form onSubmit={handleSubmit}>145<input146type="text"147value={title}148onChange={e => setTitle(e.target.value)}149placeholder="New todo..."150disabled={isPending}151style={{ padding: 8, width: 300, marginRight: 8 }}152/>153<button type="submit" disabled={isPending}>154{isPending ? "Creating..." : "Add Todo"}155</button>156</form>157158<div style={{ marginTop: 16 }}>159{props.todos.map(todo => (160<div key={todo.id}>{todo.title}</div>161))}162</div>163</div>164</McpUseProvider>165);166}167```168169**Corresponding tool:**170```typescript171server.tool(172{173name: "create-todo",174schema: z.object({175title: z.string().describe("Todo title")176})177},178async ({ title }) => {179const todo = await createTodo(title);180return text(`Created todo: ${todo.title}`);181}182);183```184185---186187## Delete Action188189```tsx190const { callTool: deleteTodo, isPending: isDeleting } = useCallTool("delete-todo");191192const handleDelete = (id: string) => {193if (!confirm("Are you sure you want to delete this item?")) return;194195deleteTodo({ id }, {196onError: () => alert("Failed to delete item"),197});198};199200return (201<McpUseProvider autoSize>202<div>203{props.todos.map(todo => (204<div key={todo.id} style={{ display: "flex", justifyContent: "space-between", padding: 8 }}>205<span>{todo.title}</span>206<button onClick={() => handleDelete(todo.id)} disabled={isDeleting}>Delete</button>207</div>208))}209</div>210</McpUseProvider>211);212```213214---215216## Optimistic Updates217218Update UI immediately, then call tool:219220```tsx221import { useState, useEffect } from "react";222import { McpUseProvider, useWidget, useCallTool } from "mcp-use/react";223224interface Todo {225id: string;226title: string;227completed: boolean;228}229230export default function OptimisticWidget() {231const { props, isPending: isLoading } = useWidget<{ todos: Todo[] }>();232const { callToolAsync } = useCallTool("toggle-todo");233const [todos, setTodos] = useState<Todo[]>([]);234235useEffect(() => {236if (!isLoading && props.todos) {237setTodos(props.todos);238}239}, [isLoading, props.todos]);240241const handleToggle = async (id: string) => {242// Optimistic update243setTodos(prev => prev.map(todo =>244todo.id === id ? { ...todo, completed: !todo.completed } : todo245));246247try {248await callToolAsync({ id });249} catch {250// Revert on failure251setTodos(props.todos);252alert("Failed to update todo");253}254};255256if (isLoading) {257return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;258}259260return (261<McpUseProvider autoSize>262<div>263{todos.map(todo => (264<div key={todo.id}>265<input266type="checkbox"267checked={todo.completed}268onChange={() => handleToggle(todo.id)}269/>270{todo.title}271</div>272))}273</div>274</McpUseProvider>275);276}277```278279---280281## Action Buttons282283Multiple actions per item — declare a hook for each tool:284285```tsx286const { callTool: editItem } = useCallTool("edit-item");287const { callTool: duplicateItem } = useCallTool("duplicate-item");288const { callTool: archiveItem } = useCallTool("archive-item");289const { callTool: deleteItem } = useCallTool("delete-item");290291return (292<McpUseProvider autoSize>293<div>294{props.items.map(item => (295<div key={item.id} style={{ padding: 12, border: "1px solid #ddd", marginBottom: 8 }}>296<h3>{item.title}</h3>297<p>{item.description}</p>298299<div style={{ display: "flex", gap: 8 }}>300<button onClick={() => editItem({ id: item.id })}>Edit</button>301<button onClick={() => duplicateItem({ id: item.id })}>Duplicate</button>302<button onClick={() => archiveItem({ id: item.id })}>Archive</button>303<button onClick={() => deleteItem({ id: item.id })} style={{ color: "red" }}>304Delete305</button>306</div>307</div>308))}309</div>310</McpUseProvider>311);312```313314---315316## Inline Editing317318```tsx319import { useState } from "react";320import { McpUseProvider, useWidget, useCallTool } from "mcp-use/react";321322export default function EditableList() {323const { props, isPending: isLoading } = useWidget();324const { callToolAsync, isPending: isSaving } = useCallTool("update-item");325const [editingId, setEditingId] = useState<string | null>(null);326const [editValue, setEditValue] = useState("");327328const startEdit = (id: string, currentValue: string) => {329setEditingId(id);330setEditValue(currentValue);331};332333const saveEdit = async (id: string) => {334try {335await callToolAsync({ id, title: editValue });336setEditingId(null);337} catch {338alert("Failed to save");339}340};341342const cancelEdit = () => {343setEditingId(null);344setEditValue("");345};346347if (isLoading) {348return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;349}350351return (352<McpUseProvider autoSize>353<div>354{props.items.map(item => (355<div key={item.id} style={{ padding: 8, display: "flex", gap: 8 }}>356{editingId === item.id ? (357<>358<input359type="text"360value={editValue}361onChange={e => setEditValue(e.target.value)}362autoFocus363/>364<button onClick={() => saveEdit(item.id)} disabled={isSaving}>Save</button>365<button onClick={cancelEdit}>Cancel</button>366</>367) : (368<>369<span>{item.title}</span>370<button onClick={() => startEdit(item.id, item.title)}>Edit</button>371</>372)}373</div>374))}375</div>376</McpUseProvider>377);378}379```380381---382383## Batch Actions384385Select multiple items and act on them:386387```tsx388import { useState } from "react";389import { McpUseProvider, useWidget, useCallTool } from "mcp-use/react";390391export default function BatchActions() {392const { props, isPending: isLoading } = useWidget();393const { callTool: archiveItems, isPending: isArchiving } = useCallTool("archive-items");394const { callTool: deleteItems, isPending: isDeleting } = useCallTool("delete-items");395const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());396397const processing = isArchiving || isDeleting;398399const toggleSelection = (id: string) => {400const newSelection = new Set(selectedIds);401if (newSelection.has(id)) {402newSelection.delete(id);403} else {404newSelection.add(id);405}406setSelectedIds(newSelection);407};408409const handleBatchArchive = () => {410archiveItems({ ids: Array.from(selectedIds) }, {411onSuccess: () => setSelectedIds(new Set()),412onError: () => alert("Failed to archive items"),413});414};415416const handleBatchDelete = () => {417deleteItems({ ids: Array.from(selectedIds) }, {418onSuccess: () => setSelectedIds(new Set()),419onError: () => alert("Failed to delete items"),420});421};422423if (isLoading) {424return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;425}426427return (428<McpUseProvider autoSize>429<div>430{selectedIds.size > 0 && (431<div style={{ padding: 12, backgroundColor: "#f5f5f5", marginBottom: 16 }}>432<span>{selectedIds.size} selected</span>433<button onClick={handleBatchArchive} disabled={processing} style={{ marginLeft: 8 }}>434Archive435</button>436<button onClick={handleBatchDelete} disabled={processing} style={{ marginLeft: 8 }}>437Delete438</button>439</div>440)}441442{props.items.map(item => (443<div key={item.id} style={{ padding: 8, display: "flex", gap: 8 }}>444<input445type="checkbox"446checked={selectedIds.has(item.id)}447onChange={() => toggleSelection(item.id)}448/>449<span>{item.title}</span>450</div>451))}452</div>453</McpUseProvider>454);455}456```457458**Corresponding tool:**459```typescript460server.tool(461{462name: "delete-items",463schema: z.object({464ids: z.array(z.string()).describe("Item IDs to delete")465})466},467async ({ ids }) => {468await Promise.all(ids.map(id => deleteItem(id)));469return text(`Deleted ${ids.length} items`);470}471);472```473474---475476## Handling Tool Errors477478Use `isError` and `error` from the hook instead of manual error state:479480```tsx481const { callTool, isError, error, isPending } = useCallTool("some-tool");482483return (484<McpUseProvider autoSize>485<div>486{isError && (487<div style={{ padding: 12, backgroundColor: "#ffebee", color: "#c62828", marginBottom: 16 }}>488{error instanceof Error ? error.message : "Action failed"}489</div>490)}491492<button onClick={() => callTool({ /* params */ })} disabled={isPending}>493Perform Action494</button>495</div>496</McpUseProvider>497);498```499500---501502## Per-Item Loading States503504For per-item loading when sharing one hook instance, track the active ID separately:505506```tsx507const { callToolAsync } = useCallTool("process-item");508const [loadingId, setLoadingId] = useState<string | null>(null);509510const handleAction = async (id: string) => {511setLoadingId(id);512try {513await callToolAsync({ id });514} catch {515alert("Failed");516} finally {517setLoadingId(null);518}519};520521return (522<McpUseProvider autoSize>523<div>524{props.items.map(item => (525<div key={item.id}>526<span>{item.title}</span>527<button528onClick={() => handleAction(item.id)}529disabled={loadingId === item.id}530>531{loadingId === item.id ? "Processing..." : "Process"}532</button>533</div>534))}535</div>536</McpUseProvider>537);538```539540---541542## Confirmation Dialogs543544```tsx545const { callTool: deleteItem } = useCallTool("delete-item");546547const handleDelete = (id: string, title: string) => {548if (!confirm(`Are you sure you want to delete "${title}"?`)) return;549550deleteItem({ id }, {551onError: () => alert("Failed to delete"),552});553};554```555556Or with a custom dialog:557558```tsx559import { useState } from "react";560import { useCallTool } from "mcp-use/react";561562const { callToolAsync } = useCallTool("delete-item");563const [confirmDialog, setConfirmDialog] = useState<{ id: string; title: string } | null>(null);564565const handleDeleteClick = (id: string, title: string) => {566setConfirmDialog({ id, title });567};568569const handleConfirmDelete = async () => {570if (!confirmDialog) return;571572try {573await callToolAsync({ id: confirmDialog.id });574setConfirmDialog(null);575} catch {576alert("Failed to delete");577}578};579580return (581<McpUseProvider autoSize>582<div>583{props.items.map(item => (584<div key={item.id}>585<span>{item.title}</span>586<button onClick={() => handleDeleteClick(item.id, item.title)}>Delete</button>587</div>588))}589590{confirmDialog && (591<div style={{592position: "fixed", top: 0, left: 0, right: 0, bottom: 0,593backgroundColor: "rgba(0,0,0,0.5)", display: "flex",594alignItems: "center", justifyContent: "center"595}}>596<div style={{ backgroundColor: "white", padding: 24, borderRadius: 8 }}>597<h3>Confirm Delete</h3>598<p>Delete "{confirmDialog.title}"?</p>599<button onClick={handleConfirmDelete}>Delete</button>600<button onClick={() => setConfirmDialog(null)}>Cancel</button>601</div>602</div>603)}604</div>605</McpUseProvider>606);607```608609---610611## Triggering LLM Responses: `sendFollowUpMessage`612613`sendFollowUpMessage` from `useWidget()` sends a message to the conversation and triggers a new LLM turn — as if the user typed it. Use this to let widget interactions drive the conversation.614615```tsx616import { McpUseProvider, useWidget } from "mcp-use/react";617618export default function AnalysisWidget() {619const { props, isPending, sendFollowUpMessage } = useWidget();620621if (isPending) {622return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;623}624625return (626<McpUseProvider autoSize>627<div style={{ padding: 20 }}>628<h2>Results for "{props.query}"</h2>629{props.items.map(item => (630<div key={item.id} style={{ padding: 8, borderBottom: "1px solid #ddd" }}>631<strong>{item.name}</strong> — ${item.price}632</div>633))}634635<button636onClick={() => sendFollowUpMessage(637`Compare the top 3 results for "${props.query}" and recommend the best one.`638)}639style={{ marginTop: 16, padding: "8px 16px" }}640>641Ask AI to Compare642</button>643</div>644</McpUseProvider>645);646}647```648649### Combining with `useCallTool`650651A widget can use both — `useCallTool` for data mutations and `sendFollowUpMessage` for triggering LLM reasoning:652653```tsx654import { useState } from "react";655import { McpUseProvider, useWidget, useCallTool } from "mcp-use/react";656657export default function TodoWidget() {658const { props, isPending, state, setState, sendFollowUpMessage } = useWidget();659const { callTool: toggleTodo } = useCallTool("toggle-todo");660661if (isPending) {662return <McpUseProvider autoSize><div>Loading...</div></McpUseProvider>;663}664665const tasks = state?.tasks || props.tasks || [];666const remaining = tasks.filter(t => !t.completed).length;667668return (669<McpUseProvider autoSize>670<div style={{ padding: 20 }}>671{tasks.map(t => (672<div key={t.id} style={{ display: "flex", gap: 8, padding: 8 }}>673<input674type="checkbox"675checked={t.completed}676onChange={() => toggleTodo({ id: t.id, completed: !t.completed })}677/>678{t.title}679</div>680))}681682<button683onClick={() => sendFollowUpMessage(684`I have ${remaining} tasks left. Help me prioritize them.`685)}686style={{ marginTop: 16, padding: "8px 16px" }}687>688Ask AI to Prioritize689</button>690</div>691</McpUseProvider>692);693}694```695696---697698## Complete Example699700```tsx701import { useState } from "react";702import { McpUseProvider, useWidget, useCallTool, type WidgetMetadata } from "mcp-use/react";703import { z } from "zod";704705const propsSchema = z.object({706todos: z.array(z.object({707id: z.string(),708title: z.string(),709completed: z.boolean()710}))711});712713type Props = z.infer<typeof propsSchema>;714715export const widgetMetadata: WidgetMetadata = {716description: "Interactive todo list",717props: propsSchema,718exposeAsTool: false719};720721export default function InteractiveTodoList() {722const { props, isPending: isLoading } = useWidget<Props>();723const { callTool: createTodo, isPending: isCreating } = useCallTool("create-todo");724const { callTool: toggleTodo } = useCallTool("toggle-todo");725const { callTool: deleteTodo } = useCallTool("delete-todo");726const [newTodo, setNewTodo] = useState("");727const [deletingId, setDeletingId] = useState<string | null>(null);728729if (isLoading) {730return <McpUseProvider autoSize><div>Loading todos...</div></McpUseProvider>;731}732733const handleCreate = (e: React.FormEvent) => {734e.preventDefault();735if (!newTodo.trim()) return;736737createTodo({ title: newTodo }, {738onSuccess: () => setNewTodo(""),739onError: () => alert("Failed to create todo"),740});741};742743const handleToggle = (id: string, completed: boolean) => {744toggleTodo({ id, completed: !completed });745};746747const handleDelete = (id: string) => {748setDeletingId(id);749deleteTodo({ id }, {750onError: () => alert("Failed to delete"),751onSettled: () => setDeletingId(null),752});753};754755return (756<McpUseProvider autoSize>757<div style={{ padding: 20 }}>758<h2>Todos ({props.todos.length})</h2>759760<form onSubmit={handleCreate} style={{ marginBottom: 16 }}>761<input762type="text"763value={newTodo}764onChange={e => setNewTodo(e.target.value)}765placeholder="New todo..."766disabled={isCreating}767style={{ padding: 8, width: 300, marginRight: 8 }}768/>769<button type="submit" disabled={isCreating}>770{isCreating ? "Adding..." : "Add"}771</button>772</form>773774<div>775{props.todos.map(todo => (776<div777key={todo.id}778style={{779display: "flex", alignItems: "center", gap: 8,780padding: 8, borderBottom: "1px solid #eee"781}}782>783<input784type="checkbox"785checked={todo.completed}786onChange={() => handleToggle(todo.id, todo.completed)}787/>788<span style={{789flex: 1,790textDecoration: todo.completed ? "line-through" : "none",791color: todo.completed ? "#999" : "inherit"792}}>793{todo.title}794</span>795<button796onClick={() => handleDelete(todo.id)}797disabled={deletingId === todo.id}798style={{ color: "red" }}799>800{deletingId === todo.id ? "Deleting..." : "Delete"}801</button>802</div>803))}804</div>805806{props.todos.length === 0 && (807<p style={{ color: "#999", textAlign: "center" }}>No todos yet</p>808)}809</div>810</McpUseProvider>811);812}813```814815---816817## Best Practices8188191. **Use `useCallTool` for built-in state management** - No need for manual `isPending`/`error` state8202. **Declare hooks at the top level** - One hook per tool name; React rules apply8213. **Use `callTool` for fire-and-forget** - Handle success/error via callbacks8224. **Use `callToolAsync` for sequential operations** - When you need to await results or chain calls8235. **Use `isError`/`error` from the hook** - Instead of manual error state for single-tool widgets8246. **Optimistic updates** - Update local state before the call, revert on error8257. **Confirm destructive actions** - Use confirm() for deletes8268. **Use `sendFollowUpMessage` for LLM reasoning** - When you want the AI to analyze, compare, or respond based on widget context rather than mutating data827828---829830## Next Steps831832- **Style widgets** → [ui-guidelines.md](ui-guidelines.md)833- **Advanced patterns** → [advanced.md](advanced.md)834- **See examples** → [../patterns/common-patterns.md](../patterns/common-patterns.md)835