← All Skills

MCP UI Apps

v1.0.0 MCP
11 files, 154.1 KB ~5,685 words · 23 min read Updated 2026-03-26

Interactive widget UIs for ChatGPT and Claude. Skybridge rendering, structuredContent, human-in-the-loop patterns.

$ npx snappy-skills install mcp-ui-apps
zip ↓
Documents
SKILL.md
7.4 KB

MCP UI Apps — Interactive Widgets for ChatGPT and Claude#

Build MCP servers that render interactive widget UIs inline in ChatGPT and Claude. Both platforms support rich, interactive content through MCP — the patterns are nearly identical.

When to Use#

  • Building an MCP server with visual, interactive output
  • Creating widgets that render inline in ChatGPT or Claude
  • Working with the text/html+skybridge pattern
  • Setting up OAuth2 on Cloudflare Workers
  • Implementing human-in-the-loop: widgets that trigger actions
  • Hosting widget JS/CSS assets on a CDN

How It Works#

Both ChatGPT and Claude support MCP servers that return rich UI through a three-layer pattern:

1. TOOL returns data
   ↓ structuredContent + _meta["openai/outputTemplate"]
2. RESOURCE serves HTML shell
   ↓ text/html+skybridge mimeType
3. WIDGET JS reads data, renders UI
   ↓ window.openai.toolOutput (ChatGPT) / equivalent API (Claude)
4. Client renders the widget inline

The key insight: widgets are not passive displays. They can call other tools, send messages on behalf of the user, and persist their own state.


Quick Start#

1. Tool returns data + points to widget resource#

typescriptexport function getWidgetMeta() {
  return {
    'openai/outputTemplate': 'ui://mymcp/list',
    'openai/toolInvocation/invoking': 'Loading...',
    'openai/toolInvocation/invoked': 'Ready',
    'openai/widgetAccessible': true,
  };
}

export async function handler(params) {
  const data = await fetchData(params);
  return {
    content: [{ type: 'text', text: 'Summary for non-widget contexts' }],
    structuredContent: { items: data, title: 'My Data' },
    _meta: getWidgetMeta(),
  };
}

2. Resource serves minimal HTML template#

typescriptserver.resource('ui-list', 'ui://mymcp/list', {
  title: 'List Widget',
  mimeType: 'text/html+skybridge',
}, async (uri) => ({
  contents: [{
    uri: uri.href,
    mimeType: 'text/html+skybridge',
    text: `<div id="root"></div>
           <link rel="stylesheet" href="https://mymcp.workers.dev/widgets/list.css">
           <script src="https://mymcp.workers.dev/widgets/list.js"></script>`,
  }],
}));

3. Widget JS reads data and renders#

javascript(function() {
  var data = window.openai ? window.openai.toolOutput : null;
  if (!data) return;

  var items = data.structuredContent?.items || [];
  document.getElementById('root').innerHTML = items.map(renderItem).join('');
})();

4. Widget calls tools (human-in-the-loop)#

javascript// Invoke another MCP tool from the widget
button.addEventListener('click', function() {
  window.openai.callTool('my_tool', { id: 123 });
});

// Send a message as if the user typed it
window.openai.sendFollowUpMessage({ prompt: 'Merge these entities?' });

// Persist UI state across re-renders
window.openai.setWidgetState({ selectedTab: 'entities' });

Widget API Reference#

API Purpose
toolOutput Read data from the MCP tool that spawned this widget
callTool(name, args) Invoke another MCP tool
sendFollowUpMessage({ prompt }) Post a message as the user
setWidgetState(state) Persist UI state
widgetState Read persisted state
theme 'light' or 'dark' — current client theme
maxHeight Max widget height in pixels
displayMode 'inline' / 'pip' / 'fullscreen'
requestDisplayMode({ mode }) Request fullscreen or picture-in-picture
openExternal({ href }) Open a link in the browser

Architecture#

  • Runtime: Cloudflare Workers (serverless, global edge)
  • Auth: OAuth2 PKCE via @cloudflare/workers-oauth-provider
  • State: Durable Objects + SQLite
  • Base: McpAgent from agents/mcp
  • Validation: Zod schemas
  • Protocol: MCP over SSE
src/
├── index.ts               ← Worker entry (OAuth + McpAgent)
├── oauth-handler.ts       ← OAuth approval UI
├── resources/
│   └── ui.ts             ← Widget resources (text/html+skybridge)
├── widgets/
│   └── assets.ts         ← Widget JS/CSS as string constants
├── tools/
│   └── meta/widgets/     ← Widget tools (structuredContent + _meta)
└── services/
    └── */adapter.ts      ← Backend service adapters

Critical Concepts#

text/html+skybridge#

The mimeType that tells the client to render HTML as an interactive widget in a sandboxed iframe.

structuredContent#

JSON data returned by the tool that flows to the widget via window.openai.toolOutput. This is how you pass data from your backend to the UI.

_meta["openai/outputTemplate"]#

Points the tool to the resource URI that serves the widget HTML. Both must use the same URI.

CDN-hosted assets#

Widget JS/CSS must be served from a URL accessible to the client's sandbox — typically the same Cloudflare Worker via /widgets/* routes.


Common Mistakes#

Design#

Wrong Right
Building a 1:1 clone of your platform Conversation-first: glanceable summaries, action enablers
More than 2 CTAs per card Max 2: one primary, one secondary
Deep navigation (tabs, sidebars) Single-purpose. Need more? Spawn a new tool call
Passive display only Interactive: callTool, sendFollowUpMessage

Technical#

Wrong Right
Widget shows "No data" then works on refresh Listen for openai:set_globals — data arrives async
Inline <script> tags External scripts: <script src="https://...">
Returning HTML from tool Return structuredContent, let widget JS render
text/html mimeType Must use text/html+skybridge
Resource URI mismatch outputTemplate URI must match registered resource
_meta in registration overrides handler _meta Both must match. See widgets.md

Deployment Checklist#

  • OAuth2 PKCE configured with KV namespace
  • McpAgent class with Durable Object binding
  • Widget tools return structuredContent + _meta
  • Resources registered with text/html+skybridge
  • Widget assets served from /widgets/* with CORS
  • npm run build — no TypeScript errors
  • npm run deploy — deployed to Workers
  • Test in client — widgets render inline
  • Verify openai:set_globals listener — no empty widget flash

Deep Dives#

Topic File
Conversation-first design philosophy conversation-first-design.md
Reactive widgets + empty widget fix reactive-widgets.md
Full architecture deep dive architecture.md
Basic widget implementation widgets.md
Human-in-the-loop patterns interactive-widgets.md
One widget per domain pattern domain-widgets.md
UI design guidelines ui-guidelines.md
CDN asset hosting + cache busting cdn-assets.md
Step-by-step tutorial quickstart.md
Complete code examples examples.md
---
name: mcp-ui-apps
description: Build MCP servers with interactive widget UIs for ChatGPT and Claude. OAuth2 PKCE on Cloudflare Workers, McpAgent with Durable Objects, text/html+skybridge rendering, structuredContent data flow, human-in-the-loop patterns (callTool, sendFollowUpMessage, setWidgetState), conversation-first design, reactive data hydration, and CDN-hosted widget assets.
---

# MCP UI Apps — Interactive Widgets for ChatGPT and Claude

Build MCP servers that render interactive widget UIs inline in ChatGPT and Claude. Both platforms support rich, interactive content through MCP — the patterns are nearly identical.

## When to Use

- Building an MCP server with visual, interactive output
- Creating widgets that render inline in ChatGPT or Claude
- Working with the `text/html+skybridge` pattern
- Setting up OAuth2 on Cloudflare Workers
- Implementing human-in-the-loop: widgets that trigger actions
- Hosting widget JS/CSS assets on a CDN

## How It Works

Both ChatGPT and Claude support MCP servers that return rich UI through a three-layer pattern:

```
1. TOOL returns data
   ↓ structuredContent + _meta["openai/outputTemplate"]
2. RESOURCE serves HTML shell
   ↓ text/html+skybridge mimeType
3. WIDGET JS reads data, renders UI
   ↓ window.openai.toolOutput (ChatGPT) / equivalent API (Claude)
4. Client renders the widget inline
```

The key insight: **widgets are not passive displays**. They can call other tools, send messages on behalf of the user, and persist their own state.

---

## Quick Start

### 1. Tool returns data + points to widget resource

```typescript
export function getWidgetMeta() {
  return {
    'openai/outputTemplate': 'ui://mymcp/list',
    'openai/toolInvocation/invoking': 'Loading...',
    'openai/toolInvocation/invoked': 'Ready',
    'openai/widgetAccessible': true,
  };
}

export async function handler(params) {
  const data = await fetchData(params);
  return {
    content: [{ type: 'text', text: 'Summary for non-widget contexts' }],
    structuredContent: { items: data, title: 'My Data' },
    _meta: getWidgetMeta(),
  };
}
```

### 2. Resource serves minimal HTML template

```typescript
server.resource('ui-list', 'ui://mymcp/list', {
  title: 'List Widget',
  mimeType: 'text/html+skybridge',
}, async (uri) => ({
  contents: [{
    uri: uri.href,
    mimeType: 'text/html+skybridge',
    text: `<div id="root"></div>
           <link rel="stylesheet" href="https://mymcp.workers.dev/widgets/list.css">
           <script src="https://mymcp.workers.dev/widgets/list.js"></script>`,
  }],
}));
```

### 3. Widget JS reads data and renders

```javascript
(function() {
  var data = window.openai ? window.openai.toolOutput : null;
  if (!data) return;

  var items = data.structuredContent?.items || [];
  document.getElementById('root').innerHTML = items.map(renderItem).join('');
})();
```

### 4. Widget calls tools (human-in-the-loop)

```javascript
// Invoke another MCP tool from the widget
button.addEventListener('click', function() {
  window.openai.callTool('my_tool', { id: 123 });
});

// Send a message as if the user typed it
window.openai.sendFollowUpMessage({ prompt: 'Merge these entities?' });

// Persist UI state across re-renders
window.openai.setWidgetState({ selectedTab: 'entities' });
```

---

## Widget API Reference

| API | Purpose |
|-----|---------|
| `toolOutput` | Read data from the MCP tool that spawned this widget |
| `callTool(name, args)` | Invoke another MCP tool |
| `sendFollowUpMessage({ prompt })` | Post a message as the user |
| `setWidgetState(state)` | Persist UI state |
| `widgetState` | Read persisted state |
| `theme` | `'light'` or `'dark'` — current client theme |
| `maxHeight` | Max widget height in pixels |
| `displayMode` | `'inline'` / `'pip'` / `'fullscreen'` |
| `requestDisplayMode({ mode })` | Request fullscreen or picture-in-picture |
| `openExternal({ href })` | Open a link in the browser |

---

## Architecture

- **Runtime**: Cloudflare Workers (serverless, global edge)
- **Auth**: OAuth2 PKCE via @cloudflare/workers-oauth-provider
- **State**: Durable Objects + SQLite
- **Base**: McpAgent from agents/mcp
- **Validation**: Zod schemas
- **Protocol**: MCP over SSE

```
src/
├── index.ts               ← Worker entry (OAuth + McpAgent)
├── oauth-handler.ts       ← OAuth approval UI
├── resources/
│   └── ui.ts             ← Widget resources (text/html+skybridge)
├── widgets/
│   └── assets.ts         ← Widget JS/CSS as string constants
├── tools/
│   └── meta/widgets/     ← Widget tools (structuredContent + _meta)
└── services/
    └── */adapter.ts      ← Backend service adapters
```

---

## Critical Concepts

### `text/html+skybridge`
The mimeType that tells the client to render HTML as an interactive widget in a sandboxed iframe.

### `structuredContent`
JSON data returned by the tool that flows to the widget via `window.openai.toolOutput`. This is how you pass data from your backend to the UI.

### `_meta["openai/outputTemplate"]`
Points the tool to the resource URI that serves the widget HTML. Both must use the same URI.

### CDN-hosted assets
Widget JS/CSS must be served from a URL accessible to the client's sandbox — typically the same Cloudflare Worker via `/widgets/*` routes.

---

## Common Mistakes

### Design

| Wrong | Right |
|-------|-------|
| Building a 1:1 clone of your platform | Conversation-first: glanceable summaries, action enablers |
| More than 2 CTAs per card | Max 2: one primary, one secondary |
| Deep navigation (tabs, sidebars) | Single-purpose. Need more? Spawn a new tool call |
| Passive display only | Interactive: callTool, sendFollowUpMessage |

### Technical

| Wrong | Right |
|-------|-------|
| Widget shows "No data" then works on refresh | Listen for `openai:set_globals` — data arrives async |
| Inline `<script>` tags | External scripts: `<script src="https://...">` |
| Returning HTML from tool | Return `structuredContent`, let widget JS render |
| `text/html` mimeType | Must use `text/html+skybridge` |
| Resource URI mismatch | `outputTemplate` URI must match registered resource |
| `_meta` in registration overrides handler `_meta` | Both must match. See [widgets.md](widgets.md) |

---

## Deployment Checklist

- [ ] OAuth2 PKCE configured with KV namespace
- [ ] McpAgent class with Durable Object binding
- [ ] Widget tools return `structuredContent` + `_meta`
- [ ] Resources registered with `text/html+skybridge`
- [ ] Widget assets served from `/widgets/*` with CORS
- [ ] `npm run build` — no TypeScript errors
- [ ] `npm run deploy` — deployed to Workers
- [ ] Test in client — widgets render inline
- [ ] Verify `openai:set_globals` listener — no empty widget flash

---

## Deep Dives

| Topic | File |
|-------|------|
| Conversation-first design philosophy | [conversation-first-design.md](conversation-first-design.md) |
| Reactive widgets + empty widget fix | [reactive-widgets.md](reactive-widgets.md) |
| Full architecture deep dive | [architecture.md](architecture.md) |
| Basic widget implementation | [widgets.md](widgets.md) |
| Human-in-the-loop patterns | [interactive-widgets.md](interactive-widgets.md) |
| One widget per domain pattern | [domain-widgets.md](domain-widgets.md) |
| UI design guidelines | [ui-guidelines.md](ui-guidelines.md) |
| CDN asset hosting + cache busting | [cdn-assets.md](cdn-assets.md) |
| Step-by-step tutorial | [quickstart.md](quickstart.md) |
| Complete code examples | [examples.md](examples.md) |

Keyboard Shortcuts

Search in document⌘K
Focus search/
Previous file tab
Next file tab
Close overlayEsc
Show shortcuts?