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