MCP App Resource Caching: Versioned UI Resources and Stale ChatGPT App Fixes

Versioned MCP App resources keep host caches, deployed bundles, and tool metadata in sync.
You can build a correct MCP App and still watch an old iframe render after deploy.
The tool runs. structuredContent has the new field. The server logs show the latest code. Then ChatGPT, Claude, or a local host replica loads yesterday’s JavaScript bundle because the UI resource URI did not change.
That search intent is under-covered compared with basic “what is an MCP App” and “how do I render a resource” guides. Developers need a direct answer for stale ChatGPT App widgets, versioned ui:// resources, host prefetching, and rollout tests.
TL;DR: Treat an MCP App UI resource as a versioned build artifact. Keep the HTML resource static and data-free, put invocation data in content, structuredContent, and _meta, change the ui:// URI when the bundle changes, keep old resource URIs readable during rollout, and test tools/list plus resources/read in CI. This fixes most stale iframe and “new backend, old widget” bugs before you debug React.
Why MCP Apps Have a Caching Problem
MCP Apps split the app into two parts:
- A server tool that returns model-visible text, typed data, and optional app-only metadata.
- A UI resource that returns the HTML document the host renders in a sandboxed iframe.
That split is a good design. The MCP Apps announcement says UI templates are resources referenced in tool metadata, which lets hosts prefetch and review templates before a tool runs. The same split also helps caching because the UI shell can stay stable while each tool call returns different data.
The tradeoff is that the resource URI becomes a cache key in practice.
If this URI always stays the same:
const resourceUri = 'ui://invoices/summary.html';
then a host has no obvious way to know whether the HTML behind it is the same build, a new build, or a rollback. Some hosts may re-read it often. Others may prefetch it, keep it for a conversation, or keep a reviewed copy around a submitted app version.
You should design as if the host may cache more than you expect.
The Bug Pattern
The stale-resource bug usually looks like one of these:
- The tool result includes
structuredContent.statusLabel, but the UI bundle still readsstructuredContent.status. - The app’s new CSS does not appear in ChatGPT after deployment.
- A button calls a server tool with an old argument shape.
- The local browser tab works, but the hosted ChatGPT App or Claude App still renders an older component.
- A production conversation keeps using an old view after a deploy, while a fresh local inspector session works.
Those symptoms are easy to misdiagnose because the backend is often correct. The stale piece is the UI resource the host loaded.
Start by checking the resource contract, not the component:
- Call
tools/list. - Read the tool’s
_meta.ui.resourceUri. - Call
resources/readfor that exact URI. - Check the MIME type.
- Inspect the returned HTML for the current build id.
OpenAI’s current Apps SDK troubleshooting guide gives the same first check for “structured content only, no component”: the tool descriptor should point at a registered HTML resource with text/html;profile=mcp-app, and the resource should load without CSP errors.
Version the Resource URI
The simplest production rule is this:
Change the UI resource URI when the UI bundle changes.
Good:
const buildId = '2026-07-27-a13f9c2';
const resourceUri = `ui://invoices/summary/${buildId}.html`;
Also fine:
const resourceUri = 'ui://invoices/summary/v42.html';
Riskier:
const resourceUri = 'ui://invoices/summary.html';
A mutable URI can work during early development, especially in a local inspector. It is a poor production release boundary because it asks every host cache to guess whether the bytes changed.
Prefer a path segment or filename for the version. A query string can be a valid URI shape, but some logging, review, or cache layers make path-based versions easier to read:
// Easier to audit in logs and tests.
ui://invoices/summary/2026-07-27-a13f9c2.html
// Works in many URI parsers, but easier to normalize away by accident.
ui://invoices/summary.html?build=2026-07-27-a13f9c2
The exact scheme matters less than the invariant: the tool descriptor and the registered resource must use the same exact URI.
Keep Resource HTML Static
Versioning only works cleanly if the resource is a template, not a per-user response.
Keep this in the resource:
- HTML shell.
- JavaScript bundle.
- CSS.
- Mount point.
- Resource metadata such as CSP, permissions, and presentation hints.
- A visible build id in a script variable, meta tag, comment, or data attribute.
Keep this out of the resource:
- User-specific records.
- Access tokens.
- Current search results.
- Private API responses.
- One-off conversation state.
- Model-visible summaries.
Put tool-call data here instead:
| Data lane | Use it for | Cache behavior |
|---|---|---|
content | Short text the model and fallback clients can use | Changes per tool call |
structuredContent | Typed render data for the UI and, depending on host, model-visible state | Changes per tool call |
_meta | App-only values such as cursors, view IDs, cache keys, and private UI hints | Changes per tool call |
| UI resource | Static app shell and resource metadata | Safe to prefetch and cache by URI |
This split matters for privacy too. A prefetched resource may load before a specific tool call finishes. If your resource HTML contains user data, you have mixed static UI delivery with per-user state.
Mirror the Version in Tool Metadata
A UI resource cannot update itself if the tool still points at the old URI.
For low-level MCP App code, keep the URI in one constant:
export const INVOICE_RESOURCE_BUILD = '2026-07-27-a13f9c2';
export const INVOICE_RESOURCE_URI = `ui://invoices/summary/${INVOICE_RESOURCE_BUILD}.html`;
Use it when registering the resource:
server.registerResource(
'invoice-summary-view',
INVOICE_RESOURCE_URI,
{
title: 'Invoice Summary View',
description: 'Interactive invoice summary UI.',
mimeType: 'text/html;profile=mcp-app',
},
async () => ({
contents: [
{
uri: INVOICE_RESOURCE_URI,
mimeType: 'text/html;profile=mcp-app',
text: invoiceSummaryHtml,
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
},
},
},
},
],
}),
);
Use the same constant in the tool descriptor:
server.registerTool(
'show_invoice_summary',
{
title: 'Show invoice summary',
description: 'Show an invoice summary with line items and payment status.',
inputSchema: {
type: 'object',
properties: {
invoiceId: { type: 'string' },
},
required: ['invoiceId'],
additionalProperties: false,
},
_meta: {
ui: {
resourceUri: INVOICE_RESOURCE_URI,
visibility: ['model', 'app'],
},
// Optional ChatGPT compatibility alias when targeting ChatGPT.
'openai/outputTemplate': INVOICE_RESOURCE_URI,
},
},
async ({ invoiceId }) => {
const invoice = await loadInvoice(invoiceId);
return {
content: [{ type: 'text', text: `Invoice ${invoice.number} is ${invoice.status}.` }],
structuredContent: invoice,
};
},
);
If your framework uses helper functions, the same rule applies. Keep the resource config and the tool metadata tied to one source of truth.
In sunpeak, the build step compiles resource bundles and the framework is built around decoupling and versioning MCP App resources from the MCP server. The practical test is still host-facing: the tool should point at the built resource URI, and resources/read should return the matching HTML.
Put a Build Id in the HTML
When a stale widget appears, you need a fast way to prove which bundle loaded.
Add a build id to the resource HTML:
<meta name="mcp-app-build" content="2026-07-27-a13f9c2" />
<script>
window.__MCP_APP_BUILD__ = '2026-07-27-a13f9c2';
</script>
Then make it visible to tests. You do not need to show it in the UI, but the app can expose it as a data attribute:
export function InvoiceSummaryApp() {
return <main data-build-id={window.__MCP_APP_BUILD__}>{/* app content */}</main>;
}
Now a rendered E2E test can tell the difference between:
- wrong data,
- wrong tool descriptor,
- wrong resource HTML,
- wrong external asset,
- old conversation state.
That is much faster than guessing from screenshots.
Keep Old Resource Versions Readable
Do not delete the old resource the moment the new one deploys.
A host may still hold:
- an active iframe using the old URI,
- a conversation that can re-open the old resource,
- a published metadata snapshot that points at the old resource,
- a prefetched resource from before the deploy,
- a mobile client with a different refresh cycle.
Keep at least one previous resource version available:
const activeResourceUri = 'ui://invoices/summary/2026-07-27-a13f9c2.html';
const previousResourceUri = 'ui://invoices/summary/2026-07-20-4b8910d.html';
const resources = {
[activeResourceUri]: activeInvoiceHtml,
[previousResourceUri]: previousInvoiceHtml,
};
server.registerResourceTemplate(
'invoice-summary-view',
'ui://invoices/summary/{build}.html',
{
title: 'Invoice Summary View',
mimeType: 'text/html;profile=mcp-app',
},
async (uri) => {
const html = resources[String(uri)];
if (!html) {
throw new Error(`Unknown invoice resource build: ${uri}`);
}
return {
contents: [{ uri: String(uri), mimeType: 'text/html;profile=mcp-app', text: html }],
};
},
);
Your actual SDK API may differ, but the rollout rule is portable: new tool descriptors point at the new resource URI, old resource URIs still read successfully for a while.
How long is “a while”? For an internal app, a few days may be enough. For a public ChatGPT App or interactive Claude Connector, keep old versions through the review window and one normal release cycle. If the UI and tool schema changed in a breaking way, keep the old tool result shape compatible too.
Do Not Version With User Data
Do not do this:
const resourceUri = `ui://invoices/${userId}/${invoiceId}/summary.html`;
That turns resource routing into per-user state. It also puts identifiers into logs, metadata, review tools, and any host cache key that records the URI.
Use a stable resource URI for the component build:
const resourceUri = 'ui://invoices/summary/2026-07-27-a13f9c2.html';
Then pass user-specific data in the tool result:
return {
content: [{ type: 'text', text: `Invoice ${invoice.number} is ready.` }],
structuredContent: {
invoiceId: invoice.id,
number: invoice.number,
status: invoice.status,
total: invoice.total,
},
_meta: {
cursor: invoice.cursor,
},
};
The resource URI identifies the UI build. The tool result identifies the work the user asked for.
Watch External Assets Too
Even if the ui:// resource changes, your HTML can still load stale assets if it points at mutable URLs:
<script src="https://cdn.example.com/app.js"></script>
<link rel="stylesheet" href="https://cdn.example.com/app.css" />
For MCP Apps, self-contained HTML is usually simpler. Inline the built JavaScript and CSS into the resource, or use hashed asset URLs and declare those domains in resource CSP.
Good:
<script src="https://cdn.example.com/assets/app.a13f9c2.js"></script>
<link rel="stylesheet" href="https://cdn.example.com/assets/app.a13f9c2.css" />
Risky:
<script src="https://cdn.example.com/assets/app.js"></script>
<link rel="stylesheet" href="https://cdn.example.com/assets/app.css" />
If you use external assets, remember the resource metadata needs the right CSP:
_meta: {
ui: {
csp: {
resourceDomains: ['https://cdn.example.com'],
},
},
}
If a host caches the HTML and the browser caches the asset, you now have two cache layers to debug. Hashed asset filenames keep that manageable.
Resource Change Notifications Are Not Enough
The MCP resources spec includes optional resource capabilities such as subscriptions and list change notifications. The MCP Apps bridge can also forward list-change notifications so a view can refresh its resource cache or UI.
Those features are useful, but they are not a replacement for versioned resource URIs.
Reasons:
- Capabilities are optional.
- Hosts differ in when they subscribe.
- A notification may not affect an iframe that is already mounted.
- A published app may use scanned metadata from review.
- A user may reopen an old conversation after the deploy.
Use notifications for dynamic resource sets and live updates. Use versioned URIs for release boundaries.
Test the Cache Contract in CI
Add one fast protocol test before E2E rendering.
The test shape:
import { expect, test } from 'vitest';
import { createMcpClient } from './test-client';
import { EXPECTED_BUILD_ID } from '../src/build-info';
test('UI tool points at the current resource build', async () => {
const mcp = await createMcpClient();
const tools = await mcp.listTools();
const tool = tools.find((item) => item.name === 'show_invoice_summary');
expect(tool?._meta?.ui?.resourceUri).toContain(EXPECTED_BUILD_ID);
});
test('current UI resource is readable and has the expected build id', async () => {
const mcp = await createMcpClient();
const tools = await mcp.listTools();
const tool = tools.find((item) => item.name === 'show_invoice_summary');
const resourceUri = tool?._meta?.ui?.resourceUri;
expect(resourceUri).toBeTruthy();
const resource = await mcp.readResource(resourceUri);
const html = resource.contents[0];
expect(html.mimeType).toBe('text/html;profile=mcp-app');
expect(html.text).toContain(EXPECTED_BUILD_ID);
});
Then add a rollout test for the previous build:
const PREVIOUS_BUILD_RESOURCE_URI = 'ui://invoices/summary/2026-07-20-4b8910d.html';
test('previous UI resource stays readable during rollout', async () => {
const mcp = await createMcpClient();
const resource = await mcp.readResource(PREVIOUS_BUILD_RESOURCE_URI);
expect(resource.contents[0]?.mimeType).toBe('text/html;profile=mcp-app');
});
This test catches the failure where a deploy updates the tool descriptor but removes the old resource too quickly.
Test the Rendered Build Too
Protocol tests prove the server returns the right resource. A rendered test proves the host actually loaded it.
In a Playwright-style inspector test:
import { expect, test } from 'sunpeak/test';
import { EXPECTED_BUILD_ID } from '../src/build-info';
test('invoice app renders the current build', async ({ inspector }) => {
const result = await inspector.renderTool('show_invoice_summary', {
invoiceId: 'inv_123',
});
await expect(result.app().locator('[data-build-id]')).toHaveAttribute(
'data-build-id',
EXPECTED_BUILD_ID,
);
});
If that fails, you know the problem is outside your component state. The host or inspector rendered a different resource than the one your server currently advertises.
With the sunpeak Inspector, you can run this locally against ChatGPT and Claude-style host replicas, switch display modes and themes, and keep the slow live-host pass for final integration checks.
Debug Checklist for Stale Widgets
When someone says “the new ChatGPT App deploy did not show up,” run this checklist in order:
- Does
tools/listexpose the expected_meta.ui.resourceUri? - Does the URI include the current build id?
- Does
resources/readfor that exact URI returntext/html;profile=mcp-app? - Does the HTML include the current build id?
- Does the HTML load hashed or inlined assets?
- Does resource CSP allow any external asset domains?
- Does the old resource URI still read successfully for active conversations?
- Did the published app or plugin metadata get refreshed if the host snapshots metadata?
- Does a fresh conversation render the new URI?
- Does an old conversation keep rendering the old URI by design?
That order separates server state, resource state, host state, and conversation state.
A Practical Rollout Plan
For each production UI change:
- Build the resource HTML with a new build id.
- Register the new
ui://resource URI. - Keep the previous resource URI readable.
- Update tool metadata to point at the new URI.
- Keep tool output backward-compatible for at least one rollout window.
- Run protocol tests for current and previous resource URIs.
- Run inspector E2E tests that assert the rendered build id.
- Run one live-host smoke test if the app is already connected to ChatGPT or Claude.
- Remove old resource versions only after the rollout window closes.
If you do this consistently, stale resource bugs become normal release checks instead of production mysteries.
Where sunpeak Fits
You can implement versioned resources by hand in any MCP server. The important contract is not specific to a framework: resource URIs identify UI builds, and tool results carry invocation data.
sunpeak helps by making that contract easier to test. A new project gives you local resource builds, host replicas, simulations, and Playwright-style tests. For an existing MCP server, npx sunpeak inspect --server <url> lets you inspect the current tool metadata and render app resources without burning live host time.
The useful habit is to add the cache checks before the UI feels done. If your app can prove which resource URI it advertises, which HTML it serves, and which build the host rendered, most ChatGPT App and Claude Connector cache bugs get much shorter.
Start with MCP App UI resources if the resource contract is new to you. If you already have stale widgets, add the versioned URI tests first, then fix the deploy path.
Get Started
npx sunpeak newFurther Reading
- MCP App UI resources - ui:// URIs, MIME types, and resource links
- MCP App resource metadata - CSP, permissions, and widget fields
- MCP App conformance testing - verify resources in CI
- How to deploy an MCP App to production
- How to debug ChatGPT Apps
- MCP App framework
- ChatGPT App framework
- MCP App Inspector
- OpenAI Apps SDK troubleshooting
- MCP Apps announcement - UI templates and caching
- MCP resources specification
Frequently Asked Questions
Do MCP App hosts cache UI resources?
Yes, hosts can cache or prefetch MCP App UI resources because the UI template is a resource referenced by URI. The MCP Apps design separates static presentation from dynamic tool results partly so hosts can review, prefetch, and cache templates. Treat a UI resource URI as a build artifact identifier, not as a permanent pointer to whatever HTML is newest.
Why does my ChatGPT App still show an old widget after deploy?
The usual cause is that the tool descriptor still points at the same UI resource URI, so ChatGPT or another host can keep using a cached resource. It can also happen when the published app metadata has not been refreshed, an old conversation still references the previous resource, or the resource HTML loads cached external assets. Version the ui:// resource URI when the bundle changes and keep old resources readable during rollout.
Should I put a build hash in an MCP App resource URI?
Yes for production UI bundles. Put a build hash, release number, or date-based version in the ui:// URI, then use that exact URI in tool metadata and resource registration. A path segment such as ui://reports/summary/2026-07-27-a13f.html is easier to audit than a mutable ui://reports/summary.html URI.
Should MCP App tool results contain the UI HTML?
Usually no. Keep the UI HTML in the resource and put invocation-specific data in content, structuredContent, or _meta. If the HTML contains user data or server result data, hosts cannot safely cache or prefetch it, and stale-resource bugs become harder to reason about.
How long should I keep old MCP App resources available?
Keep old resource URIs readable at least through the deployment window, review window, and any expected active conversations that may still reference them. For public apps, keep one or more previous resource versions until you are comfortable that host caches, published metadata, and long-lived conversations have moved forward.
Can resources/list_changed invalidate a cached MCP App resource?
Resource list change notifications can tell capable clients that the resource set changed, but they are not a complete deployment cache strategy. Some hosts may ignore them, miss them, or keep already-rendered iframes alive. Versioned resource URIs and compatibility windows are more reliable for production UI changes.
How do I test MCP App resource caching problems?
Add protocol tests that collect _meta.ui.resourceUri from tools/list, call resources/read for each URI, assert text/html;profile=mcp-app, and check that the HTML includes the expected build id. Add rollout tests that prove the previous resource URI still reads successfully while the new URI is active.