A standalone reference for the Worker, its Durable Objects, the R2 archive, and the MCP surface — and why this is a genuinely unusual application of the Model Context Protocol.
Thesis
The Model Context Protocol almost always fronts a SaaS or productivity API: a ticket tracker, a calendar, a document store, a database. The agent calls a tool, the tool mutates or reads a business record, the agent moves on. helloscope uses MCP for something with no resemblance to that world. The same MCP endpoint does two things that do not exist elsewhere:
- It serves prepared binary knowledge, not raw bytes. Behind the
archive_*tools is a content-addressed store of a real Linux glibc: per-function GNU-objdump disassembly, DWARF-mapped source lines, and classified call edges, keyed by GNU build-id and served byte-verbatim. An agent asking "what doesputsactually execute, and which glibc source line is that?" gets a provenance-stamped, reproducible answer — not a raw ELF to parse itself.
- It lets a visiting agent drive a live visualization as a docent. The
join_session/apply_state/play_steptools bind an MCP session to a browser tab showing an ELF Atlas room, and let the agent narrate an interactive scene in real time — applying scene states, writing captions into a transcript the human is watching, and answering the questions the human asks in their own agent chat.
Prepared binary artifacts over MCP, and an agent that narrates an interactive room as it happens: both are far from MCP's usual home, and both fall out of one small, authless, edge-served Cloudflare Worker.
Topology
One Worker (live/src/index.ts, deployed at
helloscope-live.iunknown.workers.dev) is the entire backend. It is authless for
readers, edge-served from every Cloudflare PoP, and sized to stay inside the
Workers free tier — a deliberate constraint that shapes every decision below
(most sharply the 10 ms/request CPU budget). Four route families share the single
fetch handler, dispatched in this order:
fetch handler; hover a route.GET /ws?session=CODEupgrades the browser tab's WebSocket and hands it to aSessionRoomDurable Object addressed by the room's 6-char code (env.ROOM.getByName(code)). The socket must live somewhere addressable by code so that a completely separate MCP request can later reach the exact tab a human is looking at — that is what a Durable Object gives you and a stateless Worker cannot.POST /mcpis a hand-rolled JSON-RPC MCP server over Streamable HTTP (handleMcp). It splits by tool: the fourarchive_*tools are stateless and answered right here in the fetch path (isArchiveTool(name)→handleArchiveTool), never touching a Durable Object; the six room tools are dispatched to a per-sessionMcpSessionDO.GET /archive/<build-id>/…(handleArchiveinarchive.ts) serves the R2 archive as plain HTTP for non-agent consumers — the same objects the MCP tools read, but as raw JSON/text.- Everything else falls through to
env.ASSETS.fetch— the static room page and its dormant live-mode client.
Why these live where they do: the WebSocket and the room-driving tools need addressable, persistent per-session identity, so they get Durable Objects. The archive is immutable content, so it needs no session state at all and is served straight from R2 (and, for the tools, a per-isolate content cache) — putting it through a DO would only burn quota and latency.
The two Durable Objects
SessionRoom — one instance per room session code. It owns the browser
tab's WebSocket and is the correlation point between the human's screen and the
agent's tool calls. Three things make it the right tool:
- It holds the socket via the WebSocket Hibernation API (
ctx.acceptWebSocket(server, ["page"])pluswebSocketMessage/webSocketClosehandlers rather than per-socketaddEventListener). An idle tab costs no billed duration; the DO evaporates from memory and rehydrates on the next message. - Keepalives never wake it. The constructor installs
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping","pong")), so the browser's periodic ping is answered by the runtime itself — no billed wake, no server-sidesetInterval. - Relay→page calls are request/response correlated by a random id (
pageRpcstores a resolver inrpcWaiters, sends{type:"rpc",id,op,args}, and the page replies{type:"rpc_result",id,…}), with a 15 s timeout. The only durable state is theMcpSessionDO's record of which room code it joined — enough to survive hibernation and keep serving tool calls after an idle gap.
McpSession — one instance per MCP session id (the UUID minted at
initialize and echoed back in the Mcp-Session-Id header). Its only durable
state is a single value: which room code this MCP session joined
(ctx.storage.put("code", …)). On each room tool call it looks up its code and
forwards to the matching SessionRoom via RPC. DELETE /mcp calls terminate(),
which clears that binding.
Why Durable Objects and not KV or a global variable: the binding is live,
per-session, and bidirectional. A global Map in the isolate would not survive
across the many isolates Cloudflare spreads requests over, and could not hold an
open WebSocket. KV is eventually consistent and write-latent — wrong for a socket
handle and a request/response correlation table. A Durable Object is the one
primitive that gives a single-threaded, addressable, strongly-consistent home for
"the tab named ABC234" and "the MCP session named <uuid>". The DO's addressability
by name is exactly what lets two unrelated HTTP connections (a browser's /ws
and an agent's /mcp) rendezvous on the same session.
The R2 archive layer
The archive (contract in archive/schema.md) is a content-addressed store keyed
by GNU build-id. The object layout mirrors 1:1 into R2 keys:
<build-id>/meta.json full manifest (archive.meta/1)
<build-id>/lookup.json compact symbol index (archive.lookup/1)
<build-id>/fn/<addr-hex>-<size>.json one per function body (archive.fn/1)
<build-id>/src/<path> verbatim glibc source bytes
index.json catalogue of binaries (archive.index/1)
Two properties make this safe and cheap:
- Immutability by construction. A build-id is a hash of the binary's contents, so content under a build-id can never change. Ingestion is deterministic (
json.dumps(…, sort_keys=True, separators=(",",":")), no wall-clock timestamps, byte-verbatim source copies); two runs produce byte-identical trees. Every 200 response therefore carriesCache-Control: public, max-age=31536000, immutable, and bytes are streamed straight from R2 (new Response(obj.body, …)) — never re-serialized — so a served body is field-for-field identical to the committed fixture. That byte-identity is a gate:parity_check.pycompares served bodies againstfixtures/imports.libc.json.
- The lookup.json CPU-budget trick. The Workers free plan allows ~10 ms CPU per invocation. The libc
meta.jsonis 875 KB and costs ~8.5 ms just toJSON.parse(plus ~5 ms to index) — over budget on its own, before any work. So no request path ever parses meta.json. Ingestion emits a second, compact object —lookup.json, 174 KB for libc — carrying exactly the fields a tool needs to resolve a name to an fn-object key:[addr, size, obj_suffix, type, [[name, version, default], …]]per entry, addr-sorted. It parses in ~2 ms.archive-mcp.tscaches the parsedLookupper isolate in a module-levelMapkeyed by build-id (lookupCache) — safe as module state precisely because the content is immutable, so it is a pure content cache, never request-scoped mutable state. The HTTP/archive/.../function/<name>route shares the same resolver (resolveFnObjectKey), so the byte route and the tool route resolve names identically.
The economics close the loop: R2 has zero egress fees. An authless public tool that streams disassembly and source to any agent that asks has no per-download cost — the thing that would normally make a public binary archive financially dangerous simply is not billed.
The MCP surface
tools/list returns 10 tools — [...TOOLS, ...ARCHIVE_TOOLS] — in two groups
that share one endpoint:
6 room-driving tools (stateful, routed through the McpSession DO):
| Tool | Role |
|---|---|
join_session | Bind this MCP session to the room code on the page. Required first. |
get_agent_guide | Return the bundled wall text (state grammar, tour format). Needs no session. |
get_state | The page's current normalized scene state. |
apply_state | Apply a scene state (no beat). Normalizes illegal input, echoing the repaired state. |
play_step | Apply a state and append a narrated caption beat to the live transcript. Rejects illegal state verbatim, records nothing. |
export_tour | The beats recorded this session, as a replayable tour JSON. |
4 archive tools (stateless, answered in the fetch path, no join_session):
archive_lookup (catalogue / one binary's summary), archive_function (windowed
disassembly listing, default 200 rows / cap 500), archive_source (verbatim
numbered glibc source, cap 400 lines/call), archive_callees (outgoing
control-transfer edges).
Two design commitments run through the surface:
- Honesty/provenance as a contract carried in the payload. Every archive result is JSON that includes
build_idand aprovenanceobject (package version, origin one-liner, objdump version) so an agent can cite the exact snapshot and toolchain. Tool descriptions and resultnotefields teach the epistemics: a run of disassembly rows with no── file:line ──header carries no source claim (not "no source");archive_calleesclassifies edges asdirect/tail(exact entry points, names exact) vs.into_body(target lands inside another symbol — reported<name>+0xOFF, a non-exported internal, never the bare name) vs.internal_unresolvedvs.indirect(call */jmp *, not statically resolvable). The tools are engineered so a truthful agent cannot be led into a confident false attribution. The room side enforces the same discipline differently:play_steprefuses out-of-range state verbatim, so a narrated beat can only cite values the scene actually supports.
- One endpoint spanning both worlds is the point. Because the live-room tools and the archive tools are the same MCP server, a single agent session can answer a question that crosses the boundary — e.g. the room shows
helloemitting acallinto the PLT; the agent, in the same breath, reaches into the archive for the real libcputsbody and itslibio/ioputs.csource lines, and narrates the whole path as one continuous story. Split across two servers, that question is two disconnected lookups; unified, it is one docent's explanation.
Why this is a novel use of MCP
Strip away the domain and the pattern is: an agent shows up as a museum visitor, brings its own tokens, and is handed a room. The room is four things at once, and MCP is what makes all four reachable through one interface:
- evidence — byte-canonical fixtures and a build-id-verified archive, where every numeric claim traces back to committed bytes;
- an instrument — the scene state grammar the agent drives via
apply_state/play_step; - narration — the live docent transcript the agent writes and the human reads;
- access — the MCP endpoint itself, authless and edge-served.
That combination does not appear in the SaaS-productivity MCP canon. The usual MCP tool does work for the agent; here the agent does work for a human watching a screen, and the tool's job is to keep it honest against physical evidence. Reproducibility is first-class: content-addressing by build-id means the same question yields the same provenance-stamped answer forever, and the free-tier hard cap plus R2's zero egress make an authless public agent tool actually safe to run unattended.
The interface is model-agnostic by evidence, not assertion: the room and archive tools have been driven end-to-end by both Claude and Codex, each producing truthful tours from the same guide and the same tool contracts — a cross-model check that the honesty scaffolding lives in the tools and their descriptions, not in one model's habits.
What this enables next
- Bring-your-own-binary. The archive is already keyed by build-id and served by a generic resolver; ingest any ELF's debug package and its functions/source/edges become answerable through the same
archive_*tools with no Worker change. - A reference-VM build loop. Because ingestion is deterministic and gated by spot-check byte-equality, a clean reference VM can rebuild the debug package, re-derive the archive, and prove byte-identity against what is served — closing the loop from "a binary on disk" to "a provenance-stamped room an agent can narrate" without a human in the middle.
Notes where behavior differs from live/README.md
- Tool count.
live/README.mddocuments a "Tools (7)" table and never states thattools/listactually returns 11 tools; the fourarchive_*tools are appended ([...TOOLS, ...ARCHIVE_TOOLS]inindex.ts) and appear in the same listing. The README's archive section covers the HTTP routes but not the MCP tools.archive/schema.mdis the accurate source ("They appear in the sametools/listas the 7 room tools"). - lookup.json size. The
archive-mcp.tsheader comment andschema.mdboth cite lookup.json as "~300 KB"; the committed libclookup.jsonis actually 178 KB (meta.json is 875 KB). The 300 KB figure is a conservative target, not the measured size — this doc cites the real 174 KB. public/index.htmlprovenance.live/README.mdcallspublic/index.html"a copy ofmockups/v8.htmlwith three__EXHIBIT__hooks." Per the repo-rootCLAUDE.md, that page is now generated byapp/build.mjs; the hand-copy description is stale relative to the current build pipeline (the runtime behavior is unchanged — it is still the static room page served viaenv.ASSETS).