All posts

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

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkUI Resourcesresource_link
MCP App UI resources are the HTML documents hosts fetch and render after a tool points at a ui:// URI.

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:

  1. The host discovers a tool with tools/list.
  2. The tool descriptor includes _meta.ui.resourceUri.
  3. The model or user flow calls that tool.
  4. The host reads the matching resource with resources/read.
  5. The host renders the returned HTML with the MCP App bridge.
  6. 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:

  • content for model-visible text and fallback clients.
  • structuredContent for the object your UI renders.
  • _meta for 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 of https:// or file://.
  • 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.

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:

CheckWhy it matters
Tool has _meta.ui.resourceUriHost knows which view belongs to the tool
URI starts with ui://Host treats it as an app UI resource
Resource can be readHost can fetch the iframe HTML
Resource returns text/html;profile=mcp-appHost can render it as an MCP App view
HTML contains the app mount point and scriptsReact, Vue, or your UI runtime can boot
Resource _meta.ui.csp allows required originsFetches, images, fonts, and frames are not blocked
Tool returns useful contentText-only clients and the model still get a summary
Tool returns valid structuredContentThe 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.

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:

  1. Check tools/list. Does the tool include _meta.ui.resourceUri?
  2. Copy the URI exactly. Does resources/read return content for that URI?
  3. Check the MIME type. Is it text/html;profile=mcp-app?
  4. Inspect the HTML. Does it contain the app mount point and built scripts?
  5. Check resource metadata. Are required CSP domains present?
  6. Render in a local inspector. Are there console errors before connect()?
  7. Check tool output. Does structuredContent match 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:

LayerQuestionDeep dive
Tool metadataWhich tool launches which UI, and who can call it?MCP App tool metadata
UI resourceWhat HTML document does the host fetch and render?This post
Resource metadataWhat sandbox, CSP, permissions, and presentation hints apply?MCP App resource metadata
Tool resultWhat data does the model and UI receive?MCP App tool results
Conformance testsDoes 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

Documentation →
npx sunpeak new

Further Reading

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.