Full-Code Guides
Building MCP Apps
Build interactive UIs for MCP tools using the MCP Apps SDK
What’s an MCP App?
An MCP App is an interactive UI rendered inside MCP clients (Claude, Cursor, etc.). It connects to the host via the @modelcontextprotocol/ext-apps SDK, receives tool input and results, and renders a rich interactive display.
MCP Apps are:
- Built as single-file HTML bundles
- Served as MCP resources by your server
- Connected to tools via
_meta.ui.resourceUri - Styled automatically by the host theme
Stack
- React 19 with React Compiler (
babel-plugin-react-compiler) - Tailwind v4 + shadcn/ui components
- TanStack Router (hash-based, works in embedded contexts)
- @modelcontextprotocol/ext-apps SDK for host communication
- Vite + vite-plugin-singlefile (builds to a single HTML file)
How It Works
- Your MCP server exposes tools with
_meta.ui.resourceUripointing to a resource - When a tool is called, the MCP client loads the resource (an HTML bundle)
- The HTML bundle connects to the host via the MCP Apps SDK
- The UI receives tool input and results through event handlers
- The UI renders an interactive display and can send messages back to the conversation
App Structure
web/app.tsx — React entry point
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { McpProvider } from "./context.tsx";
import { AppRouter } from "./router.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<McpProvider>
<AppRouter />
</McpProvider>
</StrictMode>,
);
web/context.tsx — MCP state management
The McpProvider connects to the MCP host and manages state:
import {
type App,
type McpUiHostContext,
useApp,
useHostStyles,
} from "@modelcontextprotocol/ext-apps/react";
export function McpProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<McpState>(INITIAL_STATE);
const onAppCreated = useCallback((app: App) => {
app.ontoolinput = (params) => {
setState((prev) => ({
...prev,
status: "tool-input",
toolInput: params.arguments,
}));
};
app.ontoolresult = (result) => {
setState((prev) => ({
...prev,
status: "tool-result",
toolResult: result.structuredContent,
}));
};
app.ontoolcancelled = () => {
setState((prev) => ({ ...prev, status: "tool-cancelled" }));
};
}, []);
const { app, isConnected } = useApp({
appInfo: { name: "MCP App", version: "1.0.0" },
capabilities: {},
onAppCreated,
});
useHostStyles(app, app?.getHostContext());
return (
<McpAppContext.Provider value={app}>
<McpStateContext.Provider value={state}>
{children}
</McpStateContext.Provider>
</McpAppContext.Provider>
);
}
Exports:
useMcpState<TInput, TResult>()— typed access to the current MCP stateuseMcpApp()— access the App instance (forsendMessage(), etc.)useMcpHostContext()— access host context (safe area insets, tool info)
web/router.tsx — Tool routing
const TOOL_PAGES: Record<string, React.ComponentType> = {
hello_world: HelloPage,
};
function ToolRouter() {
const { toolName } = useMcpState();
const Page = TOOL_PAGES[toolName];
if (!Page) return <p>Unknown tool: {toolName}</p>;
return <Page />;
}
The router reads toolName from the MCP host context and renders the matching page. RootLayout applies safe area insets from the host.
web/types.ts — State types
export type McpStatus =
| "initializing"
| "connected"
| "tool-input"
| "tool-result"
| "tool-cancelled"
| "error";
export interface McpState<TInput = unknown, TResult = unknown> {
status: McpStatus;
toolName?: string;
error?: string;
toolInput?: TInput;
toolResult?: TResult;
}
Building a Tool UI
Step-by-step: Add a new tool UI
- Create the tool (
api/tools/my-tool.ts) with_meta.ui.resourceUri - Create UI component (
web/tools/my-tool/index.tsx) - Register in router — add to
TOOL_PAGESinweb/router.tsx - Create resource (
api/resources/my-tool.ts) serving the HTML bundle - Register resource in
api/main.ts(resourcesarray)
Example: Tool UI component
Here’s the hello tool UI ( web/tools/hello/index.tsx ):
import { Badge } from "@/components/ui/badge.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import { useMcpApp, useMcpState } from "@/context.tsx";
import type { HelloInput, HelloOutput } from "../../../api/tools/hello.ts";
export default function HelloPage() {
const state = useMcpState<HelloInput, HelloOutput>();
const app = useMcpApp();
if (state.status === "initializing") {
return <div>Connecting to host...</div>;
}
if (state.status === "connected") {
return (
<Card>
<CardHeader>
<CardTitle>Hello MCP App</CardTitle>
</CardHeader>
<CardContent>
<p>
Connected. Call the <Badge variant="secondary">hello_world</Badge>{" "}
tool to see a greeting here.
</p>
</CardContent>
</Card>
);
}
if (state.status === "tool-input") {
return <div>Greeting {state.toolInput?.name ?? "someone"}...</div>;
}
if (state.status === "error") {
return <p className="text-destructive">{state.error}</p>;
}
if (state.status === "tool-cancelled") {
return <p className="text-destructive">Tool call was cancelled.</p>;
}
// tool-result
return (
<Card>
<CardHeader>
<CardTitle>{state.toolResult?.greeting}</CardTitle>
</CardHeader>
<CardContent>
<Button
onClick={() => {
app?.sendMessage({
role: "user",
content: [{ type: "text", text: "Tell me more!" }],
});
}}
>
Send Message
</Button>
</CardContent>
</Card>
);
}
Key patterns:
- Use
useMcpState<TInput, TResult>()with your tool’s input/output types - Handle all status states (
initializing,connected,tool-input,tool-result,tool-cancelled,error) - Use
app.sendMessage()to send messages back to the conversation
Example: Resource definition
See Resources for how to create the resource that serves this UI.
Host Integration
useHostStyles()— automatically inherit host theme (colors, fonts, spacing)useMcpHostContext()— access safe area insets, tool info from the hostapp.sendMessage()— send messages back to the AI conversation
Styling
- shadcn/ui components available in
web/components/ui/ - Tailwind v4 for utility classes
- Host styles automatically applied via
useHostStyles()— your UI matches the host’s theme - Design for embedded contexts: use safe area insets, compact layouts
Previous
Found an error or want to improve this page?
Edit this page