MCP App Resource Templates: UI Resources, ui:// URIs, and ChatGPT outputTemplate (October 2026)

MCP App resource templates, UI resources, and ChatGPT outputTemplate fields all point at related but different parts of the app contract.
“Resource template” means a URI pattern in core MCP, while “outputTemplate” is a ChatGPT field that points to UI. An MCP App UI resource is the HTML document itself. Those similar names cause two common bugs: a tool calls successfully but no iframe appears, or resources/templates/list advertises data that the server cannot read.
TL;DR: Use resources/templates/list for parameterized data URIs such as docs://{slug}. Link a UI tool to a concrete ui:// HTML resource with _meta.ui.resourceUri. ChatGPT’s openai/outputTemplate is an optional alias for that URI. Test both the tool-to-UI link and any expanded resource template URI.
The Naming Collision
The confusion starts because “resource template” can mean different things depending on which docs or SDK example you are reading.
| Term | Where it lives | What it does |
|---|---|---|
| MCP resource template | resources/templates/list | Advertises a URI pattern such as docs://{slug} |
| MCP App UI resource | resources/read | Returns HTML with text/html;profile=mcp-app |
| Tool UI link | tools/list metadata | Points a tool to a concrete ui:// resource |
ChatGPT outputTemplate | Optional tool metadata | Compatibility alias for the tool UI link |
| Web template | Normal frontend build | The HTML shell or component bundle for your app |
Treat those as separate layers.
A resource template is about addressing many resources. A UI resource is about rendering one reusable app shell. outputTemplate is about ChatGPT compatibility. A frontend 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. The MCP resources specification also allows completion for template arguments, but a template does not grant access to every URI it can describe.
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.
When a template includes an ID or path, validate the expanded URI before a lookup, bind it to the authenticated user, and return a clear error for missing or forbidden records. Avoid building a filesystem path directly from the URI argument. Test an ID owned by another user as well as an unknown ID.
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:
- The host calls
tools/list. - A UI-capable tool has
_meta.ui.resourceUri. - The host calls the tool.
- The host reads the resource URI with
resources/read. - The host renders the returned HTML as an MCP App.
- 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. content is model-facing; a host may also show structuredContent to the model. Tool result _meta can carry UI helper data where supported, but it still reaches the client, so do not put credentials there.
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. ChatGPT’s current UI guide calls openai/outputTemplate an optional compatibility alias. Include it for an older integration that still needs it; new UI can use only the standard field.
When both values are present, make them match. If they differ, you have created an ambiguous tool descriptor:
- A portable MCP Apps host may render
_meta.ui.resourceUri. - A compatibility consumer may read
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 surface | Resource URI | Why split it |
|---|---|---|
| Search table | ui://orders/search.html | Needs table layout and result filters |
| Order detail | ui://orders/detail.html | Needs timeline, comments, and attachments |
| Settings panel | ui://orders/settings.html | Needs different permissions and no result data |
| Full editor | ui://orders/editor.html | Needs 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 a client can discover the report://{runId} pattern. It still has to ask the server to read the concrete URI, and the server must check that the caller may access that report. A resource_link points to one URI; the template describes a family of URIs. Neither bypasses authorization.
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 orders-app
cd orders-app
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:
import { test, expect } from 'sunpeak/test';
test('all UI tools point at readable app resources', async ({ mcp }) => {
const tools = await mcp.listTools();
const resources = await mcp.listResources();
for (const tool of tools) {
const resourceUri = tool._meta?.ui?.resourceUri;
if (!resourceUri) continue;
expect(resourceUri).toMatch(/^ui:\/\//);
const listed = resources.find((resource) => resource.uri === resourceUri);
expect(listed?.mimeType).toBe('text/html;profile=mcp-app');
const html = await mcp.readResource(resourceUri);
expect(html).toMatch(/<!doctype html|<html/i);
}
});
The sunpeak mcp fixture returns arrays from listTools() and listResources(), and an HTML string from readResource(uri). It is useful for a quick contract test. To inspect CSP and other per-response content metadata, call resources/read with an official MCP SDK client or unit-test the resource handler, because content-item metadata can override the listing.
For ChatGPT compatibility:
test('ChatGPT outputTemplate matches the standard resource URI', async ({ mcp }) => {
const tools = await mcp.listTools();
for (const tool of tools) {
const resourceUri = tool._meta?.ui?.resourceUri;
const outputTemplate = tool._meta?.['openai/outputTemplate'];
if (outputTemplate) {
expect(outputTemplate).toBe(resourceUri);
}
}
});
For resource templates, use an MCP SDK client connected to your test server. The client below comes from your test setup; it returns the raw protocol response:
import { client } from './connected-mcp-client';
test('report resource template can read a representative report', async () => {
const { resourceTemplates } = await client.listResourceTemplates();
expect(resourceTemplates).toContainEqual(
expect.objectContaining({
uriTemplate: 'report://{runId}',
}),
);
const report = await client.readResource({ uri: 'report://run-fixture' });
expect(report.contents[0].mimeType).toBe('application/json');
});
Add two negative cases: an unknown runId and a valid runId owned by another user. Neither should reveal report data. Also compare the content-item CSP on the real ui:// response with a reviewed domain allowlist; a resources/list descriptor alone cannot prove the policy the host applies.
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', { query: 'ready' });
await expect(result.app().getByRole('heading', { name: /orders/i })).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 user-authorized run-specific data in content and structuredContent. Use result _meta for UI helper data only when the host supports it, and never treat it as a secret store.
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. Check the MIME type on the content item actually returned by resources/read; the listing value alone is insufficient.
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 and resources/read, plus resources/templates/list when your server actually uses URI templates, 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
npx sunpeak newFurther Reading
- MCP App UI resources - ui:// URIs, MIME types, and resource links
- MCP App tool metadata - resourceUri, visibility, and app-only tools
- MCP App resource metadata - CSP, permissions, and UI fields
- MCP App conformance testing - verify resources in CI
- MCP App resource caching and versioned UI URIs
- MCP App tool results - content, structuredContent, and _meta
- MCP App framework
- ChatGPT App framework
- sunpeak docs - MCP resources
- MCP resources specification and resource templates
- MCP Apps overview
- ChatGPT MCP Apps UI guide and outputTemplate compatibility
Frequently Asked Questions
What is a resource template in MCP?
An MCP resource template is a parameterized URI pattern, such as docs://{slug}. A server advertises it through resources/templates/list; a client reads a concrete URI through resources/read. The server still validates each expanded URI and checks access to the requested resource.
Is an MCP App UI resource the same as an MCP resource template?
No. A UI resource is a concrete HTML document a host can render, normally at a stable ui:// URI. A resource template is a pattern for many addressable resources, such as docs://{slug}. Use a fixed UI resource for one reusable app shell and a template only when callers need distinct readable URIs.
What is openai/outputTemplate in ChatGPT Apps?
_meta["openai/outputTemplate"] is ChatGPT’s optional compatibility alias for a tool’s UI resource URI. New MCP Apps should use _meta.ui.resourceUri. If both fields are present, set them from one URI constant and verify they agree.
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?
Call tools/list and check each UI resource URI, then read its content and verify the HTML MIME type and CSP. Separately call resources/templates/list, read representative expanded URIs, and test unknown and unauthorized IDs. sunpeak’s mcp.readResource(uri) returns HTML, so use an MCP SDK client when you need the full resources/read metadata.
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.