---
name: Create MCP App
description: This skill should be used when the user asks to "create an MCP App", "add a UI to an MCP tool", "build an interactive MCP View", "scaffold an MCP App", or needs guidance on MCP Apps SDK patterns, UI-resource registration, MCP App lifecycle, host integration, streaming input, fullscreen mode, or interactive UI patterns. The definitive guide for building MCP Apps with interactive UIs.
---

# MCP Apps Complete Guide

> **Purpose**: The definitive guide to building MCP Apps correctly. Invoke with `/mcp-apps` when working on MCP Apps. This merges official SDK documentation with battle-tested patterns from real-world debugging.

---

## What MCP Apps Actually Are

### The Core Concept
MCP Apps is an **official extension** to the Model Context Protocol (SEP-1865) that allows MCP servers to deliver **interactive HTML user interfaces** that render inside AI chat windows (Claude Desktop, Goose, etc.).

**Key Distinction:**
- **MCP Apps** = Official standard from `@modelcontextprotocol/ext-apps` (USE THIS)
- **MCP-UI** = Older community library from `@mcp-ui/server` (DEPRECATED - avoid)

### The Fundamental Pattern
```
MCP App = Tool Definition + UI Resource + Proper Response
```

Three things MUST be linked:
1. **Tool Definition** with `_meta.ui.resourceUri`
2. **UI Resource** registered with `text/html;profile=mcp-app` MIME type
3. **Tool Response** that triggers the host to fetch and render the UI

### How The Data Flows
```
1. Server starts, registers tools with _meta.ui.resourceUri
2. Server registers UI resources via resources/list
3. Host (Goose/Claude) calls tools/list, sees _meta.ui.resourceUri
4. User triggers tool call
5. Server executes tool, returns content + structuredContent
6. Host sees tool has UI, fetches resource via resources/read
7. Host renders HTML in sandboxed iframe
8. UI calls app.connect(), receives tool result via ontoolresult
9. UI renders the data
```

---

## Quick Reference: The Pattern

```
MCP App = Tool + UI Resource + Link
```

1. **Tool** - Called by LLM/host, returns data with `structuredContent`
2. **Resource** - Serves bundled HTML (single file via vite-plugin-singlefile)
3. **Link** - Tool's `_meta.ui.resourceUri` points to the resource URI

```
Host calls tool → Server returns result → Host fetches resource → UI receives result via ontoolresult
```

---

## Quick Start Decision Tree

### Framework Selection

| Framework | SDK Support | Best For |
|-----------|-------------|----------|
| React | `useApp` hook provided | Teams familiar with React |
| Vanilla JS | Manual lifecycle | Simple apps, no build complexity |
| Vue | Manual lifecycle | Vue teams (template available) |
| Svelte | Manual lifecycle | Svelte teams (template available) |
| Preact | Manual lifecycle | Lightweight React alternative |
| Solid | Manual lifecycle | Solid teams (template available) |

### Project Context

**Adding to existing MCP server:**
- Import `registerAppTool`, `registerAppResource` from SDK
- Add tool registration with `_meta.ui.resourceUri`
- Add resource registration serving bundled HTML

**Creating new MCP server:**
- Set up server with transport (stdio or HTTP)
- Register tools and resources
- Configure build system with `vite-plugin-singlefile`

---

## Required Packages

```bash
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
npm install -D typescript vite vite-plugin-singlefile tsx
```

> **CRITICAL**: Use `npm install` to add dependencies rather than manually writing version numbers. This lets npm resolve the latest compatible versions. Never specify version numbers from memory.

| Package | Purpose | REQUIRED |
|---------|---------|----------|
| `@modelcontextprotocol/ext-apps` | Official MCP Apps SDK (server + client) | YES |
| `@modelcontextprotocol/sdk` | Base MCP SDK with McpServer class | YES |
| `zod` | Schema validation for tool parameters | YES |
| `vite` + `vite-plugin-singlefile` | Bundle UI to single HTML file | YES |
| `tsx` | Run TypeScript server files | YES |

**Do NOT use**: `@mcp-ui/server` (deprecated, different API)

> **Note**: The SDK examples use `bun` but generated projects should use `tsx` for broader compatibility.

---

## Project Structure

```
my-mcp-app/
├── package.json
├── tsconfig.json
├── src/
│   ├── server.ts              # MCP server with tools + resources
│   └── app-ui/
│       ├── mcp-app.html       # UI HTML template
│       ├── src/
│       │   └── mcp-app.ts     # UI logic
│       └── vite.config.ts     # Bundles to single file
└── dist/
    ├── server.js              # Compiled server
    └── app-ui/
        └── mcp-app.html       # Bundled single-file UI
```

---

## Get Reference Code

Clone the SDK repository for working examples and API documentation:

```bash
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 \
  https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
```

### Framework Templates

Learn and adapt from `/tmp/mcp-ext-apps/examples/basic-server-{framework}/`:

| Template | Key Files |
|----------|-----------|
| `basic-server-vanillajs/` | `server.ts`, `src/mcp-app.ts`, `mcp-app.html` |
| `basic-server-react/` | `server.ts`, `src/mcp-app.tsx` (uses `useApp` hook) |
| `basic-server-vue/` | `server.ts`, `src/App.vue` |
| `basic-server-svelte/` | `server.ts`, `src/App.svelte` |
| `basic-server-preact/` | `server.ts`, `src/mcp-app.tsx` |
| `basic-server-solid/` | `server.ts`, `src/mcp-app.tsx` |

Each template includes:
- Complete `server.ts` with `registerAppTool` and `registerAppResource`
- Client-side app with all lifecycle handlers
- `vite.config.ts` with `vite-plugin-singlefile`
- `package.json` with all required dependencies
- `.gitignore` excluding `node_modules/` and `dist/`

### API Reference (Source Files)

Read JSDoc documentation directly from `/tmp/mcp-ext-apps/src/`:

| File | Contents |
|------|----------|
| `src/app.ts` | `App` class, handlers (`ontoolinput`, `ontoolresult`, `onhostcontextchanged`, `onteardown`), lifecycle |
| `src/server/index.ts` | `registerAppTool`, `registerAppResource`, tool visibility options |
| `src/spec.types.ts` | All type definitions: `McpUiHostContext`, CSS variable keys, display modes |
| `src/styles.ts` | `applyDocumentTheme`, `applyHostStyleVariables`, `applyHostFonts` |
| `src/react/useApp.tsx` | `useApp` hook for React apps |
| `src/react/useHostStyles.ts` | `useHostStyles`, `useHostStyleVariables`, `useHostFonts` hooks |

### Advanced Examples

| Example | Pattern Demonstrated |
|---------|---------------------|
| `examples/shadertoy-server/` | **Streaming partial input** + visibility-based pause/play (best practice for large inputs) |
| `examples/wiki-explorer-server/` | `callServerTool` for interactive data fetching |
| `examples/system-monitor-server/` | Polling pattern with interval management |
| `examples/video-resource-server/` | Binary/blob resources |
| `examples/sheet-music-server/` | `ontoolinput` - processing tool args before execution completes |
| `examples/threejs-server/` | `ontoolinputpartial` - streaming/progressive rendering |
| `examples/map-server/` | `updateModelContext` - keeping model informed of UI state |
| `examples/transcript-server/` | `updateModelContext` + `sendMessage` - background context updates + user-initiated messages |
| `examples/basic-host/` | Reference host implementation using `AppBridge` |

---

## Server-Side Implementation

### Critical Imports
```typescript
import {
  registerAppTool,
  registerAppResource,
  RESOURCE_MIME_TYPE,  // = "text/html;profile=mcp-app"
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
```

### Complete Server Template
```typescript
import {
  registerAppTool,
  registerAppResource,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as fs from "node:fs/promises";
import * as path from "node:path";

// Path to bundled UI (after vite build)
const DIST_DIR = path.join(__dirname, "app-ui");

export function createServer(): McpServer {
  const server = new McpServer({
    name: "my-mcp-app",
    version: "1.0.0",
  });

  // CRITICAL: Define the resource URI (must use ui:// scheme)
  const widgetResourceUri = "ui://my-app/widget.html";

  // ============================================
  // STEP 1: Register Tool with UI Metadata
  // ============================================
  registerAppTool(
    server,
    "my_tool",  // Tool name (snake_case recommended)
    {
      title: "My Tool",  // Human-readable title
      description: "Description for the LLM to understand when to use this",
      inputSchema: {
        // Use Zod schemas for parameters
        param1: z.string().describe("Parameter description"),
        param2: z.number().optional().describe("Optional number"),
      },
      // CRITICAL: This links tool to UI resource
      _meta: {
        ui: { resourceUri: widgetResourceUri },
      },
    },
    async (args: { param1: string; param2?: number }) => {
      // Your tool logic here
      const result = {
        data: args.param1,
        timestamp: new Date().toISOString(),
      };

      // CRITICAL: Return format
      return {
        // Text fallback for non-UI hosts
        content: [
          {
            type: "text" as const,
            text: `Result: ${JSON.stringify(result)}`,
          },
        ],
        // Structured data passed to UI via ontoolresult
        structuredContent: result,
      };
    }
  );

  // ============================================
  // STEP 2: Register UI Resource
  // ============================================
  registerAppResource(
    server as any,  // Type cast needed due to SDK types
    widgetResourceUri,  // Resource name
    widgetResourceUri,  // Resource URI (same as name for simplicity)
    { mimeType: RESOURCE_MIME_TYPE },  // CRITICAL: Use this constant
    async () => {
      console.error(`[MCP App] Reading UI resource`);
      try {
        const html = await fs.readFile(
          path.join(DIST_DIR, "mcp-app.html"),
          "utf-8"
        );
        console.error(`[MCP App] UI loaded (${html.length} bytes)`);
        return {
          contents: [
            {
              uri: widgetResourceUri,
              mimeType: RESOURCE_MIME_TYPE,
              text: html,
            },
          ],
        };
      } catch (error) {
        console.error(`[MCP App] Failed to read UI:`, error);
        return {
          contents: [
            {
              uri: widgetResourceUri,
              mimeType: RESOURCE_MIME_TYPE,
              text: "<html><body>UI not built</body></html>",
            },
          ],
        };
      }
    }
  );

  // Add more tools (with or without UI)
  server.tool(
    "helper_tool",
    "A helper tool without UI",
    {
      input: z.string().describe("Input value"),
    },
    async (args) => {
      return {
        content: [{ type: "text" as const, text: `Got: ${args.input}` }],
      };
    }
  );

  return server;
}

// Entry point
async function main() {
  const server = createServer();
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP App Server started");
}

main().catch((e) => {
  console.error(`Error: ${e.message}`);
  process.exit(1);
});
```

### Tool Registration (Quick Reference)
```typescript
const resourceUri = "ui://my-app/widget.html";

registerAppTool(
  server,
  "tool_name",
  {
    title: "Tool Title",
    description: "Description for LLM",
    inputSchema: {
      param: z.string().describe("Parameter description"),
    },
    _meta: {
      ui: { resourceUri },
    },
  },
  async (args) => {
    const result = { data: args.param };
    return {
      content: [{ type: "text" as const, text: JSON.stringify(result) }],
      structuredContent: result,  // This goes to UI
    };
  }
);
```

### Resource Registration
```typescript
registerAppResource(
  server as any,
  resourceUri,
  resourceUri,
  { mimeType: RESOURCE_MIME_TYPE },
  async () => ({
    contents: [{
      uri: resourceUri,
      mimeType: RESOURCE_MIME_TYPE,
      text: await fs.readFile(path.join(__dirname, "app-ui/mcp-app.html"), "utf-8"),
    }],
  })
);
```

### Tool Visibility Options
```typescript
// Default: visible to both model and app
_meta: { ui: { resourceUri, visibility: ["model", "app"] } }

// UI-only (hidden from model) - for refresh buttons, form submissions, internal actions
_meta: { ui: { resourceUri, visibility: ["app"] } }

// Model-only (app cannot call)
_meta: { ui: { resourceUri, visibility: ["model"] } }
```

### Server-Side Action Tools

For interactive UIs, add **action tools** that the UI calls via `callServerTool`:

```typescript
// View tool (returns UI) - called by model
registerAppTool(server, "view_board", { /* ... */ }, async (args) => {
  const data = await getData();
  return {
    content: [{ type: "text", text: `Board loaded` }],
    structuredContent: data
  };
});

// Action tool (no UI) - called by UI via callServerTool
{
  name: 'update_item',
  description: 'Update an item (used by UI for drag-drop/edit)',
  inputSchema: {
    type: 'object',
    properties: {
      itemId: { type: 'string' },
      columnId: { type: 'string' },
      name: { type: 'string' },
      value: { type: 'number' }
    },
    required: ['itemId']
  }
  // No _meta.ui - this is not a view tool
}

// Handler in executeTool:
case 'update_item':
  const result = await apiClient.updateItem(args.itemId, args);
  return {
    content: [{ type: 'text', text: `Updated ${result.name}` }],
    structuredContent: { success: true, item: result }
  };
```

---

## Client-Side Implementation

### Complete HTML Template (src/app-ui/mcp-app.html)
```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>My MCP App</title>
  <style>
    /* All styles inline - will be bundled */
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      padding: 16px;
      background: #f5f5f5;
    }
    .loading { text-align: center; color: #666; padding: 20px; }
    .error { color: #dc2626; background: #fef2f2; padding: 16px; border-radius: 8px; }
    .content { background: white; padding: 16px; border-radius: 8px; }
  </style>
</head>
<body>
  <div id="app">
    <div class="loading">Loading...</div>
  </div>
  <script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>
```

### Complete UI Logic (src/app-ui/src/mcp-app.ts)
```typescript
import { App } from "@modelcontextprotocol/ext-apps";

// Define your data types
interface MyData {
  data: string;
  timestamp: string;
}

const appEl = document.getElementById("app")!;

// Create app instance
const app = new App({
  name: "My MCP App",
  version: "1.0.0"
});

// ============================================
// CRITICAL: Set handlers BEFORE connect()
// ============================================
app.ontoolresult = (result) => {
  console.log("Tool result received:", result);

  try {
    // Get data from structuredContent (preferred) or parse text
    let data: MyData;

    if (result.structuredContent) {
      data = result.structuredContent as MyData;
    } else {
      const textContent = result.content?.find((c) => c.type === "text")?.text;
      if (textContent) {
        data = JSON.parse(textContent);
      } else {
        throw new Error("No data in result");
      }
    }

    renderContent(data);
  } catch (error) {
    console.error("Failed to parse result:", error);
    appEl.innerHTML = `<div class="error">Failed to load: ${error}</div>`;
  }
};

app.onerror = (error) => {
  console.error("App error:", error);
  appEl.innerHTML = `<div class="error">Error: ${error.message}</div>`;
};

// ============================================
// CRITICAL: Connect to host (must be called)
// ============================================
app.connect();

// Render function
function renderContent(data: MyData) {
  appEl.innerHTML = `
    <div class="content">
      <h2>Result</h2>
      <p><strong>Data:</strong> ${escapeHtml(data.data)}</p>
      <p><strong>Time:</strong> ${data.timestamp}</p>
      <button id="refresh">Refresh</button>
    </div>
  `;

  // Add interactivity
  document.getElementById("refresh")?.addEventListener("click", async () => {
    try {
      const result = await app.callServerTool({
        name: "my_tool",
        arguments: { param1: "refreshed" }
      });
      const newData = result.structuredContent as MyData;
      renderContent(newData);
    } catch (error) {
      console.error("Refresh failed:", error);
    }
  });
}

// Helper to prevent XSS
function escapeHtml(text: string): string {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}
```

### Vanilla JS Pattern (Full Lifecycle)
```typescript
import { App } from "@modelcontextprotocol/ext-apps";
import { applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";

const app = new App({ name: "My App", version: "1.0.0" });

// CRITICAL: Set ALL handlers BEFORE connect()
app.ontoolinput = (params) => {
  // Tool args available immediately (before execution completes)
  console.log("Input:", params.arguments);
};

app.ontoolinputpartial = (params) => {
  // Streaming partial input (healed JSON, always valid)
  console.log("Partial:", params.arguments);
};

app.ontoolresult = (result) => {
  // Tool execution complete - render the data
  const data = result.structuredContent;
  renderUI(data);
};

app.onhostcontextchanged = (ctx) => {
  // Theme/style updates from host
  if (ctx.theme) applyDocumentTheme(ctx.theme);
  if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
  if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
  // Handle safe area insets
  if (ctx.safeAreaInsets) {
    const { top, right, bottom, left } = ctx.safeAreaInsets;
    document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`;
  }
};

app.onteardown = async () => {
  // Cleanup before UI closes
  return {};
};

app.onerror = (error) => {
  console.error("App error:", error);
};

// THEN connect
await app.connect();
```

### React Pattern
```typescript
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";

function MyApp() {
  const [data, setData] = useState(null);

  const { app } = useApp({
    appInfo: { name: "My App", version: "1.0.0" },
    onToolResult: (result) => setData(result.structuredContent),
  });

  useHostStyles(app);  // Injects CSS variables, making var(--*) available

  return <div>{data && <Content data={data} />}</div>;
}
```

### Calling Server Tools from UI
```typescript
const result = await app.callServerTool({
  name: "tool_name",
  arguments: { param: "value" }
});
const newData = result.structuredContent;
```

### Updating Model Context
```typescript
// Keep the model informed of UI state changes
app.updateModelContext({
  summary: "User selected 3 items",
  details: { selectedIds: [1, 2, 3] }
});
```

### Debug Logging to Host
```typescript
// Send debug logs to the host application (not just iframe dev console)
await app.sendLog({ level: "info", data: "Debug message" });
await app.sendLog({ level: "error", data: { error: err.message } });
```

---

## App Lifecycle Handlers

| Handler | When It Fires | Use For |
|---------|---------------|---------|
| `ontoolinput` | Tool args available (before execution) | Show loading state, preview |
| `ontoolinputpartial` | Streaming partial input (healed JSON) | Progressive rendering during generation |
| `ontoolresult` | Tool execution complete | Render main UI |
| `onhostcontextchanged` | Theme/style/locale/display mode changes | Apply host styling, safe areas, fullscreen |
| `onteardown` | UI closing | Cleanup, save state |
| `onerror` | Error occurred | Error display |

---

## Streaming Partial Input

For large tool inputs, use `ontoolinputpartial` to show progress during LLM generation. The partial JSON is healed (always valid), enabling progressive UI updates.

**Spec:** [ui/notifications/tool-input-partial](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#streaming-tool-input)

```typescript
app.ontoolinputpartial = (params) => {
  const args = params.arguments; // Healed partial JSON - always valid, fields appear as generated
  // Use args directly for progressive rendering
};

app.ontoolinput = (params) => {
  // Final complete input - switch from preview to full render
};
```

### Streaming Use Cases

| Pattern | Example |
|---------|---------|
| Code preview | Show streaming code in `<pre>`, render on complete (`examples/shadertoy-server/`) |
| Progressive form | Fill form fields as they stream in |
| Live chart | Add data points to chart as array grows |
| Partial render | Render incomplete structured data (tables, lists, trees) |

### Simple Streaming Pattern (Code Preview)
```typescript
app.ontoolinputpartial = (params) => {
  codePreview.textContent = params.arguments?.code ?? "";
  codePreview.style.display = "block";
  canvas.style.display = "none";
};
app.ontoolinput = (params) => {
  codePreview.style.display = "none";
  canvas.style.display = "block";
  render(params.arguments);
};
```

---

## Visibility-Based Resource Management

Pause expensive operations (animations, WebGL, polling) when view scrolls out of viewport:

```typescript
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      animation.play(); // or: startPolling(), shaderToy.play()
    } else {
      animation.pause(); // or: stopPolling(), shaderToy.pause()
    }
  });
});
observer.observe(document.querySelector(".main"));
```

See `examples/shadertoy-server/` for a real implementation combining streaming input + visibility pause.

---

## Fullscreen Mode

Request fullscreen via `app.requestDisplayMode()`. Check availability in host context:

```typescript
let currentMode: "inline" | "fullscreen" = "inline";

app.onhostcontextchanged = (ctx) => {
  // Check if fullscreen available
  if (ctx.availableDisplayModes?.includes("fullscreen")) {
    fullscreenBtn.style.display = "block";
  }
  // Track current mode
  if (ctx.displayMode) {
    currentMode = ctx.displayMode;
    container.classList.toggle("fullscreen", currentMode === "fullscreen");
  }
};

async function toggleFullscreen() {
  const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen";
  const result = await app.requestDisplayMode({ mode: newMode });
  currentMode = result.mode;
}
```

### CSS for Fullscreen — Remove border radius in fullscreen:
```css
.main { border-radius: var(--border-radius-lg); overflow: hidden; }
.main.fullscreen { border-radius: 0; }
```

See `examples/shadertoy-server/` for complete implementation.

---

## Safe Area Insets

Always respect `safeAreaInsets` to avoid content being clipped by host chrome:

```typescript
app.onhostcontextchanged = (ctx) => {
  if (ctx.safeAreaInsets) {
    const { top, right, bottom, left } = ctx.safeAreaInsets;
    document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`;
  }
};
```

---

## Host CSS Variables

### Applying Styles

**Vanilla JS** - Use helper functions:
```typescript
import { applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";

app.onhostcontextchanged = (ctx) => {
  if (ctx.theme) applyDocumentTheme(ctx.theme);
  if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
  if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
};
```

**React** - Use hooks:
```typescript
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";

const { app } = useApp({ appInfo, capabilities, onAppCreated });
useHostStyles(app); // Injects CSS variables to document, making var(--*) available
```

### Using Variables in CSS

After applying with `applyHostStyleVariables()` or `useHostStyles()`:

```css
.container {
  background: var(--color-background-secondary);
  color: var(--color-text-primary);
  font-family: var(--font-sans);
  border-radius: var(--border-radius-md);
}
.code {
  font-family: var(--font-mono);
  font-size: var(--font-text-sm-size);
  line-height: var(--font-text-sm-line-height);
  color: var(--color-text-secondary);
}
.heading {
  font-size: var(--font-heading-lg-size);
  font-weight: var(--font-weight-semibold);
}
```

Key variable groups: `--color-background-*`, `--color-text-*`, `--color-border-*`, `--font-sans`, `--font-mono`, `--font-text-*-size`, `--font-heading-*-size`, `--border-radius-*`. See `src/spec.types.ts` for full list.

---

## Build Configuration

### Vite Config (src/app-ui/vite.config.ts)
```typescript
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [viteSingleFile()],  // CRITICAL: Bundles everything into one HTML
  root: __dirname,
  build: {
    outDir: "../../dist/app-ui",
    emptyOutDir: true,
    rollupOptions: { input: "mcp-app.html" },
  },
});
```

### tsconfig.json
```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "src/app-ui/**/*"]
}
```

**CRITICAL**: Exclude `src/app-ui/**/*` from TypeScript - Vite handles that separately.

### package.json Scripts
```json
{
  "type": "module",
  "scripts": {
    "build:ui": "cd src/app-ui && npx vite build",
    "build:server": "tsc",
    "build": "npm run build:ui && npm run build:server",
    "serve": "tsx src/server.ts",
    "start": "node dist/server.js"
  }
}
```

---

## Interactive UI Patterns

### CRITICAL: Module Scope Issue

When using `<script type="module">`, all variables are **module-scoped** and NOT accessible from:
- External `<script>` tags
- HTML attributes (`onclick`, `onsubmit`, `ondblclick`)

**Solution 1: Keep everything in the module**
```javascript
// Inside the module script - works because app and functions are in scope
function renderUI(data) {
  // Render UI
  renderHTML(data);
  // Store data globally for interactive features
  window._appData = data;
  // Setup interactivity after render
  setupInteractivity();
}

function setupInteractivity() {
  document.querySelectorAll('.card').forEach(card => {
    card.draggable = true;
    card.ondragstart = (e) => { /* has access to module's app variable */ };
    card.ondblclick = () => openEditModal(card.dataset.id);
  });
}
```

**Solution 2: Expose to window (for HTML attributes)**
```javascript
// At end of module, expose functions needed by HTML onclick/onsubmit
window.openEditModal = function(id) { /* ... */ };
window.closeEditModal = function() { /* ... */ };
window.saveItem = async function(e) {
  e.preventDefault();
  await app.callServerTool({ name: 'update_item', arguments: { id, data } });
};
```

### Drag-and-Drop Pattern

```javascript
// Store current data globally for access during drag/drop
window._appData = null;

function setupDragDrop() {
  let draggedId = null;

  // Make cards draggable
  document.querySelectorAll('.card').forEach(card => {
    card.draggable = true;

    card.ondragstart = (e) => {
      draggedId = card.dataset.id;
      card.classList.add('dragging');
      e.dataTransfer.effectAllowed = 'move';
    };

    card.ondragend = () => {
      card.classList.remove('dragging');
      document.querySelectorAll('.column').forEach(c => c.classList.remove('drag-over'));
    };
  });

  // Setup drop zones
  document.querySelectorAll('.column').forEach(col => {
    col.ondragover = (e) => {
      e.preventDefault();
      col.classList.add('drag-over');
    };

    col.ondragleave = (e) => {
      if (!col.contains(e.relatedTarget)) col.classList.remove('drag-over');
    };

    col.ondrop = async (e) => {
      e.preventDefault();
      col.classList.remove('drag-over');
      if (!draggedId) return;

      const newColumnId = col.dataset.columnId;

      // Call server to persist the move
      try {
        await app.callServerTool({
          name: 'update_item',
          arguments: { itemId: draggedId, columnId: newColumnId }
        });

        // Update local data and re-render
        const item = window._appData.items.find(i => i.id === draggedId);
        if (item) {
          item.columnId = newColumnId;
          renderUI(window._appData);  // Re-render (which calls setupDragDrop again)
        }
      } catch (err) {
        alert('Move failed: ' + err.message);
      }

      draggedId = null;
    };
  });
}
```

### Edit Modal Pattern

**HTML Structure:**
```html
<div id="editModal" class="modal-overlay" style="display:none;">
  <div class="modal-content">
    <div class="modal-header">
      <h3>Edit Item</h3>
      <button onclick="closeEditModal()">&times;</button>
    </div>
    <form onsubmit="saveItem(event)">
      <input type="hidden" id="editItemId">
      <div class="form-group">
        <label>Name</label>
        <input type="text" id="editName" required>
      </div>
      <div class="form-group">
        <label>Value</label>
        <input type="number" id="editValue">
      </div>
      <button type="submit">Save</button>
    </form>
  </div>
</div>
```

**JavaScript (in module, exposed to window):**
```javascript
window.openEditModal = function(id) {
  const item = window._appData?.items?.find(i => i.id === id);
  if (!item) return;

  document.getElementById('editItemId').value = id;
  document.getElementById('editName').value = item.name || '';
  document.getElementById('editValue').value = item.value || 0;
  document.getElementById('editModal').style.display = 'flex';
};

window.closeEditModal = function() {
  document.getElementById('editModal').style.display = 'none';
};

window.saveItem = async function(e) {
  e.preventDefault();
  const id = document.getElementById('editItemId').value;
  const name = document.getElementById('editName').value;
  const value = parseFloat(document.getElementById('editValue').value) || 0;

  try {
    await app.callServerTool({
      name: 'update_item',
      arguments: { itemId: id, name, value }
    });

    // Update local data and re-render
    const item = window._appData?.items?.find(i => i.id === id);
    if (item) {
      item.name = name;
      item.value = value;
      renderUI(window._appData);
    }
    closeEditModal();
  } catch (err) {
    alert('Save failed: ' + err.message);
  }
};
```

### CSS for Drag-and-Drop & Modals

```css
.card {
  cursor: grab;
  transition: transform 0.2s, opacity 0.2s;
}

.card:active {
  cursor: grabbing;
}

.card.dragging {
  opacity: 0.5;
  transform: rotate(2deg);
}

.column.drag-over .cards-container {
  background: #e0e7ff;
  border: 2px dashed #4f46e5;
}

.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-content {
  background: white;
  padding: 24px;
  border-radius: 8px;
  min-width: 300px;
}
```

### Key Lessons Learned

1. **Module scope**: `<script type="module">` creates isolated scope - use `window.fn = fn` for HTML attribute access
2. **Re-render after changes**: Store data in `window._appData`, update it, call render function again
3. **Setup handlers after render**: Call `setupDragDrop()` at end of render function since DOM changes
4. **Use dataset attributes**: `card.dataset.id` maps to `data-id="..."` in HTML
5. **Column identification**: Add `data-column-id` or `data-stage-id` to drop zone containers
6. **Action vs View tools**: View tools have `_meta.ui.resourceUri`, action tools don't

---

## Data Handling Gotchas

### Array Data Must Be Validated
```typescript
// BAD - crashes if data.items isn't an array
const html = data.items.map(item => `<li>${item}</li>`).join('');

// GOOD - defensive coding
let items: Item[] = [];
if (Array.isArray(data.items)) {
  items = data.items;
} else if (data.items && typeof data.items === 'object') {
  // Handle nested { items: [...] } format
  const nested = (data.items as any).items;
  if (Array.isArray(nested)) items = nested;
}
const html = items.map(item => `<li>${item}</li>`).join('');
```

### Always Escape User Content
```typescript
function escapeHtml(text: string): string {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}
```

---

## Critical Rules

### MUST DO

| Requirement | Why It Matters |
|-------------|----------------|
| Use `McpServer` class | Low-level `Server` class doesn't work with helpers |
| Use `registerAppTool` | Properly sets up `_meta.ui.resourceUri` in both formats |
| Use `registerAppResource` | Registers resource with correct MIME type |
| Use `RESOURCE_MIME_TYPE` constant | Must be exactly `text/html;profile=mcp-app` |
| Use `ui://` scheme for URIs | Required by spec |
| Bundle UI to SINGLE HTML file | Host expects one file, use `vite-plugin-singlefile` |
| Set ALL handlers BEFORE `app.connect()` | Miss initial data otherwise |
| Call `app.connect()` | UI won't communicate without it |
| Return `structuredContent` in tool response | This is how UI receives typed data |
| Return text `content` as fallback | Non-UI hosts need this |
| Use Zod schemas for tool params | McpServer requires Zod, not plain objects |
| Exclude app-ui from tsconfig | Vite compiles UI separately |
| Handle `safeAreaInsets` | Avoid content clipped by host chrome |
| Use `npm install` for dependencies | Never hardcode version numbers from memory |
| Use host CSS variables | Theme integration, don't hardcode colors |
| Use `ontoolinputpartial` for large inputs | Show progress during LLM generation |

### MUST NOT

| Anti-Pattern | What Happens |
|--------------|--------------|
| Using `Server` instead of `McpServer` | `registerAppTool` won't work |
| Using `@mcp-ui/server` | Different API, deprecated |
| Plain object inputSchema | TypeScript errors, runtime failures |
| Multiple JS/CSS files in UI | Host can't load them |
| Setting handlers after `connect()` | Miss the initial tool result |
| Forgetting `_meta.ui.resourceUri` | Host won't fetch UI |
| Wrong MIME type | Host ignores resource |
| `import.meta.url` in CommonJS | Build errors |
| Returning `_meta` in tool response | Not needed if tool definition has it |
| Hardcoded styles instead of CSS vars | Breaks host theme integration |
| Ignoring safe area insets | Content clipped on some hosts |
| Manually writing dependency versions | Version mismatches, use `npm install` |

---

## Host-Specific Notes

### Goose
- **Minimum version**: 1.19.0 for MCP Apps
- **Works in**: Goose Desktop, Goose Web (`goose web`), Goose CLI
- **Config location**: `~/.config/goose/config.yaml`
- **Enable alpha features**: Add `ALPHA_FEATURES: true` to config
- **Logs**: Server stderr goes to Goose logs

### Claude Desktop
- **Config location**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Node path**: Use full path like `/opt/homebrew/opt/node@22/bin/node`
- **Logs**: Check Claude Desktop developer console

---

## Testing

### Quick Pipe Tests (JSONRPC)
```bash
# Test tools/list - verify _meta.ui.resourceUri present
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node dist/server.js

# Test resources/list - verify MIME type correct
echo '{"jsonrpc":"2.0","id":1,"method":"resources/list","params":{}}' | node dist/server.js

# Test resources/read - verify HTML returned
echo '{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"ui://my-app/widget.html"}}' | node dist/server.js
```

### Using basic-host (Interactive Testing)

Test MCP Apps locally with the basic-host example from the SDK repo:

```bash
# Terminal 1: Build and run your server
npm run build && npm run serve

# Terminal 2: Run basic-host (from cloned repo)
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
# Open http://localhost:8080
```

Configure `SERVERS` with a JSON array of your server URLs (default: `http://localhost:3001/mcp`).

---

## Debugging Checklist

When UI doesn't render:

1. **Check tools/list** - Does tool have `_meta.ui.resourceUri`?
2. **Check resources/list** - Is resource listed with correct MIME type?
3. **Check resources/read** - Does it return HTML content?
4. **Check browser console** - Any JS errors in the iframe?
5. **Check server stderr** - Is resource being fetched?
6. **Verify build** - Is `dist/app-ui/mcp-app.html` a single file with inlined JS?
7. **Use sendLog** - Send debug logs to host from UI:
   ```typescript
   await app.sendLog({ level: "info", data: "Debug message" });
   await app.sendLog({ level: "error", data: { error: err.message } });
   ```

Add logging to resource handler:
```typescript
async () => {
  console.error(`[DEBUG] resources/read called for: ${uri}`);
  // ...
}
```

---

## Common Errors

| Error | Cause | Fix |
|-------|-------|-----|
| `n.map is not a function` | Data isn't array | Validate with `Array.isArray()` |
| UI shows "Loading..." forever | `connect()` not called | Add `await app.connect()` |
| UI doesn't receive data | Handlers set after connect | Set handlers BEFORE `connect()` |
| TypeScript errors with inputSchema | Plain objects used | Use Zod schemas |
| Host doesn't fetch resource | Tool missing `_meta` | Use `registerAppTool` helper |
| onclick/ondblclick not firing | Functions scoped in module | Assign to `window` |
| MIME type mismatch | Hardcoded wrong type | Use `RESOURCE_MIME_TYPE` constant |
| "UI not built" error | Vite didn't run | Run `npm run build:ui` |
| `_meta.ui.resourceUri` not in tools/list | Using wrong SDK | Use `@modelcontextprotocol/ext-apps/server` |
| Drag-and-drop not working | Event handlers not accessible | Use module-internal handlers |
| Content clipped at edges | Missing safe area handling | Handle `ctx.safeAreaInsets` |
| Theme looks wrong in host | Hardcoded colors | Use `--color-*` CSS variables |

---

## Summary: The Golden Path

1. **Install**: `@modelcontextprotocol/ext-apps`, `@modelcontextprotocol/sdk`, `zod`, `vite`, `vite-plugin-singlefile`
2. **Server**: Use `McpServer` + `registerAppTool` + `registerAppResource`
3. **Schemas**: Use Zod for all tool parameters
4. **UI**: Use `App` from ext-apps, set ALL handlers BEFORE `connect()`
5. **Streaming**: Use `ontoolinputpartial` for large inputs, `ontoolinput` for final
6. **Styling**: Apply host CSS variables, handle safe area insets
7. **Fullscreen**: Check `availableDisplayModes`, use `requestDisplayMode()`
8. **Resources**: Use IntersectionObserver to pause expensive ops when off-screen
9. **Build**: Vite with singlefile plugin, exclude from tsconfig
10. **Test**: Verify tools/list shows `_meta`, resources/read returns HTML, use basic-host

Follow this exactly and MCP Apps will work. Deviate and you'll debug for hours.

---

*This guide merges the official @modelcontextprotocol/ext-apps SDK documentation with battle-tested patterns from real-world debugging. Follow these patterns exactly.*
