subagent-driven-development
OBobra/superpowers298k近 7 天新增 2,631 个 star
Build MCP Apps with interactive React UIs using the Leanmcp UI SDK. Use this skill when users ask for "MCP with UI", "MCP App", "interactive MCP", "leanmcp UI", "MCP dashboard", or want to create rich visual interfaces for their MCP tools. This skill covers @UIApp decorator, React components with useTool/useResource hooks, ToolDataGrid, ToolForm, ToolButton, and ChatGPT Apps support.
This skill guides you in building MCP Apps - MCP servers with interactive React UIs using the Leanmcp UI SDK.
MCP Apps extend MCP servers to deliver interactive UIs. Apps run in sandboxed iframes and communicate via JSON-RPC over postMessage. When a tool is called, the UI can be displayed alongside the tool response.
mcp/*/index.ts) - Tool methods decorated with @Tool AND @UIAppmcp/*/*.tsx) - React component using @leanmcp/ui componentsleanmcp dev builds and wraps components with AppProvider{
"dependencies": {
"@leanmcp/core": "^0.4.7",
"@leanmcp/ui": "^0.3.7",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"dotenv": "^16.5.0"
},
"devDependencies": {
"@leanmcp/cli": "latest",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/node": "^20.0.0",
"typescript": "^5.6.3"
}
}
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"jsx": "react-jsx",
"jsxImportSource": "react",
"outDir": "./dist",
"strict": true
},
"include": ["**/*.ts", "**/*.tsx", "mcp/**/*.ts", "mcp/**/*.tsx"]
}
my-mcp-app/
├── main.ts # Entry point
├── mcp/
│ └── products/
│ ├── index.ts # Service with @Tool + @UIApp
│ └── ProductsDashboard.tsx # React component
├── package.json
└── tsconfig.json
| Import | Use In | Contents |
|---|---|---|
@leanmcp/ui/server | Service files (index.ts) | UIApp, GPTApp decorators only |
@leanmcp/ui | Component files (.tsx) | React components, hooks, styles |
CRITICAL: @UIApp is a METHOD decorator, not a class decorator!
// mcp/products/index.ts
import { Tool } from '@leanmcp/core';
import { UIApp } from '@leanmcp/ui/server'; // Server import!
export class ProductsService {
@Tool({ description: 'List products' })
@UIApp({ component: './ProductsDashboard' }) // On method with @Tool
async listProducts(args: { page?: number }) {
return { products: [...], total: 100 };
}
}
// WRONG: @UIApp on class
@UIApp({ component: './Dashboard' }) // WRONG!
export class MyService { ... }
@UIApp({
component: './ComponentName', // Required: relative path (string, not import)
uri: 'ui://custom/path', // Optional: custom URI
title: 'Page Title', // Optional
styles: 'body { ... }' // Optional: additional CSS
})
// mcp/products/ProductsDashboard.tsx
import { RequireConnection, ToolDataGrid } from '@leanmcp/ui';
import '@leanmcp/ui/styles.css';
export function ProductsDashboard() {
return (
<RequireConnection loading={<div>Loading...</div>}>
<div style={{ padding: '20px' }}>
<h1>Products</h1>
<ToolDataGrid
dataTool="listProducts"
columns={[
{ key: 'name', header: 'Name', sortable: true },
{ key: 'price', header: 'Price' }
]}
transformData={(result) => ({
rows: result.products,
total: result.total
})}
pagination
/>
</div>
</RequireConnection>
);
}
import '@leanmcp/ui/styles.css'Foo.tsx exports function Foo()'./Component' not direct importButton that calls a tool with loading state and confirmation:
import { ToolButton } from '@leanmcp/ui';
<ToolButton
tool="delete-item"
args={{ id: item.id }}
confirm={{
title: 'Delete Item?',
description: 'This action cannot be undone.'
}}
variant="destructive"
resultDisplay="toast"
onToolSuccess={() => refetch()}
>
Delete
</ToolButton>
Props:
tool - Tool name to callargs - Arguments to passconfirm - Confirmation dialog configvariant - default, destructive, outline, ghostresultDisplay - toast, inline, noneonToolSuccess / onToolError - CallbacksForm that submits to a tool:
import { ToolForm } from '@leanmcp/ui';
<ToolForm
toolName="create-product"
fields={[
{ name: 'name', label: 'Product Name', type: 'text', required: true },
{ name: 'price', label: 'Price', type: 'number', min: 0 },
{ name: 'category', label: 'Category', type: 'select', options: [
{ value: 'electronics', label: 'Electronics' },
{ value: 'clothing', label: 'Clothing' }
]},
{ name: 'inStock', label: 'In Stock', type: 'switch' }
]}
submitText="Create Product"
showSuccessToast
resetOnSuccess
onSuccess={(result) => console.log('Created:', result)}
/>
Field types: text, number, email, textarea, select, switch, slider, date
Server-paginated table with sorting and row actions:
import { ToolDataGrid } from '@leanmcp/ui';
<ToolDataGrid
dataTool="listProducts" // NOT 'toolName'!
columns={[
{ key: 'name', header: 'Name', sortable: true }, // NOT 'field'/'headerName'!
{ key: 'price', header: 'Price', render: (v) => `$${v.toFixed(2)}` }
]}
transformData={(r) => ({ rows: r.products, total: r.total })}
pagination
pageSize={20}
refreshInterval={30000} // NOT 'autoRefresh'!
rowActions={[
{
label: 'Edit',
tool: 'edit-product',
getArgs: (row) => ({ id: row.id })
},
{
label: 'Delete',
tool: 'delete-product',
getArgs: (row) => ({ id: row.id }),
variant: 'destructive',
confirm: { title: 'Delete?', description: 'Cannot undo' }
}
]}
/>
Common Mistakes to Avoid:
dataTool not toolNamekey/header not field/headerNamerefreshInterval not autoRefreshtransformDataSelect dropdown with tool-based options:
import { ToolSelect } from '@leanmcp/ui';
<ToolSelect
optionsTool="list-categories"
transformOptions={(r) => r.categories.map(c => ({ value: c.id, label: c.name }))}
onSelectTool="set-category"
argName="categoryId"
placeholder="Select category"
/>
Display MCP resources with auto-refresh:
import { ResourceView } from '@leanmcp/ui';
<ResourceView
uri="config://settings"
refreshInterval={5000}
render={(data) => (
<pre>{JSON.stringify(data, null, 2)}</pre>
)}
/>
Call MCP tools programmatically:
import { useTool } from '@leanmcp/ui';
function MyComponent() {
const { call, result, loading, error } = useTool('get-data', {
retry: 3,
transform: (r) => r.data
});
useEffect(() => {
call({ filter: 'active' });
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{result?.value}</div>;
}
Read MCP resources:
import { useResource } from '@leanmcp/ui';
function ConfigView() {
const { data, loading, refresh } = useResource('config://settings', {
refreshInterval: 5000
});
return (
<div>
{loading && <p>Loading...</p>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
<button onClick={refresh}>Refresh</button>
</div>
);
}
Access host environment (theme, viewport):
import { useHostContext } from '@leanmcp/ui';
function ThemedComponent() {
const { theme, viewport } = useHostContext();
return (
<div className={theme === 'dark' ? 'dark-mode' : 'light-mode'}>
Content
</div>
);
}
For ChatGPT Apps integration:
Access structuredContent from tool response:
import { useToolOutput } from '@leanmcp/ui';
function ChannelsView() {
const toolOutput = useToolOutput<{ channels: Channel[] }>();
if (!toolOutput?.channels) return <div>No channels</div>;
return (
<ul>
{toolOutput.channels.map(ch => <li key={ch.id}>{ch.name}</li>)}
</ul>
);
}
Persistent state across sessions:
import { useWidgetState } from '@leanmcp/ui';
function FilteredView() {
const [state, setState] = useWidgetState<{ filter: string }>({ filter: 'all' });
return (
<select
value={state.filter}
onChange={(e) => setState({ filter: e.target.value })}
>
<option value="all">All</option>
<option value="active">Active</option>
</select>
);
}
For ChatGPT Apps specifically:
import { Tool } from '@leanmcp/core';
import { GPTApp } from '@leanmcp/ui/server';
export class SlackService {
@Tool({ description: 'Compose Slack message' })
@GPTApp({
component: './SlackComposer',
name: 'slack-composer'
})
async composeMessage() {
return { channels: [...] };
}
}
// mcp/dashboard/index.ts
import { Tool, Resource, SchemaConstraint } from '@leanmcp/core';
import { UIApp } from '@leanmcp/ui/server';
class CreateItemInput {
@SchemaConstraint({ description: 'Item name', minLength: 1 })
name!: string;
@SchemaConstraint({ description: 'Item value', minimum: 0 })
value!: number;
}
export class DashboardService {
private items: any[] = [];
@Tool({ description: 'View dashboard with items' })
@UIApp({ component: './Dashboard' })
async viewDashboard() {
return { items: this.items, total: this.items.length };
}
@Tool({ description: 'Create a new item', inputClass: CreateItemInput })
async createItem(input: CreateItemInput) {
const item = { id: crypto.randomUUID(), ...input, createdAt: new Date().toISOString() };
this.items.push(item);
return { success: true, item };
}
@Tool({ description: 'Delete an item' })
async deleteItem(args: { id: string }) {
this.items = this.items.filter(i => i.id !== args.id);
return { success: true };
}
@Resource({ description: 'Dashboard statistics' })
async stats() {
return {
contents: [{
uri: 'dashboard://stats',
mimeType: 'application/json',
text: JSON.stringify({
total: this.items.length,
totalValue: this.items.reduce((sum, i) => sum + i.value, 0)
})
}]
};
}
}
// mcp/dashboard/Dashboard.tsx
import { RequireConnection, ToolDataGrid, ToolForm, ToolButton, useResource } from '@leanmcp/ui';
import '@leanmcp/ui/styles.css';
export function Dashboard() {
const { data: stats, refresh } = useResource('dashboard://stats');
return (
<RequireConnection loading={<div>Connecting...</div>}>
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
<h1>Dashboard</h1>
{/* Stats */}
{stats && (
<div style={{ marginBottom: '20px', padding: '10px', background: '#f5f5f5', borderRadius: '8px' }}>
<p>Total Items: {stats.total}</p>
<p>Total Value: ${stats.totalValue}</p>
</div>
)}
{/* Create Form */}
<div style={{ marginBottom: '20px' }}>
<h2>Create Item</h2>
<ToolForm
toolName="createItem"
fields={[
{ name: 'name', label: 'Name', type: 'text', required: true },
{ name: 'value', label: 'Value', type: 'number', min: 0, required: true }
]}
submitText="Create"
showSuccessToast
resetOnSuccess
onSuccess={() => refresh()}
/>
</div>
{/* Items Table */}
<h2>Items</h2>
<ToolDataGrid
dataTool="viewDashboard"
columns={[
{ key: 'name', header: 'Name', sortable: true },
{ key: 'value', header: 'Value', render: (v) => `$${v}` },
{ key: 'createdAt', header: 'Created', render: (v) => new Date(v).toLocaleDateString() }
]}
transformData={(r) => ({ rows: r.items, total: r.total })}
rowActions={[
{
label: 'Delete',
tool: 'deleteItem',
getArgs: (row) => ({ id: row.id }),
variant: 'destructive',
confirm: { title: 'Delete item?', description: 'This cannot be undone.' }
}
]}
pagination
showRefresh
getRowKey={(row) => row.id}
/>
</div>
</RequireConnection>
);
}
Apps automatically adapt to host theme via CSS variables:
:root {
--color-background-primary: #ffffff;
--color-text-primary: #171717;
}
.dark {
--color-background-primary: #0a0a0a;
--color-text-primary: #fafafa;
}
Access in React:
const { theme } = useHostContext();
const isDark = theme === 'dark';
.tsx files alongside service index.ts files@UIApp on methods paired with @Tool@leanmcp/ui/server in service files@leanmcp/ui in component filesRequireConnection@leanmcp/ui/styles.css@UIApp on class@UIApp({ component: ... })OBobra/superpowers298k近 7 天新增 2,631 个 star
MAmattpocock/skills288k近 7 天新增 10,178 个 star
MAmattpocock/skills288k近 7 天新增 10,178 个 star
MAmattpocock/skills288k近 7 天新增 10,178 个 star
AFaffaan-m/ECC278k近 7 天新增 4,046 个 star
AFaffaan-m/ECC278k近 7 天新增 4,046 个 star