Legacy MCP-UI Adapter
For New Apps
Building a new app? Use the MCP Apps patterns directly - see Getting Started for the recommended approach with registerAppTool, _meta.ui.resourceUri, and AppRenderer.
This page is for migrating existing MCP-UI widgets to work in MCP Apps hosts.
The MCP Apps adapter in @mcp-ui/server enables existing MCP-UI HTML widgets to run inside MCP Apps-compliant hosts. This is a backward-compatibility layer for apps that were built using the legacy MCP-UI postMessage protocol.
When to Use This Adapter
- Existing MCP-UI widgets: You have HTML widgets using the
ui-lifecycle-*message format - Gradual migration: You want your existing widgets to work in both legacy MCP-UI hosts and new MCP Apps hosts
- Protocol translation: Your widget uses
postMessagecalls that need to be translated to JSON-RPC
Overview
The adapter automatically translates between the MCP-UI postMessage protocol and MCP Apps JSON-RPC, allowing your existing widgets to work in MCP Apps hosts without code changes.
How It Works
┌─────────────────────────────────────────────────────────────────┐
│ MCP Apps Host │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Sandbox Iframe │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Tool UI Iframe │ │ │
│ │ │ ┌───────────────┐ ┌──────────────────────────┐ │ │ │
│ │ │ │ MCP-UI │───▶│ MCP Apps Adapter │ │ │ │
│ │ │ │ Widget │◀───│ (injected script) │ │ │ │
│ │ │ └───────────────┘ └──────────────────────────┘ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ MCP-UI Protocol │ JSON-RPC │ │ │
│ │ │ ▼ ▼ │ │ │
│ │ │ postMessage postMessage │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ MCP Apps SEP Protocol │
└─────────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
The adapter:
- Intercepts MCP-UI messages from your widget
- Translates them to MCP Apps SEP JSON-RPC format
- Sends them to the host via postMessage
- Receives host responses and translates them back to MCP-UI format
Quick Start
1. Create a UI Resource with the MCP Apps Adapter
import { createUIResource } from '@mcp-ui/server';
const widgetUI = await createUIResource({
uri: 'ui://my-server/widget',
encoding: 'text',
content: {
type: 'rawHtml',
htmlString: `
<html>
<body>
<div id="app">Loading...</div>
<script>
// Listen for render data from the adapter
window.addEventListener('message', (event) => {
if (event.data.type === 'ui-lifecycle-iframe-render-data') {
const { toolInput, toolOutput } = event.data.payload.renderData;
document.getElementById('app').textContent =
JSON.stringify({ toolInput, toolOutput }, null, 2);
}
});
// Signal that the widget is ready
window.parent.postMessage({ type: 'ui-lifecycle-iframe-ready' }, '*');
</script>
</body>
</html>
`,
},
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
2. Register the Resource and Tool
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { createUIResource } from '@mcp-ui/server';
import { registerAppTool, registerAppResource } from '@modelcontextprotocol/ext-apps/server';
import { z } from 'zod';
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
// Create the UI resource (from step 1)
const widgetUI = await createUIResource({
uri: 'ui://my-server/widget',
// ... (same as above)
});
// Register the resource so the host can fetch it
registerAppResource(
server,
'widget_ui', // Resource name
widgetUI.resource.uri, // Resource URI
{}, // Resource metadata
async () => ({
contents: [widgetUI.resource]
})
);
// Register the tool with _meta linking to the UI resource
registerAppTool(
server,
'my_widget',
{
description: 'An interactive widget',
inputSchema: {
query: z.string().describe('User query'),
},
// This tells MCP Apps hosts where to find the UI
_meta: {
ui: {
resourceUri: widgetUI.resource.uri
}
}
},
async ({ query }) => {
return {
content: [{ type: 'text', text: `Processing: ${query}` }],
};
}
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
The key requirement for MCP Apps hosts is that the tool's _meta.ui.resourceUri points to the UI resource URI. This tells the host where to fetch the widget HTML.
3. Add the MCP-UI Embedded Resource to Tool Responses
To support MCP-UI hosts (which expect embedded resources in tool responses), also return a createUIResource result:
registerAppTool(
server,
'my_widget',
{
description: 'An interactive widget',
inputSchema: {
query: z.string().describe('User query'),
},
// For MCP Apps hosts - points to the registered resource
_meta: {
ui: {
resourceUri: widgetUI.resource.uri
}
}
},
async ({ query }) => {
// Create an embedded UI resource for MCP-UI hosts
const embeddedResource = await createUIResource({
uri: `ui://my-server/widget/${query}`,
encoding: 'text',
content: {
type: 'rawHtml',
htmlString: renderWidget(query), // Your widget HTML
},
});
return {
content: [
{ type: 'text', text: `Processing: ${query}` },
embeddedResource // Include for MCP-UI hosts
],
};
}
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Important: The embedded MCP-UI resource should not enable the MCP Apps adapter. It is for hosts that expect embedded resources in tool responses. MCP Apps hosts will ignore the embedded resource and instead fetch the UI from the registered resource URI in
_meta.
Protocol Translation Reference
Widget → Host (Outgoing)
| MCP-UI Action | MCP Apps Method | Description |
|---|---|---|
tool | tools/call | Call another tool |
prompt | ui/message | Send a follow-up message to the conversation |
link | ui/open-link | Open a URL in a new tab |
notify | notifications/message | Log a message to the host |
intent | ui/message | Send an intent (translated to message) |
ui-size-change | ui/notifications/size-changed | Request widget resize |
Host → Widget (Incoming)
| MCP Apps Notification | MCP-UI Message | Description |
|---|---|---|
ui/notifications/tool-input | ui-lifecycle-iframe-render-data | Complete tool arguments |
ui/notifications/tool-input-partial | ui-lifecycle-iframe-render-data | Streaming partial arguments |
ui/notifications/tool-result | ui-lifecycle-iframe-render-data | Tool execution result |
ui/notifications/host-context-changed | ui-lifecycle-iframe-render-data | Theme, locale, viewport changes |
ui/notifications/size-changed | ui-lifecycle-iframe-render-data | Host informs of size constraints |
ui/notifications/tool-cancelled | ui-lifecycle-tool-cancelled | Tool execution was cancelled |
ui/resource-teardown | ui-lifecycle-teardown | Host notifies UI before teardown |
Configuration Options
createUIResource({
// ...
adapters: {
mcpApps: {
enabled: true,
config: {
// Timeout for async operations (default: 30000ms)
timeout: 60000,
},
},
},
});2
3
4
5
6
7
8
9
10
11
12
MIME Type
When the MCP Apps adapter is enabled, the resource MIME type is automatically set to text/html;profile=mcp-app, the MCP Apps equivalent to text/html.
Receiving Data in Your Widget
The adapter sends data to your widget via the standard MCP-UI ui-lifecycle-iframe-render-data message:
window.addEventListener('message', (event) => {
if (event.data.type === 'ui-lifecycle-iframe-render-data') {
const { renderData } = event.data.payload;
// Tool input arguments
const toolInput = renderData.toolInput;
// Tool execution result (if available)
const toolOutput = renderData.toolOutput;
// Widget state (if supported by host)
const widgetState = renderData.widgetState;
// Host context
const theme = renderData.theme; // 'light' | 'dark' | 'system'
const locale = renderData.locale; // e.g., 'en-US'
const displayMode = renderData.displayMode; // 'inline' | 'fullscreen' | 'pip'
const maxHeight = renderData.maxHeight;
// Update your UI with the data
updateWidget(renderData);
}
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Sending Actions from Your Widget
Use standard MCP-UI postMessage calls - the adapter translates them automatically:
// Send a prompt to the conversation
window.parent.postMessage({
type: 'prompt',
payload: { prompt: 'What is the weather like today?' }
}, '*');
// Open a link
window.parent.postMessage({
type: 'link',
payload: { url: 'https://example.com' }
}, '*');
// Call another tool
window.parent.postMessage({
type: 'tool',
payload: {
toolName: 'get_weather',
params: { city: 'San Francisco' }
}
}, '*');
// Send a notification
window.parent.postMessage({
type: 'notify',
payload: { message: 'Widget loaded successfully' }
}, '*');
// Request resize
window.parent.postMessage({
type: 'ui-size-change',
payload: { width: 500, height: 400 }
}, '*');2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Mutual Exclusivity with Apps SDK Adapter
Only one adapter can be enabled at a time. The TypeScript types enforce this:
// ✅ Valid: MCP Apps adapter only
adapters: { mcpApps: { enabled: true } }
// ✅ Valid: Apps SDK adapter only (for ChatGPT)
adapters: { appsSdk: { enabled: true } }
// ❌ TypeScript error: Cannot enable both
adapters: { mcpApps: { enabled: true }, appsSdk: { enabled: true } }2
3
4
5
6
7
8
If you need to support both MCP Apps hosts and ChatGPT, create separate resources:
// For MCP Apps hosts
const mcpAppsResource = await createUIResource({
uri: 'ui://my-server/widget-mcp-apps',
content: { type: 'rawHtml', htmlString: widgetHtml },
});
// For ChatGPT/Apps SDK hosts
const appsSdkResource = await createUIResource({
uri: 'ui://my-server/widget-apps-sdk',
content: { type: 'rawHtml', htmlString: widgetHtml },
});2
3
4
5
6
7
8
9
10
11
Complete Example
See the mcp-apps-demo example for a complete working implementation.
import express from 'express';
import cors from 'cors';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createUIResource } from '@mcp-ui/server';
import { registerAppTool, registerAppResource } from '@modelcontextprotocol/ext-apps/server';
import { z } from 'zod';
const app = express();
app.use(cors({ origin: '*', exposedHeaders: ['Mcp-Session-Id'] }));
app.use(express.json());
// ... (transport setup)
const server = new McpServer({ name: 'demo', version: '1.0.0' });
const graphUI = await createUIResource({
uri: 'ui://demo/graph',
encoding: 'text',
content: {
type: 'rawHtml',
htmlString: `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: system-ui; padding: 20px; }
.data { background: #f5f5f5; padding: 10px; border-radius: 8px; }
</style>
</head>
<body>
<h1>graph</h1>
<div class="data" id="data">Waiting for data...</div>
<button onclick="sendPrompt()">Ask Follow-up</button>
<script>
window.addEventListener('message', (e) => {
if (e.data.type === 'ui-lifecycle-iframe-render-data') {
document.getElementById('data').textContent =
JSON.stringify(e.data.payload.renderData, null, 2);
}
});
function sendPrompt() {
window.parent.postMessage({
type: 'prompt',
payload: { prompt: 'Tell me more about this data' }
}, '*');
}
window.parent.postMessage({ type: 'ui-lifecycle-iframe-ready' }, '*');
</script>
</body>
</html>
`,
},
});
// Register the UI resource
registerAppResource(
server,
'graph_ui',
graphUI.resource.uri,
{},
async () => ({
contents: [graphUI.resource]
})
);
// Register the tool with _meta linking to the UI resource
registerAppTool(
server,
'show_graph',
{
description: 'Display an interactive graph',
inputSchema: {
title: z.string().describe('Graph title'),
},
// For MCP Apps hosts - points to the registered resource
_meta: {
ui: {
resourceUri: graphUI.resource.uri
}
}
},
async ({ title }) => {
// Create embedded resource for MCP-UI hosts
const embeddedResource = await createUIResource({
uri: `ui://demo/graph/${encodeURIComponent(title)}`,
encoding: 'text',
content: {
type: 'rawHtml',
htmlString: `<html><body><h1>Graph: ${title}</h1></body></html>`,
},
});
return {
content: [
{ type: 'text', text: `Graph: ${title}` },
embeddedResource // Included for MCP-UI hosts
],
};
}
);
// ... (server setup)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
Debugging
The adapter logs debug information to the browser console. Look for messages prefixed with [MCP Apps Adapter]:
[MCP Apps Adapter] Initializing adapter...
[MCP Apps Adapter] Sending ui/initialize request
[MCP Apps Adapter] Received JSON-RPC message: {...}
[MCP Apps Adapter] Intercepted MCP-UI message: prompt2
3
4
Host-Side Rendering (Client SDK)
The @mcp-ui/client package provides React components for rendering MCP Apps tool UIs in your host application.
AppRenderer Component
AppRenderer is the high-level component that handles the complete lifecycle of rendering an MCP tool's UI:
import { AppRenderer, type AppRendererHandle } from '@mcp-ui/client';
function ToolUI({ client, toolName, toolInput, toolResult }) {
const appRef = useRef<AppRendererHandle>(null);
return (
<AppRenderer
ref={appRef}
client={client}
toolName={toolName}
sandbox={{ url: new URL('http://localhost:8765/sandbox_proxy.html') }}
toolInput={toolInput}
toolResult={toolResult}
hostContext={{ theme: 'dark' }}
onOpenLink={async ({ url }) => {
if (url.startsWith('https://') || url.startsWith('http://')) {
window.open(url);
}
}}
onMessage={async (params) => {
console.log('Message from tool UI:', params);
return { isError: false };
}}
onError={(error) => console.error('Tool UI error:', error)}
/>
);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Key Props:
client- Optional MCP client for automatic resource fetching and MCP request forwardingtoolName- Name of the tool to render UI forsandbox- Sandbox configuration with the sandbox proxy URLhtml- Optional pre-fetched HTML (skips resource fetching)toolResourceUri- Optional pre-fetched resource URItoolInput/toolResult- Tool arguments and results to pass to the UIhostContext- Theme, locale, viewport info for the guest UIonOpenLink/onMessage/onLoggingMessage- Handlers for guest UI requestsonFallbackRequest- Catch-all for JSON-RPC requests not handled by the built-in handlers (see Handling Custom Requests)
Ref Methods:
sendToolListChanged()- Notify guest when tools changesendResourceListChanged()- Notify guest when resources changesendPromptListChanged()- Notify guest when prompts changeteardownResource()- Clean up before unmounting
Handling Custom Requests (onFallbackRequest)
AppRenderer includes built-in handlers for standard MCP Apps methods (tools/call, ui/message, ui/open-link, etc.). The onFallbackRequest prop lets you handle any JSON-RPC request that doesn't match a built-in handler. This is useful for:
- Experimental methods — prototype new capabilities (e.g.,
x/clipboard/write,x/analytics/track) - MCP methods not yet in the Apps spec — support standard MCP methods like
sampling/createMessagebefore they're officially added to MCP Apps
Under the hood, this is wired to AppBridge's fallbackRequestHandler from the MCP SDK Protocol class. The guest UI sends a standard JSON-RPC request via postMessage, and if AppBridge has no registered handler for the method, it delegates to onFallbackRequest.
Host-side handler:
import { AppRenderer, type JSONRPCRequest } from '@mcp-ui/client';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
<AppRenderer
client={client}
toolName="my-tool"
sandbox={sandboxConfig}
onFallbackRequest={async (request, extra) => {
switch (request.method) {
case 'x/clipboard/write':
await navigator.clipboard.writeText(request.params?.text as string);
return { success: true };
case 'sampling/createMessage':
// Forward to MCP server
return client.createMessage(request.params);
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown method: ${request.method}`);
}
}}
/>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Guest-side (inside tool UI HTML):
import { sendExperimentalRequest } from '@mcp-ui/server';
// Send a custom request to the host — returns a Promise with the response
const result = await sendExperimentalRequest('x/clipboard/write', { text: 'hello' });2
3
4
The sendExperimentalRequest helper sends a properly formatted JSON-RPC request via window.parent.postMessage. The full request/response cycle flows through PostMessageTransport and the sandbox proxy, just like built-in methods.
Method Naming Convention
Use the x/<namespace>/<action> prefix for experimental methods (e.g., x/clipboard/write). Standard MCP methods not yet in the Apps spec (e.g., sampling/createMessage) should use their canonical method names. When an experimental method proves useful, it can be promoted to a standard method in the ext-apps spec.
Using Without an MCP Client
You can use AppRenderer without a full MCP client by providing custom handlers:
<AppRenderer
// No client - use callbacks instead
toolName="my-tool"
toolResourceUri="ui://my-server/my-tool"
sandbox={{ url: sandboxUrl }}
onReadResource={async ({ uri }) => {
// Proxy to your MCP client in a different context
return myMcpProxy.readResource({ uri });
}}
onCallTool={async (params) => {
return myMcpProxy.callTool(params);
}}
/>2
3
4
5
6
7
8
9
10
11
12
13
Or provide pre-fetched HTML directly:
<AppRenderer
toolName="my-tool"
sandbox={{ url: sandboxUrl }}
html={preloadedHtml} // Skip all resource fetching
toolInput={args}
/>2
3
4
5
6
AppFrame Component
AppFrame is the lower-level component for when you already have the HTML content and an AppBridge instance:
import { AppFrame, AppBridge } from '@mcp-ui/client';
function LowLevelToolUI({ html, client }) {
const bridge = useMemo(() => new AppBridge(client, hostInfo, capabilities), [client]);
return (
<AppFrame
html={html}
sandbox={{ url: sandboxUrl }}
appBridge={bridge}
toolInput={{ query: 'test' }}
onSizeChanged={(size) => console.log('Size changed:', size)}
/>
);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
Sandbox Proxy
Both components require a sandbox proxy HTML file to be served. This provides security isolation for the guest UI. The sandbox proxy URL should point to a page that loads the MCP Apps sandbox proxy script.
Declaring UI Extension Support
When creating your MCP client, declare UI extension support using the provided type and capabilities:
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import {
type ClientCapabilitiesWithExtensions,
UI_EXTENSION_CAPABILITIES,
} from '@mcp-ui/client';
const capabilities: ClientCapabilitiesWithExtensions = {
// Standard capabilities
roots: { listChanged: true },
// UI extension support (SEP-1724 pattern)
extensions: UI_EXTENSION_CAPABILITIES,
};
const client = new Client(
{ name: 'my-app', version: '1.0.0' },
{ capabilities }
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
This tells MCP servers that your client can render UI resources with MIME type text/html;profile=mcp-app.
Note: This uses the
extensionsfield pattern from SEP-1724, which is not yet part of the official MCP protocol.
Related Resources
- Getting Started - Recommended patterns for new MCP Apps
- MCP Apps SEP Specification
- @modelcontextprotocol/ext-apps
- Apps SDK Integration - For ChatGPT integration (separate from MCP Apps)
- Protocol Details - MCP-UI wire format reference

