---
name: tldraw/create-mcp-app
source: https://app.decimal.ai/s/tldraw-create-mcp-app@1/SKILL.md
source_sha256: 18c66f878c4c
---

# Create MCP App

Build interactive UIs that run inside MCP-enabled hosts like Claude Desktop. An MCP App combines an MCP tool with an HTML resource to display rich, interactive content.

## Core Concept: Tool + Resource

Every MCP App requires two parts linked together:

1. **Tool** - Called by the LLM/host, returns data
2. **Resource** - Serves the bundled HTML UI that displays the data
3. **Link** - The tool's `_meta.ui.resourceUri` references the resource

```
Host calls tool → Server returns result → Host renders resource UI → UI receives result
```

## 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/Svelte/Preact/Solid | Manual lifecycle       | Framework preference             |

### 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`

## Getting 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`                                             |

## Critical Implementation Notes

### Adding Dependencies

Use `npm install` to add dependencies rather than manually writing version numbers:

```bash
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
```

This lets npm resolve the latest compatible versions. Never specify version numbers from memory.

### TypeScript Server Execution

Use `tsx` as a devDependency for running TypeScript server files:

```bash
npm install -D tsx
```

```json
"scripts": {
  "serve": "tsx server.ts"
}
```

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

### Handler Registration Order

Register ALL handlers BEFORE calling `app.connect()`:

```typescript
const app = new App({ name: 'My App', version: '1.0.0' })

// Register handlers first
app.ontoolinput = (params) => {
	/* handle input */
}
app.ontoolresult = (result) => {
	/* handle result */
}
app.onhostcontextchanged = (ctx) => {
	/* handle context */
}
app.onteardown = async () => {
	return {}
}

// Then connect
await app.connect()
```

### Tool Visibility

Control who can access tools via `_meta.ui.visibility`:

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

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

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

### Host Styling Integration

**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, use `var()`:

```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.

### Safe Area Handling

Always respect `safeAreaInsets`:

```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`
	}
}
```

### 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
}
```

**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 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'))
```

### 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 pattern** - 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.

## Common Mistakes to Avoid

1. **Handlers after connect()** - Register ALL handlers BEFORE calling `app.connect()`
2. **Missing single-file bundling** - Must use `vite-plugin-singlefile`
3. **Forgetting resource registration** - Both tool AND resource must be registered
4. **Missing resourceUri link** - Tool must have `_meta.ui.resourceUri`
5. **Ignoring safe area insets** - Always handle `ctx.safeAreaInsets`
6. **No text fallback** - Always provide `content` array for non-UI hosts
7. **Hardcoded styles** - Use host CSS variables for theme integration
8. **No streaming for large inputs** - Use `ontoolinputpartial` to show progress during generation

## Testing

### Using basic-host

Test MCP Apps locally with the basic-host example:

```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`).

### Debug with sendLog

Send debug logs to the host application (rather than just the iframe's dev console):

```typescript
await app.sendLog({ level: 'info', data: 'Debug message' })
await app.sendLog({ level: 'error', data: { error: err.message } })
```