MCP App UI Resources: ui:// URIs, MIME Types, and Resource Links

MCP App UI resources are the HTML documents hosts fetch and render after a tool points at a ui:// URI.
Most MCP App rendering bugs are resource bugs.
The tool runs. The result has data. The model can describe the result. Then the UI never appears, or the iframe opens blank. In that moment, the bug usually lives in the resource contract, not the React component.
MCP Apps render because a tool points at a UI resource, the host reads that resource, and the host loads the returned HTML as an app view. If any part of that chain is off by one URI, one MIME type, or one missing script, the app does not mount.
TL;DR: A UI resource is the HTML document an MCP App host renders in an iframe. Use a stable ui:// URI, return text/html;profile=mcp-app, keep app data in structuredContent, and test tools/list plus resources/read before debugging the frontend. _meta.ui.resourceUri is the normal tool-to-view link. resource_link is useful when a tool result needs to point at a resource explicitly.
The Render Path
An MCP App is still built from normal MCP parts:
- The host discovers a tool with
tools/list. - The tool descriptor includes
_meta.ui.resourceUri. - The model or user flow calls that tool.
- The host reads the matching resource with
resources/read. - The host renders the returned HTML with the MCP App bridge.
- The UI receives tool input, tool output, host context, and lifecycle events.
That path is why UI resources deserve their own tests. If the resource cannot be read, or if it has the wrong MIME type, the app can fail before your component code runs.
The MCP resources docs describe the base protocol: resources have URIs, MIME types, and readable contents. MCP Apps use that same resource surface for interactive HTML.
What Belongs in a UI Resource
A UI resource should contain the app shell:
- An HTML document.
- The JavaScript bundle that mounts the app.
- CSS or links to allowed CSS assets.
- Resource metadata such as CSP, permissions, domain, and border hints.
The tool result should contain the run-specific data:
contentfor model-visible text and fallback clients.structuredContentfor the object your UI renders._metafor host-only or component-only metadata.
Keep those jobs separate. The resource is the reusable view. The tool result is the data for one invocation.
const resourceUri = 'ui://orders/view.html';
registerAppResource(
server,
'Orders View',
resourceUri,
{
description: 'Interactive order search results',
},
async () => ({
contents: [
{
uri: resourceUri,
mimeType: 'text/html;profile=mcp-app',
text: ordersHtml,
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
resourceDomains: ['https://cdn.example.com'],
},
prefersBorder: true,
},
},
},
],
}),
);
If the same ordersHtml can render search results, empty states, permission errors, and pagination states, your UI resource is doing the right job.
URI Rules That Prevent Blank Iframes
The URI can be simple, but it has to be exact.
Use a stable ui:// URI:
const resourceUri = 'ui://orders/view.html';
Then use that same string everywhere the app references the resource:
registerAppTool(
server,
'search-orders',
{
title: 'Search Orders',
description: 'Search orders and show the matching results.',
inputSchema: { query: z.string() },
_meta: {
ui: {
resourceUri,
visibility: ['model', 'app'],
},
},
},
async ({ query }) => {
const result = await searchOrders(query);
return {
content: [{ type: 'text', text: `Found ${result.orders.length} orders.` }],
structuredContent: result,
};
},
);
Good URI habits:
- Use
ui://for UI resources instead ofhttps://orfile://. - Include a view-like suffix such as
/view.html. - Treat URI changes as breaking changes for tool metadata and tests.
- Avoid random build hashes in the
ui://URI. Put cache-busted files inside the HTML instead. - Keep one canonical URI constant in code so the tool and resource cannot drift.
The host does not infer a resource from the tool name. It follows the URI.
MIME Type Matters
For MCP Apps, the resource content should use:
text/html;profile=mcp-app
That tells a compatible host to treat the resource as an MCP App view. Plain text/html may still look like HTML, but it does not carry the same app intent. A host may refuse to render it as an interactive view, skip MCP App bridge setup, or treat it as a normal resource.
In SDK code, prefer a shared constant when your library provides one:
import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
mimeType: RESOURCE_MIME_TYPE,
In tests, assert the literal value. A wrong MIME type is easier to fix in a protocol test than after a blank iframe appears in ChatGPT or Claude.
_meta.ui.resourceUri vs resource_link
Developers often search for resource_link when they are really debugging _meta.ui.resourceUri, so it is worth separating the two.
_meta.ui.resourceUri lives on the tool descriptor. It is the stable link from a UI-launching tool to the HTML resource the host should render.
_meta: {
ui: {
resourceUri: 'ui://orders/view.html',
},
}
resource_link lives in tool result content. It points at a resource from the result itself.
return {
content: [
{ type: 'text', text: 'Opened the order dashboard.' },
{
type: 'resource_link',
uri: 'ui://orders/view.html',
name: 'Order Dashboard',
mimeType: 'text/html;profile=mcp-app',
},
],
structuredContent: result,
};
For most UI-launching MCP App tools, _meta.ui.resourceUri should be the source of truth because hosts can see it during tool discovery. Use resource_link when the result should explicitly reference a resource, such as a generated report, a downloadable file, or a specific view selected at runtime.
If you use both, test that they match when they are meant to represent the same view.
How sunpeak Maps Resources
In a sunpeak framework project, you usually do not register the raw resource by hand. A resource component lives in src/resources/<name>/, and the tool points at it with resource.
// src/tools/search-orders.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
resource: 'orders',
title: 'Search Orders',
description: 'Search orders and show the matching results.',
annotations: { readOnlyHint: true },
_meta: {
ui: {
visibility: ['model', 'app'],
},
},
};
export const schema = {
query: z.string().describe('Search text for order number, customer, or status'),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function (args: Args, _extra: ToolHandlerExtra) {
const result = await searchOrders(args.query);
return {
content: [{ type: 'text' as const, text: `Found ${result.orders.length} orders.` }],
structuredContent: result,
};
}
// src/resources/orders/orders.tsx
import { useToolData } from 'sunpeak';
export const resource = {
description: 'Interactive order search results',
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
},
prefersBorder: true,
},
},
};
export function OrdersResource() {
const { output } = useToolData<unknown, { orders: Array<{ id: string; total: number }> }>();
return (
<main>
<h1>Orders</h1>
<ul>
{output?.orders.map((order) => (
<li key={order.id}>
{order.id}: ${order.total}
</li>
))}
</ul>
</main>
);
}
The framework handles the generated URI and resource registration, while your app code still maps to the standard MCP App contract. That is also why the same conceptual checks apply in tests: the built server should expose a UI-capable tool, a readable ui:// resource, and the app MIME type.
A Resource Checklist
Before rendering the app in a browser, verify this contract:
| Check | Why it matters |
|---|---|
Tool has _meta.ui.resourceUri | Host knows which view belongs to the tool |
URI starts with ui:// | Host treats it as an app UI resource |
| Resource can be read | Host can fetch the iframe HTML |
Resource returns text/html;profile=mcp-app | Host can render it as an MCP App view |
| HTML contains the app mount point and scripts | React, Vue, or your UI runtime can boot |
Resource _meta.ui.csp allows required origins | Fetches, images, fonts, and frames are not blocked |
Tool returns useful content | Text-only clients and the model still get a summary |
Tool returns valid structuredContent | The UI has the data shape it expects |
This checklist catches wiring bugs before E2E tests. E2E tests are still needed, but they should not be the first time you learn that ui://orders/view.html was renamed to ui://order/view.html.
Protocol Test Example
Add a test that proves every UI tool points at a readable MCP App resource.
import { test, expect } from 'sunpeak/test';
test('UI tools point at readable MCP App resources', async ({ mcp }) => {
const tools = await mcp.listTools();
const uiTools = tools.filter((tool) => tool._meta?.ui?.resourceUri);
expect(uiTools.length).toBeGreaterThan(0);
for (const tool of uiTools) {
const resourceUri = tool._meta?.ui?.resourceUri;
expect(resourceUri, `${tool.name} is missing a UI resource URI`).toMatch(/^ui:\/\//);
const resource = await mcp.readResource(resourceUri as string);
expect(resource.contents.length).toBeGreaterThan(0);
const html = resource.contents.find((item) => item.uri === resourceUri);
expect(html?.mimeType).toBe('text/html;profile=mcp-app');
expect(html?.text).toContain('<!doctype html');
expect(html?.text).toContain('id="root"');
}
});
Tune the HTML assertions to your build. Some apps use <div id="app">, others use framework-specific entry markup. The point is to catch an empty resource, wrong bundle, or wrong MIME type while the failure is still clear.
Test resource_link When You Use It
If a tool result returns resource_link, test the result too.
test('search result includes a valid resource link when requested', async ({ mcp }) => {
const result = await mcp.callTool('search-orders', { query: 'open' });
const link = result.content.find((item) => item.type === 'resource_link');
expect(link?.uri).toBe('ui://orders/view.html');
expect(link?.mimeType).toBe('text/html;profile=mcp-app');
const resource = await mcp.readResource(link?.uri as string);
expect(resource.contents[0]?.mimeType).toBe('text/html;profile=mcp-app');
});
This is useful when your app can return different resources based on user input. For a fixed UI-launching tool, testing _meta.ui.resourceUri is usually enough.
Debugging Blank UI Resources
Use this order when the tool works but the app view does not render:
- Check
tools/list. Does the tool include_meta.ui.resourceUri? - Copy the URI exactly. Does
resources/readreturn content for that URI? - Check the MIME type. Is it
text/html;profile=mcp-app? - Inspect the HTML. Does it contain the app mount point and built scripts?
- Check resource metadata. Are required CSP domains present?
- Render in a local inspector. Are there console errors before
connect()? - Check tool output. Does
structuredContentmatch what the component expects?
Do this before changing component state code. Many UI resource bugs look like frontend bugs because the visible symptom is a blank iframe.
Where This Fits With Metadata Posts
Think of the MCP App server contract in layers:
| Layer | Question | Deep dive |
|---|---|---|
| Tool metadata | Which tool launches which UI, and who can call it? | MCP App tool metadata |
| UI resource | What HTML document does the host fetch and render? | This post |
| Resource metadata | What sandbox, CSP, permissions, and presentation hints apply? | MCP App resource metadata |
| Tool result | What data does the model and UI receive? | MCP App tool results |
| Conformance tests | Does the whole protocol surface work before E2E? | MCP App conformance testing |
If you are building a ChatGPT App, a Claude Connector, or a portable MCP App, this layer is worth testing early because every host has to cross it before your UI can do anything useful.
Build and Test the Resource First
A reliable MCP App starts with a boring resource contract:
- One stable
ui://URI. - One readable HTML resource.
- The app MIME type.
- Resource metadata for CSP and presentation.
- Tool metadata that points at the resource.
- Tool output that gives the UI valid data.
When that contract is under test, the frontend loop gets much easier. You can debug React, state, styling, and host behavior with confidence that the host can actually find and load the app.
sunpeak’s inspector and test fixtures are built around that flow. You can run npx sunpeak inspect --server URL against an existing MCP server, or scaffold a full app with npx sunpeak new, then test the resource path locally before spending paid host sessions on live checks.
Get Started
npx sunpeak newFurther Reading
- MCP App tool metadata - resourceUri, visibility, and app-only tools
- MCP App resource metadata - CSP, permissions, and ChatGPT widget fields
- MCP App conformance testing - verify UI resources in CI
- MCP App tool results - content, structuredContent, and _meta
- How to build an MCP App
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak docs - MCP resources
- sunpeak docs - registerAppResource
- sunpeak docs - Tool and Resource Contract
- MCP Apps overview
- OpenAI Apps SDK reference
Frequently Asked Questions
What is a UI resource in an MCP App?
A UI resource is an MCP resource that contains the HTML document a host renders for an MCP App. It usually uses a ui:// URI and the text/html;profile=mcp-app MIME type so ChatGPT, Claude, and other MCP App hosts can treat it as an interactive iframe instead of a generic file.
What does ui:// mean in an MCP App resource URI?
ui:// is the URI scheme MCP Apps use for app UI resources. The exact URI is developer-defined, such as ui://orders/view.html, but it must match every place that references it, including the tool _meta.ui.resourceUri field and any resource_link content item.
What MIME type should an MCP App UI resource use?
Use text/html;profile=mcp-app for interactive MCP App HTML resources. That MIME type tells compatible hosts that the resource should run as an MCP App view with the app bridge, resource metadata, CSP handling, and host lifecycle events.
What is the difference between _meta.ui.resourceUri and resource_link?
_meta.ui.resourceUri is tool metadata that tells the host which UI resource belongs to a UI-launching tool. resource_link is a content item in a tool result that can point at a resource in the returned response. For most app-launching tools, keep _meta.ui.resourceUri as the stable routing field, then use resource_link when the result itself needs to reference a specific resource.
Does the UI resource contain app data?
Usually no. The UI resource should contain the HTML, JavaScript, CSS, and resource metadata needed to boot the app. The data for a specific tool call should come from structuredContent, content, or app-only tool calls so the same resource can render many results.
How do I test MCP App UI resources?
Write protocol tests that call tools/list, collect every _meta.ui.resourceUri, call resources/read for each ui:// URI, assert the returned MIME type is text/html;profile=mcp-app, and verify the HTML contains the app mount point. Then render the same state in an inspector E2E test to catch bridge and iframe issues.
Why does my MCP App tool return data but not render UI?
Common causes are a missing _meta.ui.resourceUri, a ui:// typo, a resource that is not registered, resources/read returning the wrong MIME type, HTML that does not include the built bundle, or CSP blocking the script before the app connects to the host.