home / skills / tldraw / tldraw / create-mcp-app

This skill guides you in scaffolding and building MCP Apps with interactive UIs, including tool-resource patterns, lifecycle, and host integration.

npx playbooks add skill tldraw/tldraw --skill create-mcp-app

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
12.9 KB
---
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, or host integration. Provides comprehensive guidance for building MCP Apps with interactive UIs.
---

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

Overview

This skill teaches how to build and scaffold MCP Apps: interactive HTML UIs paired with MCP tools that run inside MCP-enabled hosts (for example, Claude Desktop). It covers project structure, SDK APIs, lifecycle handlers, host integration, and bundling so you can deliver robust, theme-aware, and streaming-capable interfaces. Practical patterns and templates for React and vanilla JS are included for quick adoption.

How this skill works

An MCP App consists of a tool (server-side code returning data) and a resource (bundled HTML/JS UI). The tool includes a _meta.ui.resourceUri that links to the resource. Hosts call the tool, the server returns results, and the host renders the resource UI which receives results and host context updates. The SDK exposes lifecycle handlers (ontoolinput, ontoolinputpartial, ontoolresult, onhostcontextchanged, onteardown) and helpers/hooks for style and font injection.

When to use it

  • You need to add an interactive UI to an MCP tool or scaffold a new MCP App.
  • You want streaming or progressive rendering as LLM input is generated (large inputs, code previews).
  • You must integrate host theming, fonts, or safe-area insets into the UI.
  • You need to control resource visibility between model and app or support fullscreen/display modes.
  • You want examples and templates for React, vanilla JS, or other frameworks.

Best practices

  • Register all app handlers before calling app.connect() to avoid missed events.
  • Bundle the resource as a single file using vite-plugin-singlefile so hosts can load it reliably.
  • Use ontoolinputpartial for progressive updates and switch to ontoolinput when final input arrives.
  • Apply host styles and fonts via provided helpers/hooks and use CSS variables (var(--*)) instead of hardcoded colors.
  • Pause expensive rendering or polling when the resource is not visible with an IntersectionObserver.
  • Always provide a non-UI text fallback (content array) for hosts that can’t render the HTML UI.

Example use cases

  • React dashboard that displays streaming LLM code previews using ontoolinputpartial and transitions to full render on ontoolinput.
  • Vanilla JS shader or WebGL canvas that pauses when scrolled out of view and supports fullscreen via requestDisplayMode.
  • Interactive map or chart that polls a server, using visibility checks to stop polling when hidden.
  • Form UI that is visible only to the app (_meta.ui.visibility = ["app"]) and submits to a model-visible tool.
  • Tool that updates model context from the UI (updateModelContext) and sends background messages (sendMessage).

FAQ

How do I test an MCP App locally?

Run your server (npm run build && npm run serve) and start the basic-host example with SERVERS pointing to your server; open the provided URL to exercise the app in a reference host.

When should I use ontoolinputpartial?

Use it for large or streaming inputs where you want progressive UI updates (code preview, incremental form population, live charts). The partial JSON is always valid for safe progressive rendering.

How do I integrate host themes and fonts?

Use SDK helpers or React hooks (useHostStyles) to inject CSS variables and host fonts, then reference them with var(--*) in your CSS for consistent theming.