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

  1. Your MCP server exposes tools with _meta.ui.resourceUri pointing to a resource
  2. When a tool is called, the MCP client loads the resource (an HTML bundle)
  3. The HTML bundle connects to the host via the MCP Apps SDK
  4. The UI receives tool input and results through event handlers
  5. 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 state
  • useMcpApp() — access the App instance (for sendMessage() , 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

  1. Create the tool ( api/tools/my-tool.ts ) with _meta.ui.resourceUri
  2. Create UI component ( web/tools/my-tool/index.tsx )
  3. Register in router — add to TOOL_PAGES in web/router.tsx
  4. Create resource ( api/resources/my-tool.ts ) serving the HTML bundle
  5. Register resource in api/main.ts ( resources array)

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 host
  • app.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