Dev.to AI 🤖 Ai 👁 0 📖 8 min read

My MCP app shipped every update to nobody until they reconnected

The first deploy of games in Fyreflock's chat box went out with no Play button. The site was on the new build, the server was serving the new page, and Claude Desktop kept drawing the old one anyway, because it had cache

My MCP app shipped every update to nobody until they reconnected

The first deploy of games in Fyreflock's chat box went out with no Play button. The site was on the new build, the server was serving the new page, and Claude Desktop kept drawing the old one anyway, because it had cached the box under its URI and had no reason to ask again.

Fixing it took two more tries over the next two days, and the first of those made things worse. This is all three versions, and what the one that stuck costs on every open.

The box, briefly

Fyreflock is a random chat app, and part of it runs inside AI chats as an MCP server. When you ask Claude or ChatGPT to open it, the model calls one tool, fyreflock_open, and the host draws an MCP App: an HTML page in a sandboxed iframe. From then on you type into that box and it talks to my server directly. The model is out of the loop.

The tool points at the page through _meta:

// apps/server/src/mcp/app/index.ts
registerAppTool(server, 'fyreflock_open', {
  // ...
  _meta: { ui: { resourceUri: APP_RESOURCE_URI }, 'openai/outputTemplate': APP_RESOURCE_URI },
}, async () => { /* ... */ });

and the page itself is a resource registered under that same URI. Simple enough. The host reads the tool list, sees the URI, fetches the resource, renders it.

The catch is the word "fetches". It means once.

Attempt 1: a fixed URI, ui://fyreflock/chat

This was the original. One URI, one page, the page rebuilt on every deploy.

I already had a hint this would bite. On 2026-10-19 I'd watched ChatGPT serve the box from its cache across a page reload. Then the games deploy landed, and a Claude Desktop that had opened the box before kept the pre-games page. Hosts cache an MCP App by its URI and don't read it again. From the server's side, nothing was wrong. Every request that reached me got the new page. The requests just stopped coming.

So any change to the box shipped to exactly the people who had never seen it, which for a feature in its first week is roughly nobody.

Attempt 2: put a hash in the URI

The obvious fix for a cache keyed by name is to change the name:

// ace4245, since replaced
export const APP_RESOURCE_URI =
  `ui://fyreflock/chat/${createHash('sha256').update(HTML).digest('hex').slice(0, 10)}`;

Same bytes, same URI. New bytes, new URI, cache miss, fresh page. Cache busting the way every bundler does it, and I shipped it feeling pretty clever.

It got past the page cache and walked straight into the next one. The host doesn't discover the URI on its own. It learns it from the tool list, and it reads the tool list when the connector connects. So a host connected yesterday still held yesterday's tool list, which still pointed at yesterday's hash.

And that hash no longer existed on my server. The resource was registered under the current hash only. A person who hadn't reconnected didn't get an old box. They got a box that failed to load.

flowchart TD
    A[Deploy: box bytes change] --> B[Server registers ui://fyreflock/chat/NEWHASH]
    C[Host connected last week] --> D[Its tool list still says OLDHASH]
    D --> E[Host asks for ui://fyreflock/chat/OLDHASH]
    B --> F{Server has OLDHASH?}
    E --> F
    F -->|no| G[Resource not found, no box at all]
    F -->|"yes, only if they reconnect"| H[New box]

So the hash turned "people see an old box until they reconnect" into "people see no box until they reconnect". Strictly worse. In my defence it lasted a day.

Attempt 3: cache the loader, fetch the box

If the host is going to keep whatever sits at the URI forever, the fix is to make the thing it keeps not matter. The URI went back to fixed, ui://fyreflock/box/v1, and what it holds now is a loader. Every time it opens, the loader asks my site for the current box as a plain script, /box/live.js, and mounts whatever comes back.

sequenceDiagram
    participant H as Host (Claude, ChatGPT)
    participant L as Cached loader, ui://fyreflock/box/v1
    participant S as fyreflock.com
    H->>L: open the box (from its cache)
    L->>S: script src /box/live.js
    alt script arrives in time
        S-->>L: window.__fyreflockBox(current page)
        L->>L: mount it, data-box-from=live
    else refused, error, or 4 s passes
        L->>L: mount the copy it was built with, data-box-from=inline
    end

The script is just one function call with the whole page as a string:

// apps/server/src/mcp/app/index.ts
export function boxLiveScript(origin: string): string {
  return `window.__fyreflockBox(${literal(page(origin))});\n`;
}

That literal is doing more than it looks like. It's JSON.stringify plus one replace, because a raw </script anywhere inside that string would end the loader's script tag in the middle of the box:

const literal = (s: string) => JSON.stringify(s).replaceAll('</', '<\\/');

There's a test for exactly that, since it's the kind of thing that works fine until the games bundle happens to contain the string.

Why a script tag and not fetch

Two reasons. A <script src> from a domain the app declares in its CSP resourceDomains is something hosts already allow, and I had a probe for it: a test widget I run inside real hosts, whose check 1d loads a script from a declared domain and reports whether it ran. On 2026-10-28 it loaded in Claude Desktop and claude.ai. ChatGPT I haven't measured, and if it refuses, the loader mounts its built-in copy, which is exactly what everyone got before. Nobody ends up worse off.

The second reason is that mounting someone else's HTML into your document doesn't run its scripts. Neither DOMParser nor importNode will execute a <script> they bring along. So the loader swaps in the new head and body, then replaces every script element with a fresh clone, because a freshly created script element is one the browser will actually run:

// boxShell() in apps/server/src/mcp/app/index.ts, trimmed
var next = new DOMParser().parseFromString(html, 'text/html');
document.head.replaceWith(document.importNode(next.head, true));
document.body.replaceWith(document.importNode(next.body, true));
var scripts = document.querySelectorAll('script');
for (var j = 0; j < scripts.length; j++) {
  var old = scripts[j], run = document.createElement('script');
  for (var k = 0; k < old.attributes.length; k++) run.setAttribute(old.attributes[k].name, old.attributes[k].value);
  run.textContent = old.textContent;
  old.replaceWith(run);
}

A mounted flag makes the first copy to arrive win, so a slow live script landing after the four second fallback doesn't mount a second box on top of the first.

The old hashes still get answered

Hosts that read their tool list during attempt 2 still ask for hashed URIs. Instead of refusing them, the server registers a resource template that matches any of them and hands back the loader:

const HASHED_URI = new ResourceTemplate('ui://fyreflock/chat/{version}', { list: undefined });
// Answered rather than refused: a host still holding an old tool list asks for a hash this server no longer has.
server.registerResource('Fyreflock chat (earlier address)', HASHED_URI, /* ... */
  async (uri) => ({ contents: [{ uri: uri.href, mimeType: RESOURCE_MIME_TYPE, text: boxShell(origin) /* ... */ }] }));

People connected during the hash day needed one more reconnect to pick up v1. After that, a deploy reaches them the next time they open the box. Testing it in my fake host covered the handshake surviving the mount, an unreachable site falling back in about 110 ms, a non-script response, a CSP that blocks it, and a loader captured before a change mounting the changed box.

What being current costs

Here's the bill. I fetched /box/live.js from production 50 times today, one fresh connection each:

Fetch time for /box/live.js: median 1173 ms uncompressed, 837 ms gzipped, 523 ms for a 304, all far under the 4 second fallback

The box is 488 KB of JavaScript as a string, 167 KB gzipped. The chat widget is 110 KB of that; the games bundle is the rest. Even the slowest uncompressed run, 2.9 s, came in well before the loader's four second give-up. The 304 is the interesting row. It moves zero bytes and still costs about half a second, because it's still a round trip to the origin before anything can draw.

That was the first version of attempt 3: Cache-Control: no-cache, so every open revalidated and an unchanged box came back as a 304. Correct, and a round trip to the origin on every single open. The next day it became this:

// apps/server/src/http.ts
res.type('text/javascript')
  .set('Cache-Control', 'public, max-age=60, stale-while-revalidate=86400')
  .send(live);

A minute of plain caching, then a day where the browser may draw the copy it has at once and refetch behind it. I didn't lean on Cloudflare for this because, per the comment I left myself, its Free plan won't cache for less than two hours on its own, and a deploy that takes two hours to show up is the problem I started with.

The price is honest staleness. In a browser that honours stale-while-revalidate, the first open after a deploy can draw the previous box instantly while fetching the new one, and the open after that is current. I traded "always current, always half a second late" for "instant, one open behind".

The part that's still wrong

The loader carries its fallback inline, the full page as of the day the host fetched it. So the loader is about as big as the live script, 488 KB, and the copy inside it only gets older. If my site is unreachable a month from now, a host that cached v1 today will mount a month-old box. That's fine for chat and less fine for a game whose rules changed since.

And the loader itself is now the one thing I can never update without asking everybody to reconnect. Any change to it ships with v2, and I've written that rule into the code comment above APP_RESOURCE_URI so I can't forget it on a Friday.

What I'd do differently

Probe before cache busting. The hash looked right because I was reasoning about one cache. There were two, the page cache and the tool list, and the second one is invisible from the server unless you log which URIs hosts actually request. A single log line on resource URIs the server doesn't know would have shown the broken boxes the first time a host asked for one.

I'd also have started with the loader. "The thing that gets cached forever should be tiny and dumb" is old advice from service workers, and I relearned it the slow way.

The tradeoff I'm still chewing on

The fallback is either a fat inline copy that ages, or no copy at all and a box that shows nothing when my site is down. Right now I keep the fat copy. If you've shipped UI into a host that caches you forever (MCP Apps, browser extensions, embedded widgets), which did you pick: a stale fallback, or failing visibly so people know something's wrong?

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.