Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
368 lines
13 KiB
Markdown
368 lines
13 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, 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 } })
|
|
```
|