1295 lines
39 KiB
Markdown
1295 lines
39 KiB
Markdown
---
|
|
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()">×</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.*
|