⚙️ [a2ui-blog-host]: ui/initialize ...
📡 [public-edge-worker]: InitializeResult (200 OK)
✅ [status]: STATUS_LIVE_ARTIFACT // NO_SERVER_COMPUTE_ENGAGED
Blog › MCP, A2UI, and a Durable Object per Human
using a2ui in MCP Apps · Vol. 08 LIVE_ARTIFACT

MCP, A2UI, and a Durable Object per Human

2026-08-12 9 min read series using a2ui in MCP Apps · Vol 8 build 7214d4b

Portable, but ephemeral

Part 1 proved the same A2UI payload renders across Claude, ChatGPT, and my own site — portable, but ephemeral. The render is real, then the session ends and it's gone: nothing in MCP or A2UI claims the job of remembering it. This piece is about giving it that memory. The proof is at the end: an analysis I made in Claude, reopened cold from ChatGPT. Same object, different vendor, still there.

What follows is how it actually went — a longer story than I expected, not because the architecture was hard, but because of what it took to get one ordinary client to talk to it.


Where the state lives

Three protocols, five layers — scanned across instead of read down:

A layer_stack diagram comparing MCP, A2UI and A2A across five layers — transport, envelope, document model, session, state ownership — showing none of the three owns the top layer.

Three protocols, all careful, all correct within their own scope, and none of them owns the thing a real application needs — a place for "what did this person see, and is it still there when they come back."

I made an analysis in Claude, MCP Apps rendered it beautifully — a proper layer_stack, live, in the same window I was chatting in. Nothing wrong with it in the moment: fully interactive, exactly what the payload asked for, for as long as the session stayed open. Then the session ended, and it went with it. Not a bug: the rendering is real and portable, and there is nowhere for it to persist, because nothing in either protocol claims that job.

That's the target this whole experiment is built around: not a prettier render, but that same analysis still there, addressed to me specifically, the next time I show up — from any client, not just the one that made it. Here's that moment, live — an analysis of part 1 itself, rendered and saved to my history inside Claude:

Screen recording: analysing part 1 inside Claude, the result rendering live as an A2UI surface and being saved to reading history.

The artifact it produced — the thing this whole piece is about keeping — is a real A2UI surface, a challenge reading of part 1 rendered from the catalogue's own atoms. Here's its head:

The rendered A2UI analysis surface: a source-attribution bar for part 1, the title "One Shared Renderer Isn't Proof of Portability Yet", and a mental-model card reading "MCP is the pipe and A2UI is the parcel."

That's just the head of it; the full reading runs six rungs down. It's a single self-contained file, exported straight from the store — which is the whole point in miniature: the artifact outlives the session that drew it, and it doesn't need the session, the connector, or even a renderer to open.

So: where does the state live? Nowhere, currently. That's not a rhetorical setup for part two — it's the actual, verified answer, and it's worth leaving the piece there for a moment before filling it in.


Filling the gap

The architecture

A layer_stack diagram of the resolved architecture — the same five layers, now each with one owner: Cloudflare for identity and state, MCP and A2UI for the middle three.

Three of these five layers I inherited unchanged the moment I picked Workers, MCP, and A2UI — transport, envelope, and document model came with the territory. The two nobody handed me were identity and state ownership, and those are the two this piece is actually about.

System overview: a Cloudflare Worker hosting the MCP server and OAuth endpoints, with Cloudflare Access in front of /authorize, and one Durable Object per human keyed on the OAuth sub.

The same flow in time order — two legs, one token:

A sequence_diagram of the auth flow: a browser leg establishing WHO through Cloudflare Access and Google, a machine leg establishing WHICH TENANT through client_id and secret, meeting only in the issued token, then every tool call resolving to that person's own Durable Object.

The MCP server runs on a Cloudflare Worker, with a small OAuth 2.0 authorization server sitting next to it. Three paths share one Worker route: the public, anonymous /mcp this piece keeps coming back to; the OAuth flow itself, /mcp-oauth/authorize and /mcp-oauth/token; and /mcp-auth, the actual authenticated MCP endpoint — the only one of the three that checks a Bearer token. The client_id and secret identify the tenant, not the person — one registered client per enterprise, and that tenant governs its own users. That's worth stating plainly because it's easy to get backwards: I don't map a client credential to a Google identity anywhere. Those are two separate legs.

The human arrives on the other one. A Cloudflare Access application sits in front of the /authorize path — and only that path, never /token, because the token exchange is machine-to-machine with no browser in the loop, and an Access challenge there would simply fail. Access authenticates against my Google Workspace identity and hands the Worker a signed assertion; the Worker reads the stable sub out of it, binds it to the authorization code, and carries it through into whatever token gets issued. It isn't a parallel check running alongside the OAuth flow — Access terminates in front of the Worker, so by the time a request reaches /authorize it has already been authenticated. One gate, on one path. No Access in front means no identity and an anonymous code, which is exactly the old behaviour — enabling this was additive, not a rewrite.

State lives in one Durable Object per human, keyed on that sub and never on email. A store keyed on email would silently follow the address instead of the person — a bug I've hit before and won't reintroduce. Each reader's Durable Object carries its own SQLite database — not "SQLite" as a shared resource, a genuinely separate database per human — so history is queryable relationally rather than read-whole-then-filter, and it costs nothing while idle, which for one reader's workspace is almost always. It holds a profile table and a readings table, reached through save_reading, list_readings, export_reading, get_profile. The public, unauthenticated /mcp endpoint never touches any of this — it's anonymous by design, so there is nobody to key a Durable Object on.

On Project Think

The reason I reached for a Durable Object at all was Cloudflare's Project Think — durable state for agents, announced during Agents Week in April 2026, still experimental. Think is a full agent runtime — sub-agents, sandboxed execution, durable functions; I only needed the ground floor: one Durable Object with SQLite per entity, keyed on the OAuth sub. Their consumer is an agent that needs to remember; mine is an artifact that needs to persist. The same pattern carried over unmodified — and that it generalises that far past its intended use is the part I found worth writing down.

Six defects, one shape

Getting one connector attached turned into an afternoon of debugging, and every single thing I found was the same failure wearing a different face: the server answering successfully while telling the client something it could not use.

Fix Defect
c760d11 401 on the .well-known metadata that names the authorization server — the one document a client needs before it can authenticate was itself demanding a token. A discovery deadlock.
c760d11 /mcp-oauth/register answered 200 with a JSON-RPC error body instead of a clean 404. A client reasonably reads a 200 as "registration succeeded," then fails later on the client_id that was never actually issued.
97f9c9d server/discover returned zero tools while its own instruction text promised "you get all tools unfiltered." The prose and the payload disagreed with each other.
fffbaed CORS was advertised on the OPTIONS preflight and absent from the actual response — the server told the browser it could read the answer, then didn't let it.
c7dc527 Non-standard _gate and _serverDetectedModel keys sat at the result root. _meta is the only extension point MCP actually sanctions; a strict client is entitled to reject the whole response over this.
fa0bfc8 Stopped implementing server/discover on an invented contract — answered -32601, correct for the protocol version this server actually declares, rather than keep guessing at a shape it had never checked against the real one.

Five of those six landed before I found the real one, and every one of the five was a genuine defect worth fixing on its own terms — none of them was the bug. All five, I want to be honest, were diagnosed by reading this server's own logs, and this server's logs said status: ok at every single step, because from the server's side, every step genuinely had succeeded.

The answer took about ten seconds once I finally opened the browser's own devtools instead of reading another log line:

text
POST /backend-api/aip/connectors/mcp/refresh_actions  →  424 Failed Dependency
{"kind":"invalid_response","upstream_status":200,
 "developer_message":"MCP server/discover response was invalid
  from a2uicatalog.ai"}

upstream_status: 200. The server was fine. The response was rejected on shape. That distinction — succeeded server-side, rejected client-side — is invisible from where the server sits and stated in plain language by the client that received it. If there's a single operating lesson worth carrying out of this whole afternoon, it's this one: when a client fails against a server whose own logs are clean, stop reading the server's logs.

What's actually proven

Underneath all of that debugging, the thing this piece set out to demonstrate held up completely. Both authorize events in the working flow carried identified: true — Access, Google, the OAuth sub, the per-human Durable Object, working end to end, with a real client, live. Claude and ChatGPT both now connect to the authenticated endpoint with genuinely separate per-user profiles.

Here's the part that makes it worth the afternoon: the analysis I made in Claude at the top of this piece, reopened cold from ChatGPT — a different client, a different vendor's model, that never saw the session that created it. Same object, keyed on the same sub, out of the same Durable Object:

Screen recording: opening the workspace in ChatGPT, going to history, and reopening the part-1 analysis that was created earlier in Claude — the same stored durable object, rendered in a different client.

Part 1 proved the same document renders across clients. This is the stronger claim: the same object — created in one client, addressed to me, still there when I come back through another that delegates the individual user, the way claude.ai and ChatGPT both do.

That qualifier is doing real work: rendering travels to any MCP client, but the identity claim depends on one specific thing — the individual signing in. Cloudflare Access gates /authorize with a user-to-machine login, so the sub that gets bound is theirs. The only way that collapses is a connector that authorises once at install and reuses the token for everyone — then one Durable Object per human becomes one per installation. That's a real failure mode to design against, but it's not the default: the per-user Access login is what holds the guarantee up.

And notice the shape of that dependency, because it's the same one part 1 ended on. There, portability held only where the renderer was shared or faithfully re-implemented — it was conditional on who owns the rendering. Here, durability holds wherever the individual signs in, and breaks only where a host binds one identity for everyone — conditional on who surfaces the person. Both pieces land on the same seam: the guarantee is real, and it belongs to whoever owns that layer on the host you're standing in.

The gap isn't a dead end, just a different owner. On a host with native per-user storage — Gemini Enterprise, where each user has Drive — the same durability is reachable without connector delegation. Part 1's Wall Builder already wrote to Drive there; wiring the read-back is the next experiment.