Deploying updates to Model Context Protocol (MCP) apps isn't as simple as pushing new HTML to a server. Developer fuloxdev recently documented a painful debugging journey with their app, Fyreflock, where updates shipped silently to a vacuum. The core issue: AI hosts like Claude Desktop and ChatGPT cache MCP App resources aggressively, keyed by URI. If the URI doesn't change, the host never asks for the new content. The result was a live site serving new builds while users stared at stale, cached interfaces until they manually reconnected.
The Cache-Busting Trap
The first instinct for any web developer is to bust the cache by changing the URI. Fuloxdev initially switched to a content-hash-based URI, generating strings like ui://fyreflock/chat/[hash]. This seemed logical: new content equals new hash equals new URI equals cache miss. However, MCP hosts don't discover URIs dynamically; they learn them from the tool list at connection time. When the server updated the resource registration to the new hash, hosts connected previously were still holding the old tool list pointing to the old hash. Since the server no longer registered that old hash URI, users didn't see an old boxβthey saw a broken box that failed to load entirely. It was strictly worse than the original bug.
The Loader Pattern Solution
The fix that stuck involved reverting to a static URI, ui://fyreflock/box/v1, but changing what that URI served. Instead of the full application HTML, the static URI now serves a lightweight 'loader' script. This loader is cached by the host forever. On every open, the loader executes and dynamically fetches the current application code from a separate endpoint (/box/live.js). This decouples the host's immutable cache from the developer's mutable content. The loader includes a fallback mechanism: if the live script fails to load within four seconds, it mounts the inline copy of the application code that was cached with the loader, ensuring the app never fails to render completely.
Performance and Staleness Trade-offs
This architecture introduces a deliberate trade-off between freshness and speed. The live script is substantialβ488 KB uncompressed, 167 KB gzipped. Fetching it on every open adds latency. To mitigate this, the developer implemented a Cache-Control: public, max-age=60, stale-while-revalidate=86400 header. This allows the browser to serve the cached version instantly while revalidating in the background. The result is that users might see the previous version of the app for one open after a deploy, but subsequent opens will show the latest build. This 'eventual consistency' for UI updates is often preferable to the blank screen errors caused by aggressive cache busting.
The Invisible Second Cache
Fuloxdev notes that the initial failure stemmed from reasoning about only one cache layer. In reality, there were two: the page cache and the tool list cache. The tool list is invisible from the server side unless developers explicitly log which URIs hosts request. A single log line tracking unknown resource URIs would have revealed that hosts were still asking for hashed URIs that no longer existed on the server. This oversight turned 'people see an old box' into 'people see no box,' a regression that lasted a day before being caught.
Handling Legacy Connections
To address users who were still connected during the failed 'Attempt 2' hash phase, the server now registers a resource template that matches any hashed URI pattern (ui://fyreflock/chat/{version}). Instead of returning a 404 Not Found error for these stale requests, the server returns the loader script. This ensures that even users with outdated tool lists receive a functional box that can fetch the latest content, rather than a broken interface. After this fix, those users needed only one more reconnect to fully transition to the new v1 static URI system.
Why Script Tags Over Fetch
The loader uses a tag to load /box/live.js rather than a fetch() call for two specific reasons. First, hosts like Claude Desktop and claude.ai already allow script loading from domains declared in the app's CSP resourceDomains, a behavior verified by internal probes. Second, mounting HTML via DOMParser or importNode does not execute embedded scripts. The loader must swap the head and body, then replace every script element with a fresh clone to ensure the browser actually runs the new code. This complexity justifies the script tag approach, which handles execution natively.
The Aging Fallback Problem
The loader carries an inline fallback of the full page as of the day the host fetched it. This means the loader itself is roughly the same size as the live script (488 KB), and the cached copy inside it ages over time. If the site becomes unreachable months later, hosts will mount a month-old interface. While acceptable for simple chat widgets, this is risky for games with changing rules. The loader itself can never be updated without forcing all users to reconnect, a constraint now documented in code comments to prevent accidental breakage during deploys.
Key Takeaways
- Host Caching is Aggressive: MCP hosts cache resources by URI and do not re-fetch them unless the URI changes or the user reconnects.
- Tool Lists are Static: Changing resource URIs dynamically breaks connections for users who haven't refreshed their tool list, leading to 404s.
- Loader Pattern is Robust: Serving a static, tiny loader that dynamically fetches the real app content is the most reliable way to update MCP Apps.
- Fallbacks are Mandatory: Always include an inline fallback in the loader to handle network failures or slow responses, ensuring the app never appears broken.
The Bottom Line
Cache busting is a naive strategy for MCP Apps because it ignores the static nature of tool lists; the loader pattern is the only viable path to reliable updates, even if it forces developers to accept eventual consistency and larger initial payloads.