Skip to main content
All posts

MCP App Resource Templates: UI Resources, ui:// URIs, and ChatGPT outputTemplate

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkResource TemplatesoutputTemplate
MCP App resource templates, UI resources, and ChatGPT outputTemplate fields all point at related but different parts of the app contract.

MCP App resource templates, UI resources, and ChatGPT outputTemplate fields all point at related but different parts of the app contract.

The next gap in MCP App search intent is the word “template.”

Developers run into it from several directions:

  • MCP resource template
  • ChatGPT App outputTemplate
  • OpenAI Apps SDK resource template
  • ui:// resource template
  • resources/templates/list
  • why is my ChatGPT App template not rendering

Those searches sound like one topic, but they cover three different contracts. If you mix them up, the tool may run while the iframe stays blank, or a dynamic resource may work in protocol tests while the app UI never opens.

TL;DR: In MCP, a resource template is a URI pattern for dynamic resources. In MCP Apps, a UI resource is a concrete HTML resource, usually with a stable ui:// URI. In ChatGPT Apps, openai/outputTemplate is a compatibility field that points a tool at the UI resource ChatGPT should render. For portable MCP Apps, keep _meta.ui.resourceUri as the source of truth, use resource templates only for truly dynamic resources, and test both resources/templates/list and resources/read.

The Naming Collision

The confusion starts because “resource template” can mean different things depending on which docs or SDK example you are reading.

TermWhere it livesWhat it does
MCP resource templateMCP resources protocolAdvertises a URI pattern such as docs://{slug}
MCP App UI resourceMCP Apps extensionReturns the HTML app that a host renders
ChatGPT outputTemplateChatGPT Apps compatibility metadataPoints a tool at the UI resource ChatGPT should render
Web templateNormal frontend buildThe HTML shell or component bundle for your app

Treat those as separate layers.

A resource template is about discoverability. A UI resource is about rendering. outputTemplate is about ChatGPT compatibility. Your Vite, Astro, Next.js, or React build template is just how you produce the HTML that the MCP resource returns.

What an MCP Resource Template Is

The base MCP resources protocol lets a server expose resources with stable URIs:

{
  "uri": "docs://quickstart",
  "name": "Quickstart",
  "mimeType": "text/markdown"
}

That works when the server can list every useful resource up front. It does not work as well when resources are dynamic, user-specific, or too numerous to list.

That is where resource templates fit. A server can advertise a URI pattern:

{
  "uriTemplate": "docs://{slug}",
  "name": "Documentation page",
  "description": "Read one documentation page by slug",
  "mimeType": "text/markdown"
}

The client learns that docs://{slug} is a readable pattern. Later, it can call resources/read for a concrete URI such as docs://auth-oauth or docs://billing-webhooks.

Use resource templates for resources that are naturally addressable:

  • Documentation pages by slug.
  • Reports by ID.
  • Tickets by key.
  • Generated artifacts by run ID.
  • Records the user selected from a search result.

Do not use a resource template just because your app UI has different states. A search dashboard can render empty results, 20 results, permission errors, and pagination from one stable UI resource. The data changes, not the UI resource URI.

What an MCP App UI Resource Is

An MCP App UI resource is the HTML document a host renders in an iframe. It is still an MCP resource, but it has a different job from a document, image, or markdown page.

The app render path usually looks like this:

  1. The host calls tools/list.
  2. A UI-capable tool has _meta.ui.resourceUri.
  3. The host calls the tool.
  4. The host reads the resource URI with resources/read.
  5. The host renders the returned HTML as an MCP App.
  6. The app receives tool input, tool output, host context, and lifecycle events through the bridge.

The tool points at a concrete UI resource:

{
  "name": "search_orders",
  "title": "Search Orders",
  "description": "Search customer orders and render an interactive results table.",
  "_meta": {
    "ui": {
      "resourceUri": "ui://orders/search.html"
    }
  }
}

The resource returns the reusable app shell:

{
  "contents": [
    {
      "uri": "ui://orders/search.html",
      "mimeType": "text/html;profile=mcp-app",
      "text": "<!doctype html><html>...</html>",
      "_meta": {
        "ui": {
          "prefersBorder": true,
          "csp": {
            "connectDomains": ["https://api.example.com"],
            "resourceDomains": ["https://cdn.example.com"]
          }
        }
      }
    }
  ]
}

The data for one search does not belong in that HTML. The tool result carries it:

{
  "content": [{ "type": "text", "text": "Found 12 orders." }],
  "structuredContent": {
    "orders": [
      {
        "id": "ord_123",
        "status": "ready",
        "total": 249
      }
    ]
  },
  "_meta": {
    "nextCursor": "cursor_abc"
  }
}

That split lets one UI resource render many states. It also keeps model-visible data, app-only data, and reusable HTML in the right places.

Where ChatGPT outputTemplate Fits

ChatGPT Apps examples often include _meta["openai/outputTemplate"]. That field points at the UI template ChatGPT should render for a tool.

For new MCP Apps, prefer the standard field:

{
  "_meta": {
    "ui": {
      "resourceUri": "ui://orders/search.html"
    },
    "openai/outputTemplate": "ui://orders/search.html"
  }
}

Use _meta.ui.resourceUri as the source of truth because it is the portable MCP Apps path. Include openai/outputTemplate when you need ChatGPT compatibility or you are migrating older Apps SDK code.

The two values should match. If they differ, you have created a host-specific bug:

  • A portable MCP Apps host may render _meta.ui.resourceUri.
  • A ChatGPT compatibility path may render openai/outputTemplate.
  • Your tests may pass in one host and fail in another because each host fetched a different resource.

When you migrate, make the mapping boring:

const resourceUri = 'ui://orders/search.html';

const toolMeta = {
  ui: {
    resourceUri,
  },
  'openai/outputTemplate': resourceUri,
};

If you later rename the resource, one constant changes.

When You Need Multiple UI Resources

A single UI resource is enough for many apps. Use one stable resource when the same app shell can render different tool results.

Good single-resource examples:

  • Search results with filters and pagination.
  • A dashboard with loading, empty, and error states.
  • A form that moves through draft, review, and submitted states.
  • A details panel that receives the selected record in structuredContent.

Use multiple UI resources when the screens have different contracts, metadata, or bundles:

App surfaceResource URIWhy split it
Search tableui://orders/search.htmlNeeds table layout and result filters
Order detailui://orders/detail.htmlNeeds timeline, comments, and attachments
Settings panelui://orders/settings.htmlNeeds different permissions and no result data
Full editorui://orders/editor.htmlNeeds heavier bundle and broader CSP

That split is about the app contract, not the data volume. If two screens need different CSP, permissions, host presentation, or JavaScript bundles, split them. If the same component can render the state from tool data, keep one resource.

When You Need MCP Resource Templates

Resource templates are useful when the user or model needs to address resources by URI outside the initial app render.

For example, a search tool can return a list of reports:

{
  "content": [
    { "type": "text", "text": "Found 3 reports." },
    {
      "type": "resource_link",
      "uri": "report://run-2026-07-10",
      "name": "July reliability report",
      "mimeType": "application/json"
    }
  ],
  "structuredContent": {
    "reports": [{ "id": "run-2026-07-10", "title": "July reliability report" }]
  }
}

The server can advertise this resource template:

{
  "uriTemplate": "report://{runId}",
  "name": "Report output",
  "description": "Read one generated report by run ID",
  "mimeType": "application/json"
}

Now the host has a way to understand that report://run-2026-07-10 is not a one-off mystery URI. It belongs to a readable pattern.

In an MCP App, the same tool might also open a UI:

{
  "name": "find_reports",
  "_meta": {
    "ui": {
      "resourceUri": "ui://reports/search.html"
    }
  }
}

That is fine. The UI resource renders the interactive search app. The resource template describes addressable report data. They are related, but they do different jobs.

What sunpeak Handles

In a sunpeak MCP App project, you usually do not hand-write every low-level resource field. A resource directory becomes an app resource, and a tool file points at it with the resource field.

Conceptually, this:

export const tool = {
  title: 'Search Orders',
  description: 'Search customer orders',
  resource: 'orders-search',
};

maps to the same MCP App contract:

  • The tool is discoverable through tools/list.
  • The tool points at a stable UI resource.
  • The resource is readable with resources/read.
  • The rendered app receives the tool result through the host bridge.

That does not remove the need to understand the contract. It gives you fewer places to make a typo, plus a local inspector where you can test the result before a live ChatGPT or Claude pass.

Run the app locally:

npx sunpeak new
pnpm dev

Or inspect an existing MCP server:

npx sunpeak inspect --server http://localhost:8000/mcp

Use the sunpeak inspector to switch hosts, themes, display modes, tool input, tool output, and resource states. A template bug is much easier to fix when you can see whether tools/list, resources/read, and iframe rendering agree.

Tests That Catch Template Bugs

Template bugs are mostly contract bugs, so start with protocol tests before browser tests.

For UI resources:

test('all UI tools point at readable app resources', async ({ mcp }) => {
  const tools = await mcp.listTools();

  for (const tool of tools.tools) {
    const resourceUri = tool._meta?.ui?.resourceUri;

    if (!resourceUri) continue;

    expect(resourceUri).toMatch(/^ui:\/\//);

    const resource = await mcp.readResource(resourceUri);
    expect(resource.contents[0].mimeType).toBe('text/html;profile=mcp-app');
    expect(resource.contents[0].text).toContain('<html');
  }
});

For ChatGPT compatibility:

test('ChatGPT outputTemplate matches the standard resource URI', async ({ mcp }) => {
  const tools = await mcp.listTools();

  for (const tool of tools.tools) {
    const resourceUri = tool._meta?.ui?.resourceUri;
    const outputTemplate = tool._meta?.['openai/outputTemplate'];

    if (outputTemplate) {
      expect(outputTemplate).toBe(resourceUri);
    }
  }
});

For resource templates:

test('report resource template can read a representative report', async ({ mcp }) => {
  const templates = await mcp.listResourceTemplates();

  expect(templates.resourceTemplates).toContainEqual(
    expect.objectContaining({
      uriTemplate: 'report://{runId}',
    }),
  );

  const report = await mcp.readResource('report://run-fixture');

  expect(report.contents[0].mimeType).toBe('application/json');
});

Then add an inspector E2E test that proves the app actually renders:

test('orders search renders from the UI resource', async ({ inspector }) => {
  const result = await inspector.renderTool('search_orders', {
    input: { query: 'ready' },
    simulation: 'ready-orders',
  });

  await expect(result.app().getByRole('heading', { name: /orders/i })).toBeVisible();
  await expect(result.app().getByText('ord_123')).toBeVisible();
});

The protocol tests tell you whether the host can discover and read the resources. The browser test tells you whether the HTML, bridge, data, and component work together.

Common Mistakes

Putting tool data in the UI resource

If the HTML resource contains a specific search result, you have made the resource harder to cache, harder to test, and easier to leak.

Keep the UI resource reusable. Put run-specific data in structuredContent and app-only helper data in tool result _meta.

Using a resource template for every app state

Do not create ui://orders/{query}.html just because the user can search many queries. Use ui://orders/search.html and pass the query result as tool output.

Use URI templates for addressable resources, not transient UI state.

Letting ChatGPT and standard fields drift

If _meta.ui.resourceUri points at ui://orders/search.html but openai/outputTemplate points at ui://orders/old.html, one host path is stale.

Put both fields behind one constant or framework mapping.

Forgetting MIME types

For MCP App HTML, use the app HTML MIME type your target hosts expect. In the current MCP Apps docs and sunpeak examples, that is text/html;profile=mcp-app.

If resources/read returns generic text or a file download MIME type, the host may not treat it as an app.

Testing only the browser

A browser test can show a blank iframe, but it may not tell you why. Add protocol tests for tools/list, resources/read, and resources/templates/list so broken metadata fails before Playwright opens a page.

A Practical Rule

Use this rule when the terminology gets blurry:

  • If it tells the host which HTML app to render, it is a UI resource link.
  • If it returns the HTML app, it is a UI resource.
  • If it describes many possible resource URIs, it is a resource template.
  • If it exists only for ChatGPT compatibility, keep it mapped to the standard MCP Apps field.

That distinction keeps the app portable. ChatGPT Apps, Claude Connectors, and other MCP App hosts can all work from the same base contract: stable ui:// resources, clear tool metadata, readable resources, and separate run-specific tool results.

If you are building with sunpeak, let the framework generate the boring parts, then test the contract anyway. Resource bugs are cheap to catch in tools/list and resources/read. They are expensive when they show up as a blank iframe during review.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What is a resource template in MCP?

An MCP resource template is a parameterized resource URI pattern, such as docs://{slug}, that lets a client discover resources whose exact URIs are not known ahead of time. The server advertises templates through resources/templates/list, and the client can later read a concrete resource URI with resources/read.

Is an MCP App UI resource the same as an MCP resource template?

No. An MCP App UI resource is the HTML document a host renders in an iframe, usually behind a stable ui:// URI. An MCP resource template is a URI pattern for dynamic resources. A UI resource can be one concrete resource, while a resource template describes many possible resources.

What is openai/outputTemplate in ChatGPT Apps?

_meta["openai/outputTemplate"] is a ChatGPT Apps compatibility field that points a tool at the UI template ChatGPT should render. For portable MCP Apps, prefer _meta.ui.resourceUri as the source of truth and include openai/outputTemplate only when you need ChatGPT-specific compatibility.

Should MCP App data go in the resource template?

Usually no. Put app HTML, JavaScript, CSS, and resource metadata in the UI resource. Put per-tool-call data in content, structuredContent, or tool result _meta. Use resource templates for addressable resources, not for stuffing run-specific app state into HTML.

When should I use an MCP resource template in an MCP App?

Use an MCP resource template when the app or host needs to read many addressable resources, such as docs://{slug}, invoice://{id}, or report://{runId}. Do not use a template just because your UI renders different data. A single stable ui:// resource can render many tool results.

How do I test MCP App resource templates?

Test both layers. For UI resources, call tools/list, collect _meta.ui.resourceUri, then call resources/read and assert the MCP App HTML MIME type. For resource templates, call resources/templates/list, expand representative URIs, read them with resources/read, and verify missing or unauthorized IDs return clear errors.

Can one ChatGPT App or Claude Connector use multiple UI resources?

Yes. A multi-view app can use separate stable ui:// resources for different screens, such as search, details, and settings. Keep each URI stable, give each resource its own metadata, and test that every UI-capable tool points at a resource the host can read.