MCP, A2UI, and a Durable Object per Human
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:

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:

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:

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

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.
The same flow in time order — two legs, one token:

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:
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:

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.